From 671da7175262d57a5df2289ff9b9c982ee54ca0f Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 16 Jun 2026 16:28:41 +0200 Subject: [PATCH 01/85] =?UTF-8?q?feat(#112):=20reprise=20apr=C3=A8s=20refu?= =?UTF-8?q?s=20via=20lien=20e-mail=20(/reprise=3Ftoken=3D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branche le flux front : chargement du dossier, session reprise, wizards parent/AM préremplis et resoumission via reprise-resoumettre. Co-authored-by: Cursor --- frontend/lib/config/app_router.dart | 6 + frontend/lib/models/am_registration_data.dart | 45 +++++ frontend/lib/models/reprise_dossier.dart | 53 ++++++ .../lib/models/user_registration_data.dart | 37 +++++ .../auth/am_register_step1_screen.dart | 3 +- .../auth/am_register_step4_screen.dart | 61 ++++++- .../auth/parent_register_step1_screen.dart | 3 +- .../auth/parent_register_step5_screen.dart | 60 +++++++ .../screens/auth/reprise_entry_screen.dart | 154 ++++++++++++++++++ frontend/lib/services/api/api_config.dart | 3 + frontend/lib/services/auth_service.dart | 86 ++++++++++ frontend/lib/services/reprise_session.dart | 67 ++++++++ 12 files changed, 575 insertions(+), 3 deletions(-) create mode 100644 frontend/lib/models/reprise_dossier.dart create mode 100644 frontend/lib/screens/auth/reprise_entry_screen.dart create mode 100644 frontend/lib/services/reprise_session.dart diff --git a/frontend/lib/config/app_router.dart b/frontend/lib/config/app_router.dart index 4dfe66e..693c424 100644 --- a/frontend/lib/config/app_router.dart +++ b/frontend/lib/config/app_router.dart @@ -21,6 +21,7 @@ import '../screens/auth/am_register_step4_screen.dart'; import '../screens/auth/create_password_screen.dart'; import '../screens/auth/forgot_password_screen.dart'; import '../screens/auth/reset_password_screen.dart'; +import '../screens/auth/reprise_entry_screen.dart'; import '../screens/home/home_screen.dart'; import '../screens/administrateurs/admin_dashboardScreen.dart'; import '../screens/gestionnaire/gestionnaire_dashboard_screen.dart'; @@ -66,6 +67,11 @@ class AppRouter { builder: (BuildContext context, GoRouterState state) => ResetPasswordScreen(token: state.uri.queryParameters['token']), ), + GoRoute( + path: '/reprise', + builder: (BuildContext context, GoRouterState state) => + RepriseEntryScreen(token: state.uri.queryParameters['token']), + ), GoRoute( path: '/home', builder: (BuildContext context, GoRouterState state) => const HomeScreen(), diff --git a/frontend/lib/models/am_registration_data.dart b/frontend/lib/models/am_registration_data.dart index 44604df..d61cbdd 100644 --- a/frontend/lib/models/am_registration_data.dart +++ b/frontend/lib/models/am_registration_data.dart @@ -33,6 +33,9 @@ class AmRegistrationData extends ChangeNotifier { /// Places libres actuellement (0 ≤ valeur ≤ capacité) — API `places_disponibles`. int? placesAvailable; + /// Photo déjà en base lors d'une reprise (#112) — sert pour l'affichage et `photo_url` API. + String? repriseExistingPhotoUrl; + // Step 3: Presentation & CGU String presentationText = ''; bool cguAccepted = false; @@ -104,6 +107,43 @@ class AmRegistrationData extends ChangeNotifier { notifyListeners(); } + /// Reprise après refus (#112). + void resetForReprise({ + required String firstName, + required String lastName, + required String phone, + required String email, + required String streetAddress, + required String postalCode, + required String city, + String? existingPhotoUrl, + }) { + this.firstName = firstName; + this.lastName = lastName; + this.phone = phone; + this.email = email; + this.streetAddress = streetAddress; + this.postalCode = postalCode; + this.city = city; + password = ''; + repriseExistingPhotoUrl = existingPhotoUrl; + photoPath = existingPhotoUrl; + photoBytes = null; + photoFilename = null; + photoConsent = false; + dateOfBirth = null; + birthCity = ''; + birthCountry = ''; + nir = ''; + agrementNumber = ''; + agreementDate = null; + capacity = null; + placesAvailable = null; + presentationText = ''; + cguAccepted = false; + notifyListeners(); + } + // --- Getters for validation or display --- bool get isStep1Complete => firstName.trim().length >= 2 && @@ -118,6 +158,8 @@ class AmRegistrationData extends ChangeNotifier { /// Photo réelle (pas seulement un placeholder asset). bool get _hasUserPhoto => (photoBytes != null && photoBytes!.isNotEmpty) || + (repriseExistingPhotoUrl != null && + repriseExistingPhotoUrl!.trim().isNotEmpty) || (photoPath != null && photoPath!.isNotEmpty && !photoPath!.startsWith('assets/')); @@ -145,6 +187,9 @@ class AmRegistrationData extends ChangeNotifier { bool get isRegistrationComplete => isStep1Complete && isStep2Complete && isStep3Complete; + /// Reprise (#112) : champs renvoyés par PATCH reprise-resoumettre. + bool get isRepriseSubmitReady => isStep1Complete && cguAccepted; + @override String toString() { return 'AmRegistrationData(' diff --git a/frontend/lib/models/reprise_dossier.dart b/frontend/lib/models/reprise_dossier.dart new file mode 100644 index 0000000..e0cc685 --- /dev/null +++ b/frontend/lib/models/reprise_dossier.dart @@ -0,0 +1,53 @@ +/// Réponse GET /auth/reprise-dossier. Ticket #111, #112. +class RepriseDossier { + 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? numeroDossier; + final String role; + final String? photoUrl; + final String? genre; + final String? situationFamiliale; + + const RepriseDossier({ + required this.id, + required this.email, + this.prenom, + this.nom, + this.telephone, + this.adresse, + this.ville, + this.codePostal, + this.numeroDossier, + required this.role, + this.photoUrl, + this.genre, + this.situationFamiliale, + }); + + bool get isParent => role == 'parent'; + bool get isAm => role == 'assistante_maternelle'; + + factory RepriseDossier.fromJson(Map json) { + return RepriseDossier( + 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(), + numeroDossier: json['numero_dossier']?.toString(), + role: json['role']?.toString() ?? '', + photoUrl: json['photo_url']?.toString(), + genre: json['genre']?.toString(), + situationFamiliale: json['situation_familiale']?.toString(), + ); + } +} diff --git a/frontend/lib/models/user_registration_data.dart b/frontend/lib/models/user_registration_data.dart index 5e8892f..5f29ce5 100644 --- a/frontend/lib/models/user_registration_data.dart +++ b/frontend/lib/models/user_registration_data.dart @@ -178,6 +178,43 @@ class UserRegistrationData extends ChangeNotifier { notifyListeners(); } + /// Reprise après refus (#112) : réinitialise le flux parent avec les données dossier. + void resetForReprise({ + required String firstName, + required String lastName, + required String phone, + required String email, + required String address, + required String postalCode, + required String city, + }) { + parent1 = ParentData( + firstName: firstName, + lastName: lastName, + phone: phone, + email: email, + address: address, + postalCode: postalCode, + city: city, + password: '', + ); + parent2 = null; + children.clear(); + motivationText = ''; + cguAccepted = false; + bankDetails = null; + attestationCafNumber = ''; + consentQuotientFamilial = false; + notifyListeners(); + } + + /// Reprise (#112) : coordonnées parent principal + CGU. + bool get isRepriseSubmitReady => + parent1.firstName.isNotEmpty && + parent1.lastName.isNotEmpty && + parent1.email.isNotEmpty && + cguAccepted; + // Méthode pour vérifier si toutes les données requises sont là (simplifié) bool isRegistrationComplete() { // Ajouter ici les validations nécessaires diff --git a/frontend/lib/screens/auth/am_register_step1_screen.dart b/frontend/lib/screens/auth/am_register_step1_screen.dart index 66129e6..1c5ca0f 100644 --- a/frontend/lib/screens/auth/am_register_step1_screen.dart +++ b/frontend/lib/screens/auth/am_register_step1_screen.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import '../../models/am_registration_data.dart'; import '../../widgets/personal_info_form_screen.dart'; +import '../../services/reprise_session.dart'; import '../../models/card_assets.dart'; class AmRegisterStep1Screen extends StatelessWidget { @@ -29,7 +30,7 @@ class AmRegisterStep1Screen extends StatelessWidget { cardColor: CardColorHorizontal.blue, initialData: initialData, minPersonNameLength: 2, - previousRoute: '/register-choice', + previousRoute: RepriseSession.isActive ? '/login' : '/register-choice', onSubmit: (data, {hasSecondPerson, sameAddress}) { registrationData.updateIdentityInfo( firstName: data.firstName, diff --git a/frontend/lib/screens/auth/am_register_step4_screen.dart b/frontend/lib/screens/auth/am_register_step4_screen.dart index 90f536f..3a93079 100644 --- a/frontend/lib/screens/auth/am_register_step4_screen.dart +++ b/frontend/lib/screens/auth/am_register_step4_screen.dart @@ -8,6 +8,7 @@ import '../../models/am_registration_data.dart'; import '../../models/card_assets.dart'; import '../../config/display_config.dart'; import '../../services/auth_service.dart'; +import '../../services/reprise_session.dart'; import '../../widgets/hover_relief_widget.dart'; import '../../widgets/image_button.dart'; import '../../widgets/custom_navigation_button.dart'; @@ -27,7 +28,21 @@ class _AmRegisterStep4ScreenState extends State { Future _submitAMRegistration(AmRegistrationData registrationData) async { if (_isSubmitting) return; - if (!registrationData.isRegistrationComplete) { + if (RepriseSession.isActive) { + if (!registrationData.isRepriseSubmitReady) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Vérifiez vos coordonnées et acceptez les conditions.', + style: GoogleFonts.merienda(fontSize: 14), + ), + backgroundColor: Colors.red.shade700, + ), + ); + return; + } + } else if (!registrationData.isRegistrationComplete) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -42,6 +57,22 @@ class _AmRegisterStep4ScreenState extends State { } setState(() => _isSubmitting = true); try { + if (RepriseSession.isActive) { + await AuthService.resoumettreReprise( + token: RepriseSession.token!, + prenom: registrationData.firstName, + nom: registrationData.lastName, + telephone: registrationData.phone, + adresse: registrationData.streetAddress, + ville: registrationData.city, + codePostal: registrationData.postalCode, + photoUrl: RepriseSession.photoUrl, + ); + RepriseSession.clear(); + if (!mounted) return; + _showRepriseConfirmationModal(context); + return; + } await AuthService.registerAM(registrationData); if (!mounted) return; _showConfirmationModal(context); @@ -244,6 +275,34 @@ class _AmRegisterStep4ScreenState extends State { ); } + void _showRepriseConfirmationModal(BuildContext context) { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext dialogContext) { + return AlertDialog( + title: Text( + 'Dossier resoumis', + style: GoogleFonts.merienda(fontWeight: FontWeight.bold), + ), + content: Text( + 'Vos modifications ont été enregistrées. Votre dossier est de nouveau en attente de validation.', + style: GoogleFonts.merienda(fontSize: 14), + ), + actions: [ + TextButton( + child: Text('OK', style: GoogleFonts.merienda(fontWeight: FontWeight.bold)), + onPressed: () { + Navigator.of(dialogContext).pop(); + context.go('/login'); + }, + ), + ], + ); + }, + ); + } + void _showConfirmationModal(BuildContext context) { showDialog( context: context, diff --git a/frontend/lib/screens/auth/parent_register_step1_screen.dart b/frontend/lib/screens/auth/parent_register_step1_screen.dart index becad34..fd5de45 100644 --- a/frontend/lib/screens/auth/parent_register_step1_screen.dart +++ b/frontend/lib/screens/auth/parent_register_step1_screen.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import '../../models/user_registration_data.dart'; import '../../widgets/personal_info_form_screen.dart'; +import '../../services/reprise_session.dart'; import '../../models/card_assets.dart'; class ParentRegisterStep1Screen extends StatelessWidget { @@ -29,7 +30,7 @@ class ParentRegisterStep1Screen extends StatelessWidget { title: 'Informations du Parent Principal', cardColor: CardColorHorizontal.peach, initialData: initialData, - previousRoute: '/register-choice', + previousRoute: RepriseSession.isActive ? '/login' : '/register-choice', onSubmit: (data, {hasSecondPerson, sameAddress}) { registrationData.updateParent1(ParentData( firstName: data.firstName, diff --git a/frontend/lib/screens/auth/parent_register_step5_screen.dart b/frontend/lib/screens/auth/parent_register_step5_screen.dart index 883f9ee..ac0512f 100644 --- a/frontend/lib/screens/auth/parent_register_step5_screen.dart +++ b/frontend/lib/screens/auth/parent_register_step5_screen.dart @@ -14,6 +14,7 @@ import '../../widgets/personal_info_form_screen.dart'; import '../../widgets/child_card_widget.dart'; import '../../widgets/presentation_form_screen.dart'; import '../../services/auth_service.dart'; +import '../../services/reprise_session.dart'; class ParentRegisterStep5Screen extends StatefulWidget { const ParentRegisterStep5Screen({super.key}); @@ -27,8 +28,39 @@ class _ParentRegisterStep5ScreenState extends State { Future _submitRegistration(BuildContext context, UserRegistrationData data) async { if (_isSubmitting) return; + if (RepriseSession.isActive) { + if (!data.isRepriseSubmitReady) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Vérifiez vos coordonnées et acceptez les conditions.', + style: GoogleFonts.merienda(fontSize: 14), + ), + backgroundColor: Colors.red.shade700, + ), + ); + return; + } + } setState(() => _isSubmitting = true); try { + if (RepriseSession.isActive) { + final token = RepriseSession.token!; + final p = data.parent1; + await AuthService.resoumettreReprise( + token: token, + prenom: p.firstName, + nom: p.lastName, + telephone: p.phone, + adresse: p.address, + ville: p.city, + codePostal: p.postalCode, + ); + RepriseSession.clear(); + if (!context.mounted) return; + _showRepriseSuccessModal(context); + return; + } await AuthService.registerParent(data); if (!context.mounted) return; _showSuccessModal(context); @@ -279,6 +311,34 @@ class _ParentRegisterStep5ScreenState extends State { ); } + void _showRepriseSuccessModal(BuildContext context) { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext dialogContext) { + return AlertDialog( + title: Text( + 'Dossier resoumis', + style: GoogleFonts.merienda(fontWeight: FontWeight.bold), + ), + content: Text( + 'Vos modifications ont été enregistrées. Votre dossier est de nouveau en attente de validation.', + style: GoogleFonts.merienda(fontSize: 14), + ), + actions: [ + TextButton( + child: Text('OK', style: GoogleFonts.merienda(fontWeight: FontWeight.bold)), + onPressed: () { + Navigator.of(dialogContext).pop(); + context.go('/login'); + }, + ), + ], + ); + }, + ); + } + void _showSuccessModal(BuildContext context) { showDialog( context: context, diff --git a/frontend/lib/screens/auth/reprise_entry_screen.dart b/frontend/lib/screens/auth/reprise_entry_screen.dart new file mode 100644 index 0000000..c2fff65 --- /dev/null +++ b/frontend/lib/screens/auth/reprise_entry_screen.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../config/app_router.dart'; +import '../../services/auth_service.dart'; +import '../../services/reprise_session.dart'; + +/// Point d'entrée `/reprise?token=` — charge le dossier et ouvre le wizard (#112). +class RepriseEntryScreen extends StatefulWidget { + final String? token; + + const RepriseEntryScreen({super.key, this.token}); + + @override + State createState() => _RepriseEntryScreenState(); +} + +class _RepriseEntryScreenState extends State { + bool _loading = true; + String? _error; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _load()); + } + + Future _load() async { + final token = widget.token?.trim() ?? ''; + if (token.isEmpty) { + setState(() { + _loading = false; + _error = 'Lien invalide ou expiré.'; + }); + return; + } + + setState(() { + _loading = true; + _error = null; + }); + + try { + final dossier = await AuthService.getRepriseDossier(token); + if (!mounted) return; + + RepriseSession.clear(); + RepriseSession.start(token: token, dossier: dossier); + + if (dossier.isParent) { + RepriseSession.applyToParent(userRegistrationDataNotifier, dossier); + context.go('/parent-register-step1'); + return; + } + if (dossier.isAm) { + RepriseSession.applyToAm(amRegistrationDataNotifier, dossier); + context.go('/am-register-step1'); + return; + } + + RepriseSession.clear(); + setState(() { + _loading = false; + _error = 'Type de dossier non pris en charge pour la reprise.'; + }); + } catch (e) { + if (!mounted) return; + RepriseSession.clear(); + setState(() { + _loading = false; + _error = e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Impossible de charger votre dossier.'; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Stack( + children: [ + Positioned.fill( + child: Image.asset( + 'assets/images/paper2.png', + fit: BoxFit.cover, + repeat: ImageRepeat.repeat, + ), + ), + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Padding( + padding: const EdgeInsets.all(24), + child: _buildBody(), + ), + ), + ), + ], + ), + ); + } + + Widget _buildBody() { + if (_loading) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 20), + Text( + 'Chargement de votre dossier…', + style: GoogleFonts.merienda(fontSize: 16), + textAlign: TextAlign.center, + ), + ], + ); + } + + final err = _error ?? 'Lien invalide ou expiré.'; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, size: 48, color: Colors.red.shade700), + const SizedBox(height: 16), + Text( + 'Reprise du dossier', + style: GoogleFonts.merienda( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + Text( + err, + style: GoogleFonts.merienda(fontSize: 14, color: Colors.black87), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + TextButton( + onPressed: () => context.go('/login'), + child: Text( + 'Retour à la connexion', + style: GoogleFonts.merienda( + decoration: TextDecoration.underline, + color: const Color(0xFF2D6A4F), + ), + ), + ), + ], + ); + } +} diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index 11d6a28..84f04a7 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -49,6 +49,9 @@ class ApiConfig { /// Ticket #127 — mot de passe oublié (back à livrer en parallèle). static const String forgotPassword = '/auth/forgot-password'; static const String resetPassword = '/auth/reset-password'; + /// Ticket #112 — reprise après refus (#111 back). + static const String repriseDossier = '/auth/reprise-dossier'; + static const String repriseResoumettre = '/auth/reprise-resoumettre'; // Users endpoints static const String users = '/users'; diff --git a/frontend/lib/services/auth_service.dart b/frontend/lib/services/auth_service.dart index 94f5d28..e89ca5e 100644 --- a/frontend/lib/services/auth_service.dart +++ b/frontend/lib/services/auth_service.dart @@ -8,6 +8,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../models/user.dart'; import '../models/am_registration_data.dart'; import '../models/user_registration_data.dart'; +import '../models/reprise_dossier.dart'; import '../utils/parent_registration_payload.dart'; import 'api/api_config.dart'; import 'api/tokenService.dart'; @@ -258,6 +259,91 @@ class AuthService { throw Exception(msg); } + /// Charge le dossier pour reprise (lien e-mail). GET /auth/reprise-dossier. Ticket #112. + static Future getRepriseDossier(String token) async { + final cleaned = token.trim(); + if (cleaned.isEmpty) { + throw Exception('Lien invalide ou expiré.'); + } + final uri = Uri.parse( + '${ApiConfig.baseUrl}${ApiConfig.repriseDossier}', + ).replace(queryParameters: {'token': cleaned}); + late final http.Response response; + try { + response = await http.get(uri, headers: ApiConfig.headers); + } on http.ClientException { + throw Exception( + 'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau puis réessayez.', + ); + } + if (response.statusCode == 404) { + throw Exception('Lien invalide ou expiré.'); + } + if (response.statusCode != 200) { + final decoded = _tryDecodeJsonMap(response.body); + throw Exception(_extractErrorMessage(decoded, response.statusCode)); + } + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw Exception('Réponse invalide du serveur.'); + } + return RepriseDossier.fromJson(decoded); + } + + /// Resoumission après refus. PATCH /auth/reprise-resoumettre. Ticket #112. + static Future resoumettreReprise({ + required String token, + String? prenom, + String? nom, + String? telephone, + String? adresse, + String? ville, + String? codePostal, + String? photoUrl, + }) async { + final cleaned = token.trim(); + if (cleaned.isEmpty) { + throw Exception('Lien invalide ou expiré.'); + } + final body = {'token': cleaned}; + if (prenom != null && prenom.trim().isNotEmpty) body['prenom'] = prenom.trim(); + if (nom != null && nom.trim().isNotEmpty) body['nom'] = nom.trim(); + if (telephone != null && telephone.trim().isNotEmpty) { + body['telephone'] = telephone.trim(); + } + if (adresse != null && adresse.trim().isNotEmpty) { + body['adresse'] = adresse.trim(); + } + if (ville != null && ville.trim().isNotEmpty) body['ville'] = ville.trim(); + if (codePostal != null && codePostal.trim().isNotEmpty) { + body['code_postal'] = codePostal.trim(); + } + if (photoUrl != null && photoUrl.trim().isNotEmpty) { + body['photo_url'] = photoUrl.trim(); + } + + late final http.Response response; + try { + response = await http.patch( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.repriseResoumettre}'), + headers: ApiConfig.headers, + body: jsonEncode(body), + ); + } on http.ClientException { + throw Exception( + 'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau puis réessayez.', + ); + } + if (response.statusCode == 404) { + throw Exception('Lien invalide ou expiré.'); + } + if (response.statusCode == 200 || response.statusCode == 201) { + return; + } + final decoded = _tryDecodeJsonMap(response.body); + throw Exception(_extractErrorMessage(decoded, response.statusCode)); + } + /// Déconnexion de l'utilisateur static Future logout() async { await TokenService.clearAll(); diff --git a/frontend/lib/services/reprise_session.dart b/frontend/lib/services/reprise_session.dart new file mode 100644 index 0000000..637d158 --- /dev/null +++ b/frontend/lib/services/reprise_session.dart @@ -0,0 +1,67 @@ +import 'package:p_tits_pas/models/am_registration_data.dart'; +import 'package:p_tits_pas/models/reprise_dossier.dart'; +import 'package:p_tits_pas/models/user_registration_data.dart'; +import 'package:p_tits_pas/services/api/api_config.dart'; + +/// Contexte reprise après refus (token e-mail ou identify). Ticket #112. +class RepriseSession { + RepriseSession._(); + + static String? _token; + static String? _role; + static String? _photoUrl; + + static bool get isActive => + _token != null && _token!.trim().isNotEmpty; + + static String? get token => _token; + + static bool get isParent => _role == 'parent'; + + static bool get isAm => _role == 'assistante_maternelle'; + + static String? get photoUrl => _photoUrl; + + static void start({ + required String token, + required RepriseDossier dossier, + }) { + _token = token.trim(); + _role = dossier.role; + _photoUrl = dossier.photoUrl?.trim().isNotEmpty == true + ? ApiConfig.absoluteMediaUrl(dossier.photoUrl) + : null; + } + + static void clear() { + _token = null; + _role = null; + _photoUrl = null; + } + + static void applyToParent(UserRegistrationData data, RepriseDossier dossier) { + data.resetForReprise( + firstName: dossier.prenom ?? '', + lastName: dossier.nom ?? '', + phone: dossier.telephone ?? '', + email: dossier.email, + address: dossier.adresse ?? '', + postalCode: dossier.codePostal ?? '', + city: dossier.ville ?? '', + ); + } + + static void applyToAm(AmRegistrationData data, RepriseDossier dossier) { + final photo = _photoUrl; + data.resetForReprise( + firstName: dossier.prenom ?? '', + lastName: dossier.nom ?? '', + phone: dossier.telephone ?? '', + email: dossier.email, + streetAddress: dossier.adresse ?? '', + postalCode: dossier.codePostal ?? '', + city: dossier.ville ?? '', + existingPhotoUrl: photo, + ); + } +} From d70577b1c3c4661eacfd1928525f1fa0d0f0a0a7 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 16 Jun 2026 16:39:53 +0200 Subject: [PATCH 02/85] =?UTF-8?q?feat(#112):=20reprise=20apr=C3=A8s=20refu?= =?UTF-8?q?s=20=E2=80=94=20dossier=20complet=20GET/PATCH?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET reprise-dossier : parents[], enfants[], texte_motivation (famille) ou fiche AM (#119) - PATCH reprise-resoumettre : co-parent, enfants (update par id), motivation, fiche AM - Resoumission famille : tous les parents en en_attente + invalidation token groupée - DTOs étendus + est_multiple sur enfants dossier famille - Tests unitaires getRepriseDossier parent/AM Co-authored-by: Cursor --- backend/src/routes/auth/auth.controller.ts | 3 +- backend/src/routes/auth/auth.module.ts | 16 +- backend/src/routes/auth/auth.service.spec.ts | 87 ++++- backend/src/routes/auth/auth.service.ts | 340 +++++++++++++++++- .../src/routes/auth/dto/enfant-reprise.dto.ts | 11 + .../routes/auth/dto/reprise-dossier.dto.ts | 49 ++- .../auth/dto/resoumettre-reprise.dto.ts | 162 ++++++++- .../dto/dossier-famille-complet.dto.ts | 3 + backend/src/routes/parents/parents.service.ts | 1 + 9 files changed, 655 insertions(+), 17 deletions(-) create mode 100644 backend/src/routes/auth/dto/enfant-reprise.dto.ts diff --git a/backend/src/routes/auth/auth.controller.ts b/backend/src/routes/auth/auth.controller.ts index 7e2c6d3..cdc016e 100644 --- a/backend/src/routes/auth/auth.controller.ts +++ b/backend/src/routes/auth/auth.controller.ts @@ -102,8 +102,7 @@ export class AuthController { @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); + return this.authService.resoumettreReprise(dto); } @Public() diff --git a/backend/src/routes/auth/auth.module.ts b/backend/src/routes/auth/auth.module.ts index 34d4631..3ebf0df 100644 --- a/backend/src/routes/auth/auth.module.ts +++ b/backend/src/routes/auth/auth.module.ts @@ -12,11 +12,25 @@ import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entit import { AppConfigModule } from 'src/modules/config'; import { NumeroDossierModule } from 'src/modules/numero-dossier/numero-dossier.module'; import { MailModule } from 'src/modules/mail/mail.module'; +import { ParentsModule } from '../parents/parents.module'; +import { DossiersModule } from '../dossiers/dossiers.module'; +import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famille.entity'; +import { ParentsChildren } from 'src/entities/parents_children.entity'; @Module({ imports: [ - TypeOrmModule.forFeature([Users, Parents, Children, AssistanteMaternelle]), + TypeOrmModule.forFeature([ + Users, + Parents, + Children, + AssistanteMaternelle, + DossierFamille, + DossierFamilleEnfant, + ParentsChildren, + ]), forwardRef(() => UserModule), + ParentsModule, + DossiersModule, AppConfigModule, MailModule, NumeroDossierModule, diff --git a/backend/src/routes/auth/auth.service.spec.ts b/backend/src/routes/auth/auth.service.spec.ts index 0361563..e373abd 100644 --- a/backend/src/routes/auth/auth.service.spec.ts +++ b/backend/src/routes/auth/auth.service.spec.ts @@ -5,11 +5,13 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { ConfigService } from '@nestjs/config'; import { AuthService } from './auth.service'; import { UserService } from '../user/user.service'; +import { ParentsService } from '../parents/parents.service'; +import { DossiersService } from '../dossiers/dossiers.service'; import { AppConfigService } from 'src/modules/config/config.service'; import { MailService } from 'src/modules/mail/mail.service'; import { NumeroDossierService } from 'src/modules/numero-dossier/numero-dossier.service'; import { Parents } from 'src/entities/parents.entity'; -import { Users } from 'src/entities/users.entity'; +import { Users, RoleType, StatutUtilisateurType } from 'src/entities/users.entity'; import { Children } from 'src/entities/children.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; @@ -19,6 +21,20 @@ describe('AuthService (#118 create-password API)', () => { findOne: jest.fn(), createQueryBuilder: jest.fn(), update: jest.fn(), + manager: { transaction: jest.fn() }, + }; + + const userServiceMock = { + findByTokenReprise: jest.fn(), + }; + + const parentsServiceMock = { + getDossierFamilleByNumero: jest.fn(), + getFamilyUserIds: jest.fn(), + }; + + const dossiersServiceMock = { + getDossierByNumero: jest.fn(), }; const mailServiceMock = { @@ -45,7 +61,9 @@ describe('AuthService (#118 create-password API)', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ AuthService, - { provide: UserService, useValue: {} }, + { provide: UserService, useValue: userServiceMock }, + { provide: ParentsService, useValue: parentsServiceMock }, + { provide: DossiersService, useValue: dossiersServiceMock }, { provide: JwtService, useValue: {} }, { provide: ConfigService, useValue: { get: jest.fn() } }, { provide: AppConfigService, useValue: appConfigMock }, @@ -136,4 +154,69 @@ describe('AuthService (#118 create-password API)', () => { expect(exec).toHaveBeenCalled(); }); }); + + describe('Reprise après refus (#112)', () => { + const token = '11111111-1111-1111-1111-111111111111'; + + it('getRepriseDossier throws when token invalid', async () => { + userServiceMock.findByTokenReprise.mockResolvedValue(null); + await expect(service.getRepriseDossier(token)).rejects.toThrow(NotFoundException); + }); + + it('getRepriseDossier returns famille complète pour parent', async () => { + userServiceMock.findByTokenReprise.mockResolvedValue({ + id: 'p1', + email: 'claire@test.fr', + prenom: 'Claire', + nom: 'MARTIN', + role: RoleType.PARENT, + numero_dossier: '2026-000021', + statut: StatutUtilisateurType.REFUSE, + }); + parentsServiceMock.getDossierFamilleByNumero.mockResolvedValue({ + numero_dossier: '2026-000021', + parents: [{ user_id: 'p1', email: 'claire@test.fr', statut: StatutUtilisateurType.REFUSE }], + enfants: [{ id: 'e1', first_name: 'Emma', status: 'actif' }], + texte_motivation: 'Motivation test', + }); + + const result = await service.getRepriseDossier(token); + expect(result.parents).toHaveLength(1); + expect(result.enfants).toHaveLength(1); + expect(result.texte_motivation).toBe('Motivation test'); + expect(result.numero_dossier).toBe('2026-000021'); + }); + + it('getRepriseDossier returns fiche AM complète', async () => { + userServiceMock.findByTokenReprise.mockResolvedValue({ + id: 'am1', + email: 'am@test.fr', + role: RoleType.ASSISTANTE_MATERNELLE, + numero_dossier: '2026-000003', + }); + dossiersServiceMock.getDossierByNumero.mockResolvedValue({ + type: 'am', + dossier: { + numero_dossier: '2026-000003', + user: { + id: 'am1', + email: 'am@test.fr', + prenom: 'Marie', + nom: 'DUPONT', + statut: StatutUtilisateurType.REFUSE, + }, + numero_agrement: 'AGR-1', + nir: '123456789012345', + biographie: 'Bio', + nb_max_enfants: 4, + place_disponible: 2, + }, + }); + + const result = await service.getRepriseDossier(token); + expect(result.numero_agrement).toBe('AGR-1'); + expect(result.biographie).toBe('Bio'); + expect(result.nb_max_enfants).toBe(4); + }); + }); }); diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index b291009..9a42ae9 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -7,7 +7,7 @@ import { BadRequestException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { QueryFailedError, Repository } from 'typeorm'; +import { EntityManager, In, QueryFailedError, Repository } from 'typeorm'; import { UserService } from '../user/user.service'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; @@ -27,11 +27,15 @@ import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famil import { StatutDossierType } from 'src/entities/dossiers.entity'; import { LoginDto } from './dto/login.dto'; import { RepriseDossierDto } from './dto/reprise-dossier.dto'; +import { ResoumettreRepriseDto } from './dto/resoumettre-reprise.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'; +import { ParentsService } from '../parents/parents.service'; +import { DossiersService } from '../dossiers/dossiers.service'; +import { DossierAmCompletDto } from '../dossiers/dto/dossier-am-complet.dto'; @Injectable() export class AuthService { @@ -44,6 +48,8 @@ export class AuthService { private readonly appConfigService: AppConfigService, private readonly mailService: MailService, private readonly numeroDossierService: NumeroDossierService, + private readonly parentsService: ParentsService, + private readonly dossiersService: DossiersService, @InjectRepository(Parents) private readonly parentsRepo: Repository, @InjectRepository(Users) @@ -873,12 +879,72 @@ export class AuthService { return { success: true, message: 'Deconnexion'} } - /** GET dossier reprise – token seul. Ticket #111 */ + /** GET dossier reprise – token seul. Ticket #111, enrichi #112 */ async getRepriseDossier(token: string): Promise { const user = await this.usersService.findByTokenReprise(token); if (!user) { throw new NotFoundException('Token reprise invalide ou expiré.'); } + + const base = this.userToRepriseBase(user); + + if (user.role === RoleType.PARENT && user.numero_dossier) { + try { + const famille = await this.parentsService.getDossierFamilleByNumero(user.numero_dossier); + return { + ...base, + parents: famille.parents, + enfants: famille.enfants, + texte_motivation: famille.texte_motivation, + }; + } catch (err) { + if (err instanceof NotFoundException) { + return base; + } + throw err; + } + } + + if (user.role === RoleType.ASSISTANTE_MATERNELLE && user.numero_dossier) { + try { + const unifie = await this.dossiersService.getDossierByNumero(user.numero_dossier); + if (unifie.type === 'am') { + return this.mergeAmRepriseDossier(base, unifie.dossier as DossierAmCompletDto); + } + } catch (err) { + if (err instanceof NotFoundException) { + return base; + } + throw err; + } + } + + return base; + } + + /** PATCH resoumission reprise – dossier complet. Ticket #111, enrichi #112 */ + async resoumettreReprise(dto: ResoumettreRepriseDto): Promise<{ + message: string; + statut: StatutUtilisateurType; + user_id: string; + numero_dossier?: string; + }> { + const user = await this.usersService.findByTokenReprise(dto.token); + if (!user) { + throw new NotFoundException('Token reprise invalide ou expiré.'); + } + + if (user.role === RoleType.PARENT) { + return this.resoumettreRepriseParent(user, dto); + } + if (user.role === RoleType.ASSISTANTE_MATERNELLE) { + return this.resoumettreRepriseAM(user, dto); + } + + throw new BadRequestException('Rôle non pris en charge pour la reprise.'); + } + + private userToRepriseBase(user: Users): RepriseDossierDto { return { id: user.id, email: user.email, @@ -896,12 +962,270 @@ export class AuthService { }; } - /** 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); + private mergeAmRepriseDossier(base: RepriseDossierDto, dossier: DossierAmCompletDto): RepriseDossierDto { + const u = dossier.user; + return { + ...base, + email: u.email, + prenom: u.prenom, + nom: u.nom, + telephone: u.telephone, + adresse: u.adresse, + ville: u.ville, + code_postal: u.code_postal, + photo_url: u.photo_url, + consentement_photo: u.consentement_photo, + date_naissance: u.date_naissance, + lieu_naissance_ville: u.lieu_naissance_ville, + lieu_naissance_pays: u.lieu_naissance_pays, + numero_agrement: dossier.numero_agrement, + nir: dossier.nir, + date_agrement: dossier.date_agrement, + nb_max_enfants: dossier.nb_max_enfants, + place_disponible: dossier.place_disponible, + biographie: dossier.biographie, + }; + } + + private applyTitulaireUserFields(user: Users, dto: ResoumettreRepriseDto): void { + 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; + } + + private async resoumettreRepriseParent( + titulaire: Users, + dto: ResoumettreRepriseDto, + ): Promise<{ message: string; statut: StatutUtilisateurType; user_id: string; numero_dossier?: string }> { + const motivation = + dto.texte_motivation?.trim() ?? + dto.presentation_dossier?.trim(); + + return this.usersRepo.manager.transaction(async (manager) => { + const familyUsers = titulaire.numero_dossier + ? await manager.find(Users, { + where: { + role: RoleType.PARENT, + numero_dossier: titulaire.numero_dossier, + }, + }) + : [titulaire]; + + const titulaireLive = familyUsers.find((u) => u.id === titulaire.id) ?? titulaire; + this.applyTitulaireUserFields(titulaireLive, dto); + + const coParentUser = familyUsers.find((u) => u.id !== titulaire.id); + if (coParentUser) { + if (dto.co_parent_prenom !== undefined) coParentUser.prenom = dto.co_parent_prenom; + if (dto.co_parent_nom !== undefined) coParentUser.nom = dto.co_parent_nom; + if (dto.co_parent_telephone !== undefined) coParentUser.telephone = dto.co_parent_telephone; + + if (dto.co_parent_meme_adresse) { + coParentUser.adresse = titulaireLive.adresse; + coParentUser.code_postal = titulaireLive.code_postal; + coParentUser.ville = titulaireLive.ville; + } else { + if (dto.co_parent_adresse !== undefined) coParentUser.adresse = dto.co_parent_adresse; + if (dto.co_parent_code_postal !== undefined) coParentUser.code_postal = dto.co_parent_code_postal; + if (dto.co_parent_ville !== undefined) coParentUser.ville = dto.co_parent_ville; + } + } + + if (titulaire.numero_dossier && motivation !== undefined) { + const dossierFamille = await manager.findOne(DossierFamille, { + where: { numero_dossier: titulaire.numero_dossier }, + }); + if (dossierFamille) { + dossierFamille.presentation = motivation || undefined; + await manager.save(DossierFamille, dossierFamille); + } + } + + if (dto.enfants?.length) { + const familyChildIds = await this.getFamilyChildrenIds(manager, titulaireLive.id); + for (const enfantDto of dto.enfants) { + if (!familyChildIds.has(enfantDto.id)) { + throw new BadRequestException( + `Enfant inconnu pour ce dossier : ${enfantDto.id}`, + ); + } + + const enfant = await manager.findOne(Children, { where: { id: enfantDto.id } }); + if (!enfant) { + throw new BadRequestException(`Enfant introuvable : ${enfantDto.id}`); + } + + if (enfantDto.prenom !== undefined) enfant.first_name = enfantDto.prenom; + if (enfantDto.nom !== undefined) enfant.last_name = enfantDto.nom; + if (enfantDto.genre !== undefined) enfant.gender = enfantDto.genre; + if (enfantDto.date_naissance !== undefined) { + enfant.birth_date = new Date(enfantDto.date_naissance); + enfant.status = StatutEnfantType.ACTIF; + } + if (enfantDto.date_previsionnelle_naissance !== undefined) { + enfant.due_date = new Date(enfantDto.date_previsionnelle_naissance); + if (!enfantDto.date_naissance) { + enfant.status = StatutEnfantType.A_NAITRE; + } + } + if (enfantDto.grossesse_multiple !== undefined) { + enfant.is_multiple = enfantDto.grossesse_multiple; + } + + if (enfantDto.photo_base64 && enfantDto.photo_filename) { + enfant.photo_url = await this.sauvegarderPhotoDepuisBase64( + enfantDto.photo_base64, + enfantDto.photo_filename, + ); + } + + await manager.save(Children, enfant); + } + } + + for (const familyUser of familyUsers) { + familyUser.statut = StatutUtilisateurType.EN_ATTENTE; + familyUser.token_reprise = undefined; + familyUser.token_reprise_expire_le = undefined; + await manager.save(Users, familyUser); + } + + return { + message: 'Dossier resoumis avec succès. Il est de nouveau en attente de validation.', + statut: StatutUtilisateurType.EN_ATTENTE, + user_id: titulaireLive.id, + numero_dossier: titulaireLive.numero_dossier, + }; + }); + } + + private async resoumettreRepriseAM( + user: Users, + dto: ResoumettreRepriseDto, + ): Promise<{ message: string; statut: StatutUtilisateurType; user_id: string; numero_dossier?: string }> { + if ( + dto.capacite_accueil !== undefined && + dto.places_disponibles !== undefined && + dto.places_disponibles > dto.capacite_accueil + ) { + throw new BadRequestException( + 'Le nombre de places disponibles ne peut pas dépasser la capacité d\'accueil.', + ); + } + + let urlPhoto: string | undefined; + const photoB64 = (dto.photo_base64 ?? '').trim(); + if (photoB64) { + const nomFichier = (dto.photo_filename ?? '').trim() || 'photo_am.jpg'; + urlPhoto = await this.sauvegarderPhotoDepuisBase64(photoB64, nomFichier); + } else if (dto.photo_url !== undefined) { + urlPhoto = dto.photo_url; + } + + if (dto.nir !== undefined) { + const nirNormalized = dto.nir.replace(/\s/g, '').toUpperCase(); + const nirValidation = validateNir(nirNormalized, { + dateNaissance: dto.date_naissance ?? user.date_naissance?.toISOString().slice(0, 10), + }); + if (!nirValidation.valid) { + throw new BadRequestException(nirValidation.error || 'NIR invalide'); + } + } + + return this.usersRepo.manager.transaction(async (manager) => { + const userLive = await manager.findOne(Users, { where: { id: user.id } }); + if (!userLive) { + throw new NotFoundException('Utilisateur introuvable.'); + } + + this.applyTitulaireUserFields(userLive, dto); + if (urlPhoto !== undefined) userLive.photo_url = urlPhoto; + if (dto.consentement_photo !== undefined) { + userLive.consentement_photo = dto.consentement_photo; + if (dto.consentement_photo) { + userLive.date_consentement_photo = new Date(); + } + } + if (dto.date_naissance !== undefined) { + userLive.date_naissance = new Date(dto.date_naissance); + } + if (dto.lieu_naissance_ville !== undefined) { + userLive.lieu_naissance_ville = dto.lieu_naissance_ville; + } + if (dto.lieu_naissance_pays !== undefined) { + userLive.lieu_naissance_pays = dto.lieu_naissance_pays; + } + + const am = await manager.findOne(AssistanteMaternelle, { where: { user_id: user.id } }); + if (!am) { + throw new NotFoundException('Fiche assistante maternelle introuvable.'); + } + + if (dto.nir !== undefined) { + const nirNormalized = dto.nir.replace(/\s/g, '').toUpperCase(); + const nirDejaUtilise = await manager.findOne(AssistanteMaternelle, { + where: { nir: nirNormalized }, + }); + if (nirDejaUtilise && nirDejaUtilise.user_id !== user.id) { + throw new ConflictException( + 'Un compte assistante maternelle avec ce numéro NIR existe déjà.', + ); + } + am.nir = nirNormalized; + } + if (dto.numero_agrement !== undefined) { + const agrement = dto.numero_agrement.trim(); + const agrementDejaUtilise = await manager.findOne(AssistanteMaternelle, { + where: { approval_number: agrement }, + }); + if (agrementDejaUtilise && agrementDejaUtilise.user_id !== user.id) { + throw new ConflictException( + 'Un compte assistante maternelle avec ce numéro d\'agrément existe déjà.', + ); + } + am.approval_number = agrement; + } + if (dto.date_agrement !== undefined) { + am.agreement_date = new Date(dto.date_agrement); + } + if (dto.capacite_accueil !== undefined) { + am.max_children = dto.capacite_accueil; + } + if (dto.places_disponibles !== undefined) { + am.places_available = dto.places_disponibles; + } + if (dto.biographie !== undefined) { + am.biography = dto.biographie; + } + + userLive.statut = StatutUtilisateurType.EN_ATTENTE; + userLive.token_reprise = undefined; + userLive.token_reprise_expire_le = undefined; + + await manager.save(AssistanteMaternelle, am); + await manager.save(Users, userLive); + + return { + message: 'Dossier resoumis avec succès. Il est de nouveau en attente de validation.', + statut: StatutUtilisateurType.EN_ATTENTE, + user_id: userLive.id, + numero_dossier: userLive.numero_dossier, + }; + }); + } + + private async getFamilyChildrenIds( + manager: EntityManager, + parentUserId: string, + ): Promise> { + const familyUserIds = await this.parentsService.getFamilyUserIds(parentUserId); + const links = await manager.find(ParentsChildren, { + where: { parentId: In(familyUserIds) }, + }); + return new Set(links.map((l) => l.enfantId)); } /** POST reprise-identify : numero_dossier + email → type + token. Ticket #111 */ diff --git a/backend/src/routes/auth/dto/enfant-reprise.dto.ts b/backend/src/routes/auth/dto/enfant-reprise.dto.ts new file mode 100644 index 0000000..fa94060 --- /dev/null +++ b/backend/src/routes/auth/dto/enfant-reprise.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsUUID } from 'class-validator'; +import { EnfantInscriptionDto } from './enfant-inscription.dto'; + +/** Enfant existant modifiable lors de la resoumission reprise (#112). */ +export class EnfantRepriseDto extends EnfantInscriptionDto { + @ApiProperty({ description: 'UUID enfant existant (obligatoire en reprise)' }) + @IsUUID() + @IsNotEmpty() + id: string; +} diff --git a/backend/src/routes/auth/dto/reprise-dossier.dto.ts b/backend/src/routes/auth/dto/reprise-dossier.dto.ts index e81c6e4..308a1dd 100644 --- a/backend/src/routes/auth/dto/reprise-dossier.dto.ts +++ b/backend/src/routes/auth/dto/reprise-dossier.dto.ts @@ -1,7 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; import { RoleType } from 'src/entities/users.entity'; +import { + DossierFamilleEnfantDto, + DossierFamilleParentDto, +} from '../../parents/dto/dossier-famille-complet.dto'; -/** Réponse GET /auth/reprise-dossier – données dossier pour préremplir le formulaire reprise. Ticket #111 */ +/** Réponse GET /auth/reprise-dossier – dossier complet pour préremplir le wizard reprise. #111 + #112 */ export class RepriseDossierDto { @ApiProperty() id: string; @@ -41,4 +45,47 @@ export class RepriseDossierDto { @ApiProperty({ required: false }) situation_familiale?: string; + + // --- Parent (dossier famille, aligné #119) --- + + @ApiProperty({ type: [DossierFamilleParentDto], required: false }) + parents?: DossierFamilleParentDto[]; + + @ApiProperty({ type: [DossierFamilleEnfantDto], required: false }) + enfants?: DossierFamilleEnfantDto[]; + + @ApiProperty({ required: false, description: 'Motivation / présentation dossier famille' }) + texte_motivation?: string; + + // --- AM (aligné DossierAmCompletDto) --- + + @ApiProperty({ required: false }) + consentement_photo?: boolean; + + @ApiProperty({ required: false }) + date_naissance?: Date; + + @ApiProperty({ required: false }) + lieu_naissance_ville?: string; + + @ApiProperty({ required: false }) + lieu_naissance_pays?: string; + + @ApiProperty({ required: false }) + numero_agrement?: string; + + @ApiProperty({ required: false }) + nir?: string; + + @ApiProperty({ required: false }) + date_agrement?: Date; + + @ApiProperty({ required: false }) + nb_max_enfants?: number; + + @ApiProperty({ required: false }) + place_disponible?: number; + + @ApiProperty({ required: false }) + biographie?: string; } diff --git a/backend/src/routes/auth/dto/resoumettre-reprise.dto.ts b/backend/src/routes/auth/dto/resoumettre-reprise.dto.ts index efec456..046f5f5 100644 --- a/backend/src/routes/auth/dto/resoumettre-reprise.dto.ts +++ b/backend/src/routes/auth/dto/resoumettre-reprise.dto.ts @@ -1,12 +1,29 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsOptional, IsString, MaxLength, IsUUID } from 'class-validator'; +import { + IsOptional, + IsString, + MaxLength, + IsUUID, + IsBoolean, + IsArray, + ValidateNested, + IsInt, + Min, + Max, + IsDateString, + Matches, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { EnfantRepriseDto } from './enfant-reprise.dto'; -/** Body PUT /auth/reprise-resoumettre – token + champs modifiables. Ticket #111 */ +/** Body PATCH /auth/reprise-resoumettre – token + champs modifiables du wizard. #111 + #112 */ export class ResoumettreRepriseDto { @ApiProperty({ description: 'Token reprise (reçu par email)' }) @IsUUID() token: string; + // --- Identité titulaire (parent principal ou AM) --- + @ApiProperty({ required: false }) @IsOptional() @IsString() @@ -42,8 +59,147 @@ export class ResoumettreRepriseDto { @MaxLength(10) code_postal?: string; - @ApiProperty({ required: false, description: 'Pour AM' }) + @ApiProperty({ required: false, description: 'Pour AM (URL photo existante)' }) @IsOptional() @IsString() photo_url?: string; + + @ApiProperty({ required: false, description: 'Pour AM (nouvelle photo base64)' }) + @IsOptional() + @IsString() + photo_base64?: string; + + @ApiProperty({ required: false, description: 'Pour AM (nom fichier photo)' }) + @IsOptional() + @IsString() + photo_filename?: string; + + // --- Co-parent (reprise parent) --- + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + co_parent_email?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + co_parent_prenom?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + co_parent_nom?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @Matches(/^(\+33|0)[1-9](\d{2}){4}$/, { + message: 'Le numéro de téléphone du co-parent doit être valide', + }) + co_parent_telephone?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsBoolean() + co_parent_meme_adresse?: boolean; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + co_parent_adresse?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + co_parent_code_postal?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + co_parent_ville?: string; + + // --- Motivation dossier famille --- + + @ApiProperty({ required: false, description: 'Alias texte_motivation' }) + @IsOptional() + @IsString() + @MaxLength(2000) + texte_motivation?: string; + + @ApiProperty({ required: false, description: 'Alias presentation_dossier (inscription)' }) + @IsOptional() + @IsString() + @MaxLength(2000) + presentation_dossier?: string; + + @ApiProperty({ type: [EnfantRepriseDto], required: false }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => EnfantRepriseDto) + enfants?: EnfantRepriseDto[]; + + // --- AM --- + + @ApiProperty({ required: false }) + @IsOptional() + @IsBoolean() + consentement_photo?: boolean; + + @ApiProperty({ required: false }) + @IsOptional() + @IsDateString() + date_naissance?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(100) + lieu_naissance_ville?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(100) + lieu_naissance_pays?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(50) + numero_agrement?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @Matches(/^[1-3]\d{4}(?:2A|2B|\d{2})\d{6}\d{2}$/, { + message: 'Le NIR doit contenir 15 caractères (chiffres, ou 2A/2B pour la Corse)', + }) + nir?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsDateString() + date_agrement?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsInt() + @Min(1) + @Max(10) + capacite_accueil?: number; + + @ApiProperty({ required: false }) + @IsOptional() + @IsInt() + @Min(0) + @Max(10) + places_disponibles?: number; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(2000) + biographie?: string; } 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 d051b0d..64b565c 100644 --- a/backend/src/routes/parents/dto/dossier-famille-complet.dto.ts +++ b/backend/src/routes/parents/dto/dossier-famille-complet.dto.ts @@ -54,6 +54,9 @@ export class DossierFamilleEnfantDto { description: 'Consentement affichage photo (colonne consentement_photo)', }) consent_photo?: boolean; + + @ApiProperty({ required: false, description: 'Grossesse multiple (est_multiple)' }) + est_multiple?: 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 ae9bedd..2e06914 100644 --- a/backend/src/routes/parents/parents.service.ts +++ b/backend/src/routes/parents/parents.service.ts @@ -221,6 +221,7 @@ export class ParentsService { status: child.status, photo_url: child.photo_url ?? undefined, consent_photo: child.consent_photo, + est_multiple: child.is_multiple, }; } From 25c10c885a785bda2a3ce62b57c8d2bce43ecc85 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 16 Jun 2026 16:42:35 +0200 Subject: [PATCH 03/85] docs(#112): note alignement front reprise dossier complet Co-authored-by: Cursor --- docs/tmp/112-back-reprise-alignement-front.md | 235 ++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 docs/tmp/112-back-reprise-alignement-front.md diff --git a/docs/tmp/112-back-reprise-alignement-front.md b/docs/tmp/112-back-reprise-alignement-front.md new file mode 100644 index 0000000..d15350b --- /dev/null +++ b/docs/tmp/112-back-reprise-alignement-front.md @@ -0,0 +1,235 @@ +# #112 — Alignement front après évolution back (reprise dossier complet) + +**Branche déployée :** `feature/112-reprise-apres-refus-front` +**Commit back :** `d70577b1` — `feat(#112): reprise après refus — dossier complet GET/PATCH` +**Date :** 2026-06-16 + +Ce document décrit le **contrat API réel** après extension du back, et ce que le front doit encore brancher pour exploiter le dossier complet (au-delà de l’identité seule). + +--- + +## 1. Endpoints (inchangés côté URL) + +| Méthode | Route | Auth | +|---------|-------|------| +| `GET` | `/api/v1/auth/reprise-dossier?token={uuid}` | Public | +| `PATCH` | `/api/v1/auth/reprise-resoumettre` | Public | +| `POST` | `/api/v1/auth/reprise-identify` | Public (inchangé) | + +> **Note :** le ticket #111 parlait de `PUT` ; l’implémentation reste en **`PATCH`** (comme avant). + +--- + +## 2. `GET /auth/reprise-dossier` — réponse enrichie + +### Champs communs (toujours présents) + +Identiques à avant : `id`, `email`, `prenom`, `nom`, `telephone`, `adresse`, `ville`, `code_postal`, `numero_dossier`, `role`, `photo_url`, `genre`, `situation_familiale`. + +### Rôle `parent` (+ champs #119) + +Alignés sur `DossierFamilleCompletDto` : + +```json +{ + "parents": [ + { + "user_id": "uuid", + "email": "…", + "prenom": "…", + "nom": "…", + "telephone": "…", + "adresse": "…", + "ville": "…", + "code_postal": "…", + "statut": "refuse", + "co_parent_id": "uuid-parent-entity" + } + ], + "enfants": [ + { + "id": "uuid-enfant", + "first_name": "Emma", + "last_name": "MARTIN", + "genre": "F", + "status": "actif", + "birth_date": "2023-02-15T00:00:00.000Z", + "due_date": null, + "photo_url": "/uploads/photos/…", + "consent_photo": true, + "est_multiple": false + } + ], + "texte_motivation": "Nous recherchons…" +} +``` + +**Mapping front suggéré :** + +| JSON back | Modèle / wizard parent | +|-----------|-------------------------| +| `parents[]` | `UserRegistrationData.parent1` + `parent2` (matcher par `email` ou ordre : titulaire = `id` du GET racine) | +| `enfants[].first_name` / `last_name` | `ChildData.firstName` / `lastName` | +| `enfants[].birth_date` | `ChildData.birthDate` (ISO → `DateTime`) | +| `enfants[].due_date` | `ChildData.dueDate` (enfant `a_naitre`) | +| `enfants[].status` | `actif` = né, `a_naitre` = à naître | +| `enfants[].photo_url` | `ApiConfig.absoluteMediaUrl()` + conserver pour reprise sans re-upload | +| `enfants[].id` | **Obligatoire** pour le PATCH (update par id) | +| `enfants[].est_multiple` | `grossesse_multiple` si utilisé | +| `texte_motivation` | étape présentation / motivation | + +Si `numero_dossier` absent : pas de `parents[]` / `enfants[]` / `texte_motivation` (identité seule). + +### Rôle `assistante_maternelle` + +Champs racine + fiche pro (structure **aplatie**, pas de sous-objet `user`) : + +```json +{ + "consentement_photo": true, + "date_naissance": "1985-03-12T00:00:00.000Z", + "lieu_naissance_ville": "Paris", + "lieu_naissance_pays": "France", + "numero_agrement": "AGR-2024-12345", + "nir": "123456789012345", + "date_agrement": "2024-06-01T00:00:00.000Z", + "nb_max_enfants": 4, + "place_disponible": 2, + "biographie": "…" +} +``` + +**Mapping `AmRegistrationData` :** + +| JSON back | Champ front | +|-----------|-------------| +| `nb_max_enfants` | `capaciteAccueil` | +| `place_disponible` | `placesDisponibles` | +| `numero_agrement` | `numeroAgrement` | +| `biographie` | `biographie` / présentation | +| `photo_url` | déjà géré via `RepriseSession.photoUrl` | + +--- + +## 3. `PATCH /auth/reprise-resoumettre` — body étendu + +### Commun + +```json +{ "token": "uuid-reprise" } +``` + +### Parent — champs à envoyer depuis le wizard + +| Champ PATCH | Source wizard | Notes | +|-------------|---------------|-------| +| `prenom`, `nom`, `telephone`, `adresse`, `ville`, `code_postal` | Parent 1 (titulaire token) | Champs racine | +| `co_parent_prenom`, `co_parent_nom`, `co_parent_telephone` | Parent 2 | | +| `co_parent_meme_adresse`, `co_parent_adresse`, `co_parent_code_postal`, `co_parent_ville` | Parent 2 adresse | | +| `texte_motivation` **ou** `presentation_dossier` | Étape motivation | Les deux alias acceptés | +| `enfants[]` | Liste enfants | Voir ci-dessous | + +**Structure `enfants[]` (miroir inscription + `id` obligatoire) :** + +```json +{ + "id": "uuid-enfant-existant", + "prenom": "Emma", + "nom": "MARTIN", + "date_naissance": "2023-02-15", + "date_previsionnelle_naissance": null, + "genre": "F", + "photo_base64": "data:image/jpeg;base64,…", + "photo_filename": "emma.jpg", + "grossesse_multiple": false +} +``` + +- **v1 back :** update par `id` uniquement — pas de création/suppression d’enfant. +- Si `id` inconnu pour ce dossier → **400** `Enfant inconnu pour ce dossier : {id}`. +- Sans nouvelle photo : ne pas envoyer `photo_base64` (l’existant est conservé). + +### AM — champs à envoyer + +| Champ PATCH | Source | +|-------------|--------| +| Identité + `photo_url` ou `photo_base64` + `photo_filename` | Étapes 1–2 | +| `consentement_photo`, `date_naissance`, `lieu_naissance_ville`, `lieu_naissance_pays` | Identité | +| `numero_agrement`, `nir`, `date_agrement` | Pro | +| `capacite_accueil`, `places_disponibles` | Pro | +| `biographie` | Présentation | + +Validation NIR identique à l’inscription si `nir` fourni. + +### Réponse succès (nouveau format) + +```json +{ + "message": "Dossier resoumis avec succès. Il est de nouveau en attente de validation.", + "statut": "en_attente", + "user_id": "uuid", + "numero_dossier": "2026-000021" +} +``` + +Code HTTP : **200** (pas de corps `Users` brut comme l’ancien back). + +### Effet métier + +- **Parent :** tous les users `role=parent` avec le même `numero_dossier` passent en `en_attente` ; `token_reprise` invalidé sur **tous** (symétrique refus #110). +- **AM :** un seul user. + +--- + +## 4. Fichiers front à modifier (checklist) + +### Modèles + +- [ ] `lib/models/reprise_dossier.dart` — parser `parents[]`, `enfants[]`, `texte_motivation`, champs AM +- [ ] Réutiliser ou mapper vers `DossierFamilleEnfant` / structures existantes (#119 admin) si possible + +### Session / préremplissage + +- [ ] `lib/services/reprise_session.dart` + - `applyToParent` : remplir parent1/parent2 depuis `parents[]`, enfants, motivation + - `applyToAm` : remplir tous les champs AM + +### API + +- [ ] `lib/services/auth_service.dart` — `resoumettreReprise()` : accepter body complet (parent + AM), pas seulement identité +- [ ] Étendre `UserRegistrationData` / `AmRegistrationData` helpers `toReprisePatchBody()` si utile + +### Écrans fin de parcours + +- [ ] `parent_register_step5_screen.dart` — PATCH avec co-parent, enfants, motivation +- [ ] `am_register_step4_screen.dart` — PATCH avec fiche AM complète + +### Hors scope back (inchangé) + +RIB / IBAN / attestation CAF (étape 5 wizard parent) : **non persistés** — rien à envoyer en reprise. + +### Non implémenté front (ticket #112 initial) + +- [ ] Modale login « J’ai un numéro de dossier » → `POST /auth/reprise-identify` (back prêt, front absent) + +--- + +## 5. Tests manuels suggérés + +1. Refuser un dossier parent complet (≥1 enfant + co-parent + motivation). +2. Ouvrir le lien mail `/reprise?token=…`. +3. Vérifier dans DevTools que le GET contient `enfants[]` et `texte_motivation`. +4. Après branchement front : wizard prérempli sur toutes les étapes. +5. Resoumettre → statut `en_attente` pour les deux parents ; dossier visible file validation admin (#119). + +--- + +## 6. Références code back + +``` +backend/src/routes/auth/dto/reprise-dossier.dto.ts +backend/src/routes/auth/dto/resoumettre-reprise.dto.ts +backend/src/routes/auth/dto/enfant-reprise.dto.ts +backend/src/routes/auth/auth.service.ts → getRepriseDossier, resoumettreReprise +backend/src/routes/parents/dto/dossier-famille-complet.dto.ts +``` From f300505225bac6257a8dd68dccea91892a1ad813 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 16 Jun 2026 16:46:51 +0200 Subject: [PATCH 04/85] feat(#112): aligner reprise front sur dossier complet GET/PATCH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Préremplit parents, enfants, motivation et fiche AM depuis reprise-dossier et envoie le body PATCH complet (co-parent, enfants par id, champs pro AM). Co-authored-by: Cursor --- frontend/lib/models/am_registration_data.dart | 34 ++-- frontend/lib/models/dossier_unifie.dart | 4 + frontend/lib/models/reprise_dossier.dart | 66 +++++++- .../lib/models/user_registration_data.dart | 47 +++--- .../auth/am_register_step4_screen.dart | 14 +- .../auth/parent_register_step5_screen.dart | 12 +- frontend/lib/services/auth_service.dart | 33 +--- frontend/lib/services/reprise_session.dart | 37 +++-- .../utils/parent_registration_payload.dart | 14 ++ frontend/lib/utils/reprise_mapper.dart | 120 ++++++++++++++ frontend/lib/utils/reprise_payload.dart | 150 ++++++++++++++++++ 11 files changed, 439 insertions(+), 92 deletions(-) create mode 100644 frontend/lib/utils/reprise_mapper.dart create mode 100644 frontend/lib/utils/reprise_payload.dart diff --git a/frontend/lib/models/am_registration_data.dart b/frontend/lib/models/am_registration_data.dart index d61cbdd..08ecc60 100644 --- a/frontend/lib/models/am_registration_data.dart +++ b/frontend/lib/models/am_registration_data.dart @@ -117,6 +117,16 @@ class AmRegistrationData extends ChangeNotifier { required String postalCode, required String city, String? existingPhotoUrl, + bool consentementPhoto = false, + DateTime? dateOfBirth, + String birthCity = '', + String birthCountry = '', + String nir = '', + String agrementNumber = '', + DateTime? agreementDate, + int? capacity, + int? placesAvailable, + String presentationText = '', }) { this.firstName = firstName; this.lastName = lastName; @@ -130,16 +140,16 @@ class AmRegistrationData extends ChangeNotifier { photoPath = existingPhotoUrl; photoBytes = null; photoFilename = null; - photoConsent = false; - dateOfBirth = null; - birthCity = ''; - birthCountry = ''; - nir = ''; - agrementNumber = ''; - agreementDate = null; - capacity = null; - placesAvailable = null; - presentationText = ''; + photoConsent = consentementPhoto; + dateOfBirth = dateOfBirth; + this.birthCity = birthCity; + this.birthCountry = birthCountry; + this.nir = nir; + this.agrementNumber = agrementNumber; + agreementDate = agreementDate; + this.capacity = capacity; + placesAvailable = placesAvailable; + this.presentationText = presentationText; cguAccepted = false; notifyListeners(); } @@ -187,8 +197,8 @@ class AmRegistrationData extends ChangeNotifier { bool get isRegistrationComplete => isStep1Complete && isStep2Complete && isStep3Complete; - /// Reprise (#112) : champs renvoyés par PATCH reprise-resoumettre. - bool get isRepriseSubmitReady => isStep1Complete && cguAccepted; + /// Reprise (#112) : dossier AM complet renvoyé par PATCH reprise-resoumettre. + bool get isRepriseSubmitReady => isRegistrationComplete; @override String toString() { diff --git a/frontend/lib/models/dossier_unifie.dart b/frontend/lib/models/dossier_unifie.dart index 98cde26..07b2d56 100644 --- a/frontend/lib/models/dossier_unifie.dart +++ b/frontend/lib/models/dossier_unifie.dart @@ -184,6 +184,7 @@ class EnfantDossier { final String? dueDate; final String? photoUrl; final bool consentPhoto; + final bool estMultiple; EnfantDossier({ required this.id, @@ -195,6 +196,7 @@ class EnfantDossier { this.dueDate, this.photoUrl, this.consentPhoto = false, + this.estMultiple = false, }); String get fullName => '${firstName ?? ''} ${lastName ?? ''}'.trim(); @@ -223,6 +225,8 @@ class EnfantDossier { photoUrl: resolvedPhoto, consentPhoto: json['consent_photo'] == true || json['consentPhoto'] == true, + estMultiple: + json['est_multiple'] == true || json['estMultiple'] == true, ); } } diff --git a/frontend/lib/models/reprise_dossier.dart b/frontend/lib/models/reprise_dossier.dart index e0cc685..0275354 100644 --- a/frontend/lib/models/reprise_dossier.dart +++ b/frontend/lib/models/reprise_dossier.dart @@ -1,4 +1,6 @@ -/// Réponse GET /auth/reprise-dossier. Ticket #111, #112. +import 'package:p_tits_pas/models/dossier_unifie.dart'; + +/// Réponse GET /auth/reprise-dossier. Tickets #111, #112. class RepriseDossier { final String id; final String email; @@ -14,6 +16,21 @@ class RepriseDossier { final String? genre; final String? situationFamiliale; + final List parents; + final List enfants; + final String? texteMotivation; + + final bool? consentementPhoto; + final String? dateNaissance; + final String? lieuNaissanceVille; + final String? lieuNaissancePays; + final String? numeroAgrement; + final String? nir; + final String? dateAgrement; + final int? nbMaxEnfants; + final int? placeDisponible; + final String? biographie; + const RepriseDossier({ required this.id, required this.email, @@ -28,12 +45,44 @@ class RepriseDossier { this.photoUrl, this.genre, this.situationFamiliale, + this.parents = const [], + this.enfants = const [], + this.texteMotivation, + this.consentementPhoto, + this.dateNaissance, + this.lieuNaissanceVille, + this.lieuNaissancePays, + this.numeroAgrement, + this.nir, + this.dateAgrement, + this.nbMaxEnfants, + this.placeDisponible, + this.biographie, }); bool get isParent => role == 'parent'; bool get isAm => role == 'assistante_maternelle'; factory RepriseDossier.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() + : []; + + final nbMax = json['nb_max_enfants']; + final places = json['place_disponible']; + return RepriseDossier( id: json['id']?.toString() ?? '', email: json['email']?.toString() ?? '', @@ -48,6 +97,21 @@ class RepriseDossier { photoUrl: json['photo_url']?.toString(), genre: json['genre']?.toString(), situationFamiliale: json['situation_familiale']?.toString(), + parents: parentsList, + enfants: enfantsList, + texteMotivation: (json['texte_motivation'] ?? json['presentation_dossier']) + ?.toString(), + consentementPhoto: json['consentement_photo'] == true, + dateNaissance: json['date_naissance']?.toString(), + lieuNaissanceVille: json['lieu_naissance_ville']?.toString(), + lieuNaissancePays: json['lieu_naissance_pays']?.toString(), + numeroAgrement: json['numero_agrement']?.toString(), + nir: json['nir']?.toString(), + dateAgrement: json['date_agrement']?.toString(), + nbMaxEnfants: nbMax is int ? nbMax : (nbMax is num ? nbMax.toInt() : null), + placeDisponible: + places is int ? places : (places is num ? places.toInt() : null), + biographie: json['biographie']?.toString(), ); } } diff --git a/frontend/lib/models/user_registration_data.dart b/frontend/lib/models/user_registration_data.dart index 5f29ce5..e418e7d 100644 --- a/frontend/lib/models/user_registration_data.dart +++ b/frontend/lib/models/user_registration_data.dart @@ -43,6 +43,10 @@ class ChildData { /// 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 + /// UUID enfant en base (reprise #112) — requis pour PATCH reprise-resoumettre. + String? repriseChildId; + /// Photo déjà stockée (affichage reprise sans re-upload). + String? existingPhotoUrl; ChildData({ this.firstName = '', @@ -55,6 +59,8 @@ class ChildData { this.imageFile, this.imageBytes, required this.cardColor, // Rendre requis dans le constructeur + this.repriseChildId, + this.existingPhotoUrl, }); ChildData copyWith({ @@ -68,6 +74,8 @@ class ChildData { Object? imageFile = _unsetImage, Object? imageBytes = _unsetImageBytes, CardColorVertical? cardColor, + String? repriseChildId, + String? existingPhotoUrl, }) { return ChildData( firstName: firstName ?? this.firstName, @@ -81,6 +89,8 @@ class ChildData { imageBytes: identical(imageBytes, _unsetImageBytes) ? this.imageBytes : imageBytes as Uint8List?, cardColor: cardColor ?? this.cardColor, + repriseChildId: repriseChildId ?? this.repriseChildId, + existingPhotoUrl: existingPhotoUrl ?? this.existingPhotoUrl, ); } } @@ -180,27 +190,17 @@ class UserRegistrationData extends ChangeNotifier { /// Reprise après refus (#112) : réinitialise le flux parent avec les données dossier. void resetForReprise({ - required String firstName, - required String lastName, - required String phone, - required String email, - required String address, - required String postalCode, - required String city, + required ParentData parent1Data, + ParentData? parent2Data, + List? childrenData, + String motivation = '', }) { - parent1 = ParentData( - firstName: firstName, - lastName: lastName, - phone: phone, - email: email, - address: address, - postalCode: postalCode, - city: city, - password: '', - ); - parent2 = null; - children.clear(); - motivationText = ''; + parent1 = parent1Data; + parent2 = parent2Data; + children + ..clear() + ..addAll(childrenData ?? const []); + motivationText = motivation; cguAccepted = false; bankDetails = null; attestationCafNumber = ''; @@ -208,11 +208,16 @@ class UserRegistrationData extends ChangeNotifier { notifyListeners(); } - /// Reprise (#112) : coordonnées parent principal + CGU. + /// Reprise (#112) : coordonnées + enfants connus + CGU. bool get isRepriseSubmitReady => parent1.firstName.isNotEmpty && parent1.lastName.isNotEmpty && parent1.email.isNotEmpty && + children.isNotEmpty && + children.every( + (c) => + c.repriseChildId != null && c.repriseChildId!.trim().isNotEmpty, + ) && cguAccepted; // Méthode pour vérifier si toutes les données requises sont là (simplifié) diff --git a/frontend/lib/screens/auth/am_register_step4_screen.dart b/frontend/lib/screens/auth/am_register_step4_screen.dart index 3a93079..83520b9 100644 --- a/frontend/lib/screens/auth/am_register_step4_screen.dart +++ b/frontend/lib/screens/auth/am_register_step4_screen.dart @@ -9,6 +9,7 @@ import '../../models/card_assets.dart'; import '../../config/display_config.dart'; import '../../services/auth_service.dart'; import '../../services/reprise_session.dart'; +import '../../utils/reprise_payload.dart'; import '../../widgets/hover_relief_widget.dart'; import '../../widgets/image_button.dart'; import '../../widgets/custom_navigation_button.dart'; @@ -59,14 +60,11 @@ class _AmRegisterStep4ScreenState extends State { try { if (RepriseSession.isActive) { await AuthService.resoumettreReprise( - token: RepriseSession.token!, - prenom: registrationData.firstName, - nom: registrationData.lastName, - telephone: registrationData.phone, - adresse: registrationData.streetAddress, - ville: registrationData.city, - codePostal: registrationData.postalCode, - photoUrl: RepriseSession.photoUrl, + await ReprisePayload.amPatch( + registrationData, + RepriseSession.token!, + existingPhotoUrl: RepriseSession.photoUrlForApi, + ), ); RepriseSession.clear(); if (!mounted) return; diff --git a/frontend/lib/screens/auth/parent_register_step5_screen.dart b/frontend/lib/screens/auth/parent_register_step5_screen.dart index ac0512f..87ef640 100644 --- a/frontend/lib/screens/auth/parent_register_step5_screen.dart +++ b/frontend/lib/screens/auth/parent_register_step5_screen.dart @@ -15,6 +15,7 @@ import '../../widgets/child_card_widget.dart'; import '../../widgets/presentation_form_screen.dart'; import '../../services/auth_service.dart'; import '../../services/reprise_session.dart'; +import '../../utils/reprise_payload.dart'; class ParentRegisterStep5Screen extends StatefulWidget { const ParentRegisterStep5Screen({super.key}); @@ -33,7 +34,7 @@ class _ParentRegisterStep5ScreenState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - 'Vérifiez vos coordonnées et acceptez les conditions.', + 'Vérifiez vos coordonnées, les enfants et acceptez les conditions.', style: GoogleFonts.merienda(fontSize: 14), ), backgroundColor: Colors.red.shade700, @@ -46,15 +47,8 @@ class _ParentRegisterStep5ScreenState extends State { try { if (RepriseSession.isActive) { final token = RepriseSession.token!; - final p = data.parent1; await AuthService.resoumettreReprise( - token: token, - prenom: p.firstName, - nom: p.lastName, - telephone: p.phone, - adresse: p.address, - ville: p.city, - codePostal: p.postalCode, + ReprisePayload.parentPatch(data, token), ); RepriseSession.clear(); if (!context.mounted) return; diff --git a/frontend/lib/services/auth_service.dart b/frontend/lib/services/auth_service.dart index e89ca5e..956339a 100644 --- a/frontend/lib/services/auth_service.dart +++ b/frontend/lib/services/auth_service.dart @@ -291,43 +291,20 @@ class AuthService { } /// Resoumission après refus. PATCH /auth/reprise-resoumettre. Ticket #112. - static Future resoumettreReprise({ - required String token, - String? prenom, - String? nom, - String? telephone, - String? adresse, - String? ville, - String? codePostal, - String? photoUrl, - }) async { - final cleaned = token.trim(); + static Future resoumettreReprise(Map body) async { + final cleaned = body['token']?.toString().trim() ?? ''; if (cleaned.isEmpty) { throw Exception('Lien invalide ou expiré.'); } - final body = {'token': cleaned}; - if (prenom != null && prenom.trim().isNotEmpty) body['prenom'] = prenom.trim(); - if (nom != null && nom.trim().isNotEmpty) body['nom'] = nom.trim(); - if (telephone != null && telephone.trim().isNotEmpty) { - body['telephone'] = telephone.trim(); - } - if (adresse != null && adresse.trim().isNotEmpty) { - body['adresse'] = adresse.trim(); - } - if (ville != null && ville.trim().isNotEmpty) body['ville'] = ville.trim(); - if (codePostal != null && codePostal.trim().isNotEmpty) { - body['code_postal'] = codePostal.trim(); - } - if (photoUrl != null && photoUrl.trim().isNotEmpty) { - body['photo_url'] = photoUrl.trim(); - } + final payload = Map.from(body); + payload['token'] = cleaned; late final http.Response response; try { response = await http.patch( Uri.parse('${ApiConfig.baseUrl}${ApiConfig.repriseResoumettre}'), headers: ApiConfig.headers, - body: jsonEncode(body), + body: jsonEncode(payload), ); } on http.ClientException { throw Exception( diff --git a/frontend/lib/services/reprise_session.dart b/frontend/lib/services/reprise_session.dart index 637d158..8309371 100644 --- a/frontend/lib/services/reprise_session.dart +++ b/frontend/lib/services/reprise_session.dart @@ -2,6 +2,7 @@ import 'package:p_tits_pas/models/am_registration_data.dart'; import 'package:p_tits_pas/models/reprise_dossier.dart'; import 'package:p_tits_pas/models/user_registration_data.dart'; import 'package:p_tits_pas/services/api/api_config.dart'; +import 'package:p_tits_pas/utils/reprise_mapper.dart'; /// Contexte reprise après refus (token e-mail ou identify). Ticket #112. class RepriseSession { @@ -10,6 +11,7 @@ class RepriseSession { static String? _token; static String? _role; static String? _photoUrl; + static String? _photoUrlForApi; static bool get isActive => _token != null && _token!.trim().isNotEmpty; @@ -20,16 +22,22 @@ class RepriseSession { static bool get isAm => _role == 'assistante_maternelle'; + /// URL absolue pour l'affichage. static String? get photoUrl => _photoUrl; + /// Chemin relatif API (`/uploads/…`) pour PATCH sans re-upload. + static String? get photoUrlForApi => _photoUrlForApi; + static void start({ required String token, required RepriseDossier dossier, }) { _token = token.trim(); _role = dossier.role; - _photoUrl = dossier.photoUrl?.trim().isNotEmpty == true - ? ApiConfig.absoluteMediaUrl(dossier.photoUrl) + final raw = dossier.photoUrl?.trim(); + _photoUrlForApi = raw != null && raw.isNotEmpty ? raw : null; + _photoUrl = _photoUrlForApi != null + ? ApiConfig.absoluteMediaUrl(_photoUrlForApi) : null; } @@ -37,22 +45,15 @@ class RepriseSession { _token = null; _role = null; _photoUrl = null; + _photoUrlForApi = null; } static void applyToParent(UserRegistrationData data, RepriseDossier dossier) { - data.resetForReprise( - firstName: dossier.prenom ?? '', - lastName: dossier.nom ?? '', - phone: dossier.telephone ?? '', - email: dossier.email, - address: dossier.adresse ?? '', - postalCode: dossier.codePostal ?? '', - city: dossier.ville ?? '', - ); + RepriseMapper.applyParentDossier(data, dossier); } static void applyToAm(AmRegistrationData data, RepriseDossier dossier) { - final photo = _photoUrl; + final displayPhoto = _photoUrl; data.resetForReprise( firstName: dossier.prenom ?? '', lastName: dossier.nom ?? '', @@ -61,7 +62,17 @@ class RepriseSession { streetAddress: dossier.adresse ?? '', postalCode: dossier.codePostal ?? '', city: dossier.ville ?? '', - existingPhotoUrl: photo, + existingPhotoUrl: displayPhoto, + consentementPhoto: dossier.consentementPhoto ?? false, + dateOfBirth: RepriseMapper.parseIsoDate(dossier.dateNaissance), + birthCity: dossier.lieuNaissanceVille ?? '', + birthCountry: dossier.lieuNaissancePays ?? '', + nir: dossier.nir ?? '', + agrementNumber: dossier.numeroAgrement ?? '', + agreementDate: RepriseMapper.parseIsoDate(dossier.dateAgrement), + capacity: dossier.nbMaxEnfants, + placesAvailable: dossier.placeDisponible, + presentationText: dossier.biographie ?? '', ); } } diff --git a/frontend/lib/utils/parent_registration_payload.dart b/frontend/lib/utils/parent_registration_payload.dart index 05f8d79..70a9459 100644 --- a/frontend/lib/utils/parent_registration_payload.dart +++ b/frontend/lib/utils/parent_registration_payload.dart @@ -149,6 +149,20 @@ class ParentRegistrationPayload { return body; } + /// Enfant pour PATCH reprise (inclut `id` si connu). + static Map childToRepriseJson( + ChildData c, + int index, + String parentNom, + ) { + final map = _childToJson(c, index, parentNom); + final id = c.repriseChildId?.trim(); + if (id != null && id.isNotEmpty) { + map['id'] = id; + } + return map; + } + static Map _childToJson(ChildData c, int index, String parentNom) { final map = { 'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre', diff --git a/frontend/lib/utils/reprise_mapper.dart b/frontend/lib/utils/reprise_mapper.dart new file mode 100644 index 0000000..a44947a --- /dev/null +++ b/frontend/lib/utils/reprise_mapper.dart @@ -0,0 +1,120 @@ +import 'package:p_tits_pas/models/card_assets.dart'; +import 'package:p_tits_pas/models/dossier_unifie.dart'; +import 'package:p_tits_pas/models/reprise_dossier.dart'; +import 'package:p_tits_pas/models/user_registration_data.dart'; +import 'package:p_tits_pas/services/api/api_config.dart'; + +/// Mapping GET reprise-dossier → modèles wizard. Ticket #112. +class RepriseMapper { + RepriseMapper._(); + + static const List _childCardColors = [ + CardColorVertical.lavender, + CardColorVertical.pink, + CardColorVertical.peach, + CardColorVertical.lime, + CardColorVertical.red, + CardColorVertical.green, + CardColorVertical.blue, + ]; + + static ParentData parentFromDossier(ParentDossier p) { + return ParentData( + firstName: p.prenom ?? '', + lastName: p.nom ?? '', + phone: p.telephone ?? '', + email: p.email, + address: p.adresse ?? '', + postalCode: p.codePostal ?? '', + city: p.ville ?? '', + password: '', + ); + } + + static ChildData childFromEnfant(EnfantDossier e, int index) { + final isUnborn = e.status == 'a_naitre'; + final dob = isUnborn + ? isoToDdMmYyyy(e.dueDate) + : isoToDdMmYyyy(e.birthDate); + final photo = e.photoUrl?.trim(); + return ChildData( + firstName: e.firstName ?? '', + lastName: e.lastName ?? '', + dob: dob, + genre: e.gender ?? '', + photoConsent: e.consentPhoto, + multipleBirth: e.estMultiple, + isUnbornChild: isUnborn, + cardColor: _childCardColors[index % _childCardColors.length], + repriseChildId: e.id, + existingPhotoUrl: photo != null && photo.isNotEmpty + ? ApiConfig.absoluteMediaUrl(photo) + : null, + ); + } + + static void applyParentDossier(UserRegistrationData data, RepriseDossier dossier) { + ParentDossier? titulaire; + ParentDossier? coParent; + + if (dossier.parents.isNotEmpty) { + for (final p in dossier.parents) { + if (p.id == dossier.id) { + titulaire = p; + } else { + coParent = p; + } + } + titulaire ??= dossier.parents.first; + if (coParent == null && dossier.parents.length > 1) { + coParent = dossier.parents.firstWhere( + (p) => p.id != titulaire!.id, + orElse: () => dossier.parents.last, + ); + } + } + + final p1 = titulaire != null + ? parentFromDossier(titulaire) + : ParentData( + firstName: dossier.prenom ?? '', + lastName: dossier.nom ?? '', + phone: dossier.telephone ?? '', + email: dossier.email, + address: dossier.adresse ?? '', + postalCode: dossier.codePostal ?? '', + city: dossier.ville ?? '', + password: '', + ); + + final children = dossier.enfants + .asMap() + .entries + .map((e) => childFromEnfant(e.value, e.key)) + .toList(); + + data.resetForReprise( + parent1Data: p1, + parent2Data: coParent != null ? parentFromDossier(coParent) : null, + childrenData: children, + motivation: dossier.texteMotivation ?? '', + ); + } + + static DateTime? parseIsoDate(String? raw) { + if (raw == null || raw.trim().isEmpty) return null; + try { + return DateTime.parse(raw.trim()); + } catch (_) { + return null; + } + } + + static String isoToDdMmYyyy(String? iso) { + final dt = parseIsoDate(iso); + if (dt == null) return ''; + return '${dt.day.toString().padLeft(2, '0')}/' + '${dt.month.toString().padLeft(2, '0')}/' + '${dt.year}'; + } +} diff --git a/frontend/lib/utils/reprise_payload.dart b/frontend/lib/utils/reprise_payload.dart new file mode 100644 index 0000000..6ce887a --- /dev/null +++ b/frontend/lib/utils/reprise_payload.dart @@ -0,0 +1,150 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +import '../models/am_registration_data.dart'; +import '../models/user_registration_data.dart'; +import 'nir_utils.dart'; +import 'parent_registration_payload.dart'; + +/// Body PATCH /auth/reprise-resoumettre. Ticket #112. +class ReprisePayload { + ReprisePayload._(); + + static Map parentPatch( + UserRegistrationData data, + String token, + ) { + final base = ParentRegistrationPayload.toJson(data); + base.remove('email'); + base.remove('acceptation_cgu'); + base.remove('acceptation_privacy'); + base['token'] = token.trim(); + + final enfants = data.children.asMap().entries.map((e) { + final childMap = ParentRegistrationPayload.childToRepriseJson( + e.value, + e.key, + data.parent1.lastName.trim(), + ); + return childMap; + }).toList(); + base['enfants'] = enfants; + + return base; + } + + static Future> amPatch( + AmRegistrationData data, + String token, { + String? existingPhotoUrl, + }) async { + final body = {'token': token.trim()}; + + _put(body, 'prenom', data.firstName.trim()); + _put(body, 'nom', data.lastName.trim()); + _put(body, 'telephone', data.phone.trim()); + _put(body, 'adresse', data.streetAddress.trim()); + _put(body, 'code_postal', data.postalCode.trim()); + _put(body, 'ville', data.city.trim()); + + body['consentement_photo'] = data.photoConsent; + + if (data.dateOfBirth != null) { + final d = data.dateOfBirth!; + body['date_naissance'] = + '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; + } + _put(body, 'lieu_naissance_ville', data.birthCity.trim()); + _put(body, 'lieu_naissance_pays', data.birthCountry.trim()); + _put(body, 'numero_agrement', data.agrementNumber.trim()); + if (data.nir.trim().isNotEmpty) { + body['nir'] = normalizeNir(data.nir); + } + if (data.agreementDate != null) { + final d = data.agreementDate!; + body['date_agrement'] = + '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; + } + if (data.capacity != null) { + body['capacite_accueil'] = data.capacity; + } + if (data.placesAvailable != null) { + body['places_disponibles'] = data.placesAvailable; + } + if (data.presentationText.trim().isNotEmpty) { + body['biographie'] = data.presentationText.trim(); + } + + final photo = await _amPhotoPayload(data, existingPhotoUrl); + if (photo != null) { + body.addAll(photo); + } + + return body; + } + + static Future?> _amPhotoPayload( + AmRegistrationData data, + String? existingPhotoUrl, + ) async { + if (data.photoBytes != null && data.photoBytes!.isNotEmpty) { + final mime = _imageMimeForBytes(data.photoBytes!); + final fn = (data.photoFilename ?? '').trim(); + return { + 'photo_base64': + 'data:$mime;base64,${base64Encode(data.photoBytes!)}', + 'photo_filename': fn.isNotEmpty ? fn : 'photo_am.jpg', + }; + } + + if (!kIsWeb && + data.photoPath != null && + data.photoPath!.isNotEmpty && + !data.photoPath!.startsWith('assets/') && + !data.photoPath!.startsWith('http')) { + try { + final file = File(data.photoPath!); + if (await file.exists()) { + final bytes = await file.readAsBytes(); + final mime = _imageMimeForBytes(bytes); + return { + 'photo_base64': 'data:$mime;base64,${base64Encode(bytes)}', + 'photo_filename': + _basenameFromPath(data.photoPath!) ?? 'photo_am.jpg', + }; + } + } catch (_) {} + } + + final url = (existingPhotoUrl ?? data.repriseExistingPhotoUrl ?? '').trim(); + if (url.isNotEmpty) { + return {'photo_url': url}; + } + return null; + } + + static String _imageMimeForBytes(Uint8List bytes) { + if (bytes.length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xD8) { + return 'image/jpeg'; + } + if (bytes.length >= 8 && + bytes[0] == 0x89 && + bytes[1] == 0x50 && + bytes[2] == 0x4E && + bytes[3] == 0x47) { + return 'image/png'; + } + return 'image/jpeg'; + } + + static String? _basenameFromPath(String path) { + final parts = path.replaceAll('\\', '/').split('/'); + return parts.isEmpty ? null : parts.last; + } + + static void _put(Map m, String key, String value) { + if (value.isNotEmpty) m[key] = value; + } +} From 9b7231f1dafb43cef4978f03ff0db06eaa6265fa Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 16 Jun 2026 17:00:15 +0200 Subject: [PATCH 05/85] =?UTF-8?q?fix(#112):=20afficher=20photos=20enfants?= =?UTF-8?q?=20en=20reprise=20+=20consentement=20coh=C3=A9rent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Charge existingPhotoUrl dans les cartes enfant (étapes 3 et 5) et pré-coche le consentement photo lorsqu'une photo est déjà en base. Co-authored-by: Cursor --- .../lib/models/user_registration_data.dart | 7 +++++-- .../auth/parent_register_step3_screen.dart | 10 +++++++--- frontend/lib/utils/reprise_mapper.dart | 5 ++++- frontend/lib/widgets/child_card_widget.dart | 18 +++++++++++++++++- .../lib/widgets/registration_photo_slot.dart | 3 +++ 5 files changed, 36 insertions(+), 7 deletions(-) diff --git a/frontend/lib/models/user_registration_data.dart b/frontend/lib/models/user_registration_data.dart index e418e7d..bedd247 100644 --- a/frontend/lib/models/user_registration_data.dart +++ b/frontend/lib/models/user_registration_data.dart @@ -30,6 +30,7 @@ class ParentData { class ChildData { static const Object _unsetImage = Object(); static const Object _unsetImageBytes = Object(); + static const Object _unsetExistingPhotoUrl = Object(); String firstName; String lastName; @@ -75,7 +76,7 @@ class ChildData { Object? imageBytes = _unsetImageBytes, CardColorVertical? cardColor, String? repriseChildId, - String? existingPhotoUrl, + Object? existingPhotoUrl = _unsetExistingPhotoUrl, }) { return ChildData( firstName: firstName ?? this.firstName, @@ -90,7 +91,9 @@ class ChildData { identical(imageBytes, _unsetImageBytes) ? this.imageBytes : imageBytes as Uint8List?, cardColor: cardColor ?? this.cardColor, repriseChildId: repriseChildId ?? this.repriseChildId, - existingPhotoUrl: existingPhotoUrl ?? this.existingPhotoUrl, + existingPhotoUrl: identical(existingPhotoUrl, _unsetExistingPhotoUrl) + ? this.existingPhotoUrl + : existingPhotoUrl as String?, ); } } diff --git a/frontend/lib/screens/auth/parent_register_step3_screen.dart b/frontend/lib/screens/auth/parent_register_step3_screen.dart index e13b3a9..ec5ed5d 100644 --- a/frontend/lib/screens/auth/parent_register_step3_screen.dart +++ b/frontend/lib/screens/auth/parent_register_step3_screen.dart @@ -166,7 +166,11 @@ class _ParentRegisterStep3ScreenState extends State { if (await f.exists()) file = f; } catch (_) {} } - final updatedChild = oldChild.copyWith(imageBytes: bytes, imageFile: file); + final updatedChild = oldChild.copyWith( + imageBytes: bytes, + imageFile: file, + existingPhotoUrl: null, + ); registrationData.updateChild(childIndex, updatedChild); } } @@ -306,7 +310,7 @@ class _ParentRegisterStep3ScreenState extends State { onClearImage: () => setState(() { final c = registrationData.children[index]; registrationData.updateChild( - index, c.copyWith(imageFile: null, imageBytes: null)); + index, c.copyWith(imageFile: null, imageBytes: null, existingPhotoUrl: null)); }), onDateSelect: () => _selectDate(context, index, registrationData), onFirstNameChanged: (value) => setState(() { @@ -414,7 +418,7 @@ class _ParentRegisterStep3ScreenState extends State { onClearImage: () => setState(() { final c = registrationData.children[index]; registrationData.updateChild( - index, c.copyWith(imageFile: null, imageBytes: null)); + index, c.copyWith(imageFile: null, imageBytes: null, existingPhotoUrl: null)); }), onDateSelect: () => _selectDate(context, index, registrationData), onFirstNameChanged: (value) => setState(() { diff --git a/frontend/lib/utils/reprise_mapper.dart b/frontend/lib/utils/reprise_mapper.dart index a44947a..6720f9f 100644 --- a/frontend/lib/utils/reprise_mapper.dart +++ b/frontend/lib/utils/reprise_mapper.dart @@ -37,12 +37,15 @@ class RepriseMapper { ? isoToDdMmYyyy(e.dueDate) : isoToDdMmYyyy(e.birthDate); final photo = e.photoUrl?.trim(); + final hasPhoto = photo != null && photo.isNotEmpty; return ChildData( firstName: e.firstName ?? '', lastName: e.lastName ?? '', dob: dob, genre: e.gender ?? '', - photoConsent: e.consentPhoto, + // Inscription initiale exigeait la coche pour envoyer la photo ; le back + // ne persistait pas toujours consent_photo — on pré-coche si photo en base. + photoConsent: e.consentPhoto || hasPhoto, multipleBirth: e.estMultiple, isUnbornChild: isUnborn, cardColor: _childCardColors[index % _childCardColors.length], diff --git a/frontend/lib/widgets/child_card_widget.dart b/frontend/lib/widgets/child_card_widget.dart index 1afbf6e..4ff744f 100644 --- a/frontend/lib/widgets/child_card_widget.dart +++ b/frontend/lib/widgets/child_card_widget.dart @@ -1,4 +1,5 @@ import 'dart:math' as math; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -20,7 +21,7 @@ bool _hasChildPhoto(ChildData c) { return registrationPhotoSlotHasImage( imageBytes: c.imageBytes, imageFile: c.imageFile, - imagePathOrAsset: null, + imagePathOrAsset: c.existingPhotoUrl, ); } @@ -33,6 +34,20 @@ Widget _buildChildPhotoImage(ChildData c, {required BoxFit fit}) { if (f != null) { return kIsWeb ? Image.network(f.path, fit: fit) : Image.file(f, fit: fit); } + final url = c.existingPhotoUrl?.trim(); + if (url != null && url.isNotEmpty) { + if (url.startsWith('http://') || url.startsWith('https://')) { + return Image.network(url, fit: fit); + } + if (!kIsWeb) { + try { + final file = File(url); + if (file.existsSync()) { + return Image.file(file, fit: fit); + } + } catch (_) {} + } + } return Image.asset('assets/images/photo.png', fit: BoxFit.contain); } @@ -221,6 +236,7 @@ class _ChildCardWidgetState extends State { scaleFactor: scaleFactor, imageBytes: widget.childData.imageBytes, imageFile: widget.childData.imageFile, + imagePathOrAsset: widget.childData.existingPhotoUrl, onTapPick: !config.isReadonly ? widget.onPickImage : null, onClear: !config.isReadonly ? widget.onClearImage : null, baseShadowColor: baseCardColorForShadow, diff --git a/frontend/lib/widgets/registration_photo_slot.dart b/frontend/lib/widgets/registration_photo_slot.dart index 7dde753..2338ef9 100644 --- a/frontend/lib/widgets/registration_photo_slot.dart +++ b/frontend/lib/widgets/registration_photo_slot.dart @@ -71,6 +71,9 @@ class RegistrationPhotoSlot extends StatelessWidget { if (p.startsWith('assets/')) { return Image.asset(p, fit: fit); } + if (p.startsWith('http://') || p.startsWith('https://')) { + return Image.network(p, fit: fit); + } if (!kIsWeb) { try { final file = File(p); From c43800928646c982de61e7f7c4d5ecf9cbe53e7d Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 16 Jun 2026 17:01:18 +0200 Subject: [PATCH 06/85] =?UTF-8?q?feat(#112):=20lien=20login=20=C2=AB=20J'a?= =?UTF-8?q?i=20un=20num=C3=A9ro=20de=20dossier=20=C2=BB=20+=20reprise-iden?= =?UTF-8?q?tify?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modale numéro + e-mail, obtention du token puis redirection vers /reprise. Co-authored-by: Cursor --- frontend/lib/screens/auth/login_screen.dart | 29 +++ frontend/lib/services/api/api_config.dart | 1 + frontend/lib/services/auth_service.dart | 45 +++++ .../widgets/auth/reprise_identify_dialog.dart | 168 ++++++++++++++++++ 4 files changed, 243 insertions(+) create mode 100644 frontend/lib/widgets/auth/reprise_identify_dialog.dart diff --git a/frontend/lib/screens/auth/login_screen.dart b/frontend/lib/screens/auth/login_screen.dart index feb5476..ab1c2e0 100644 --- a/frontend/lib/screens/auth/login_screen.dart +++ b/frontend/lib/screens/auth/login_screen.dart @@ -10,6 +10,7 @@ import '../../widgets/image_button.dart'; import '../../widgets/custom_app_text_field.dart'; import '../../services/auth_service.dart'; import '../../widgets/auth/change_password_dialog.dart'; +import '../../widgets/auth/reprise_identify_dialog.dart'; class LoginScreen extends StatefulWidget { const LoginScreen({super.key}); @@ -100,6 +101,31 @@ class _LoginPageState extends State { _handleLogin(); } + Future _openRepriseIdentify() async { + final token = await showDialog( + context: context, + builder: (ctx) => RepriseIdentifyDialog( + initialEmail: _emailController.text.trim(), + ), + ); + if (!mounted || token == null || token.isEmpty) return; + context.go('/reprise?token=${Uri.encodeComponent(token)}'); + } + + Widget _buildRepriseDossierLink({double fontSize = 14}) { + return TextButton( + onPressed: _isLoading ? null : _openRepriseIdentify, + child: Text( + 'J’ai un numéro de dossier', + style: GoogleFonts.merienda( + fontSize: fontSize, + color: const Color(0xFF2D6A4F), + decoration: TextDecoration.underline, + ), + ), + ); + } + /// Gère la connexion de l'utilisateur Future _handleLogin() async { // Réinitialiser le message d'erreur @@ -351,6 +377,8 @@ class _LoginPageState extends State { ), ), const SizedBox(height: 10), + Center(child: _buildRepriseDossierLink()), + const SizedBox(height: 10), // Lien mot de passe oublié Center( child: TextButton( @@ -617,6 +645,7 @@ class _LoginPageState extends State { onPressed: _handleLogin, ), const SizedBox(height: 12), + _buildRepriseDossierLink(), TextButton( onPressed: () => context.go('/forgot-password'), child: Text( diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index 84f04a7..da671bc 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -52,6 +52,7 @@ class ApiConfig { /// Ticket #112 — reprise après refus (#111 back). static const String repriseDossier = '/auth/reprise-dossier'; static const String repriseResoumettre = '/auth/reprise-resoumettre'; + static const String repriseIdentify = '/auth/reprise-identify'; // Users endpoints static const String users = '/users'; diff --git a/frontend/lib/services/auth_service.dart b/frontend/lib/services/auth_service.dart index 956339a..76c8f81 100644 --- a/frontend/lib/services/auth_service.dart +++ b/frontend/lib/services/auth_service.dart @@ -290,6 +290,51 @@ class AuthService { return RepriseDossier.fromJson(decoded); } + /// Modale login : numéro + e-mail → token reprise. POST /auth/reprise-identify. #112. + static Future identifyReprise({ + required String numeroDossier, + required String email, + }) async { + final num = numeroDossier.trim(); + final mail = normalizeEmailText(email); + if (num.isEmpty || mail.isEmpty) { + throw Exception('Numéro de dossier et e-mail requis.'); + } + late final http.Response response; + try { + response = await http.post( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.repriseIdentify}'), + headers: ApiConfig.headers, + body: jsonEncode({ + 'numero_dossier': num, + 'email': mail, + }), + ); + } on http.ClientException { + throw Exception( + 'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau puis réessayez.', + ); + } + if (response.statusCode == 404) { + throw Exception( + 'Aucun dossier en reprise trouvé pour ce numéro et cet e-mail.', + ); + } + if (response.statusCode != 200 && response.statusCode != 201) { + final decoded = _tryDecodeJsonMap(response.body); + throw Exception(_extractErrorMessage(decoded, response.statusCode)); + } + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw Exception('Réponse invalide du serveur.'); + } + final token = decoded['token']?.toString().trim() ?? ''; + if (token.isEmpty) { + throw Exception('Réponse invalide du serveur.'); + } + return token; + } + /// Resoumission après refus. PATCH /auth/reprise-resoumettre. Ticket #112. static Future resoumettreReprise(Map body) async { final cleaned = body['token']?.toString().trim() ?? ''; diff --git a/frontend/lib/widgets/auth/reprise_identify_dialog.dart b/frontend/lib/widgets/auth/reprise_identify_dialog.dart new file mode 100644 index 0000000..da8130a --- /dev/null +++ b/frontend/lib/widgets/auth/reprise_identify_dialog.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../services/auth_service.dart'; +import '../../utils/email_utils.dart'; +import '../custom_app_text_field.dart'; + +/// Modale login : numéro de dossier + e-mail → token reprise (#112). +class RepriseIdentifyDialog extends StatefulWidget { + final String? initialEmail; + + const RepriseIdentifyDialog({super.key, this.initialEmail}); + + @override + State createState() => _RepriseIdentifyDialogState(); +} + +class _RepriseIdentifyDialogState extends State { + final _formKey = GlobalKey(); + late final TextEditingController _numeroCtrl; + late final TextEditingController _emailCtrl; + + bool _loading = false; + String? _error; + + @override + void initState() { + super.initState(); + _numeroCtrl = TextEditingController(); + _emailCtrl = TextEditingController(text: widget.initialEmail ?? ''); + } + + @override + void dispose() { + _numeroCtrl.dispose(); + _emailCtrl.dispose(); + super.dispose(); + } + + String? _validateNumero(String? value) { + final v = value?.trim() ?? ''; + if (v.isEmpty) { + return 'Indiquez votre numéro de dossier.'; + } + return null; + } + + String? _validateEmail(String? value) { + final v = value?.trim() ?? ''; + if (v.isEmpty) { + return 'Indiquez votre adresse e-mail.'; + } + if (!isValidEmailFormat(v)) { + return 'L’adresse e-mail n’est pas valide.'; + } + return null; + } + + Future _submit() async { + if (_loading) return; + setState(() => _error = null); + if (!(_formKey.currentState?.validate() ?? false)) return; + + setState(() => _loading = true); + try { + final token = await AuthService.identifyReprise( + numeroDossier: _numeroCtrl.text, + email: _emailCtrl.text, + ); + if (!mounted) return; + Navigator.of(context).pop(token); + } catch (e) { + if (!mounted) return; + setState(() { + _loading = false; + _error = e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Impossible de retrouver votre dossier.'; + }); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text( + 'Reprendre mon dossier', + style: GoogleFonts.merienda(fontWeight: FontWeight.bold), + ), + content: SizedBox( + width: 420, + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Saisissez le numéro de dossier et l’e-mail utilisés lors ' + 'de l’inscription (dossier refusé en attente de correction).', + style: GoogleFonts.merienda(fontSize: 13), + ), + const SizedBox(height: 16), + CustomAppTextField( + controller: _numeroCtrl, + labelText: 'Numéro de dossier', + hintText: 'Ex. 2026-000021', + textInputAction: TextInputAction.next, + validator: _validateNumero, + style: CustomAppTextFieldStyle.lavande, + fieldHeight: 48, + fieldWidth: double.infinity, + ), + const SizedBox(height: 12), + CustomAppTextField( + controller: _emailCtrl, + labelText: 'E-mail', + hintText: 'Votre adresse e-mail', + keyboardType: TextInputType.emailAddress, + autocorrect: false, + enableSuggestions: false, + inputFormatters: const [EmailMaxLengthFormatter()], + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + validator: _validateEmail, + style: CustomAppTextFieldStyle.lavande, + fieldHeight: 48, + fieldWidth: double.infinity, + ), + if (_error != null) ...[ + const SizedBox(height: 12), + Text( + _error!, + style: GoogleFonts.merienda( + fontSize: 12, + color: Colors.red.shade700, + ), + ), + ], + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: _loading ? null : () => Navigator.of(context).pop(), + child: Text('Annuler', style: GoogleFonts.merienda()), + ), + TextButton( + onPressed: _loading ? null : _submit, + child: _loading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text( + 'Continuer', + style: GoogleFonts.merienda( + fontWeight: FontWeight.bold, + color: const Color(0xFF2D6A4F), + ), + ), + ), + ], + ); + } +} From 9d54d9b19bf14ffdb26aa9c2d2230dcdf9b00e71 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 16 Jun 2026 17:26:19 +0200 Subject: [PATCH 07/85] =?UTF-8?q?feat(#112):=20email=20accus=C3=A9=20resou?= =?UTF-8?q?mission=20aux=20parents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Après PATCH reprise-resoumettre (parent), envoi à chaque parent du dossier : confirmation resoumission + rappel numero_dossier. Co-authored-by: Cursor --- backend/src/modules/mail/mail.service.spec.ts | 17 +++++++++ backend/src/modules/mail/mail.service.ts | 37 +++++++++++++++++++ backend/src/routes/auth/auth.service.ts | 23 +++++++++++- docs/tmp/112-back-reprise-alignement-front.md | 9 +++++ 4 files changed, 85 insertions(+), 1 deletion(-) diff --git a/backend/src/modules/mail/mail.service.spec.ts b/backend/src/modules/mail/mail.service.spec.ts index d88d1d8..6d474a6 100644 --- a/backend/src/modules/mail/mail.service.spec.ts +++ b/backend/src/modules/mail/mail.service.spec.ts @@ -98,4 +98,21 @@ describe('MailService (ticket #28)', () => { expect(html).toContain(`https://app.test/reset-password?token=${encodeURIComponent(token)}`); expect(html).not.toContain('create-password'); }); + + it('sendResoumissionPendingEmail — accusé resoumission avec n° dossier', async () => { + await service.sendResoumissionPendingEmail( + 'parent@test.fr', + 'Claire', + 'MARTIN', + '2026-000024', + ); + expect(sendEmailSpy).toHaveBeenCalledTimes(1); + const [to, subject, html] = sendEmailSpy.mock.calls[0]; + expect(to).toBe('parent@test.fr'); + expect(subject).toContain('resoumis'); + expect(subject).toContain('2026-000024'); + expect(html).toContain('Claire'); + expect(html).toContain('2026-000024'); + expect(html).toContain('en attente de validation'); + }); }); diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index 2d96a6e..f063426 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -301,6 +301,43 @@ export class MailService { await this.sendEmail(to, subject, html); } + /** + * Accusé de resoumission après refus (reprise #112) : dossier à nouveau en attente de validation. + */ + async sendResoumissionPendingEmail( + 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 dossier a été resoumis — ${safe(numeroDossier)}`; + const html = ` +
+

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

+

Nous avons bien reçu la resoumission de votre dossier sur ${safe(appName)}.

+

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

+

Votre dossier est de nouveau 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.service.ts b/backend/src/routes/auth/auth.service.ts index 9a42ae9..f78a06e 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -1004,7 +1004,7 @@ export class AuthService { dto.texte_motivation?.trim() ?? dto.presentation_dossier?.trim(); - return this.usersRepo.manager.transaction(async (manager) => { + const result = await this.usersRepo.manager.transaction(async (manager) => { const familyUsers = titulaire.numero_dossier ? await manager.find(Users, { where: { @@ -1098,8 +1098,29 @@ export class AuthService { statut: StatutUtilisateurType.EN_ATTENTE, user_id: titulaireLive.id, numero_dossier: titulaireLive.numero_dossier, + parentsPourEmail: familyUsers, }; }); + + const numeroDossier = result.numero_dossier ?? ''; + try { + for (const parent of result.parentsPourEmail) { + await this.mailService.sendResoumissionPendingEmail( + parent.email, + parent.prenom ?? '', + parent.nom ?? '', + numeroDossier, + ); + } + } catch (err) { + this.logger.error( + "[resoumettreRepriseParent] Échec envoi email accusé resoumission (dossier conservé)", + err instanceof Error ? err.stack : String(err), + ); + } + + const { parentsPourEmail: _parentsPourEmail, ...response } = result; + return response; } private async resoumettreRepriseAM( diff --git a/docs/tmp/112-back-reprise-alignement-front.md b/docs/tmp/112-back-reprise-alignement-front.md index d15350b..65eff55 100644 --- a/docs/tmp/112-back-reprise-alignement-front.md +++ b/docs/tmp/112-back-reprise-alignement-front.md @@ -179,6 +179,15 @@ Code HTTP : **200** (pas de corps `Users` brut comme l’ancien back). - **Parent :** tous les users `role=parent` avec le même `numero_dossier` passent en `en_attente` ; `token_reprise` invalidé sur **tous** (symétrique refus #110). - **AM :** un seul user. +### E-mail accusé resoumission (parent) + +Après `PATCH` réussi, un e-mail est envoyé à **chaque parent** du dossier (`sendResoumissionPendingEmail`) : +- confirmation de resoumission ; +- rappel du **numéro de dossier** ; +- mention « en attente de validation ». + +Échec SMTP : logué, **ne bloque pas** la resoumission (même règle que l'inscription initiale). + --- ## 4. Fichiers front à modifier (checklist) From c26ed0037414cf87d11d3720185473e3308082cd Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 16 Jun 2026 17:29:17 +0200 Subject: [PATCH 08/85] =?UTF-8?q?fix(#112):=20pr=C3=A9remplissage=20AM=20c?= =?UTF-8?q?omplet=20en=20reprise=20+=20lien=20login=20repositionn=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse dates/places/consentement de façon robuste, rafraîchit l'étape 2 AM et place « J'ai un numéro de dossier » sous « Créer un compte ». Co-authored-by: Cursor --- frontend/lib/models/reprise_dossier.dart | 29 ++++---- .../auth/am_register_step2_screen.dart | 6 ++ frontend/lib/screens/auth/login_screen.dart | 5 +- frontend/lib/services/reprise_session.dart | 22 +----- frontend/lib/utils/reprise_mapper.dart | 69 ++++++++++++++++++- 5 files changed, 93 insertions(+), 38 deletions(-) diff --git a/frontend/lib/models/reprise_dossier.dart b/frontend/lib/models/reprise_dossier.dart index 0275354..3d416f0 100644 --- a/frontend/lib/models/reprise_dossier.dart +++ b/frontend/lib/models/reprise_dossier.dart @@ -1,4 +1,5 @@ import 'package:p_tits_pas/models/dossier_unifie.dart'; +import 'package:p_tits_pas/utils/reprise_mapper.dart'; /// Réponse GET /auth/reprise-dossier. Tickets #111, #112. class RepriseDossier { @@ -20,7 +21,7 @@ class RepriseDossier { final List enfants; final String? texteMotivation; - final bool? consentementPhoto; + final bool consentementPhoto; final String? dateNaissance; final String? lieuNaissanceVille; final String? lieuNaissancePays; @@ -48,7 +49,7 @@ class RepriseDossier { this.parents = const [], this.enfants = const [], this.texteMotivation, - this.consentementPhoto, + this.consentementPhoto = false, this.dateNaissance, this.lieuNaissanceVille, this.lieuNaissancePays, @@ -80,9 +81,6 @@ class RepriseDossier { .toList() : []; - final nbMax = json['nb_max_enfants']; - final places = json['place_disponible']; - return RepriseDossier( id: json['id']?.toString() ?? '', email: json['email']?.toString() ?? '', @@ -101,17 +99,22 @@ class RepriseDossier { enfants: enfantsList, texteMotivation: (json['texte_motivation'] ?? json['presentation_dossier']) ?.toString(), - consentementPhoto: json['consentement_photo'] == true, - dateNaissance: json['date_naissance']?.toString(), + consentementPhoto: RepriseMapper.optionalBool(json['consentement_photo']) || + RepriseMapper.optionalBool(json['consent_photo']), + dateNaissance: RepriseMapper.optionalDateString(json['date_naissance']), lieuNaissanceVille: json['lieu_naissance_ville']?.toString(), lieuNaissancePays: json['lieu_naissance_pays']?.toString(), - numeroAgrement: json['numero_agrement']?.toString(), + numeroAgrement: (json['numero_agrement'] ?? json['numero_agrement_am']) + ?.toString(), nir: json['nir']?.toString(), - dateAgrement: json['date_agrement']?.toString(), - nbMaxEnfants: nbMax is int ? nbMax : (nbMax is num ? nbMax.toInt() : null), - placeDisponible: - places is int ? places : (places is num ? places.toInt() : null), - biographie: json['biographie']?.toString(), + dateAgrement: RepriseMapper.optionalDateString(json['date_agrement']), + nbMaxEnfants: RepriseMapper.optionalInt( + json['nb_max_enfants'] ?? json['capacite_accueil'], + ), + placeDisponible: RepriseMapper.optionalInt( + json['place_disponible'] ?? json['places_disponibles'], + ), + biographie: (json['biographie'] ?? json['presentation'])?.toString(), ); } } diff --git a/frontend/lib/screens/auth/am_register_step2_screen.dart b/frontend/lib/screens/auth/am_register_step2_screen.dart index 2f98b0e..8a7e3e4 100644 --- a/frontend/lib/screens/auth/am_register_step2_screen.dart +++ b/frontend/lib/screens/auth/am_register_step2_screen.dart @@ -29,6 +29,12 @@ class AmRegisterStep2Screen extends StatelessWidget { ); return ProfessionalInfoFormScreen( + key: ValueKey( + 'am-pro-${registrationData.dateOfBirth?.millisecondsSinceEpoch}' + '-${registrationData.agreementDate?.millisecondsSinceEpoch}' + '-${registrationData.placesAvailable}' + '-${registrationData.nir}', + ), stepText: 'Étape 2/4', title: 'Vos informations professionnelles', cardColor: CardColorHorizontal.green, diff --git a/frontend/lib/screens/auth/login_screen.dart b/frontend/lib/screens/auth/login_screen.dart index ab1c2e0..6f1efd0 100644 --- a/frontend/lib/screens/auth/login_screen.dart +++ b/frontend/lib/screens/auth/login_screen.dart @@ -377,8 +377,6 @@ class _LoginPageState extends State { ), ), const SizedBox(height: 10), - Center(child: _buildRepriseDossierLink()), - const SizedBox(height: 10), // Lien mot de passe oublié Center( child: TextButton( @@ -412,6 +410,7 @@ class _LoginPageState extends State { ), ), ), + Center(child: _buildRepriseDossierLink()), ], ), ), @@ -645,7 +644,6 @@ class _LoginPageState extends State { onPressed: _handleLogin, ), const SizedBox(height: 12), - _buildRepriseDossierLink(), TextButton( onPressed: () => context.go('/forgot-password'), child: Text( @@ -669,6 +667,7 @@ class _LoginPageState extends State { ), ), ), + _buildRepriseDossierLink(), ], ), ), diff --git a/frontend/lib/services/reprise_session.dart b/frontend/lib/services/reprise_session.dart index 8309371..6f9fc8a 100644 --- a/frontend/lib/services/reprise_session.dart +++ b/frontend/lib/services/reprise_session.dart @@ -53,26 +53,6 @@ class RepriseSession { } static void applyToAm(AmRegistrationData data, RepriseDossier dossier) { - final displayPhoto = _photoUrl; - data.resetForReprise( - firstName: dossier.prenom ?? '', - lastName: dossier.nom ?? '', - phone: dossier.telephone ?? '', - email: dossier.email, - streetAddress: dossier.adresse ?? '', - postalCode: dossier.codePostal ?? '', - city: dossier.ville ?? '', - existingPhotoUrl: displayPhoto, - consentementPhoto: dossier.consentementPhoto ?? false, - dateOfBirth: RepriseMapper.parseIsoDate(dossier.dateNaissance), - birthCity: dossier.lieuNaissanceVille ?? '', - birthCountry: dossier.lieuNaissancePays ?? '', - nir: dossier.nir ?? '', - agrementNumber: dossier.numeroAgrement ?? '', - agreementDate: RepriseMapper.parseIsoDate(dossier.dateAgrement), - capacity: dossier.nbMaxEnfants, - placesAvailable: dossier.placeDisponible, - presentationText: dossier.biographie ?? '', - ); + RepriseMapper.applyAmDossier(data, dossier); } } diff --git a/frontend/lib/utils/reprise_mapper.dart b/frontend/lib/utils/reprise_mapper.dart index 6720f9f..6691b7d 100644 --- a/frontend/lib/utils/reprise_mapper.dart +++ b/frontend/lib/utils/reprise_mapper.dart @@ -1,3 +1,4 @@ +import 'package:p_tits_pas/models/am_registration_data.dart'; import 'package:p_tits_pas/models/card_assets.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart'; import 'package:p_tits_pas/models/reprise_dossier.dart'; @@ -106,13 +107,79 @@ class RepriseMapper { static DateTime? parseIsoDate(String? raw) { if (raw == null || raw.trim().isEmpty) return null; + final s = raw.trim(); try { - return DateTime.parse(raw.trim()); + return DateTime.parse(s); } catch (_) { + final parts = s.split('/'); + if (parts.length == 3) { + 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 DateTime(year, month, day); + } + } return null; } } + static String? optionalDateString(dynamic value) { + if (value == null) return null; + if (value is String) { + final s = value.trim(); + return s.isEmpty ? null : s; + } + if (value is DateTime) return value.toIso8601String(); + return null; + } + + static int? optionalInt(dynamic value) { + if (value == null) return null; + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is String) { + final s = value.trim(); + if (s.isEmpty) return null; + return int.tryParse(s); + } + return null; + } + + static bool optionalBool(dynamic value) { + if (value == true) return true; + if (value is String && value.toLowerCase() == 'true') return true; + return false; + } + + static void applyAmDossier(AmRegistrationData data, RepriseDossier dossier) { + final rawPhoto = dossier.photoUrl?.trim(); + final hasPhoto = rawPhoto != null && rawPhoto.isNotEmpty; + final displayPhoto = + hasPhoto ? ApiConfig.absoluteMediaUrl(rawPhoto) : null; + + data.resetForReprise( + firstName: dossier.prenom ?? '', + lastName: dossier.nom ?? '', + phone: dossier.telephone ?? '', + email: dossier.email, + streetAddress: dossier.adresse ?? '', + postalCode: dossier.codePostal ?? '', + city: dossier.ville ?? '', + existingPhotoUrl: displayPhoto, + consentementPhoto: dossier.consentementPhoto || hasPhoto, + dateOfBirth: parseIsoDate(dossier.dateNaissance), + birthCity: dossier.lieuNaissanceVille ?? '', + birthCountry: dossier.lieuNaissancePays ?? '', + nir: dossier.nir ?? '', + agrementNumber: dossier.numeroAgrement ?? '', + agreementDate: parseIsoDate(dossier.dateAgrement), + capacity: dossier.nbMaxEnfants, + placesAvailable: dossier.placeDisponible, + presentationText: dossier.biographie ?? '', + ); + } + static String isoToDdMmYyyy(String? iso) { final dt = parseIsoDate(iso); if (dt == null) return ''; From df776d820071de866a1dae1aa8595cf6c516d451 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 16 Jun 2026 19:12:14 +0200 Subject: [PATCH 09/85] fix(#112): dates et places AM en reprise (resetForReprise) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrige l’ombre des paramètres dateOfBirth, agreementDate et placesAvailable dans resetForReprise ; renforce le parsing reprise-dossier et ajoute un test. Co-authored-by: Cursor --- frontend/lib/models/am_registration_data.dart | 6 +- frontend/lib/models/reprise_dossier.dart | 80 ++++++++++++++--- .../auth/am_register_step2_screen.dart | 88 ++++++++++--------- frontend/lib/services/auth_service.dart | 5 +- frontend/lib/utils/reprise_mapper.dart | 39 +++++++- .../professional_info_form_screen.dart | 70 ++++++++++----- frontend/test/reprise_am_parse_test.dart | 52 +++++++++++ 7 files changed, 256 insertions(+), 84 deletions(-) create mode 100644 frontend/test/reprise_am_parse_test.dart diff --git a/frontend/lib/models/am_registration_data.dart b/frontend/lib/models/am_registration_data.dart index 08ecc60..1de1aef 100644 --- a/frontend/lib/models/am_registration_data.dart +++ b/frontend/lib/models/am_registration_data.dart @@ -141,14 +141,14 @@ class AmRegistrationData extends ChangeNotifier { photoBytes = null; photoFilename = null; photoConsent = consentementPhoto; - dateOfBirth = dateOfBirth; + this.dateOfBirth = dateOfBirth; this.birthCity = birthCity; this.birthCountry = birthCountry; this.nir = nir; this.agrementNumber = agrementNumber; - agreementDate = agreementDate; + this.agreementDate = agreementDate; this.capacity = capacity; - placesAvailable = placesAvailable; + this.placesAvailable = placesAvailable; this.presentationText = presentationText; cguAccepted = false; notifyListeners(); diff --git a/frontend/lib/models/reprise_dossier.dart b/frontend/lib/models/reprise_dossier.dart index 3d416f0..fe425ca 100644 --- a/frontend/lib/models/reprise_dossier.dart +++ b/frontend/lib/models/reprise_dossier.dart @@ -64,7 +64,22 @@ class RepriseDossier { bool get isParent => role == 'parent'; bool get isAm => role == 'assistante_maternelle'; + static Map? _nestedMap(dynamic value) { + if (value is Map) return Map.from(value); + return null; + } + + static dynamic _firstNonNull(List values) { + for (final v in values) { + if (v != null) return v; + } + return null; + } + factory RepriseDossier.fromJson(Map json) { + final nestedUser = _nestedMap(json['user']); + final nestedDossier = _nestedMap(json['dossier']); + final parentsRaw = json['parents']; final parentsList = parentsRaw is List ? parentsRaw @@ -100,19 +115,64 @@ class RepriseDossier { texteMotivation: (json['texte_motivation'] ?? json['presentation_dossier']) ?.toString(), consentementPhoto: RepriseMapper.optionalBool(json['consentement_photo']) || - RepriseMapper.optionalBool(json['consent_photo']), - dateNaissance: RepriseMapper.optionalDateString(json['date_naissance']), - lieuNaissanceVille: json['lieu_naissance_ville']?.toString(), - lieuNaissancePays: json['lieu_naissance_pays']?.toString(), - numeroAgrement: (json['numero_agrement'] ?? json['numero_agrement_am']) - ?.toString(), - nir: json['nir']?.toString(), - dateAgrement: RepriseMapper.optionalDateString(json['date_agrement']), + RepriseMapper.optionalBool(json['consent_photo']) || + RepriseMapper.optionalBool(nestedUser?['consentement_photo']), + dateNaissance: RepriseMapper.optionalDateString( + _firstNonNull([ + json['date_naissance'], + json['dateNaissance'], + nestedUser?['date_naissance'], + nestedUser?['dateNaissance'], + ]), + ), + lieuNaissanceVille: _firstNonNull([ + json['lieu_naissance_ville'], + json['lieuNaissanceVille'], + nestedUser?['lieu_naissance_ville'], + nestedUser?['lieuNaissanceVille'], + ])?.toString(), + lieuNaissancePays: _firstNonNull([ + json['lieu_naissance_pays'], + json['lieuNaissancePays'], + nestedUser?['lieu_naissance_pays'], + nestedUser?['lieuNaissancePays'], + ])?.toString(), + numeroAgrement: _firstNonNull([ + json['numero_agrement'], + json['numero_agrement_am'], + json['numeroAgrement'], + nestedDossier?['numero_agrement'], + nestedDossier?['numeroAgrement'], + ])?.toString(), + nir: _firstNonNull([ + json['nir'], + nestedDossier?['nir'], + ])?.toString(), + dateAgrement: RepriseMapper.optionalDateString( + _firstNonNull([ + json['date_agrement'], + json['dateAgrement'], + nestedDossier?['date_agrement'], + nestedDossier?['dateAgrement'], + ]), + ), nbMaxEnfants: RepriseMapper.optionalInt( - json['nb_max_enfants'] ?? json['capacite_accueil'], + _firstNonNull([ + json['nb_max_enfants'], + json['capacite_accueil'], + json['nbMaxEnfants'], + nestedDossier?['nb_max_enfants'], + nestedDossier?['capacite_accueil'], + ]), ), placeDisponible: RepriseMapper.optionalInt( - json['place_disponible'] ?? json['places_disponibles'], + _firstNonNull([ + json['place_disponible'], + json['places_disponibles'], + json['placesDisponibles'], + nestedDossier?['place_disponible'], + nestedDossier?['places_disponibles'], + ]), ), biographie: (json['biographie'] ?? json['presentation'])?.toString(), ); diff --git a/frontend/lib/screens/auth/am_register_step2_screen.dart b/frontend/lib/screens/auth/am_register_step2_screen.dart index 8a7e3e4..6f50782 100644 --- a/frontend/lib/screens/auth/am_register_step2_screen.dart +++ b/frontend/lib/screens/auth/am_register_step2_screen.dart @@ -11,51 +11,53 @@ class AmRegisterStep2Screen extends StatelessWidget { @override Widget build(BuildContext context) { - final registrationData = Provider.of(context, listen: false); + return Consumer( + builder: (context, registrationData, _) { + final initialData = ProfessionalInfoData( + photoPath: registrationData.photoPath, + photoBytes: registrationData.photoBytes, + photoFilename: registrationData.photoFilename, + photoConsent: registrationData.photoConsent, + dateOfBirth: registrationData.dateOfBirth, + birthCity: registrationData.birthCity, + birthCountry: registrationData.birthCountry, + nir: registrationData.nir, + agrementNumber: registrationData.agrementNumber, + agreementDate: registrationData.agreementDate, + capacity: registrationData.capacity, + placesAvailable: registrationData.placesAvailable, + ); - final initialData = ProfessionalInfoData( - photoPath: registrationData.photoPath, - photoBytes: registrationData.photoBytes, - photoFilename: registrationData.photoFilename, - photoConsent: registrationData.photoConsent, - dateOfBirth: registrationData.dateOfBirth, - birthCity: registrationData.birthCity, - birthCountry: registrationData.birthCountry, - nir: registrationData.nir, - agrementNumber: registrationData.agrementNumber, - agreementDate: registrationData.agreementDate, - capacity: registrationData.capacity, - placesAvailable: registrationData.placesAvailable, - ); - - return ProfessionalInfoFormScreen( - key: ValueKey( - 'am-pro-${registrationData.dateOfBirth?.millisecondsSinceEpoch}' - '-${registrationData.agreementDate?.millisecondsSinceEpoch}' - '-${registrationData.placesAvailable}' - '-${registrationData.nir}', - ), - stepText: 'Étape 2/4', - title: 'Vos informations professionnelles', - cardColor: CardColorHorizontal.green, - initialData: initialData, - previousRoute: '/am-register-step1', - onSubmit: (data) { - registrationData.updateProfessionalInfo( - photoPath: data.photoPath, - photoBytes: data.photoBytes, - photoFilename: data.photoFilename, - photoConsent: data.photoConsent, - dateOfBirth: data.dateOfBirth, - birthCity: data.birthCity, - birthCountry: data.birthCountry, - nir: data.nir, - agrementNumber: data.agrementNumber, - agreementDate: data.agreementDate, - capacity: data.capacity, - placesAvailable: data.placesAvailable, + return ProfessionalInfoFormScreen( + key: ValueKey( + 'am-pro-${registrationData.dateOfBirth?.millisecondsSinceEpoch}' + '-${registrationData.agreementDate?.millisecondsSinceEpoch}' + '-${registrationData.placesAvailable}' + '-${registrationData.nir}', + ), + stepText: 'Étape 2/4', + title: 'Vos informations professionnelles', + cardColor: CardColorHorizontal.green, + initialData: initialData, + previousRoute: '/am-register-step1', + onSubmit: (data) { + registrationData.updateProfessionalInfo( + photoPath: data.photoPath, + photoBytes: data.photoBytes, + photoFilename: data.photoFilename, + photoConsent: data.photoConsent, + dateOfBirth: data.dateOfBirth, + birthCity: data.birthCity, + birthCountry: data.birthCountry, + nir: data.nir, + agrementNumber: data.agrementNumber, + agreementDate: data.agreementDate, + capacity: data.capacity, + placesAvailable: data.placesAvailable, + ); + context.go('/am-register-step3'); + }, ); - context.go('/am-register-step3'); }, ); } diff --git a/frontend/lib/services/auth_service.dart b/frontend/lib/services/auth_service.dart index 76c8f81..1871e07 100644 --- a/frontend/lib/services/auth_service.dart +++ b/frontend/lib/services/auth_service.dart @@ -287,7 +287,10 @@ class AuthService { if (decoded is! Map) { throw Exception('Réponse invalide du serveur.'); } - return RepriseDossier.fromJson(decoded); + final payload = decoded['data'] is Map + ? Map.from(decoded['data'] as Map) + : decoded; + return RepriseDossier.fromJson(payload); } /// Modale login : numéro + e-mail → token reprise. POST /auth/reprise-identify. #112. diff --git a/frontend/lib/utils/reprise_mapper.dart b/frontend/lib/utils/reprise_mapper.dart index 6691b7d..53c285b 100644 --- a/frontend/lib/utils/reprise_mapper.dart +++ b/frontend/lib/utils/reprise_mapper.dart @@ -108,6 +108,15 @@ class RepriseMapper { static DateTime? parseIsoDate(String? raw) { if (raw == null || raw.trim().isEmpty) return null; final s = raw.trim(); + final iso = RegExp(r'^(\d{4})-(\d{2})-(\d{2})'); + final isoMatch = iso.firstMatch(s); + if (isoMatch != null) { + return DateTime( + int.parse(isoMatch.group(1)!), + int.parse(isoMatch.group(2)!), + int.parse(isoMatch.group(3)!), + ); + } try { return DateTime.parse(s); } catch (_) { @@ -128,10 +137,34 @@ class RepriseMapper { if (value == null) return null; if (value is String) { final s = value.trim(); - return s.isEmpty ? null : s; + return s.isEmpty || s == 'null' ? null : s; } - if (value is DateTime) return value.toIso8601String(); - return null; + if (value is DateTime) { + return '${value.year.toString().padLeft(4, '0')}-' + '${value.month.toString().padLeft(2, '0')}-' + '${value.day.toString().padLeft(2, '0')}'; + } + if (value is num) { + final ms = value.abs() > 9999999999 + ? value.toInt() + : value.toInt() * 1000; + final dt = DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true); + return '${dt.year.toString().padLeft(4, '0')}-' + '${dt.month.toString().padLeft(2, '0')}-' + '${dt.day.toString().padLeft(2, '0')}'; + } + if (value is Map) { + final y = value['year']; + final m = value['month'] ?? value['monthValue']; + final d = value['day'] ?? value['dayOfMonth']; + if (y is num && m is num && d is num) { + return '${y.toInt().toString().padLeft(4, '0')}-' + '${m.toInt().toString().padLeft(2, '0')}-' + '${d.toInt().toString().padLeft(2, '0')}'; + } + } + final s = value.toString().trim(); + return s.isEmpty || s == 'null' ? null : s; } static int? optionalInt(dynamic value) { diff --git a/frontend/lib/widgets/professional_info_form_screen.dart b/frontend/lib/widgets/professional_info_form_screen.dart index 6225cd1..9a21bd0 100644 --- a/frontend/lib/widgets/professional_info_form_screen.dart +++ b/frontend/lib/widgets/professional_info_form_screen.dart @@ -134,30 +134,7 @@ class _ProfessionalInfoFormScreenState extends State @override void initState() { super.initState(); - - final data = widget.initialData; - if (data != null) { - _selectedDate = data.dateOfBirth; - _dateOfBirthController.text = data.dateOfBirth != null - ? DateFormat('dd/MM/yyyy').format(data.dateOfBirth!) - : ''; - _birthCityController.text = data.birthCity; - _birthCountryController.text = data.birthCountry; - final nirRaw = nirToRaw(data.nir); - _nirController.text = nirRaw.length == 15 ? formatNir(nirRaw) : data.nir; - _agrementController.text = data.agrementNumber; - _selectedAgreementDate = data.agreementDate; - _agreementDateController.text = data.agreementDate != null - ? DateFormat('dd/MM/yyyy').format(data.agreementDate!) - : ''; - _capacityController.text = data.capacity?.toString() ?? ''; - _placesAvailableController.text = data.placesAvailable?.toString() ?? ''; - _photoPathFramework = data.photoPath; - _photoFile = data.photoFile; - _photoBytes = data.photoBytes; - _photoFilename = data.photoFilename; - _photoConsent = data.photoConsent; - } + _applyInitialData(widget.initialData); if (widget.mode == DisplayMode.editable) { _birthCityFocus = FocusNode(); @@ -168,6 +145,51 @@ class _ProfessionalInfoFormScreenState extends State } } + @override + void didUpdateWidget(covariant ProfessionalInfoFormScreen oldWidget) { + super.didUpdateWidget(oldWidget); + final next = widget.initialData; + final prev = oldWidget.initialData; + if (next == null) return; + if (prev == null || + prev.dateOfBirth != next.dateOfBirth || + prev.agreementDate != next.agreementDate || + prev.placesAvailable != next.placesAvailable || + prev.capacity != next.capacity || + prev.nir != next.nir || + prev.birthCity != next.birthCity || + prev.birthCountry != next.birthCountry || + prev.agrementNumber != next.agrementNumber || + prev.photoPath != next.photoPath || + prev.photoConsent != next.photoConsent) { + _applyInitialData(next); + } + } + + void _applyInitialData(ProfessionalInfoData? data) { + if (data == null) return; + _selectedDate = data.dateOfBirth; + _dateOfBirthController.text = data.dateOfBirth != null + ? DateFormat('dd/MM/yyyy').format(data.dateOfBirth!) + : ''; + _birthCityController.text = data.birthCity; + _birthCountryController.text = data.birthCountry; + final nirRaw = nirToRaw(data.nir); + _nirController.text = nirRaw.length == 15 ? formatNir(nirRaw) : data.nir; + _agrementController.text = data.agrementNumber; + _selectedAgreementDate = data.agreementDate; + _agreementDateController.text = data.agreementDate != null + ? DateFormat('dd/MM/yyyy').format(data.agreementDate!) + : ''; + _capacityController.text = data.capacity?.toString() ?? ''; + _placesAvailableController.text = data.placesAvailable?.toString() ?? ''; + _photoPathFramework = data.photoPath; + _photoFile = data.photoFile; + _photoBytes = data.photoBytes; + _photoFilename = data.photoFilename; + _photoConsent = data.photoConsent; + } + void _onBirthCityFocusChange() { if (_birthCityFocus == null || _birthCityFocus!.hasFocus) return; _applyPlaceNameFormat(_birthCityController); diff --git a/frontend/test/reprise_am_parse_test.dart b/frontend/test/reprise_am_parse_test.dart new file mode 100644 index 0000000..3fcf621 --- /dev/null +++ b/frontend/test/reprise_am_parse_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:p_tits_pas/models/am_registration_data.dart'; +import 'package:p_tits_pas/models/reprise_dossier.dart'; +import 'package:p_tits_pas/utils/reprise_mapper.dart'; + +void main() { + test('RepriseDossier parse champs AM plats', () { + final d = RepriseDossier.fromJson({ + 'id': 'am1', + 'email': 'marie@test.fr', + 'role': 'assistante_maternelle', + 'date_naissance': '1980-06-08', + 'date_agrement': '2019-09-01', + 'place_disponible': 2, + 'nb_max_enfants': 4, + 'lieu_naissance_ville': 'Ajaccio', + 'nir': '280062A00100191', + 'numero_agrement': 'AGR-2019-095001', + }); + + expect(d.dateNaissance, '1980-06-08'); + expect(d.dateAgrement, '2019-09-01'); + expect(d.placeDisponible, 2); + + final data = AmRegistrationData(); + RepriseMapper.applyAmDossier(data, d); + expect(data.dateOfBirth, DateTime(1980, 6, 8)); + expect(data.agreementDate, DateTime(2019, 9, 1)); + expect(data.placesAvailable, 2); + }); + + test('RepriseDossier parse champs AM imbriqués + wrapper data', () { + final d = RepriseDossier.fromJson({ + 'id': 'am1', + 'email': 'marie@test.fr', + 'role': 'assistante_maternelle', + 'nir': '280062A00100191', + 'numero_agrement': 'AGR-2019-095001', + 'nb_max_enfants': 4, + 'lieu_naissance_ville': 'Ajaccio', + 'user': {'date_naissance': '1980-06-08T00:00:00.000Z'}, + 'dossier': { + 'date_agrement': '2019-09-01', + 'places_disponibles': 2, + }, + }); + + expect(d.dateNaissance, isNotNull); + expect(d.dateAgrement, '2019-09-01'); + expect(d.placeDisponible, 2); + }); +} From 1dddc67933952070531da995fb72a408d866a7d8 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Wed, 17 Jun 2026 00:35:00 +0200 Subject: [PATCH 10/85] =?UTF-8?q?feat(#140):=20dashboard=20admin=20ch.6=20?= =?UTF-8?q?=E2=80=94=20fiche=20parent,=20enfants,=20affiliation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Back: PATCH /parents/:id/fiche, attach/detach enfant, GET /enfants enrichi. Front: modale parent éditable, onglet Enfants, fiche enfant, UserService. Couvre doc 28 §6.1–6.2 ; tickets liés #115 #116 #130 #131 #137 #138. Hors scope: fiche AM (#131), création admin (#129). Co-authored-by: Cursor --- .../src/routes/enfants/enfants.controller.ts | 7 +- backend/src/routes/enfants/enfants.service.ts | 4 +- .../dto/update-parent-fiche-admin.dto.ts | 57 +++ .../src/routes/parents/parents.controller.ts | 43 +- backend/src/routes/parents/parents.module.ts | 3 +- backend/src/routes/parents/parents.service.ts | 81 ++- docs/00_INDEX.md | 5 +- docs/24_DECISIONS-PROJET.md | 32 +- docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md | 331 ++++++++++++ docs/EVOLUTIONS_CDC.md | 29 +- frontend/lib/models/enfant_admin_model.dart | 106 ++++ frontend/lib/models/parent_child_summary.dart | 31 ++ frontend/lib/models/parent_model.dart | 30 +- frontend/lib/services/api/api_config.dart | 1 + frontend/lib/services/user_service.dart | 118 +++++ .../common/admin_child_detail_modal.dart | 289 +++++++++++ .../admin/common/admin_parent_edit_modal.dart | 477 ++++++++++++++++++ .../admin/enfant_management_widget.dart | 120 +++++ .../admin/parent_managmant_widget.dart | 46 +- .../widgets/admin/user_management_panel.dart | 76 ++- 20 files changed, 1818 insertions(+), 68 deletions(-) create mode 100644 backend/src/routes/parents/dto/update-parent-fiche-admin.dto.ts create mode 100644 docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md create mode 100644 frontend/lib/models/enfant_admin_model.dart create mode 100644 frontend/lib/models/parent_child_summary.dart create mode 100644 frontend/lib/widgets/admin/common/admin_child_detail_modal.dart create mode 100644 frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart create mode 100644 frontend/lib/widgets/admin/enfant_management_widget.dart diff --git a/backend/src/routes/enfants/enfants.controller.ts b/backend/src/routes/enfants/enfants.controller.ts index e112972..2579a45 100644 --- a/backend/src/routes/enfants/enfants.controller.ts +++ b/backend/src/routes/enfants/enfants.controller.ts @@ -83,7 +83,12 @@ export class EnfantsController { return this.enfantsService.findOne(id, currentUser); } - @Roles(RoleType.ADMINISTRATEUR, RoleType.SUPER_ADMIN, RoleType.PARENT) + @Roles( + RoleType.PARENT, + RoleType.ADMINISTRATEUR, + RoleType.SUPER_ADMIN, + RoleType.GESTIONNAIRE, + ) @Patch(':id') update( @Param('id', new ParseUUIDPipe()) id: string, diff --git a/backend/src/routes/enfants/enfants.service.ts b/backend/src/routes/enfants/enfants.service.ts index bfefedb..fb900a1 100644 --- a/backend/src/routes/enfants/enfants.service.ts +++ b/backend/src/routes/enfants/enfants.service.ts @@ -78,10 +78,10 @@ export class EnfantsService { return this.findOne(child.id, currentUser); } - // Liste des enfants + // Liste des enfants (admin/gestionnaire) async findAll(): Promise { return this.childrenRepository.find({ - relations: ['parentLinks'], + relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'], order: { last_name: 'ASC', first_name: 'ASC' }, }); } diff --git a/backend/src/routes/parents/dto/update-parent-fiche-admin.dto.ts b/backend/src/routes/parents/dto/update-parent-fiche-admin.dto.ts new file mode 100644 index 0000000..7d680bd --- /dev/null +++ b/backend/src/routes/parents/dto/update-parent-fiche-admin.dto.ts @@ -0,0 +1,57 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsEmail, + IsEnum, + IsOptional, + IsString, + MaxLength, +} from 'class-validator'; +import { StatutUtilisateurType } from 'src/entities/users.entity'; + +/** Mise à jour fiche parent par admin/gestionnaire (doc 28 §6.1, ticket #131). */ +export class UpdateParentFicheAdminDto { + @ApiPropertyOptional({ example: 'Dupont' }) + @IsOptional() + @IsString() + @MaxLength(100) + nom?: string; + + @ApiPropertyOptional({ example: 'Marie' }) + @IsOptional() + @IsString() + @MaxLength(100) + prenom?: string; + + @ApiPropertyOptional({ example: 'marie.dupont@example.com' }) + @IsOptional() + @IsEmail() + email?: string; + + @ApiPropertyOptional({ example: '+33612345678' }) + @IsOptional() + @IsString() + @MaxLength(20) + telephone?: string; + + @ApiPropertyOptional({ example: '10 rue de la Paix' }) + @IsOptional() + @IsString() + adresse?: string; + + @ApiPropertyOptional({ example: 'Paris' }) + @IsOptional() + @IsString() + @MaxLength(150) + ville?: string; + + @ApiPropertyOptional({ example: '75001' }) + @IsOptional() + @IsString() + @MaxLength(10) + code_postal?: string; + + @ApiPropertyOptional({ enum: StatutUtilisateurType }) + @IsOptional() + @IsEnum(StatutUtilisateurType) + statut?: StatutUtilisateurType; +} diff --git a/backend/src/routes/parents/parents.controller.ts b/backend/src/routes/parents/parents.controller.ts index 261091f..6f44bee 100644 --- a/backend/src/routes/parents/parents.controller.ts +++ b/backend/src/routes/parents/parents.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, + Delete, Get, Param, Patch, @@ -16,6 +17,7 @@ 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 { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.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'; @@ -87,7 +89,7 @@ export class ParentsController { return this.parentsService.findAll(); } - @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE) + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @Get(':id') @ApiResponse({ status: 200, type: Parents, description: 'Détails du parent par ID utilisateur' }) @ApiResponse({ status: 404, description: 'Parent non trouvé' }) @@ -105,6 +107,45 @@ export class ParentsController { return this.parentsService.create(dto); } + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) + @Patch(':id/fiche') + @ApiOperation({ summary: 'Mettre à jour la fiche parent (admin/gestionnaire) — ticket #131' }) + @ApiParam({ name: 'id', description: "UUID utilisateur du parent" }) + @ApiBody({ type: UpdateParentFicheAdminDto }) + @ApiResponse({ status: 200, type: Parents, description: 'Fiche parent mise à jour' }) + updateFicheAdmin( + @Param('id') id: string, + @Body() dto: UpdateParentFicheAdminDto, + ): Promise { + return this.parentsService.updateFicheAdmin(id, dto); + } + + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) + @Post(':id/enfants/:enfantId') + @ApiOperation({ summary: 'Rattacher un enfant à un parent — ticket #115' }) + @ApiParam({ name: 'id', description: "UUID utilisateur du parent" }) + @ApiParam({ name: 'enfantId', description: "UUID de l'enfant" }) + @ApiResponse({ status: 200, type: Parents, description: 'Parent avec enfants mis à jour' }) + attachEnfant( + @Param('id') id: string, + @Param('enfantId') enfantId: string, + ): Promise { + return this.parentsService.attachEnfant(id, enfantId); + } + + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) + @Delete(':id/enfants/:enfantId') + @ApiOperation({ summary: "Détacher un enfant d'un parent — ticket #115" }) + @ApiParam({ name: 'id', description: "UUID utilisateur du parent" }) + @ApiParam({ name: 'enfantId', description: "UUID de l'enfant" }) + @ApiResponse({ status: 200, type: Parents, description: 'Parent avec enfants mis à jour' }) + detachEnfant( + @Param('id') id: string, + @Param('enfantId') enfantId: string, + ): Promise { + return this.parentsService.detachEnfant(id, enfantId); + } + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE) @Patch(':id') @ApiBody({ type: UpdateParentsDto }) diff --git a/backend/src/routes/parents/parents.module.ts b/backend/src/routes/parents/parents.module.ts index 7506259..b746e87 100644 --- a/backend/src/routes/parents/parents.module.ts +++ b/backend/src/routes/parents/parents.module.ts @@ -4,6 +4,7 @@ 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 { ParentsChildren } from 'src/entities/parents_children.entity'; import { ParentsController } from './parents.controller'; import { ParentsService } from './parents.service'; import { Users } from 'src/entities/users.entity'; @@ -11,7 +12,7 @@ import { UserModule } from '../user/user.module'; @Module({ imports: [ - TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant]), + TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant, ParentsChildren]), forwardRef(() => UserModule), JwtModule.registerAsync({ imports: [ConfigModule], diff --git a/backend/src/routes/parents/parents.service.ts b/backend/src/routes/parents/parents.service.ts index 2e06914..2696593 100644 --- a/backend/src/routes/parents/parents.service.ts +++ b/backend/src/routes/parents/parents.service.ts @@ -17,7 +17,9 @@ import { DossierFamilleParentDto, DossierFamilleEnfantDto, } from './dto/dossier-famille-complet.dto'; +import { ParentsChildren } from 'src/entities/parents_children.entity'; import { Children } from 'src/entities/children.entity'; +import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto'; @Injectable() export class ParentsService { @@ -28,6 +30,8 @@ export class ParentsService { private readonly usersRepository: Repository, @InjectRepository(DossierFamille) private readonly dossierFamilleRepository: Repository, + @InjectRepository(ParentsChildren) + private readonly parentsChildrenRepository: Repository, ) {} // Création d’un parent @@ -62,7 +66,7 @@ export class ParentsService { // Liste des parents async findAll(): Promise { return this.parentsRepository.find({ - relations: ['user', 'co_parent', 'parentChildren', 'dossiers'], + relations: ['user', 'co_parent', 'parentChildren', 'parentChildren.child', 'dossiers'], }); } @@ -70,7 +74,7 @@ export class ParentsService { async findOne(user_id: string): Promise { const parent = await this.parentsRepository.findOne({ where: { user_id }, - relations: ['user', 'co_parent', 'parentChildren', 'dossiers'], + relations: ['user', 'co_parent', 'parentChildren', 'parentChildren.child', 'dossiers'], }); if (!parent) throw new NotFoundException('Parent introuvable'); return parent; @@ -82,6 +86,79 @@ export class ParentsService { return this.findOne(id); } + /** + * Mise à jour fiche parent (champs user + statut) par admin/gestionnaire. Ticket #131 / doc 28 §6.1. + */ + async updateFicheAdmin(parentUserId: string, dto: UpdateParentFicheAdminDto): Promise { + const parent = await this.findOne(parentUserId); + const user = parent.user; + + if (dto.email && dto.email !== user.email) { + const existing = await this.usersRepository.findOne({ where: { email: dto.email } }); + if (existing && existing.id !== user.id) { + throw new ConflictException('Cet email est déjà utilisé'); + } + user.email = dto.email; + } + + if (dto.nom !== undefined) user.nom = dto.nom; + if (dto.prenom !== undefined) user.prenom = dto.prenom; + 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.statut !== undefined) user.statut = dto.statut; + + await this.usersRepository.save(user); + return this.findOne(parentUserId); + } + + /** + * Rattacher un enfant existant à un parent (enfants_parents). Ticket #115 / doc 28 §6.2. + */ + async attachEnfant(parentUserId: string, enfantId: string): Promise { + await this.findOne(parentUserId); + + const existing = await this.parentsChildrenRepository.findOne({ + where: { parentId: parentUserId, enfantId }, + }); + if (existing) { + throw new ConflictException('Cet enfant est déjà rattaché à ce parent'); + } + + const child = await this.parentsRepository.manager.findOne(Children, { where: { id: enfantId } }); + if (!child) { + throw new NotFoundException('Enfant introuvable'); + } + + await this.parentsChildrenRepository.save( + this.parentsChildrenRepository.create({ parentId: parentUserId, enfantId }), + ); + return this.findOne(parentUserId); + } + + /** + * Détacher un enfant d'un parent sans supprimer l'enfant. Ticket #115 / doc 28 §6.2. + */ + async detachEnfant(parentUserId: string, enfantId: string): Promise { + await this.findOne(parentUserId); + + const link = await this.parentsChildrenRepository.findOne({ + where: { parentId: parentUserId, enfantId }, + }); + if (!link) { + throw new NotFoundException('Lien parent-enfant introuvable'); + } + + const totalLinks = await this.parentsChildrenRepository.count({ where: { enfantId } }); + if (totalLinks <= 1) { + throw new BadRequestException('Un enfant doit rester rattaché à au moins un responsable'); + } + + await this.parentsChildrenRepository.delete({ parentId: parentUserId, enfantId }); + return this.findOne(parentUserId); + } + /** * Liste des familles en attente (une entrée par famille). * Famille = lien co_parent ou partage d'enfants (même logique que backfill #103). diff --git a/docs/00_INDEX.md b/docs/00_INDEX.md index 2f8a601..7e5b9f6 100644 --- a/docs/00_INDEX.md +++ b/docs/00_INDEX.md @@ -36,6 +36,7 @@ Ce fichier sert d'index pour naviguer dans toute la documentation du projet. - [**23 - Liste des Tickets**](./23_LISTE-TICKETS.md) - 61 tickets Phase 1 détaillés - [**24 - Décisions Projet**](./24_DECISIONS-PROJET.md) - Décisions architecturales et fonctionnelles - [**25 - Backlog Phase 2**](./25_PHASE-2-BACKLOG.md) - Fonctionnalités techniques reportées +- [**28 - Évolution famille et responsables**](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) - Modèle dossier/famille, recompositions, v1.0.0 vs post-1.0.0 - [**26 - API Gitea**](./26_GITEA-API.md) - Procédure d'utilisation de l'API Gitea (issues, PR, branches, labels) - [**27 - Briefing frontend**](./27_BRIEFING-FRONTEND.md) - Accès Git, priorités, scripts Gitea (token) @@ -49,7 +50,7 @@ Fichiers **sans préfixe numérique** encore à la racine par **héritage** ou références outils (`.cursorrules`, etc.) — **à renommer** en `NN_` quand possible : - `CHARTE_GRAPHIQUE.md` -- `EVOLUTIONS_CDC.md` +- [`EVOLUTIONS_CDC.md`](./EVOLUTIONS_CDC.md) — écarts CDC / app ; voir aussi [**28 - Évolution famille**](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) - `SuperNounou_Cahier_Des_Charges_Complet_V1.1.md` - `SuperNounou_SSS-001.md` @@ -89,5 +90,5 @@ PgAdmin: https://app.ptits-pas.fr/pgadmin Cette documentation est maintenue par Julien Martin (julien.martin@ptits-pas.fr). -Dernière mise à jour : Novembre 2025 +Dernière mise à jour : Juin 2026 diff --git a/docs/24_DECISIONS-PROJET.md b/docs/24_DECISIONS-PROJET.md index 62bcbe5..cf38376 100644 --- a/docs/24_DECISIONS-PROJET.md +++ b/docs/24_DECISIONS-PROJET.md @@ -1,7 +1,7 @@ # 📋 Décisions Projet - P'titsPas -**Version** : 1.1 -**Date** : 9 Février 2026 +**Version** : 1.2 +**Date** : 16 Juin 2026 **Auteur** : Équipe PtitsPas --- @@ -122,6 +122,27 @@ ptitspas-app/ --- +### 5bis. Familles recomposées — contournement v1.0.0 + +**Décision** : ✅ **Second compte / second email pour les cas multi-contextes en v1.0.0** ; évolution structurelle reportée post-1.0.0 + +**Contexte** : +- Le numéro de dossier et le graphe « famille » (co-parent + enfants partagés) conviennent aux cas simples (un couple, N enfants). +- Ils ne couvrent pas proprement une même personne responsable dans **plusieurs unités familiales** (recompositions, plusieurs co-responsables successifs, tuteur/GP sur plusieurs contextes). + +**Décision v1.0.0** : +- **Contournement opérationnel** : créer un **second compte** avec **email distinct** et un **second numéro de dossier** (souvent via le gestionnaire). +- Le numéro de dossier reste la **norme** pour les cas simples, **sans obligation absolue** pour les cas complexes. +- Rôle applicatif unique **`parent`** pour tous les responsables (tuteur, GP inclus) ; qualification juridique = évolution ultérieure. + +**Évolution post-1.0.0** : +- **Parcours gestionnaire « famille complexe »** (§ 7.5 doc 28) : N responsables, **M un seul compte** avec tous ses enfants ; **A / B** limités à leur enfant via `enfants_parents`. +- Visibilité et workflows **sans fusion graphe familial** ; numéro de dossier optionnel ; qualification du lien responsable–enfant. + +**Référence** : [28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) — § 4.4, § 7.5 + +--- + ### 6. Genre enfant obligatoire (H/F) **Décision** : ✅ **Genre obligatoire (H/F uniquement)** @@ -562,6 +583,7 @@ docs/ | 14 | Migration données | ❌ Rejeté | N/A | | 15 | Doc utilisateur | ⏸️ Phase 2 | Formation | | 31 | Logs Winston | ✅ Phase 1 | Monitoring | +| 5bis | Familles recomposées — 2ᵉ compte v1.0.0 | ✅ v1.0.0 | Métier / dossier | --- @@ -571,10 +593,12 @@ docs/ |------|---------|---------------| | 25/11/2025 | 1.0 | Création du document - Toutes les décisions initiales | | 09/02/2026 | 1.1 | Configuration initiale : un seul panneau Paramètres (3 sections) dans le dashboard, plus de Setup Wizard dédié ; navigation bloquée jusqu'à sauvegarde | +| 16/06/2026 | 1.2 | Décision 5bis — familles recomposées, contournement v1.0.0 ; lien doc [28](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) | +| 16/06/2026 | 1.3 | Précision 5bis — cible post-1.0.0 : parcours gestionnaire § 7.5 (#139), visibilité par enfant | --- -**Dernière mise à jour** : 9 Février 2026 -**Version** : 1.1 +**Dernière mise à jour** : 16 Juin 2026 +**Version** : 1.2 **Statut** : ✅ Document validé diff --git a/docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md b/docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md new file mode 100644 index 0000000..b8cbfbe --- /dev/null +++ b/docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md @@ -0,0 +1,331 @@ +# Évolution — Modèle famille, responsables légaux et dossiers + +**Version** : 1.1 +**Date** : 16 juin 2026 +**Statut** : Réflexions produit / architecture — complément au [CDC](./01_CAHIER-DES-CHARGES.md) +**Documents liés** : [EVOLUTIONS_CDC.md](./EVOLUTIONS_CDC.md), [24_DECISIONS-PROJET.md](./24_DECISIONS-PROJET.md), [23_LISTE-TICKETS.md](./23_LISTE-TICKETS.md) + +--- + +## 1. Objet de ce document + +Ce document **trace les réflexions** menées en 2026 sur : + +- les **limites du modèle « famille / numéro de dossier »** en v1.0.0 ; +- les **contournements** acceptés pour la release **1.0.0** ; +- les **évolutions** envisagées post-1.0.0 (unités de dossier, affiliation parent–enfant, terminologie). + +Il ne remplace pas le CDC : il documente l’**écart assumé** entre le modèle idéal long terme et ce qui est livré en **1.0.0**, ainsi que la **feuille de route** pour aller plus loin. + +--- + +## 2. Modèle actuel (v1.0.0) — rappel + +| Concept | Implémentation | +|--------|----------------| +| **Responsable inscrit** | Rôle applicatif `parent` + entité `parents` | +| **Second adulte** | Co-parent optionnel (`id_co_parent`) — **un seul** | +| **Enfant** | Entité `enfants` | +| **Affiliation** | Table `enfants_parents` (liens many-to-many) | +| **Dossier famille** | `dossier_famille` + `dossier_famille_enfants` (motivation, etc.) | +| **Numéro de dossier** | Format `AAAA-NNNNNN`, sur `utilisateurs` et `parents` | +| **« Famille » calculée** | Graphe : co-parent **ou** enfants partagés (`getFamilyUserIds`) | +| **Workflows** | Validation, refus, reprise : souvent **par `numero_dossier`** ou par ce graphe | + +Le numéro de dossier est aujourd’hui une **aide forte** pour les cas simples (un couple, N enfants, une motivation), mais il tend à devenir **l’identifiant métier** de la famille — ce qui pose problème dans les cas complexes. + +--- + +## 3. Limitation connue v1.0.0 — familles recomposées et multi-contextes + +### 3.1 Exemple type (illustration) + +``` +Année 1 : Responsable M + co-responsable A → enfant α +Année 2 : Responsable M + co-responsable B → enfant β +``` + +Même personne **M** au centre, **deux contextes de vie** distincts. **A** n’est pas parent de β ; **B** n’est pas parent de α. + +### 3.2 Autres cas couverts par la même limitation + +Les exemples « maman / papa » sont **illustratifs**. La limitation s’applique **à toute configuration** : + +| Configuration | Même règle | +|---------------|------------| +| Couple **HH** ou **FF** | Oui | +| **Père** avec deux partenaires et deux enfants (même ville, écarts d’âge courts) | Oui | +| **Grand-parent** en tutelle ou **tuteur légal** | Oui *(voir § 5)* | +| Recomposition, demi-fratrie, garde alternée complexe | Oui | + +### 3.3 Pourquoi le modèle casse + +1. **Un `numero_dossier` par user** — M ne peut pas appartenir proprement à deux unités. +2. **Un seul co-parent** par fiche `parents`. +3. **Graphe famille trop large** — M liée à A (via α) et à B (via β) → A, B et M peuvent être fusionnés en **une seule « famille »** pour validation/refus/reprise. +4. **`dossier_famille`** — une motivation / une ancre par numéro, pas deux contextes pour la même personne. + +--- + +## 4. Décision v1.0.0 — contournement opérationnel + +### 4.1 Principe + +> **Le numéro de dossier reste la norme pour les cas simples, pas une obligation absolue.** +> Pour les cas trop complexes, le **gestionnaire** compose manuellement (création admin, rattachements) ou applique le contournement ci-dessous. + +### 4.2 Contournement accepté pour la 1.0.0 + +**Créer un second compte** avec une **adresse e-mail distincte** et un **second dossier** (second `numero_dossier`). + +| Dossier | Compte | Co-responsable | Enfant | +|---------|--------|----------------|--------| +| 1 | `m.personne@…` | A | α | +| 2 | `m.personne.famille2@…` *(ou alias)* | B | β | + +**Conséquences assumées :** + +- Une **même personne physique** peut avoir **deux identités** dans l’app. +- Pas de vue unifiée « une personne, deux contextes » en v1.0.0. +- Procédure interne gestionnaire recommandée (note « même personne physique »). +- Emails distincts **volontaires** (alias, +tag, boîte dédiée selon infra mail). + +### 4.3 Nuance — inscription publique + +Si le **dossier 1** existe déjà avec l’email de P (titulaire ou co-parent), une **2ᵉ inscription publique** où l’on saisit **le même email** comme co-parent est **bloquée** (*email déjà utilisé*). + +Le **2ᵉ dossier** passe donc surtout par : + +- **création / composition par le gestionnaire** (tickets admin #129+), ou +- **2ᵉ email** dès le départ pour la même personne physique. + +### 4.4 Piste cible — parcours gestionnaire « famille complexe » *(réflexion juin 2026)* + +Le contournement § 4.2 reste valable en **v1.0.0**. La **solution produit visée** pour les cas complexes est différente : + +> **Seul le gestionnaire** dispose d’un **parcours de création dédié** permettant de constituer une configuration avec **plus de deux responsables** (parents, co-parents, tuteurs…) **sans** se limiter au seul champ `co_parent`, en s’appuyant sur **`enfants_parents`** comme vérité de visibilité. + +**Exemple M / A / B / α / β :** + +| Compte | Enfants visibles / rattachés | +|--------|------------------------------| +| **M** (un seul compte, un email) | α **et** β | +| **A** | α uniquement | +| **B** | β uniquement | + +``` + α ─── M ─── β + │ │ + A B +``` + +- **M** voit et gère **ses deux enfants** sur **le même compte** (plus besoin d’un 2ᵉ email pour M). +- **A** et **B** ne voient **que** l’enfant qui leur est rattaché via `enfants_parents` — pas de fusion « famille » qui mélange A et B. +- Le parcours n’est **pas** proposé à l’inscription publique (trop error-prone) : **réservé au gestionnaire** (#129+ ou ticket dédié « famille complexe »). + +**Conséquences techniques (post-1.0.0) :** + +1. **Visibilité** : filtrer listes, fiches et actions parent par **liens `enfants_parents`**, pas par `getFamilyUserIds` / co-parent. +2. **Workflows** (validation, refus, reprise) : périmètre par **enfant** ou **soumission**, pas par graphe familial élargi. +3. **`co_parent`** : reste utile pour le cas simple (couple + N enfants communs) ; **insuffisant seul** pour les cas complexes — le gestionnaire compose les liens enfant par enfant. +4. **Numéro de dossier** : optionnel ou secondaire ; peut rester sur M ou sur une « unité » admin, sans imposer un numéro par co-responsable. + +*Détail : § 7.5.* + +--- + +## 5. Terminologie — « parent » aujourd’hui, qualification demain + +### 5.1 v1.0.0 + +- Rôle technique : **`parent`** pour tout responsable inscrit (y compris tuteur, grand-parent tutrice, etc.). +- UI : « Parent 1 », « co-parent », parfois exemples maman/papa — **pas de statut juridique distinct**. + +### 5.2 Évolution post-1.0.0 (piste) + +Séparer deux niveaux : + +| Niveau | Évolution | +|--------|-----------| +| **Rôle applicatif** | Conserver `parent` = « responsable du dossier » (auth, API) | +| **Qualification métier** | Nouveau champ, ex. `qualite_responsable` / `lien_avec_enfant` | + +**Valeurs possibles (exemples)** : parent biologique ou adoptif, co-parent, tuteur légal, grand-parent exerçant la garde, autre responsable légal. + +**Emplacement recommandé** : sur le **lien** `enfants_parents` (par enfant), pas seulement sur le user global. + +**UI** : + +- Libellés neutres : « Responsable 1 / 2 », « 2ᵉ responsable » ; +- **Combobox** pour qualifier le lien (admin + éventuellement inscription). + +--- + +## 6. Évolutions UI / fonctionnelles liées (backlog) + +Réflexions dashboard admin / gestionnaire (complément CDC §4.5.2–4.5.3). + +> **Statut implémentation (juin 2026)** : §6.1 et §6.2 **en cours de livraison** (tickets #130–#131, #115–#116, #137–#138). §6.3 reporté post-1.0.0 (#129). + +### 6.1 Fiche parent (#131) + +- Modale **éditable** dès l’ouverture (pas lecture seule + « Modifier » factice). +- **Retirer l’ID** UUID ; option : **n° de dossier** en lecture seule. +- **Statut** en combobox (règles métier à cadrer vs validation/refus #110). +- Bas de modale : + - `Nombre d'enfants : N` (lecture seule) ; + - **Liste des prénoms/noms** (cadre) — consultation, clic → fiche enfant (#138). + +### 6.2 Affiliation parent ↔ enfant (#115, #116, #138) + +- **Vérité métier** : `enfants_parents`. +- **Modifier l’affiliation** ≠ modifier le téléphone : gestion des **liens** (détacher / rattacher), avec garde-fous : + - ne pas supprimer l’enfant pour retirer un lien ; + - au moins un responsable par enfant ; + - prudence sur fusion « famille » et co-parents. +- **Création enfant** : prioritaire depuis **fiche parent** (contexte famille) ; onglet **Enfants** (#137) pour vue globale + rattachement. + +### 6.3 Création admin sans numéro (tickets #129+) + +Le gestionnaire doit pouvoir **tout créer** depuis l’interface ; le numéro reste **généré si utile**, **optionnel** si le cas est trop complexe — **objectif post-1.0.0** (unité de dossier). + +--- + +## 7. Évolution structurelle post-1.0.0 — « unité de dossier » + +### 7.1 Principe cible + +| Aujourd’hui | Cible | +|-------------|--------| +| `numero_dossier` = clé de la famille | **Liens parent↔enfant** = vérité | +| Famille déduite du graphe | **Unité de dossier** = regroupement **optionnel** | +| Un numéro par inscription | Numéro **optionnel** ; plusieurs unités par personne possibles | + +### 7.2 Exemple cible (cas M / A / B) — deux approches + +**Approche A — unités de dossier séparées** *(piste initiale § 7)* : + +``` +Unité 1 : numero 2026-000021 — M, A, α +Unité 2 : sans numéro (ou 2026-000089) — M, B, β +``` + +M appartient à **deux unités** ; A et B ne partagent pas la même unité. Peut impliquer **deux contextes de connexion** ou une agrégation côté M. + +**Approche B — parcours gestionnaire « famille complexe »** *(préférée, § 4.4)* : + +``` +Compte M → enfants α, β +Compte A → enfant α +Compte B → enfant β +(liens enfants_parents ; pas de fusion A↔B) +``` + +- **Un seul compte pour M** avec **les deux enfants**. +- **A** et **B** isolés sur **leur** enfant respectif. +- Création **uniquement** par le gestionnaire ; inscription publique inchangée (couple + co-parent classique). + +Les deux approches supposent de **cesser de déduire une « famille » unique** pour les workflows lorsque les liens enfant par enfant divergent. L’approche B maximise l’UX du responsable central (M) sans dupliquer son identité. + +### 7.5 Parcours gestionnaire — création « famille complexe » + +#### 7.5.1 Objectif + +Permettre au **gestionnaire** de monter un dossier où : + +- **N responsables** (≥ 2, parents ou tuteurs) sont créés ou rattachés ; +- chaque enfant est lié **explicitement** à un ou plusieurs responsables via `enfants_parents` ; +- un responsable (ex. **M**) peut être lié à **plusieurs enfants** dont les **autres** responsables (A, B) ne partagent **pas** la garde. + +#### 7.5.2 Règles produit + +| Règle | Détail | +|-------|--------| +| **Accès** | Parcours **gestionnaire uniquement** (dashboard), pas inscription publique | +| **Responsables** | Création ou rattachement de comptes `parent` ; qualification future sur le lien (§ 5) | +| **Visibilité** | Chaque responsable ne voit que **ses** enfants (liens `enfants_parents`) | +| **Co-parent UI** | Ne pas forcer « Parent 1 + co-parent unique » ; composition libre côté staff | +| **Garde-fous** | Au moins un responsable par enfant ; pas de suppression enfant pour retirer un lien | + +#### 7.5.3 Esquisse du parcours UI (gestionnaire) + +1. **Créer ou identifier** le responsable principal (ex. M). +2. **Ajouter d’autres responsables** (A, B, tuteur…) — comptes distincts. +3. **Créer les enfants** (α, β…) et, pour chaque enfant, **cocher les responsables** rattachés. +4. **Motivation / dossier** : texte global ou par enfant (à cadrer). +5. **Validation** : par soumission ou par enfant, sans valider « toute la famille » d’un coup si A et B ne doivent pas être fusionnés. + +#### 7.5.4 Impacts techniques majeurs + +| Domaine | Changement | +|---------|------------| +| **Auth / API parent** | `GET` enfants, dashboard parent : filtre `enfants_parents` pour l’utilisateur courant | +| **`getFamilyUserIds`** | Ne plus utiliser pour visibilité parent ; réservé au cas simple ou deprecated progressivement | +| **Validation / refus** | Périmètre enfant ou responsable, pas fusion A+B via graphe | +| **Reprise (#112)** | Token / périmètre = enfants liés au compte refusé, pas toute la composante connexe | +| **Admin (#115–#138)** | Prérequis : rattachement/détachement déjà en place ; ce parcours **compose** ces briques | + +#### 7.5.5 Lien avec v1.0.0 + +| Phase | Comportement | +|-------|--------------| +| **v1.0.0** | Contournement § 4.2 (2ᵉ email M) + composition manuelle gestionnaire (#115–#138) | +| **Post-1.0.0** | Parcours § 7.5 + refonte visibilité / workflows | + +### 7.3 Impacts workflows + +| Flux | Adaptation future | +|------|-------------------| +| Validation / refus (#110) | Par **enfant / soumission**, pas par graphe global | +| Reprise (#112) | Périmètre = enfants liés au compte, pas composante connexe | +| Liste « à valider » | Par soumission ou par enfant | +| Recherche gestionnaire | Numéro **ou** nom **ou** enfant | +| Visibilité parent | **Uniquement** enfants via `enfants_parents` (approche B § 7.2) | + +### 7.4 Migration + +- **Court terme** : contournement § 4 + tickets admin. +- **Moyen terme** : table **unité de dossier**, `numero_dossier` nullable. +- **Long terme** : workflows branchés sur l’unité. +- Dossiers **existants** (couple + enfants) : une unité = un numéro → **comportement actuel préservé**. + +--- + +## 8. Tickets Gitea associés + +| Ticket | Sujet | +|--------|--------| +| #110 | Refus dossier (token reprise) — livré | +| #112 | Reprise après refus — livré | +| #115 / #116 | Rattachement parent — backend / front | +| #129+ | Création dossier admin (parent / AM) | +| #131 | Fiche parent éditable (dashboard) | +| #137 | Onglet Enfants — liste globale | +| #138 | Fiche enfant + liste dans fiche parent | +| *(à créer)* | Epic « Unité de dossier / numéro optionnel » | +| #139 | **Parcours gestionnaire « famille complexe »** (§ 7.5) — N responsables, visibilité par enfant | +| *(à créer)* | Qualification responsable légal (combobox sur lien enfant) | + +--- + +## 9. Formulation type — release notes / doc gestionnaire (v1.0.0) + +> **Familles recomposées ou responsabilités multiples** +> Une même personne ne peut pas gérer proprement deux unités familiales distinctes (recompositions, plusieurs co-responsables successifs, tuteur/GP pour plusieurs contextes) avec **un seul compte**. +> **Contournement v1.0.0** : second compte avec **email distinct** et second numéro de dossier. +> S’applique à **tous les responsables inscrits** (couples HH/FF, tuteurs, grands-parents, etc.). +> **Évolution post-1.0.0** : parcours **gestionnaire** « famille complexe » (un compte M, enfants α+β ; A et B limités à leur enfant) ; visibilité par `enfants_parents` ; workflows sans fusion graphe familial. + +--- + +## 10. Historique des mises à jour + +| Date | Auteur | Modification | +|------|--------|--------------| +| 2026-06-16 | Équipe / session produit | Création — synthèse réflexions famille, tutelle, v1.0.0 vs post-1.0.0 | +| 2026-06-16 | Implémentation ch.6 | Back : `PATCH /parents/:id/fiche`, attach/detach enfant. Front : modale parent éditable, onglet Enfants, fiche enfant | +| 2026-06-16 | Réflexion produit | § 4.4 / § 7.5 — parcours gestionnaire famille complexe : M un compte (α+β), A/B visibilité restreinte par enfant | + +--- + +*Ce document sera enrichi au fil des décisions. Pour les décisions formelles archivées, voir aussi [24_DECISIONS-PROJET.md](./24_DECISIONS-PROJET.md).* diff --git a/docs/EVOLUTIONS_CDC.md b/docs/EVOLUTIONS_CDC.md index 6ee6d1e..98b57b6 100644 --- a/docs/EVOLUTIONS_CDC.md +++ b/docs/EVOLUTIONS_CDC.md @@ -2,6 +2,8 @@ Ce document liste les modifications à apporter au cahier des charges original pour le rendre conforme à l'application développée. +> **Document complémentaire (juin 2026)** — réflexions sur le **modèle famille / numéro de dossier**, familles recomposées, tuteurs et responsables légaux : voir **[28 - Évolution famille et responsables](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md)**. + ## 1. Gestion des Enfants ### Modifications à apporter dans la section "Création de compte parent" @@ -275,4 +277,29 @@ Pour chaque évolution identifiée, ce document suivra la structure suivante : - Évolution du modèle de données vers un RBAC intra-RPE. - Adaptation des écrans d'administration pour gérer les rôles locaux. - Renforcement des contrôles d'accès backend et des règles métier. -- Clarification des workflows décisionnels dans l'application. \ No newline at end of file +- Clarification des workflows décisionnels dans l'application. + +## 9. Modèle famille, numéro de dossier et responsables légaux + +### 9.1 Situation actuelle (v1.0.0) + +- Un **numéro de dossier** par inscription (`AAAA-NNNNNN`), fortement utilisé dans les workflows (validation, refus, reprise). +- **Un co-parent** par fiche `parents` ; affiliation réelle via `enfants_parents`. +- La « famille » est **déduite** du graphe (co-parent + enfants partagés) — fusion parfois trop large en cas de recompositions. + +### 9.2 Limitation v1.0.0 — familles recomposées + +Cas type : même responsable M avec co-responsable A (enfant α) puis co-responsable B (enfant β). Le modèle actuel ne permet pas de séparer proprement **deux unités familiales** pour une même personne. + +**Contournement accepté pour la release 1.0.0** : second compte avec **email distinct** et second numéro de dossier (procédure gestionnaire). S'applique aussi aux couples HH/FF, tuteurs, grands-parents, etc. + +### 9.3 Évolutions post-1.0.0 (pistes) + +| Sujet | Piste | +|-------|--------| +| **Unité de dossier** | Numéro **optionnel** ; plusieurs unités par personne | +| **Affiliation** | Gestion admin des liens parent↔enfant (#115, #116, #138) | +| **Qualification** | Combobox « lien responsable–enfant » (tuteur, GP, parent…) sur `enfants_parents` | +| **UI admin** | Fiche parent éditable (#131), liste enfants en bas de fiche | + +**Détail complet, scénarios, tickets et formulation release notes** : [28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md). \ No newline at end of file diff --git a/frontend/lib/models/enfant_admin_model.dart b/frontend/lib/models/enfant_admin_model.dart new file mode 100644 index 0000000..2dace4b --- /dev/null +++ b/frontend/lib/models/enfant_admin_model.dart @@ -0,0 +1,106 @@ +import 'package:p_tits_pas/models/user.dart'; + +/// Enfant tel que renvoyé par `GET /enfants` (dashboard admin). +class EnfantAdminModel { + final String id; + final String? firstName; + final String? lastName; + final String? gender; + final String? birthDate; + final String? dueDate; + final String status; + final String? photoUrl; + final bool consentPhoto; + final bool isMultiple; + final List parentLinks; + + EnfantAdminModel({ + required this.id, + this.firstName, + this.lastName, + this.gender, + this.birthDate, + this.dueDate, + required this.status, + this.photoUrl, + this.consentPhoto = false, + this.isMultiple = false, + this.parentLinks = const [], + }); + + String get fullName { + final fn = (firstName ?? '').trim(); + final ln = (lastName ?? '').trim(); + if (fn.isEmpty && ln.isEmpty) return 'Enfant'; + if (ln.isEmpty) return fn; + return '$fn $ln'; + } + + factory EnfantAdminModel.fromJson(Map json) { + final linksRaw = json['parentLinks'] as List?; + final links = []; + if (linksRaw != null) { + for (final item in linksRaw) { + if (item is Map) { + links.add(EnfantParentLink.fromJson(item)); + } + } + } + + return EnfantAdminModel( + id: (json['id'] ?? '').toString(), + firstName: json['first_name'] as String?, + lastName: json['last_name'] as String?, + gender: json['gender'] as String?, + birthDate: _dateString(json['birth_date']), + dueDate: _dateString(json['due_date']), + status: (json['status'] ?? '').toString(), + photoUrl: json['photo_url'] as String?, + consentPhoto: json['consent_photo'] == true, + isMultiple: json['is_multiple'] == true, + parentLinks: links, + ); + } + + Map toUpdateJson() { + return { + if (firstName != null) 'first_name': firstName, + if (lastName != null) 'last_name': lastName, + if (gender != null && gender!.isNotEmpty) 'gender': gender, + 'status': status, + if (birthDate != null && birthDate!.isNotEmpty) 'birth_date': birthDate, + if (dueDate != null && dueDate!.isNotEmpty) 'due_date': dueDate, + 'consent_photo': consentPhoto, + 'is_multiple': isMultiple, + }; + } + + static String? _dateString(dynamic v) { + if (v == null) return null; + if (v is String) return v.split('T').first; + return v.toString().split('T').first; + } +} + +class EnfantParentLink { + final String parentId; + final String? parentName; + + EnfantParentLink({required this.parentId, this.parentName}); + + factory EnfantParentLink.fromJson(Map json) { + final parent = json['parent']; + String? name; + if (parent is Map) { + final user = parent['user']; + if (user is Map) { + final u = AppUser.fromJson(user); + name = u.fullName.isNotEmpty ? u.fullName : u.email; + } + } + return EnfantParentLink( + parentId: (json['parentId'] ?? json['id_parent'] ?? '').toString(), + parentName: name, + ); + } +} diff --git a/frontend/lib/models/parent_child_summary.dart b/frontend/lib/models/parent_child_summary.dart new file mode 100644 index 0000000..d235694 --- /dev/null +++ b/frontend/lib/models/parent_child_summary.dart @@ -0,0 +1,31 @@ +/// Résumé enfant affiché dans la fiche parent (dashboard admin). +class ParentChildSummary { + final String id; + final String? firstName; + final String? lastName; + final String status; + + ParentChildSummary({ + required this.id, + this.firstName, + this.lastName, + required this.status, + }); + + String get fullName { + final fn = (firstName ?? '').trim(); + final ln = (lastName ?? '').trim(); + if (fn.isEmpty && ln.isEmpty) return 'Enfant'; + if (ln.isEmpty) return fn; + return '$fn $ln'; + } + + factory ParentChildSummary.fromJson(Map json) { + return ParentChildSummary( + id: (json['id'] ?? '').toString(), + firstName: json['first_name'] as String?, + lastName: json['last_name'] as String?, + status: (json['status'] ?? '').toString(), + ); + } +} diff --git a/frontend/lib/models/parent_model.dart b/frontend/lib/models/parent_model.dart index 5b893fd..54b4976 100644 --- a/frontend/lib/models/parent_model.dart +++ b/frontend/lib/models/parent_model.dart @@ -1,18 +1,40 @@ +import 'package:p_tits_pas/models/parent_child_summary.dart'; import 'package:p_tits_pas/models/user.dart'; class ParentModel { final AppUser user; final int childrenCount; + final List children; - ParentModel({required this.user, this.childrenCount = 0}); + ParentModel({ + required this.user, + this.childrenCount = 0, + this.children = const [], + }); factory ParentModel.fromJson(Map json) { - final userJson = json['user'] ?? json; + final userJson = Map.from(json['user'] ?? json); + if (json['numero_dossier'] != null && userJson['numero_dossier'] == null) { + userJson['numero_dossier'] = json['numero_dossier']; + } final user = AppUser.fromJson(userJson); - final children = json['parentChildren'] as List?; + + final children = []; + final links = json['parentChildren'] as List?; + if (links != null) { + for (final link in links) { + if (link is Map && link['child'] is Map) { + children.add( + ParentChildSummary.fromJson(link['child'] as Map), + ); + } + } + } + return ParentModel( user: user, - childrenCount: children?.length ?? 0, + childrenCount: children.isNotEmpty ? children.length : (links?.length ?? 0), + children: children, ); } } diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index da671bc..30508a4 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -61,6 +61,7 @@ class ApiConfig { static const String gestionnaires = '/gestionnaires'; static const String parents = '/parents'; static const String assistantesMaternelles = '/assistantes-maternelles'; + static const String enfants = '/enfants'; static const String relais = '/relais'; static const String dossiers = '/dossiers'; diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index 533af83..a98e24e 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:p_tits_pas/models/user.dart'; +import 'package:p_tits_pas/models/enfant_admin_model.dart'; import 'package:p_tits_pas/models/parent_model.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; import 'package:p_tits_pas/models/pending_family.dart'; @@ -369,6 +370,123 @@ class UserService { return data.map((e) => ParentModel.fromJson(e)).toList(); } + static Future getParent(String userId) async { + final response = await http.get( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}/$userId'), + headers: await _headers(), + ); + if (response.statusCode != 200) { + final err = jsonDecode(response.body) as Map?; + throw Exception(_toStr(err?['message']) ?? 'Erreur chargement parent'); + } + return ParentModel.fromJson(jsonDecode(response.body) as Map); + } + + static Future updateParentFiche({ + required String parentUserId, + required Map body, + }) async { + final response = await http.patch( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}/$parentUserId/fiche'), + headers: await _headers(), + body: jsonEncode(body), + ); + if (response.statusCode != 200) { + throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour parent')); + } + return ParentModel.fromJson(jsonDecode(response.body) as Map); + } + + static Future attachEnfantToParent({ + required String parentUserId, + required String enfantId, + }) async { + final response = await http.post( + Uri.parse( + '${ApiConfig.baseUrl}${ApiConfig.parents}/$parentUserId/enfants/$enfantId', + ), + headers: await _headers(), + ); + if (response.statusCode != 200 && response.statusCode != 201) { + throw Exception(_extractErrorMessage(response.body, 'Erreur rattachement enfant')); + } + return ParentModel.fromJson(jsonDecode(response.body) as Map); + } + + static Future detachEnfantFromParent({ + required String parentUserId, + required String enfantId, + }) async { + final response = await http.delete( + Uri.parse( + '${ApiConfig.baseUrl}${ApiConfig.parents}/$parentUserId/enfants/$enfantId', + ), + headers: await _headers(), + ); + if (response.statusCode != 200 && response.statusCode != 204) { + throw Exception(_extractErrorMessage(response.body, 'Erreur détachement enfant')); + } + if (response.body.isEmpty) { + return getParent(parentUserId); + } + return ParentModel.fromJson(jsonDecode(response.body) as Map); + } + + static Future> getEnfants() async { + final response = await http.get( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'), + headers: await _headers(), + ); + if (response.statusCode != 200) { + throw Exception(_extractErrorMessage(response.body, 'Erreur chargement enfants')); + } + final List data = jsonDecode(response.body); + return data + .whereType>() + .map(EnfantAdminModel.fromJson) + .toList(); + } + + static Future getEnfant(String enfantId) async { + final response = await http.get( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'), + headers: await _headers(), + ); + if (response.statusCode != 200) { + throw Exception(_extractErrorMessage(response.body, 'Erreur chargement enfant')); + } + return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map); + } + + static Future updateEnfant({ + required String enfantId, + required Map body, + }) async { + final response = await http.patch( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'), + headers: await _headers(), + body: jsonEncode(body), + ); + if (response.statusCode != 200) { + throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour enfant')); + } + return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map); + } + + static String _extractErrorMessage(String body, String fallback) { + try { + final decoded = jsonDecode(body); + if (decoded is Map) { + final message = decoded['message']; + if (message is List && message.isNotEmpty) { + return message.join(' - '); + } + return _toStr(message) ?? fallback; + } + } catch (_) {} + return fallback; + } + // Récupérer la liste des assistantes maternelles static Future> getAssistantesMaternelles() async { diff --git a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart new file mode 100644 index 0000000..46e9584 --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -0,0 +1,289 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/enfant_admin_model.dart'; +import 'package:p_tits_pas/services/user_service.dart'; + +/// Fiche enfant consultation / édition (ticket #138). +class AdminChildDetailModal extends StatefulWidget { + final EnfantAdminModel enfant; + final VoidCallback? onSaved; + + const AdminChildDetailModal({ + super.key, + required this.enfant, + this.onSaved, + }); + + @override + State createState() => _AdminChildDetailModalState(); +} + +class _AdminChildDetailModalState extends State { + late final TextEditingController _prenomCtrl; + late final TextEditingController _nomCtrl; + late final TextEditingController _birthCtrl; + late final TextEditingController _dueCtrl; + late String _status; + late String _gender; + late bool _consentPhoto; + late bool _isMultiple; + bool _dirty = false; + bool _saving = false; + + static const _statuses = ['a_naitre', 'actif', 'scolarise']; + static const _genders = ['H', 'F', 'Autre']; + + @override + void initState() { + super.initState(); + final e = widget.enfant; + _prenomCtrl = TextEditingController(text: e.firstName ?? ''); + _nomCtrl = TextEditingController(text: e.lastName ?? ''); + _birthCtrl = TextEditingController(text: e.birthDate ?? ''); + _dueCtrl = TextEditingController(text: e.dueDate ?? ''); + _status = _statuses.contains(e.status) ? e.status : 'actif'; + _gender = _genders.contains(e.gender) ? e.gender! : 'H'; + _consentPhoto = e.consentPhoto; + _isMultiple = e.isMultiple; + for (final c in [_prenomCtrl, _nomCtrl, _birthCtrl, _dueCtrl]) { + c.addListener(_markDirty); + } + } + + @override + void dispose() { + for (final c in [_prenomCtrl, _nomCtrl, _birthCtrl, _dueCtrl]) { + c.dispose(); + } + super.dispose(); + } + + void _markDirty() { + if (!_dirty) setState(() => _dirty = true); + } + + Future _save() async { + if (!_dirty) return; + setState(() => _saving = true); + try { + await UserService.updateEnfant( + enfantId: widget.enfant.id, + body: { + 'first_name': _prenomCtrl.text.trim(), + 'last_name': _nomCtrl.text.trim(), + 'status': _status, + 'gender': _gender, + if (_birthCtrl.text.trim().isNotEmpty) 'birth_date': _birthCtrl.text.trim(), + if (_dueCtrl.text.trim().isNotEmpty) 'due_date': _dueCtrl.text.trim(), + 'consent_photo': _consentPhoto, + 'is_multiple': _isMultiple, + }, + ); + if (!mounted) return; + setState(() { + _dirty = false; + _saving = false; + }); + widget.onSaved?.call(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Fiche enfant enregistrée')), + ); + } catch (e) { + if (!mounted) return; + setState(() => _saving = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } + } + + @override + Widget build(BuildContext context) { + final parents = widget.enfant.parentLinks + .map((l) => l.parentName ?? l.parentId) + .where((s) => s.isNotEmpty) + .join(', '); + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560, maxHeight: 640), + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + widget.enfant.fullName, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700), + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + if (parents.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text('Responsables : $parents', style: const TextStyle(color: Colors.black54)), + ), + const Divider(), + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + _rowField('Prénom', TextField(controller: _prenomCtrl, decoration: _decoration())), + _rowField('Nom', TextField(controller: _nomCtrl, decoration: _decoration())), + _rowDropdown( + 'Statut', + _status, + _statuses.map((s) => MapEntry(s, _statusLabel(s))).toList(), + (v) => setState(() { + _status = v; + _dirty = true; + }), + ), + _rowDropdown( + 'Genre', + _gender, + _genders.map((g) => MapEntry(g, g)).toList(), + (v) => setState(() { + _gender = v; + _dirty = true; + }), + ), + _rowField( + 'Date naissance', + TextField( + controller: _birthCtrl, + decoration: _decoration(hint: 'AAAA-MM-JJ'), + ), + ), + _rowField( + 'Date prévue', + TextField( + controller: _dueCtrl, + decoration: _decoration(hint: 'AAAA-MM-JJ'), + ), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Consentement photo'), + value: _consentPhoto, + onChanged: (v) => setState(() { + _consentPhoto = v; + _dirty = true; + }), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Naissance multiple'), + value: _isMultiple, + onChanged: (v) => setState(() { + _isMultiple = v; + _dirty = true; + }), + ), + ], + ), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Fermer'), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: !_dirty || _saving ? null : _save, + icon: _saving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save), + label: const Text('Sauvegarder'), + ), + ], + ), + ], + ), + ), + ), + ); + } + + InputDecoration _decoration({String? hint}) { + return InputDecoration(isDense: true, border: const OutlineInputBorder(), hintText: hint); + } + + Widget _rowField(String label, Widget field) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + child: Padding( + padding: const EdgeInsets.only(top: 12), + child: Text(label, style: const TextStyle(fontWeight: FontWeight.w600)), + ), + ), + Expanded(child: field), + ], + ), + ); + } + + Widget _rowDropdown( + String label, + String value, + List> items, + ValueChanged onChanged, + ) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + SizedBox( + width: 120, + child: Text(label, style: const TextStyle(fontWeight: FontWeight.w600)), + ), + Expanded( + child: DropdownButtonFormField( + value: items.any((e) => e.key == value) ? value : items.first.key, + decoration: _decoration(), + items: items + .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value))) + .toList(), + onChanged: (v) { + if (v != null) onChanged(v); + }, + ), + ), + ], + ), + ); + } + + String _statusLabel(String status) { + switch (status) { + case 'a_naitre': + return 'À naître'; + case 'actif': + return 'Actif'; + case 'scolarise': + return 'Scolarisé'; + default: + return status; + } + } +} diff --git a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart new file mode 100644 index 0000000..babe468 --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart @@ -0,0 +1,477 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/enfant_admin_model.dart'; +import 'package:p_tits_pas/models/parent_child_summary.dart'; +import 'package:p_tits_pas/models/parent_model.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/common/admin_child_detail_modal.dart'; + +/// Fiche parent éditable (doc 28 §6.1, tickets #131 / #138). +class AdminParentEditModal extends StatefulWidget { + final ParentModel parent; + final VoidCallback? onSaved; + + const AdminParentEditModal({ + super.key, + required this.parent, + this.onSaved, + }); + + @override + State createState() => _AdminParentEditModalState(); +} + +class _AdminParentEditModalState extends State { + late final TextEditingController _nomCtrl; + late final TextEditingController _prenomCtrl; + late final TextEditingController _emailCtrl; + late final TextEditingController _telCtrl; + late final TextEditingController _adresseCtrl; + late final TextEditingController _villeCtrl; + late final TextEditingController _cpCtrl; + + late String _statut; + late List _children; + bool _saving = false; + bool _dirty = false; + + static const _statuts = ['actif', 'en_attente', 'suspendu', 'refuse']; + + @override + void initState() { + super.initState(); + final u = widget.parent.user; + _nomCtrl = TextEditingController(text: u.nom ?? ''); + _prenomCtrl = TextEditingController(text: u.prenom ?? ''); + _emailCtrl = TextEditingController(text: u.email); + _telCtrl = TextEditingController(text: u.telephone ?? ''); + _adresseCtrl = TextEditingController(text: u.adresse ?? ''); + _villeCtrl = TextEditingController(text: u.ville ?? ''); + _cpCtrl = TextEditingController(text: u.codePostal ?? ''); + _statut = u.statut ?? 'en_attente'; + _children = List.of(widget.parent.children); + for (final c in [_nomCtrl, _prenomCtrl, _emailCtrl, _telCtrl, _adresseCtrl, _villeCtrl, _cpCtrl]) { + c.addListener(_markDirty); + } + } + + @override + void dispose() { + for (final c in [_nomCtrl, _prenomCtrl, _emailCtrl, _telCtrl, _adresseCtrl, _villeCtrl, _cpCtrl]) { + c.dispose(); + } + super.dispose(); + } + + void _markDirty() { + if (!_dirty) setState(() => _dirty = true); + } + + String _displayStatus(String status) { + switch (status) { + case 'actif': + return 'Actif'; + case 'en_attente': + return 'En attente'; + case 'suspendu': + return 'Suspendu'; + case 'refuse': + return 'Refusé'; + default: + return status; + } + } + + Future _save() async { + if (!_dirty) return; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Confirmer'), + content: const Text('Enregistrer les modifications de la fiche parent ?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Annuler')), + ElevatedButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('Sauvegarder')), + ], + ), + ); + if (confirmed != true || !mounted) return; + + setState(() => _saving = true); + try { + await UserService.updateParentFiche( + parentUserId: widget.parent.user.id, + body: { + 'nom': _nomCtrl.text.trim(), + 'prenom': _prenomCtrl.text.trim(), + 'email': _emailCtrl.text.trim(), + 'telephone': _telCtrl.text.trim(), + 'adresse': _adresseCtrl.text.trim(), + 'ville': _villeCtrl.text.trim(), + 'code_postal': _cpCtrl.text.trim(), + 'statut': _statut, + }, + ); + if (!mounted) return; + setState(() { + _dirty = false; + _saving = false; + }); + widget.onSaved?.call(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Fiche parent enregistrée')), + ); + } catch (e) { + if (!mounted) return; + setState(() => _saving = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } + } + + Future _openChild(ParentChildSummary child) async { + try { + final enfant = await UserService.getEnfant(child.id); + if (!mounted) return; + await showDialog( + context: context, + builder: (ctx) => AdminChildDetailModal( + enfant: enfant, + onSaved: () async { + await _reloadChildren(); + }, + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } + } + + Future _reloadChildren() async { + try { + final refreshed = await UserService.getParent(widget.parent.user.id); + if (!mounted) return; + setState(() => _children = List.of(refreshed.children)); + } catch (_) {} + } + + Future _detachChild(ParentChildSummary child) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Détacher l\'enfant'), + content: Text( + 'Retirer ${child.fullName} de la fiche de ce parent ?\n(L\'enfant ne sera pas supprimé.)', + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Annuler')), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + style: ElevatedButton.styleFrom(backgroundColor: Colors.red.shade700), + child: const Text('Détacher'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + + try { + final updated = await UserService.detachEnfantFromParent( + parentUserId: widget.parent.user.id, + enfantId: child.id, + ); + if (!mounted) return; + setState(() => _children = List.of(updated.children)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Enfant détaché')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } + } + + Future _attachChild() async { + List all; + try { + all = await UserService.getEnfants(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + return; + } + + final linkedIds = _children.map((c) => c.id).toSet(); + final candidates = all.where((e) => !linkedIds.contains(e.id)).toList(); + if (candidates.isEmpty) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Aucun enfant disponible à rattacher')), + ); + return; + } + + if (!mounted) return; + final selected = await showDialog( + context: context, + builder: (ctx) => SimpleDialog( + title: const Text('Rattacher un enfant'), + children: candidates + .map( + (e) => SimpleDialogOption( + onPressed: () => Navigator.pop(ctx, e), + child: Text(e.fullName), + ), + ) + .toList(), + ), + ); + if (selected == null || !mounted) return; + + try { + final updated = await UserService.attachEnfantToParent( + parentUserId: widget.parent.user.id, + enfantId: selected.id, + ); + if (!mounted) return; + setState(() => _children = List.of(updated.children)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Enfant rattaché')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } + } + + Widget _field(String label, TextEditingController ctrl, {TextInputType? keyboard}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 130, + child: Padding( + padding: const EdgeInsets.only(top: 12), + child: Text(label, style: const TextStyle(fontWeight: FontWeight.w600)), + ), + ), + Expanded( + child: TextFormField( + controller: ctrl, + keyboardType: keyboard, + decoration: const InputDecoration(isDense: true, border: OutlineInputBorder()), + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final numero = widget.parent.user.numeroDossier; + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 680, maxHeight: 720), + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.parent.user.fullName.isEmpty + ? 'Parent' + : widget.parent.user.fullName, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700), + ), + Text(widget.parent.user.email, style: const TextStyle(color: Colors.black54)), + ], + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + const Divider(), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (numero != null && numero.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + const SizedBox( + width: 130, + child: Text('N° dossier', style: TextStyle(fontWeight: FontWeight.w600)), + ), + Expanded(child: Text(numero)), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + const SizedBox( + width: 130, + child: Text('Statut', style: TextStyle(fontWeight: FontWeight.w600)), + ), + Expanded( + child: DropdownButtonFormField( + value: _statuts.contains(_statut) ? _statut : _statuts.first, + decoration: const InputDecoration(isDense: true, border: OutlineInputBorder()), + items: _statuts + .map( + (s) => DropdownMenuItem( + value: s, + child: Text(_displayStatus(s)), + ), + ) + .toList(), + onChanged: (v) { + if (v == null) return; + setState(() { + _statut = v; + _dirty = true; + }); + }, + ), + ), + ], + ), + ), + _field('Nom', _nomCtrl), + _field('Prénom', _prenomCtrl), + _field('Email', _emailCtrl, keyboard: TextInputType.emailAddress), + _field('Téléphone', _telCtrl, keyboard: TextInputType.phone), + if (_telCtrl.text.isNotEmpty) + Padding( + padding: const EdgeInsets.only(left: 130, bottom: 4), + child: Text( + formatPhoneForDisplay(_telCtrl.text), + style: const TextStyle(fontSize: 12, color: Colors.black54), + ), + ), + _field('Adresse', _adresseCtrl), + _field('Ville', _villeCtrl), + _field('Code postal', _cpCtrl), + const SizedBox(height: 12), + Text( + 'Nombre d\'enfants : ${_children.length}', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.all(8), + child: _children.isEmpty + ? const Text('Aucun enfant rattaché', style: TextStyle(color: Colors.black54)) + : Column( + children: _children + .map( + (c) => ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: InkWell( + onTap: () => _openChild(c), + child: Text( + c.fullName, + style: const TextStyle( + decoration: TextDecoration.underline, + color: Colors.blue, + ), + ), + ), + subtitle: Text(_childStatusLabel(c.status)), + trailing: IconButton( + tooltip: 'Détacher', + icon: Icon(Icons.link_off, color: Colors.orange.shade800), + onPressed: () => _detachChild(c), + ), + ), + ) + .toList(), + ), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: _attachChild, + icon: const Icon(Icons.link), + label: const Text('Rattacher un enfant'), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: _saving ? null : () => Navigator.of(context).pop(), + child: const Text('Fermer'), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: !_dirty || _saving ? null : _save, + icon: _saving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save), + label: Text(_dirty ? 'Sauvegarder' : 'Aucune modification'), + ), + ], + ), + ], + ), + ), + ), + ); + } + + String _childStatusLabel(String status) { + switch (status) { + case 'a_naitre': + return 'À naître'; + case 'actif': + return 'Actif'; + case 'scolarise': + return 'Scolarisé'; + default: + return status; + } + } +} diff --git a/frontend/lib/widgets/admin/enfant_management_widget.dart b/frontend/lib/widgets/admin/enfant_management_widget.dart new file mode 100644 index 0000000..3353897 --- /dev/null +++ b/frontend/lib/widgets/admin/enfant_management_widget.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/enfant_admin_model.dart'; +import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; +import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; + +/// Onglet liste globale des enfants (doc 28 §6.2, ticket #137). +class EnfantManagementWidget extends StatefulWidget { + final String searchQuery; + final String? statusFilter; + + const EnfantManagementWidget({ + super.key, + required this.searchQuery, + this.statusFilter, + }); + + @override + State createState() => _EnfantManagementWidgetState(); +} + +class _EnfantManagementWidgetState extends State { + bool _isLoading = false; + String? _error; + List _enfants = []; + + @override + void initState() { + super.initState(); + _loadEnfants(); + } + + Future _loadEnfants() async { + setState(() { + _isLoading = true; + _error = null; + }); + try { + final list = await UserService.getEnfants(); + if (!mounted) return; + setState(() { + _enfants = list; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + + String _statusLabel(String status) { + switch (status) { + case 'a_naitre': + return 'À naître'; + case 'actif': + return 'Actif'; + case 'scolarise': + return 'Scolarisé'; + default: + return status; + } + } + + Future _openEnfant(EnfantAdminModel enfant) async { + await showDialog( + context: context, + builder: (ctx) => AdminChildDetailModal( + enfant: enfant, + onSaved: _loadEnfants, + ), + ); + } + + @override + Widget build(BuildContext context) { + final query = widget.searchQuery.toLowerCase(); + final filtered = _enfants.where((e) { + final matchesName = e.fullName.toLowerCase().contains(query); + final matchesStatus = + widget.statusFilter == null || e.status == widget.statusFilter; + return matchesName && matchesStatus; + }).toList(); + + return UserList( + isLoading: _isLoading, + error: _error, + isEmpty: filtered.isEmpty, + emptyMessage: 'Aucun enfant trouvé.', + itemCount: filtered.length, + itemBuilder: (context, index) { + final enfant = filtered[index]; + final parents = enfant.parentLinks + .map((l) => l.parentName ?? 'Parent') + .join(', '); + return AdminUserCard( + title: enfant.fullName, + fallbackIcon: Icons.child_care_outlined, + avatarUrl: enfant.photoUrl, + subtitleLines: [ + 'Statut : ${_statusLabel(enfant.status)}', + if (enfant.birthDate != null && enfant.birthDate!.isNotEmpty) + 'Naissance : ${enfant.birthDate}', + if (parents.isNotEmpty) 'Responsables : $parents', + ], + actions: [ + IconButton( + icon: const Icon(Icons.visibility_outlined), + tooltip: 'Voir / modifier', + onPressed: () => _openEnfant(enfant), + ), + ], + ); + }, + ); + } +} diff --git a/frontend/lib/widgets/admin/parent_managmant_widget.dart b/frontend/lib/widgets/admin/parent_managmant_widget.dart index 6e7e32d..becbd6c 100644 --- a/frontend/lib/widgets/admin/parent_managmant_widget.dart +++ b/frontend/lib/widgets/admin/parent_managmant_widget.dart @@ -1,8 +1,7 @@ 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_parent_edit_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; @@ -80,7 +79,7 @@ class _ParentManagementWidgetState extends State { avatarUrl: parent.user.photoUrl, subtitleLines: [ parent.user.email, - 'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${parent.childrenCount}', + 'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${parent.children.isNotEmpty ? parent.children.length : parent.childrenCount}', ], actions: [ IconButton( @@ -114,45 +113,10 @@ class _ParentManagementWidgetState extends State { void _openParentDetails(ParentModel parent) { showDialog( context: context, - builder: (context) => AdminDetailModal( - title: parent.user.fullName.isEmpty ? 'Parent' : parent.user.fullName, - subtitle: parent.user.email, - fields: [ - AdminDetailField(label: 'ID', value: _v(parent.user.id)), - AdminDetailField( - label: 'Statut', - value: _displayStatus(parent.user.statut), - ), - AdminDetailField( - label: '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)), - AdminDetailField( - label: 'Code postal', - value: _v(parent.user.codePostal), - ), - AdminDetailField( - label: 'Nombre d\'enfants', - value: parent.childrenCount.toString(), - ), - ], - onEdit: () { - Navigator.of(context).pop(); - ScaffoldMessenger.of(this.context).showSnackBar( - const SnackBar(content: Text('Action Modifier a implementer')), - ); - }, - onDelete: () { - Navigator.of(context).pop(); - ScaffoldMessenger.of(this.context).showSnackBar( - const SnackBar(content: Text('Action Supprimer a implementer')), - ); - }, + builder: (context) => AdminParentEditModal( + parent: parent, + onSaved: _loadParents, ), ); } - - String _v(String? value) => (value == null || value.isEmpty) ? '-' : value; } diff --git a/frontend/lib/widgets/admin/user_management_panel.dart b/frontend/lib/widgets/admin/user_management_panel.dart index 83c12de..51cc207 100644 --- a/frontend/lib/widgets/admin/user_management_panel.dart +++ b/frontend/lib/widgets/admin/user_management_panel.dart @@ -4,6 +4,7 @@ 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/enfant_management_widget.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'; @@ -28,6 +29,7 @@ class _UserManagementPanelState extends State { final TextEditingController _searchController = TextEditingController(); final TextEditingController _amCapacityController = TextEditingController(); String? _parentStatus; + String? _enfantStatus; bool _hasPending = false; bool _pendingLoading = true; @@ -80,7 +82,7 @@ class _UserManagementPanelState extends State { } List get _tabLabels { - const base = ['Parents', 'Assistantes maternelles', 'Gestionnaires']; + const base = ['Parents', 'Enfants', '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). @@ -94,11 +96,12 @@ class _UserManagementPanelState extends State { _subIndex = index.clamp(0, maxIndex); _searchController.clear(); _parentStatus = null; + _enfantStatus = null; _amCapacityController.clear(); }); } - /// Index du contenu : -1 = À valider (si visible), 0 = Parents, 1 = AM, 2 = Gestionnaires, 3 = Admin. + /// Index du contenu : -1 = À valider, 0 = Parents, 1 = Enfants, 2 = AM, 3 = Gestionnaires, 4 = Admin. int get _contentIndexOffset => (_hasPending && !_pendingLoading) ? 1 : 0; String _searchHintForTab() { @@ -109,10 +112,12 @@ class _UserManagementPanelState extends State { case 0: return 'Rechercher un parent...'; case 1: - return 'Rechercher une assistante...'; + return 'Rechercher un enfant...'; case 2: - return 'Rechercher un gestionnaire...'; + return 'Rechercher une assistante...'; case 3: + return 'Rechercher un gestionnaire...'; + case 4: return 'Rechercher un administrateur...'; default: return 'Rechercher...'; @@ -176,6 +181,54 @@ class _UserManagementPanelState extends State { } if (_subIndex == _contentIndexOffset + 1) { + return DropdownButtonHideUnderline( + child: DropdownButton( + value: _enfantStatus, + isExpanded: true, + hint: const Padding( + padding: EdgeInsets.only(left: 10), + child: Text('Statut', style: TextStyle(fontSize: 12)), + ), + items: const [ + DropdownMenuItem( + value: null, + child: Padding( + padding: EdgeInsets.only(left: 10), + child: Text('Tous', style: TextStyle(fontSize: 12)), + ), + ), + DropdownMenuItem( + value: 'a_naitre', + child: Padding( + padding: EdgeInsets.only(left: 10), + child: Text('À naître', style: TextStyle(fontSize: 12)), + ), + ), + DropdownMenuItem( + value: 'actif', + child: Padding( + padding: EdgeInsets.only(left: 10), + child: Text('Actif', style: TextStyle(fontSize: 12)), + ), + ), + DropdownMenuItem( + value: 'scolarise', + child: Padding( + padding: EdgeInsets.only(left: 10), + child: Text('Scolarisé', style: TextStyle(fontSize: 12)), + ), + ), + ], + onChanged: (value) { + setState(() { + _enfantStatus = value; + }); + }, + ), + ); + } + + if (_subIndex == _contentIndexOffset + 2) { return TextField( controller: _amCapacityController, decoration: const InputDecoration( @@ -203,16 +256,21 @@ class _UserManagementPanelState extends State { statusFilter: _parentStatus, ); case 1: + return EnfantManagementWidget( + searchQuery: _searchController.text, + statusFilter: _enfantStatus, + ); + case 2: return AssistanteMaternelleManagementWidget( searchQuery: _searchController.text, capacityMin: int.tryParse(_amCapacityController.text), ); - case 2: + case 3: return GestionnaireManagementWidget( key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'), searchQuery: _searchController.text, ); - case 3: + case 4: return AdminManagementWidget( key: ValueKey('admins-$_adminRefreshTick'), searchQuery: _searchController.text, @@ -246,7 +304,7 @@ class _UserManagementPanelState extends State { Future _handleAddPressed() async { final contentIndex = _subIndex - _contentIndexOffset; - if (contentIndex == 2) { + if (contentIndex == 3) { final created = await showDialog( context: context, barrierDismissible: false, @@ -264,7 +322,7 @@ class _UserManagementPanelState extends State { return; } - if (contentIndex == 3) { + if (contentIndex == 4) { final created = await showDialog( context: context, barrierDismissible: false, @@ -289,7 +347,7 @@ class _UserManagementPanelState extends State { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( - 'La création est disponible pour les gestionnaires et administrateurs.', + 'La création parent / enfant / AM sera disponible avec le ticket #129.', ), ), ); From 8494341b56eb8eb1db07da45656dfea3013e0fa1 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Sun, 21 Jun 2026 17:42:00 +0200 Subject: [PATCH 11/85] =?UTF-8?q?feat(#140):=20fiches=20admin=20famille=20?= =?UTF-8?q?=E2=80=94=20IdentityBlock,=20parsing=20enfants=20et=20avatars?= =?UTF-8?q?=20web?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extrait IdentityBlock réutilisable, aligne les modales parent/enfant sur le style validation, corrige le parsing parentChildren et les URLs /uploads en Flutter web. Co-authored-by: Cursor --- frontend/lib/models/enfant_admin_model.dart | 6 +- frontend/lib/models/parent_child_summary.dart | 35 +- frontend/lib/models/parent_model.dart | 46 +- frontend/lib/services/user_service.dart | 22 +- .../common/admin_child_detail_modal.dart | 421 ++++++++++------ .../admin/common/admin_parent_edit_modal.dart | 457 ++++++++++-------- .../widgets/admin/common/admin_user_card.dart | 49 +- .../common/validation_detail_section.dart | 153 +++++- .../widgets/admin/validation_am_wizard.dart | 31 +- .../admin/validation_family_wizard.dart | 35 +- .../lib/widgets/common/identity_block.dart | 278 +++++++++++ 11 files changed, 1075 insertions(+), 458 deletions(-) create mode 100644 frontend/lib/widgets/common/identity_block.dart diff --git a/frontend/lib/models/enfant_admin_model.dart b/frontend/lib/models/enfant_admin_model.dart index 2dace4b..396e263 100644 --- a/frontend/lib/models/enfant_admin_model.dart +++ b/frontend/lib/models/enfant_admin_model.dart @@ -89,8 +89,10 @@ class EnfantParentLink { EnfantParentLink({required this.parentId, this.parentName}); factory EnfantParentLink.fromJson(Map json) { - final parent = json['parent']; + final parentId = + (json['parentId'] ?? json['id_parent'] ?? '').toString(); String? name; + final parent = json['parent']; if (parent is Map) { final user = parent['user']; if (user is Map) { @@ -99,7 +101,7 @@ class EnfantParentLink { } } return EnfantParentLink( - parentId: (json['parentId'] ?? json['id_parent'] ?? '').toString(), + parentId: parentId, parentName: name, ); } diff --git a/frontend/lib/models/parent_child_summary.dart b/frontend/lib/models/parent_child_summary.dart index d235694..e40ccd7 100644 --- a/frontend/lib/models/parent_child_summary.dart +++ b/frontend/lib/models/parent_child_summary.dart @@ -23,9 +23,38 @@ class ParentChildSummary { factory ParentChildSummary.fromJson(Map json) { return ParentChildSummary( id: (json['id'] ?? '').toString(), - firstName: json['first_name'] as String?, - lastName: json['last_name'] as String?, - status: (json['status'] ?? '').toString(), + firstName: (json['first_name'] ?? json['prenom'])?.toString(), + lastName: (json['last_name'] ?? json['nom'])?.toString(), + status: (json['status'] ?? json['statut'] ?? '').toString(), ); } + + /// Parse un lien `parentChildren` (objet enfant imbriqué ou id seul). + static ParentChildSummary? fromParentChildLink(Map link) { + final childRaw = link['child'] ?? link['enfant']; + if (childRaw is Map) { + return ParentChildSummary.fromJson(Map.from(childRaw)); + } + + if (link.containsKey('first_name') || + link.containsKey('prenom') || + link.containsKey('id')) { + final id = (link['id'] ?? '').toString(); + if (id.isNotEmpty && + (link.containsKey('first_name') || link.containsKey('prenom'))) { + return ParentChildSummary.fromJson(link); + } + } + + final enfantId = + link['enfantId'] ?? link['id_enfant'] ?? link['enfant_id']; + if (enfantId != null && enfantId.toString().isNotEmpty) { + return ParentChildSummary( + id: enfantId.toString(), + status: '', + ); + } + + return null; + } } diff --git a/frontend/lib/models/parent_model.dart b/frontend/lib/models/parent_model.dart index 54b4976..1e52b8d 100644 --- a/frontend/lib/models/parent_model.dart +++ b/frontend/lib/models/parent_model.dart @@ -13,28 +13,44 @@ class ParentModel { }); factory ParentModel.fromJson(Map json) { - final userJson = Map.from(json['user'] ?? json); - if (json['numero_dossier'] != null && userJson['numero_dossier'] == null) { - userJson['numero_dossier'] = json['numero_dossier']; + final root = Map.from(json); + final userJson = Map.from(root['user'] ?? root); + if (root['numero_dossier'] != null && userJson['numero_dossier'] == null) { + userJson['numero_dossier'] = root['numero_dossier']; } final user = AppUser.fromJson(userJson); - final children = []; - final links = json['parentChildren'] as List?; - if (links != null) { - for (final link in links) { - if (link is Map && link['child'] is Map) { - children.add( - ParentChildSummary.fromJson(link['child'] as Map), - ); - } - } - } + final children = _parseChildren(root); + final links = root['parentChildren'] ?? root['parent_children']; + final linkCount = links is List ? links.length : 0; return ParentModel( user: user, - childrenCount: children.isNotEmpty ? children.length : (links?.length ?? 0), + childrenCount: children.isNotEmpty ? children.length : linkCount, children: children, ); } + + static List _parseChildren(Map json) { + final children = []; + final seen = {}; + + void add(ParentChildSummary? child) { + if (child == null || child.id.isEmpty || seen.contains(child.id)) return; + seen.add(child.id); + children.add(child); + } + + final links = json['parentChildren'] ?? json['parent_children']; + if (links is List) { + for (final link in links) { + if (link is! Map) continue; + add(ParentChildSummary.fromParentChildLink( + Map.from(link), + )); + } + } + + return children; + } } diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index a98e24e..1d1c87f 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -367,7 +367,9 @@ class UserService { } final List data = jsonDecode(response.body); - return data.map((e) => ParentModel.fromJson(e)).toList(); + return data + .map((e) => ParentModel.fromJson(Map.from(e as Map))) + .toList(); } static Future getParent(String userId) async { @@ -379,7 +381,10 @@ class UserService { final err = jsonDecode(response.body) as Map?; throw Exception(_toStr(err?['message']) ?? 'Erreur chargement parent'); } - return ParentModel.fromJson(jsonDecode(response.body) as Map); + final decoded = jsonDecode(response.body); + return ParentModel.fromJson( + Map.from(decoded is Map ? decoded : {}), + ); } static Future updateParentFiche({ @@ -394,7 +399,7 @@ class UserService { if (response.statusCode != 200) { throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour parent')); } - return ParentModel.fromJson(jsonDecode(response.body) as Map); + return _parentModelFromBody(response.body); } static Future attachEnfantToParent({ @@ -410,7 +415,7 @@ class UserService { if (response.statusCode != 200 && response.statusCode != 201) { throw Exception(_extractErrorMessage(response.body, 'Erreur rattachement enfant')); } - return ParentModel.fromJson(jsonDecode(response.body) as Map); + return _parentModelFromBody(response.body); } static Future detachEnfantFromParent({ @@ -429,7 +434,7 @@ class UserService { if (response.body.isEmpty) { return getParent(parentUserId); } - return ParentModel.fromJson(jsonDecode(response.body) as Map); + return _parentModelFromBody(response.body); } static Future> getEnfants() async { @@ -473,6 +478,13 @@ class UserService { return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map); } + static ParentModel _parentModelFromBody(String body) { + final decoded = jsonDecode(body); + return ParentModel.fromJson( + Map.from(decoded is Map ? decoded : {}), + ); + } + static String _extractErrorMessage(String body, String fallback) { try { final decoded = jsonDecode(body); diff --git a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart index 46e9584..8e0cc97 100644 --- a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -32,6 +32,12 @@ class _AdminChildDetailModalState extends State { static const _statuses = ['a_naitre', 'actif', 'scolarise']; static const _genders = ['H', 'F', 'Autre']; + static const _labelStyle = TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.black87, + ); + @override void initState() { super.initState(); @@ -41,7 +47,7 @@ class _AdminChildDetailModalState extends State { _birthCtrl = TextEditingController(text: e.birthDate ?? ''); _dueCtrl = TextEditingController(text: e.dueDate ?? ''); _status = _statuses.contains(e.status) ? e.status : 'actif'; - _gender = _genders.contains(e.gender) ? e.gender! : 'H'; + _gender = _normalizeGender(e.gender); _consentPhoto = e.consentPhoto; _isMultiple = e.isMultiple; for (final c in [_prenomCtrl, _nomCtrl, _birthCtrl, _dueCtrl]) { @@ -57,6 +63,13 @@ class _AdminChildDetailModalState extends State { super.dispose(); } + static String _normalizeGender(String? raw) { + final g = (raw ?? '').trim(); + if (g == 'M') return 'H'; + if (_genders.contains(g)) return g; + return 'H'; + } + void _markDirty() { if (!_dirty) setState(() => _dirty = true); } @@ -72,7 +85,8 @@ class _AdminChildDetailModalState extends State { 'last_name': _nomCtrl.text.trim(), 'status': _status, 'gender': _gender, - if (_birthCtrl.text.trim().isNotEmpty) 'birth_date': _birthCtrl.text.trim(), + if (_birthCtrl.text.trim().isNotEmpty) + 'birth_date': _birthCtrl.text.trim(), if (_dueCtrl.text.trim().isNotEmpty) 'due_date': _dueCtrl.text.trim(), 'consent_photo': _consentPhoto, 'is_multiple': _isMultiple, @@ -96,124 +110,215 @@ class _AdminChildDetailModalState extends State { } } + String _parentsLine() { + final names = widget.enfant.parentLinks + .map((l) { + final n = (l.parentName ?? '').trim(); + return n.isNotEmpty ? n : 'Parent rattaché'; + }) + .where((s) => s.isNotEmpty) + .toList(); + if (names.isEmpty) return ''; + return names.join(', '); + } + @override Widget build(BuildContext context) { - final parents = widget.enfant.parentLinks - .map((l) => l.parentName ?? l.parentId) - .where((s) => s.isNotEmpty) - .join(', '); + final parents = _parentsLine(); + final showDueDate = _status == 'a_naitre'; return Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 560, maxHeight: 640), - child: Padding( - padding: const EdgeInsets.all(18), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded( - child: Text( - widget.enfant.fullName, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700), + child: SizedBox( + width: 480, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 640), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.enfant.fullName, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + if (parents.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + 'Responsables : $parents', + style: const TextStyle( + fontSize: 13, + color: Colors.black54, + ), + ), + ], + ], + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + tooltip: 'Fermer', + ), + ], + ), + const SizedBox(height: 12), + const Divider(height: 1), + const SizedBox(height: 12), + Flexible( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _labeledField( + 'Prénom', + TextField( + controller: _prenomCtrl, + decoration: _decoration(), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _labeledField( + 'Nom', + TextField( + controller: _nomCtrl, + decoration: _decoration(), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _labeledDropdown( + 'Statut', + _status, + _statuses + .map((s) => MapEntry(s, _statusLabel(s))) + .toList(), + (v) => setState(() { + _status = v; + _dirty = true; + }), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _labeledDropdown( + 'Genre', + _gender, + _genders + .map((g) => MapEntry(g, _genderLabel(g))) + .toList(), + (v) => setState(() { + _gender = v; + _dirty = true; + }), + ), + ), + ], + ), + const SizedBox(height: 12), + if (showDueDate) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _labeledField( + 'Date de naissance', + TextField( + controller: _birthCtrl, + decoration: + _decoration(hint: 'AAAA-MM-JJ'), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _labeledField( + 'Date prévue', + TextField( + controller: _dueCtrl, + decoration: + _decoration(hint: 'AAAA-MM-JJ'), + ), + ), + ), + ], + ) + else + _labeledField( + 'Date de naissance', + TextField( + controller: _birthCtrl, + decoration: _decoration(hint: 'AAAA-MM-JJ'), + ), + ), + const SizedBox(height: 16), + _switchRow( + 'Consentement photo', + _consentPhoto, + (v) => setState(() { + _consentPhoto = v; + _dirty = true; + }), + ), + _switchRow( + 'Naissance multiple', + _isMultiple, + (v) => setState(() { + _isMultiple = v; + _dirty = true; + }), + ), + ], ), ), - IconButton( - onPressed: () => Navigator.of(context).pop(), - icon: const Icon(Icons.close), - ), - ], - ), - if (parents.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text('Responsables : $parents', style: const TextStyle(color: Colors.black54)), ), - const Divider(), - Expanded( - child: SingleChildScrollView( - child: Column( - children: [ - _rowField('Prénom', TextField(controller: _prenomCtrl, decoration: _decoration())), - _rowField('Nom', TextField(controller: _nomCtrl, decoration: _decoration())), - _rowDropdown( - 'Statut', - _status, - _statuses.map((s) => MapEntry(s, _statusLabel(s))).toList(), - (v) => setState(() { - _status = v; - _dirty = true; - }), - ), - _rowDropdown( - 'Genre', - _gender, - _genders.map((g) => MapEntry(g, g)).toList(), - (v) => setState(() { - _gender = v; - _dirty = true; - }), - ), - _rowField( - 'Date naissance', - TextField( - controller: _birthCtrl, - decoration: _decoration(hint: 'AAAA-MM-JJ'), - ), - ), - _rowField( - 'Date prévue', - TextField( - controller: _dueCtrl, - decoration: _decoration(hint: 'AAAA-MM-JJ'), - ), - ), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Consentement photo'), - value: _consentPhoto, - onChanged: (v) => setState(() { - _consentPhoto = v; - _dirty = true; - }), - ), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Naissance multiple'), - value: _isMultiple, - onChanged: (v) => setState(() { - _isMultiple = v; - _dirty = true; - }), - ), - ], - ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Fermer'), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: !_dirty || _saving ? null : _save, + icon: _saving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save), + label: const Text('Sauvegarder'), + ), + ], ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Fermer'), - ), - const SizedBox(width: 8), - ElevatedButton.icon( - onPressed: !_dirty || _saving ? null : _save, - icon: _saving - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.save), - label: const Text('Sauvegarder'), - ), - ], - ), - ], + ], + ), ), ), ), @@ -221,53 +326,62 @@ class _AdminChildDetailModalState extends State { } InputDecoration _decoration({String? hint}) { - return InputDecoration(isDense: true, border: const OutlineInputBorder(), hintText: hint); - } - - Widget _rowField(String label, Widget field) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 120, - child: Padding( - padding: const EdgeInsets.only(top: 12), - child: Text(label, style: const TextStyle(fontWeight: FontWeight.w600)), - ), - ), - Expanded(child: field), - ], - ), + return InputDecoration( + isDense: true, + border: const OutlineInputBorder(), + hintText: hint, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), ); } - Widget _rowDropdown( + Widget _labeledField(String label, Widget field) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(label, style: _labelStyle), + const SizedBox(height: 4), + field, + ], + ); + } + + Widget _labeledDropdown( String label, String value, List> items, ValueChanged onChanged, ) { + final safeValue = + items.any((e) => e.key == value) ? value : items.first.key; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(label, style: _labelStyle), + const SizedBox(height: 4), + DropdownButtonFormField( + value: safeValue, + isExpanded: true, + decoration: _decoration(), + items: items + .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value))) + .toList(), + onChanged: (v) { + if (v != null) onChanged(v); + }, + ), + ], + ); + } + + Widget _switchRow(String label, bool value, ValueChanged onChanged) { return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), + padding: const EdgeInsets.symmetric(vertical: 4), child: Row( children: [ - SizedBox( - width: 120, - child: Text(label, style: const TextStyle(fontWeight: FontWeight.w600)), - ), - Expanded( - child: DropdownButtonFormField( - value: items.any((e) => e.key == value) ? value : items.first.key, - decoration: _decoration(), - items: items - .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value))) - .toList(), - onChanged: (v) { - if (v != null) onChanged(v); - }, - ), + Expanded(child: Text(label, style: _labelStyle)), + Switch( + value: value, + onChanged: onChanged, ), ], ), @@ -286,4 +400,17 @@ class _AdminChildDetailModalState extends State { return status; } } + + String _genderLabel(String gender) { + switch (gender) { + case 'H': + return 'Garçon'; + case 'F': + return 'Fille'; + case 'Autre': + return 'Autre'; + default: + return gender; + } + } } diff --git a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart index babe468..44c1585 100644 --- a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart @@ -1,12 +1,16 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/enfant_admin_model.dart'; +import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/models/parent_child_summary.dart'; import 'package:p_tits_pas/models/parent_model.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/common/admin_child_detail_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; +import 'package:p_tits_pas/widgets/common/identity_block.dart'; +import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; /// Fiche parent éditable (doc 28 §6.1, tickets #131 / #138). +/// Shell et typo alignés sur [ValidationDossierModal] / wizards validation. class AdminParentEditModal extends StatefulWidget { final ParentModel parent; final VoidCallback? onSaved; @@ -37,6 +41,10 @@ class _AdminParentEditModalState extends State { static const _statuts = ['actif', 'en_attente', 'suspendu', 'refuse']; + static const double _modalWidth = 930; + /// Corps plus haut que les modales validation pour agrandir la liste enfants. + static const double _bodyHeight = 580; + @override void initState() { super.initState(); @@ -44,20 +52,39 @@ class _AdminParentEditModalState extends State { _nomCtrl = TextEditingController(text: u.nom ?? ''); _prenomCtrl = TextEditingController(text: u.prenom ?? ''); _emailCtrl = TextEditingController(text: u.email); - _telCtrl = TextEditingController(text: u.telephone ?? ''); + _telCtrl = TextEditingController( + text: formatPhoneForDisplay(u.telephone ?? ''), + ); _adresseCtrl = TextEditingController(text: u.adresse ?? ''); _villeCtrl = TextEditingController(text: u.ville ?? ''); _cpCtrl = TextEditingController(text: u.codePostal ?? ''); _statut = u.statut ?? 'en_attente'; _children = List.of(widget.parent.children); - for (final c in [_nomCtrl, _prenomCtrl, _emailCtrl, _telCtrl, _adresseCtrl, _villeCtrl, _cpCtrl]) { + for (final c in [ + _nomCtrl, + _prenomCtrl, + _emailCtrl, + _telCtrl, + _adresseCtrl, + _villeCtrl, + _cpCtrl, + ]) { c.addListener(_markDirty); } + _reloadChildren(); } @override void dispose() { - for (final c in [_nomCtrl, _prenomCtrl, _emailCtrl, _telCtrl, _adresseCtrl, _villeCtrl, _cpCtrl]) { + for (final c in [ + _nomCtrl, + _prenomCtrl, + _emailCtrl, + _telCtrl, + _adresseCtrl, + _villeCtrl, + _cpCtrl, + ]) { c.dispose(); } super.dispose(); @@ -88,10 +115,18 @@ class _AdminParentEditModalState extends State { context: context, builder: (ctx) => AlertDialog( title: const Text('Confirmer'), - content: const Text('Enregistrer les modifications de la fiche parent ?'), + content: const Text( + 'Enregistrer les modifications de la fiche parent ?', + ), actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Annuler')), - ElevatedButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('Sauvegarder')), + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Sauvegarder'), + ), ], ), ); @@ -105,7 +140,7 @@ class _AdminParentEditModalState extends State { 'nom': _nomCtrl.text.trim(), 'prenom': _prenomCtrl.text.trim(), 'email': _emailCtrl.text.trim(), - 'telephone': _telCtrl.text.trim(), + 'telephone': normalizePhone(_telCtrl.text), 'adresse': _adresseCtrl.text.trim(), 'ville': _villeCtrl.text.trim(), 'code_postal': _cpCtrl.text.trim(), @@ -138,9 +173,7 @@ class _AdminParentEditModalState extends State { context: context, builder: (ctx) => AdminChildDetailModal( enfant: enfant, - onSaved: () async { - await _reloadChildren(); - }, + onSaved: _reloadChildren, ), ); } catch (e) { @@ -154,8 +187,26 @@ class _AdminParentEditModalState extends State { Future _reloadChildren() async { try { final refreshed = await UserService.getParent(widget.parent.user.id); + var kids = List.of(refreshed.children); + if (kids.any((c) => c.fullName == 'Enfant')) { + final all = await UserService.getEnfants(); + final byId = {for (final e in all) e.id: e}; + kids = kids + .map((c) { + if (c.fullName != 'Enfant') return c; + final e = byId[c.id]; + if (e == null) return c; + return ParentChildSummary( + id: c.id, + firstName: e.firstName, + lastName: e.lastName, + status: c.status.isNotEmpty ? c.status : e.status, + ); + }) + .toList(); + } if (!mounted) return; - setState(() => _children = List.of(refreshed.children)); + setState(() => _children = kids); } catch (_) {} } @@ -165,13 +216,19 @@ class _AdminParentEditModalState extends State { builder: (ctx) => AlertDialog( title: const Text('Détacher l\'enfant'), content: Text( - 'Retirer ${child.fullName} de la fiche de ce parent ?\n(L\'enfant ne sera pas supprimé.)', + 'Retirer ${child.fullName} de la fiche de ce parent ?\n' + '(L\'enfant ne sera pas supprimé.)', ), actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Annuler')), + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Annuler'), + ), ElevatedButton( onPressed: () => Navigator.pop(ctx, true), - style: ElevatedButton.styleFrom(backgroundColor: Colors.red.shade700), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red.shade700, + ), child: const Text('Détacher'), ), ], @@ -254,209 +311,197 @@ class _AdminParentEditModalState extends State { } } - Widget _field(String label, TextEditingController ctrl, {TextInputType? keyboard}) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 130, - child: Padding( - padding: const EdgeInsets.only(top: 12), - child: Text(label, style: const TextStyle(fontWeight: FontWeight.w600)), - ), - ), - Expanded( - child: TextFormField( - controller: ctrl, - keyboardType: keyboard, - decoration: const InputDecoration(isDense: true, border: OutlineInputBorder()), - ), - ), - ], + Widget _statusDropdown() { + return Container( + height: 34, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: Colors.black26), ), + padding: const EdgeInsets.symmetric(horizontal: 12), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _statuts.contains(_statut) ? _statut : _statuts.first, + isExpanded: true, + isDense: true, + style: const TextStyle(fontSize: 13, color: Colors.black87), + icon: const Icon(Icons.arrow_drop_down, size: 20), + items: _statuts + .map( + (s) => DropdownMenuItem( + value: s, + child: Text(_displayStatus(s)), + ), + ) + .toList(), + onChanged: (v) { + if (v == null) return; + setState(() { + _statut = v; + _dirty = true; + }); + }, + ), + ), + ); + } + + Widget _identityFields() { + return IdentityBlock.editable( + nomController: _nomCtrl, + prenomController: _prenomCtrl, + telephoneController: _telCtrl, + emailController: _emailCtrl, + adresseController: _adresseCtrl, + codePostalController: _cpCtrl, + villeController: _villeCtrl, + ); + } + + Widget _childrenPanel() { + return Container( + width: double.infinity, + decoration: ValidationFieldDecoration.container(), + child: _children.isEmpty + ? const Center( + child: Padding( + padding: EdgeInsets.all(20), + child: Text( + 'Aucun enfant rattaché', + style: TextStyle(fontSize: 14, color: Colors.black54), + ), + ), + ) + : ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: _children.length, + separatorBuilder: (_, __) => Divider( + height: 1, + color: Colors.grey.shade300, + ), + itemBuilder: (_, i) { + final c = _children[i]; + return ListTile( + dense: true, + title: InkWell( + onTap: () => _openChild(c), + child: Text( + c.fullName, + style: const TextStyle( + fontSize: 14, + color: Colors.black87, + decoration: TextDecoration.underline, + ), + ), + ), + subtitle: Text( + _childStatusLabel(c.status), + style: const TextStyle(fontSize: 12, color: Colors.black54), + ), + trailing: IconButton( + tooltip: 'Détacher', + icon: Icon(Icons.link_off, color: Colors.orange.shade800), + onPressed: () => _detachChild(c), + ), + ); + }, + ), + ); + } + + Widget _buildFooter() { + return Row( + children: [ + TextButton( + onPressed: _saving ? null : () => Navigator.of(context).pop(), + child: const Text('Fermer'), + ), + const Spacer(), + TextButton.icon( + onPressed: _attachChild, + icon: const Icon(Icons.link, size: 18), + label: const Text('Rattacher un enfant'), + ), + const SizedBox(width: 12), + ElevatedButton( + style: ValidationModalTheme.primaryElevatedStyle, + onPressed: !_dirty || _saving ? null : _save, + child: _saving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text(_dirty ? 'Sauvegarder' : 'Aucune modification'), + ), + ], ); } @override Widget build(BuildContext context) { - final numero = widget.parent.user.numeroDossier; + final maxH = MediaQuery.of(context).size.height * 0.85; + return Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 680, maxHeight: 720), - child: Padding( - padding: const EdgeInsets.all(18), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.parent.user.fullName.isEmpty - ? 'Parent' - : widget.parent.user.fullName, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700), - ), - Text(widget.parent.user.email, style: const TextStyle(color: Colors.black54)), - ], - ), - ), - IconButton( - onPressed: () => Navigator.of(context).pop(), - icon: const Icon(Icons.close), - ), - ], - ), - const Divider(), - Expanded( - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (numero != null && numero.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - children: [ - const SizedBox( - width: 130, - child: Text('N° dossier', style: TextStyle(fontWeight: FontWeight.w600)), - ), - Expanded(child: Text(numero)), - ], - ), - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - children: [ - const SizedBox( - width: 130, - child: Text('Statut', style: TextStyle(fontWeight: FontWeight.w600)), - ), - Expanded( - child: DropdownButtonFormField( - value: _statuts.contains(_statut) ? _statut : _statuts.first, - decoration: const InputDecoration(isDense: true, border: OutlineInputBorder()), - items: _statuts - .map( - (s) => DropdownMenuItem( - value: s, - child: Text(_displayStatus(s)), - ), - ) - .toList(), - onChanged: (v) { - if (v == null) return; - setState(() { - _statut = v; - _dirty = true; - }); - }, - ), - ), - ], - ), - ), - _field('Nom', _nomCtrl), - _field('Prénom', _prenomCtrl), - _field('Email', _emailCtrl, keyboard: TextInputType.emailAddress), - _field('Téléphone', _telCtrl, keyboard: TextInputType.phone), - if (_telCtrl.text.isNotEmpty) - Padding( - padding: const EdgeInsets.only(left: 130, bottom: 4), - child: Text( - formatPhoneForDisplay(_telCtrl.text), - style: const TextStyle(fontSize: 12, color: Colors.black54), - ), - ), - _field('Adresse', _adresseCtrl), - _field('Ville', _villeCtrl), - _field('Code postal', _cpCtrl), - const SizedBox(height: 12), - Text( - 'Nombre d\'enfants : ${_children.length}', - style: const TextStyle(fontWeight: FontWeight.w600), - ), - const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.all(8), - child: _children.isEmpty - ? const Text('Aucun enfant rattaché', style: TextStyle(color: Colors.black54)) - : Column( - children: _children - .map( - (c) => ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - title: InkWell( - onTap: () => _openChild(c), - child: Text( - c.fullName, - style: const TextStyle( - decoration: TextDecoration.underline, - color: Colors.blue, - ), - ), - ), - subtitle: Text(_childStatusLabel(c.status)), - trailing: IconButton( - tooltip: 'Détacher', - icon: Icon(Icons.link_off, color: Colors.orange.shade800), - onPressed: () => _detachChild(c), - ), - ), - ) - .toList(), - ), - ), - const SizedBox(height: 8), - Align( - alignment: Alignment.centerLeft, - child: TextButton.icon( - onPressed: _attachChild, - icon: const Icon(Icons.link), - label: const Text('Rattacher un enfant'), - ), - ), - ], + constraints: BoxConstraints(maxWidth: _modalWidth, maxHeight: maxH), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(18, 18, 0, 12), + child: Text( + 'Fiche parent', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700), ), ), + const Spacer(), + SizedBox(width: 160, child: _statusDropdown()), + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + tooltip: 'Fermer', + ), + ], + ), + const Divider(height: 1), + SizedBox( + height: _bodyHeight, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _identityFields(), + Row( + children: [ + Text( + 'Nombre d\'enfants : ${_children.length}', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + ], + ), + const SizedBox(height: 8), + Expanded(child: _childrenPanel()), + const SizedBox(height: 24), + _buildFooter(), + ], + ), ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: _saving ? null : () => Navigator.of(context).pop(), - child: const Text('Fermer'), - ), - const SizedBox(width: 8), - ElevatedButton.icon( - onPressed: !_dirty || _saving ? null : _save, - icon: _saving - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.save), - label: Text(_dirty ? 'Sauvegarder' : 'Aucune modification'), - ), - ], - ), - ], - ), + ), + ], ), ), ); diff --git a/frontend/lib/widgets/admin/common/admin_user_card.dart b/frontend/lib/widgets/admin/common/admin_user_card.dart index 92d4237..0e6c5ab 100644 --- a/frontend/lib/widgets/admin/common/admin_user_card.dart +++ b/frontend/lib/widgets/admin/common/admin_user_card.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:p_tits_pas/services/api/api_config.dart'; +import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; class AdminUserCard extends StatefulWidget { final String title; @@ -37,6 +39,7 @@ class _AdminUserCardState extends State { widget.subtitleLines.where((e) => e.trim().isNotEmpty).join(' '); final actionsWidth = widget.actions.isNotEmpty ? widget.actions.length * 30.0 : 0.0; + final avatarUrl = ApiConfig.absoluteMediaUrl(widget.avatarUrl); return MouseRegion( onEnter: (_) => setState(() => _isHovered = true), @@ -60,20 +63,7 @@ class _AdminUserCardState extends State { padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), child: Row( children: [ - CircleAvatar( - radius: 14, - backgroundColor: const Color(0xFFEDE5FA), - backgroundImage: widget.avatarUrl != null - ? NetworkImage(widget.avatarUrl!) - : null, - child: widget.avatarUrl == null - ? Icon( - widget.fallbackIcon, - size: 16, - color: const Color(0xFF6B3FA0), - ) - : null, - ), + _buildAvatar(avatarUrl), const SizedBox(width: 10), Expanded( child: Row( @@ -140,4 +130,35 @@ class _AdminUserCardState extends State { ), ); } + + Widget _buildAvatar(String url) { + const size = 28.0; + const bg = Color(0xFFEDE5FA); + const iconColor = Color(0xFF6B3FA0); + + if (url.isEmpty) { + return CircleAvatar( + radius: 14, + backgroundColor: bg, + child: Icon(widget.fallbackIcon, size: 16, color: iconColor), + ); + } + + return ClipOval( + child: SizedBox( + width: size, + height: size, + child: AuthNetworkImage( + url: url, + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => ColoredBox( + color: bg, + child: Icon(widget.fallbackIcon, size: 16, color: iconColor), + ), + ), + ), + ); + } } diff --git a/frontend/lib/widgets/admin/common/validation_detail_section.dart b/frontend/lib/widgets/admin/common/validation_detail_section.dart index a0f36e2..ce1a3ba 100644 --- a/frontend/lib/widgets/admin/common/validation_detail_section.dart +++ b/frontend/lib/widgets/admin/common/validation_detail_section.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'admin_detail_modal.dart'; /// Bloc type formulaire (titre de section + champs read-only) pour les modales de validation. @@ -23,6 +24,39 @@ class ValidationDetailSection extends StatelessWidget { this.rowFlex, }); + @override + Widget build(BuildContext context) { + return ValidationFormGrid( + title: title, + rowLayout: rowLayout, + rowFlex: rowFlex, + fields: fields + .map( + (f) => ValidationLabeledField( + label: f.label, + field: ValidationReadOnlyField(value: f.value), + ), + ) + .toList(), + ); + } +} + +/// Grille label/champ réutilisable (validation, fiches admin). +class ValidationFormGrid extends StatelessWidget { + final String? title; + final List fields; + final List? rowLayout; + final Map>? rowFlex; + + const ValidationFormGrid({ + super.key, + this.title, + required this.fields, + this.rowLayout, + this.rowFlex, + }); + @override Widget build(BuildContext context) { final layout = rowLayout ?? List.filled(fields.length, 1); @@ -39,7 +73,7 @@ class ValidationDetailSection extends StatelessWidget { if (count == 1) { rows.add(Padding( padding: const EdgeInsets.only(bottom: 12), - child: _buildFieldCell(rowFields.first), + child: rowFields.first, )); } else { rows.add(Padding( @@ -53,7 +87,7 @@ class ValidationDetailSection extends StatelessWidget { flex: (flexForRow != null && i < flexForRow.length) ? flexForRow[i] : 1, - child: _buildFieldCell(rowFields[i]), + child: rowFields[i], ), ], ], @@ -81,14 +115,62 @@ class ValidationDetailSection extends StatelessWidget { ], ); } +} - Widget _buildFieldCell(AdminDetailField field) { +/// Décoration commune lecture seule / édition (modales validation, fiches admin). +class ValidationFieldDecoration { + ValidationFieldDecoration._(); + + static InputDecoration input({String? hint}) { + return InputDecoration( + isDense: true, + filled: true, + fillColor: Colors.grey.shade50, + hintText: hint, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: BorderSide(color: Colors.grey.shade500), + ), + ); + } + + static BoxDecoration container() { + return BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.grey.shade300), + ); + } +} + +/// Libellé au-dessus d’un champ (même typo que [ValidationDetailSection]). +class ValidationLabeledField extends StatelessWidget { + final String label; + final Widget field; + + const ValidationLabeledField({ + super.key, + required this.label, + required this.field, + }); + + @override + Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( - field.label, + label, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, @@ -96,13 +178,66 @@ class ValidationDetailSection extends StatelessWidget { ), ), const SizedBox(height: 4), - ValidationReadOnlyField(value: field.value), + field, ], ); } } -/// Champ texte en lecture seule, style formulaire (fond gris léger, bordure). Réutilisable en éditable plus tard. +/// Champ texte éditable, même rendu que [ValidationReadOnlyField]. +class ValidationEditableField extends StatelessWidget { + final TextEditingController controller; + final TextInputType keyboardType; + final List? inputFormatters; + final String? hintText; + final int maxLines; + + const ValidationEditableField({ + super.key, + required this.controller, + this.keyboardType = TextInputType.text, + this.inputFormatters, + this.hintText, + this.maxLines = 1, + }); + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + maxLines: maxLines, + style: const TextStyle(color: Colors.black87, fontSize: 14), + decoration: ValidationFieldDecoration.input(hint: hintText), + ); + } +} + +/// Grille label/champ éditable (délègue à [ValidationFormGrid]). +class ValidationEditableSection extends StatelessWidget { + final List fields; + final List? rowLayout; + final Map>? rowFlex; + + const ValidationEditableSection({ + super.key, + required this.fields, + this.rowLayout, + this.rowFlex, + }); + + @override + Widget build(BuildContext context) { + return ValidationFormGrid( + rowLayout: rowLayout, + rowFlex: rowFlex, + fields: fields, + ); + } +} + +/// Champ texte en lecture seule, style formulaire (fond gris léger, bordure). class ValidationReadOnlyField extends StatelessWidget { final String value; final int? maxLines; @@ -118,11 +253,7 @@ class ValidationReadOnlyField extends StatelessWidget { 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), - ), + decoration: ValidationFieldDecoration.container(), child: Text( value, style: const TextStyle(color: Colors.black87, fontSize: 14), diff --git a/frontend/lib/widgets/admin/validation_am_wizard.dart b/frontend/lib/widgets/admin/validation_am_wizard.dart index 21356b9..5ea035a 100644 --- a/frontend/lib/widgets/admin/validation_am_wizard.dart +++ b/frontend/lib/widgets/admin/validation_am_wizard.dart @@ -1,13 +1,13 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart'; import 'package:p_tits_pas/utils/date_display_utils.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 'package:p_tits_pas/widgets/common/identity_block.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'validation_modal_theme.dart'; import 'validation_refus_form.dart'; @@ -60,21 +60,6 @@ class _ValidationAmWizardState extends State { 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)), - ]; - /// Panneau photo + grille droite : NIR|naissance, ville|pays, agrément|date, capa|places. List _photoProFields(DossierAM d) { final u = d.user; @@ -110,10 +95,7 @@ class _ValidationAmWizardState extends State { ]; } - static const List _personalRowLayout = [2, 2, 1, 2]; - static const Map> _personalRowFlex = { - 3: [2, 5] - }; // Code postal étroit, Ville large + static const List _photoProRowLayout = [2, 2, 2, 2]; /// Proportion photo d’identité (35×45 mm). static const double _idPhotoAspectRatio = 35 / 45; @@ -266,11 +248,10 @@ class _ValidationAmWizardState extends State { return SingleChildScrollView( child: ConstrainedBox( constraints: BoxConstraints(minWidth: constraints.maxWidth), - child: ValidationDetailSection( + child: IdentityBlock.readOnlyFromUser( + u, title: 'Identité et coordonnées', - fields: _personalFields(u), - rowLayout: _personalRowLayout, - rowFlex: _personalRowFlex, + emptyLabel: '–', ), ), ); @@ -311,7 +292,7 @@ class _ValidationAmWizardState extends State { child: ValidationDetailSection( title: 'Dossier professionnel', fields: _photoProFields(d), - rowLayout: const [2, 2, 2, 2], + rowLayout: _photoProRowLayout, ), ), ); diff --git a/frontend/lib/widgets/admin/validation_family_wizard.dart b/frontend/lib/widgets/admin/validation_family_wizard.dart index 96297bc..533f25d 100644 --- a/frontend/lib/widgets/admin/validation_family_wizard.dart +++ b/frontend/lib/widgets/admin/validation_family_wizard.dart @@ -3,11 +3,10 @@ import 'package:flutter/gestures.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart'; import 'package:p_tits_pas/utils/date_display_utils.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 'package:p_tits_pas/widgets/common/identity_block.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'validation_modal_theme.dart'; import 'validation_refus_form.dart'; @@ -108,26 +107,6 @@ class _ValidationFamilyWizardState extends State { static String _formatBirthDate(String? s) => formatIsoDateFr(s, ifEmpty: 'Non défini'); - /// 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) => ApiConfig.absoluteMediaUrl(url); @override @@ -153,11 +132,9 @@ class _ValidationFamilyWizardState extends State { final d = widget.dossier; switch (_step) { case 0: - return ValidationDetailSection( + return IdentityBlock.readOnlyFromParentDossier( + d.parents.first, title: 'Parent principal', - fields: _parentFields(d.parents.first), - rowLayout: _parentRowLayout, - rowFlex: _parentRowFlex, ); case 1: return _buildParent2Step(); @@ -178,11 +155,9 @@ class _ValidationFamilyWizardState extends State { style: TextStyle(color: Colors.black87)), ); } - return ValidationDetailSection( + return IdentityBlock.readOnlyFromParentDossier( + widget.dossier.parents[1], title: 'Deuxième parent', - fields: _parentFields(widget.dossier.parents[1]), - rowLayout: _parentRowLayout, - rowFlex: _parentRowFlex, ); } diff --git a/frontend/lib/widgets/common/identity_block.dart b/frontend/lib/widgets/common/identity_block.dart new file mode 100644 index 0000000..91a1d30 --- /dev/null +++ b/frontend/lib/widgets/common/identity_block.dart @@ -0,0 +1,278 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/dossier_unifie.dart'; +import 'package:p_tits_pas/models/user.dart'; +import 'package:p_tits_pas/utils/phone_utils.dart'; +import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; + +/// Valeurs affichées dans un [IdentityBlock] en lecture seule. +class IdentityValues { + final String nom; + final String prenom; + final String telephone; + final String email; + final String adresse; + final String codePostal; + final String ville; + + const IdentityValues({ + required this.nom, + required this.prenom, + required this.telephone, + required this.email, + required this.adresse, + required this.codePostal, + required this.ville, + }); + + static String _fmt(String? s, String empty) { + final t = (s ?? '').trim(); + return t.isEmpty ? empty : t; + } + + static String _fmtPhone(String? s, String empty) { + final tel = _fmt(s, empty); + return tel != empty ? formatPhoneForDisplay(tel) : tel; + } + + factory IdentityValues.fromUser( + AppUser user, { + String emptyLabel = 'Non défini', + }) { + return IdentityValues( + nom: _fmt(user.nom, emptyLabel), + prenom: _fmt(user.prenom, emptyLabel), + telephone: _fmtPhone(user.telephone, emptyLabel), + email: _fmt(user.email, emptyLabel), + adresse: _fmt(user.adresse, emptyLabel), + codePostal: _fmt(user.codePostal, emptyLabel), + ville: _fmt(user.ville, emptyLabel), + ); + } + + factory IdentityValues.fromParentDossier( + ParentDossier parent, { + String emptyLabel = 'Non défini', + }) { + return IdentityValues( + nom: _fmt(parent.nom, emptyLabel), + prenom: _fmt(parent.prenom, emptyLabel), + telephone: _fmtPhone(parent.telephone, emptyLabel), + email: _fmt(parent.email, emptyLabel), + adresse: _fmt(parent.adresse, emptyLabel), + codePostal: _fmt(parent.codePostal, emptyLabel), + ville: _fmt(parent.ville, emptyLabel), + ); + } +} + +/// Bloc identité : Nom/Prénom, Tél/Email, Adresse, CP/Ville. +/// Grille partagée (création de compte, validation AM/famille, fiches admin, etc.). +class IdentityBlock extends StatelessWidget { final String? title; + + final String? _nom; + final String? _prenom; + final String? _telephone; + final String? _email; + final String? _adresse; + final String? _codePostal; + final String? _ville; + + final TextEditingController? _nomCtrl; + final TextEditingController? _prenomCtrl; + final TextEditingController? _telCtrl; + final TextEditingController? _emailCtrl; + final TextEditingController? _adresseCtrl; + final TextEditingController? _cpCtrl; + final TextEditingController? _villeCtrl; + + static const List rowLayout = [2, 2, 1, 2]; + static const Map> rowFlex = {3: [2, 5]}; + + const IdentityBlock.readOnly({ + super.key, + this.title, + required String nom, + required String prenom, + required String telephone, + required String email, + required String adresse, + required String codePostal, + required String ville, + }) : _nom = nom, + _prenom = prenom, + _telephone = telephone, + _email = email, + _adresse = adresse, + _codePostal = codePostal, + _ville = ville, + _nomCtrl = null, + _prenomCtrl = null, + _telCtrl = null, + _emailCtrl = null, + _adresseCtrl = null, + _cpCtrl = null, + _villeCtrl = null; + + const IdentityBlock.editable({ + super.key, + this.title, + required TextEditingController nomController, + required TextEditingController prenomController, + required TextEditingController telephoneController, + required TextEditingController emailController, + required TextEditingController adresseController, + required TextEditingController codePostalController, + required TextEditingController villeController, + }) : _nom = null, + _prenom = null, + _telephone = null, + _email = null, + _adresse = null, + _codePostal = null, + _ville = null, + _nomCtrl = nomController, + _prenomCtrl = prenomController, + _telCtrl = telephoneController, + _emailCtrl = emailController, + _adresseCtrl = adresseController, + _cpCtrl = codePostalController, + _villeCtrl = villeController; + + /// Lecture seule à partir de [IdentityValues]. + factory IdentityBlock.readOnlyValues({ + Key? key, + String? title, + required IdentityValues values, + }) { + return IdentityBlock.readOnly( + key: key, + title: title, + nom: values.nom, + prenom: values.prenom, + telephone: values.telephone, + email: values.email, + adresse: values.adresse, + codePostal: values.codePostal, + ville: values.ville, + ); + } + + /// Lecture seule depuis un [AppUser] (validation AM, fiche AM, etc.). + factory IdentityBlock.readOnlyFromUser( + AppUser user, { + Key? key, + String? title, + String emptyLabel = 'Non défini', + }) { + return IdentityBlock.readOnlyValues( + key: key, + title: title, + values: IdentityValues.fromUser(user, emptyLabel: emptyLabel), + ); + } + + /// Lecture seule depuis un [ParentDossier] (validation famille). + factory IdentityBlock.readOnlyFromParentDossier( + ParentDossier parent, { + Key? key, + String? title, + String emptyLabel = 'Non défini', + }) { + return IdentityBlock.readOnlyValues( + key: key, + title: title, + values: IdentityValues.fromParentDossier( + parent, + emptyLabel: emptyLabel, + ), + ); + } + + bool get _isEditable => _nomCtrl != null; + @override + Widget build(BuildContext context) { + if (_isEditable) { + return ValidationFormGrid( + title: title, + rowLayout: rowLayout, + rowFlex: rowFlex, + fields: [ + ValidationLabeledField( + label: 'Nom', + field: ValidationEditableField(controller: _nomCtrl!), + ), + ValidationLabeledField( + label: 'Prénom', + field: ValidationEditableField(controller: _prenomCtrl!), + ), + ValidationLabeledField( + label: 'Téléphone', + field: ValidationEditableField( + controller: _telCtrl!, + keyboardType: TextInputType.phone, + inputFormatters: frenchPhoneInputFormatters, + ), + ), + ValidationLabeledField( + label: 'Email', + field: ValidationEditableField( + controller: _emailCtrl!, + keyboardType: TextInputType.emailAddress, + ), + ), + ValidationLabeledField( + label: 'Adresse (N° et Rue)', + field: ValidationEditableField(controller: _adresseCtrl!), + ), + ValidationLabeledField( + label: 'Code postal', + field: ValidationEditableField( + controller: _cpCtrl!, + keyboardType: TextInputType.number, + ), + ), + ValidationLabeledField( + label: 'Ville', + field: ValidationEditableField(controller: _villeCtrl!), + ), + ], + ); + } + + return ValidationFormGrid( + title: title, + rowLayout: rowLayout, + rowFlex: rowFlex, + fields: [ + ValidationLabeledField( + label: 'Nom', + field: ValidationReadOnlyField(value: _nom!), + ), + ValidationLabeledField( + label: 'Prénom', + field: ValidationReadOnlyField(value: _prenom!), + ), + ValidationLabeledField( + label: 'Téléphone', + field: ValidationReadOnlyField(value: _telephone!), + ), + ValidationLabeledField( + label: 'Email', + field: ValidationReadOnlyField(value: _email!), + ), + ValidationLabeledField( + label: 'Adresse (N° et Rue)', + field: ValidationReadOnlyField(value: _adresse!), + ), + ValidationLabeledField( + label: 'Code postal', + field: ValidationReadOnlyField(value: _codePostal!), + ), + ValidationLabeledField( + label: 'Ville', + field: ValidationReadOnlyField(value: _ville!), + ), + ], + ); + } +} From 966f4a9c9c5b858a4a5c156b034b0c778a739c1a Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Wed, 24 Jun 2026 23:06:38 +0200 Subject: [PATCH 12/85] =?UTF-8?q?feat(#140):=20liste=20enfants=20unifi?= =?UTF-8?q?=C3=A9e=20=E2=80=94=20cartes,=20=C3=A2ge=20pr=C3=A9cis=20et=20c?= =?UTF-8?q?adre=20scrollable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Partage AdminEnfantUserCard entre fiche parent et onglet Enfants, enrichit ParentChildSummary (photo, dates) et compacte la modale parent. Co-authored-by: Cursor --- .../update-gitea-issue-140-ch6-famille.js | 153 +++++++++++++++ frontend/lib/models/parent_child_summary.dart | 29 +++ frontend/lib/utils/date_display_utils.dart | 68 +++++++ .../admin/common/admin_enfant_user_card.dart | 107 +++++++++++ .../admin/common/admin_parent_edit_modal.dart | 177 ++++++++---------- .../admin/enfant_management_widget.dart | 30 +-- 6 files changed, 435 insertions(+), 129 deletions(-) create mode 100644 backend/scripts/update-gitea-issue-140-ch6-famille.js create mode 100644 frontend/lib/widgets/admin/common/admin_enfant_user_card.dart diff --git a/backend/scripts/update-gitea-issue-140-ch6-famille.js b/backend/scripts/update-gitea-issue-140-ch6-famille.js new file mode 100644 index 0000000..d2f0992 --- /dev/null +++ b/backend/scripts/update-gitea-issue-140-ch6-famille.js @@ -0,0 +1,153 @@ +/** + * Met à jour l'issue Gitea #140 — epic dashboard admin ch.6 famille. + * Usage: node backend/scripts/update-gitea-issue-140-ch6-famille.js + * Token : .gitea-token (racine), GITEA_TOKEN, ou docs/27_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/27_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é : .gitea-token ou GITEA_TOKEN (voir docs/26_GITEA-API.md)'); + process.exit(1); +} + +const body = `## Rôle de ce ticket + +**#140 est un ticket epic / livraison** : il regroupe la mise en œuvre du **chapitre 6** du doc [28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md](../docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) (§6.1 et §6.2) sur la branche \`feature/140-dashboard-admin-ch6-famille\`. + +Il **ne remplace pas** les tickets détaillés ci-dessous : il sert de **fil de livraison** (PR, recette, fermeture coordonnée). Chaque sous-ticket garde son périmètre propre ; #140 est clos quand l'ensemble est **fonctionnel et homogène en UI**. + +--- + +## Tickets couverts (périmètres embarqués) + +| Ticket | Sujet | Rôle dans #140 | +|--------|--------|----------------| +| **#115** | Rattachement parent — **backend** | API \`POST/DELETE …/enfants/:enfantId\` | +| **#116** | Rattachement parent — **frontend** | UI rattacher / détacher depuis la fiche parent | +| **#130** | \`UserService\` — APIs admin | Appels \`getParents\`, \`getParent\`, \`getEnfants\`, \`updateParentFiche\`, etc. | +| **#131** | Fiche parent éditable | Modale parent (dashboard) — *hors fiche AM* | +| **#137** | Onglet **Enfants** | Liste globale admin + accès fiche enfant | +| **#138** | Fiche enfant + liste dans fiche parent | \`AdminChildDetailModal\` + liste enfants en bas de fiche parent | + +> **Note :** fermer #140 peut entraîner la fermeture **partielle ou totale** de ces tickets selon ce qui est réellement livré et recetté dans la PR. + +--- + +## Hors scope #140 + +- **§6.3** — Création dossier admin sans numéro → **#129** +- Fiche **AM** éditable (dashboard) → reste **#131** (partie AM) +- Parcours gestionnaire « famille complexe » → **#139** +- Qualification responsable légal (combobox sur lien) → ticket à créer + +--- + +## Backend (attendu / livré) + +- [x] \`PATCH /parents/:id/fiche\` — édition fiche parent (admin/gestionnaire) +- [x] \`POST /parents/:id/enfants/:enfantId\` — rattacher un enfant existant +- [x] \`DELETE /parents/:id/enfants/:enfantId\` — détacher (garde-fou : ≥1 responsable / enfant) +- [x] \`GET /enfants\` enrichi ; \`PATCH /enfants/:id\` pour gestionnaire + +--- + +## Frontend — fait + +- [x] Modale **fiche parent** éditable (shell aligné validation, statut gélule, téléphone formaté, \`IdentityBlock\`) +- [x] Onglet **Enfants** — liste globale (\`EnfantManagementWidget\`) +- [x] Liste enfants dans fiche parent — cartes (photo, nom, âge ans/mois, statut), cadre blanc, scroll 2,5 lignes +- [x] Rattacher / détacher un enfant **existant** (API branchée) +- [x] Parsing robuste \`parentChildren\` + URLs médias \`/uploads\` en Flutter web +- [x] \`UserService\` — APIs parents / enfants / affiliation + +--- + +## Frontend — reste à faire (bloquant clôture) + +### Fiche enfant — #138 (UI) +- [ ] Reprendre \`AdminChildDetailModal\` : même **look & feel** que fiche parent / modales validation (largeur ~930 px, grille champs, photo enfant) +- [ ] Aligner dates, genre, statut sur les wizards validation famille + +### Modale **rattacher** un enfant — #116 (UI) +- [ ] Remplacer le \`SimpleDialog\` actuel par une modale cohérente : liste type \`AdminEnfantUserCard\` (photo, nom, âge), recherche éventuelle + +### Création d'un **nouvel** enfant depuis la fiche parent — doc §6.2 +- [ ] **Non implémenté** aujourd'hui (seul le rattachement d'un enfant déjà en base existe) +- [ ] À trancher : inclus dans #140 si le back expose un \`POST\` admin depuis le contexte parent, sinon ticket dédié / extension #129 + +--- + +## Recette avant merge + +1. Parent avec 0 / 1 / 3+ enfants — liste, scroll, compteur +2. Rattacher puis détacher (message si dernier responsable) +3. Clic enfant → fiche enfant (après refonte UI) +4. Onglet Enfants — même rendu cartes +5. Avatars photos (URLs absolues web) + +--- + +## Branche + +\`feature/140-dashboard-admin-ch6-famille\` + +## Références + +- Doc produit : \`docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md\` §6.1, §6.2`; + +const payload = JSON.stringify({ body }); + +const req = https.request( + { + hostname: 'git.ptits-pas.fr', + path: '/api/v1/repos/jmartin/petitspas/issues/140', + method: 'PATCH', + headers: { + Authorization: `token ${token}`, + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload), + }, + }, + (res) => { + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + const json = JSON.parse(data); + console.log('Issue #140 mise à jour :', json.html_url); + console.log('updated_at:', json.updated_at); + } else { + console.error('Erreur', res.statusCode, data); + process.exit(1); + } + }); + }, +); +req.on('error', (err) => { + console.error(err); + process.exit(1); +}); +req.write(payload); +req.end(); diff --git a/frontend/lib/models/parent_child_summary.dart b/frontend/lib/models/parent_child_summary.dart index e40ccd7..837ff4b 100644 --- a/frontend/lib/models/parent_child_summary.dart +++ b/frontend/lib/models/parent_child_summary.dart @@ -1,15 +1,23 @@ +import 'package:p_tits_pas/models/enfant_admin_model.dart'; + /// Résumé enfant affiché dans la fiche parent (dashboard admin). class ParentChildSummary { final String id; final String? firstName; final String? lastName; final String status; + final String? photoUrl; + final String? birthDate; + final String? dueDate; ParentChildSummary({ required this.id, this.firstName, this.lastName, required this.status, + this.photoUrl, + this.birthDate, + this.dueDate, }); String get fullName { @@ -26,9 +34,30 @@ class ParentChildSummary { firstName: (json['first_name'] ?? json['prenom'])?.toString(), lastName: (json['last_name'] ?? json['nom'])?.toString(), status: (json['status'] ?? json['statut'] ?? '').toString(), + photoUrl: json['photo_url']?.toString(), + birthDate: _dateString(json['birth_date']), + dueDate: _dateString(json['due_date']), ); } + factory ParentChildSummary.fromEnfant(EnfantAdminModel enfant) { + return ParentChildSummary( + id: enfant.id, + firstName: enfant.firstName, + lastName: enfant.lastName, + status: enfant.status, + photoUrl: enfant.photoUrl, + birthDate: enfant.birthDate, + dueDate: enfant.dueDate, + ); + } + + static String? _dateString(dynamic v) { + if (v == null) return null; + if (v is String) return v.split('T').first; + return v.toString().split('T').first; + } + /// Parse un lien `parentChildren` (objet enfant imbriqué ou id seul). static ParentChildSummary? fromParentChildLink(Map link) { final childRaw = link['child'] ?? link['enfant']; diff --git a/frontend/lib/utils/date_display_utils.dart b/frontend/lib/utils/date_display_utils.dart index 075f2d2..81318e3 100644 --- a/frontend/lib/utils/date_display_utils.dart +++ b/frontend/lib/utils/date_display_utils.dart @@ -9,3 +9,71 @@ String formatIsoDateFr(String? s, {String ifEmpty = '–'}) { return s.trim(); } } + +/// Parties d'âge calendaire entre [birth] et [reference] (date-only). +({int years, int months, int days}) computeChildAgeParts( + DateTime birth, + DateTime reference, +) { + final birthDay = DateTime(birth.year, birth.month, birth.day); + final refDay = DateTime(reference.year, reference.month, reference.day); + + var years = refDay.year - birthDay.year; + var months = refDay.month - birthDay.month; + var days = refDay.day - birthDay.day; + + if (days < 0) { + months--; + final prevMonth = DateTime(refDay.year, refDay.month, 0); + days += prevMonth.day; + } + if (months < 0) { + years--; + months += 12; + } + + return (years: years, months: months, days: days); +} + +String _yearLabel(int years) => years == 1 ? '1 an' : '$years ans'; + +String _monthLabel(int months) => months == 1 ? '1 mois' : '$months mois'; + +/// Libellé d'âge pour un enfant (liste admin, fiche parent, etc.). +String formatChildAgeLabel({ + String? birthDate, + String? dueDate, + String? status, +}) { + if (status == 'a_naitre') { + final due = formatIsoDateFr(dueDate, ifEmpty: ''); + if (due.isNotEmpty) return 'Naissance prévue : $due'; + return 'À naître'; + } + + if (birthDate == null || birthDate.trim().isEmpty) return ''; + + try { + final birth = DateTime.parse(birthDate.trim().split('T').first); + final now = DateTime.now(); + final parts = computeChildAgeParts(birth, now); + + if (parts.years >= 1) { + if (parts.months > 0) { + return 'Âge : ${_yearLabel(parts.years)} ${parts.months} mois'; + } + return 'Âge : ${_yearLabel(parts.years)}'; + } + if (parts.months >= 1) { + return 'Âge : ${_monthLabel(parts.months)}'; + } + + final totalDays = DateTime(now.year, now.month, now.day) + .difference(DateTime(birth.year, birth.month, birth.day)) + .inDays; + if (totalDays <= 0) return 'Âge : né récemment'; + return 'Âge : $totalDays jour${totalDays > 1 ? 's' : ''}'; + } catch (_) { + return 'Né le ${formatIsoDateFr(birthDate)}'; + } +} diff --git a/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart b/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart new file mode 100644 index 0000000..f45f059 --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/enfant_admin_model.dart'; +import 'package:p_tits_pas/models/parent_child_summary.dart'; +import 'package:p_tits_pas/utils/date_display_utils.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; + +String enfantStatusLabel(String status) { + switch (status) { + case 'a_naitre': + return 'À naître'; + case 'actif': + return 'Actif'; + case 'scolarise': + return 'Scolarisé'; + default: + return status; + } +} + +List enfantAdminSubtitleLines({ + required String status, + String? birthDate, + String? dueDate, + List extra = const [], +}) { + final lines = []; + final age = formatChildAgeLabel( + birthDate: birthDate, + dueDate: dueDate, + status: status, + ); + if (age.isNotEmpty) lines.add(age); + if (status.isNotEmpty) { + lines.add('Statut : ${enfantStatusLabel(status)}'); + } + lines.addAll(extra); + return lines; +} + +/// Carte enfant admin (photo, nom, âge) — même rendu onglet Enfants / fiche parent. +class AdminEnfantUserCard extends StatelessWidget { + final String title; + final String? photoUrl; + final List subtitleLines; + final List actions; + + const AdminEnfantUserCard({ + super.key, + required this.title, + this.photoUrl, + required this.subtitleLines, + this.actions = const [], + }); + + factory AdminEnfantUserCard.fromEnfant( + EnfantAdminModel enfant, { + List extraSubtitleLines = const [], + List actions = const [], + }) { + final parents = enfant.parentLinks + .map((l) => l.parentName ?? 'Parent') + .join(', '); + return AdminEnfantUserCard( + title: enfant.fullName, + photoUrl: enfant.photoUrl, + subtitleLines: enfantAdminSubtitleLines( + status: enfant.status, + birthDate: enfant.birthDate, + dueDate: enfant.dueDate, + extra: [ + if (parents.isNotEmpty) 'Responsables : $parents', + ...extraSubtitleLines, + ], + ), + actions: actions, + ); + } + + factory AdminEnfantUserCard.fromSummary( + ParentChildSummary child, { + List extraSubtitleLines = const [], + List actions = const [], + }) { + return AdminEnfantUserCard( + title: child.fullName, + photoUrl: child.photoUrl, + subtitleLines: enfantAdminSubtitleLines( + status: child.status, + birthDate: child.birthDate, + dueDate: child.dueDate, + extra: extraSubtitleLines, + ), + actions: actions, + ); + } + + @override + Widget build(BuildContext context) { + return AdminUserCard( + title: title, + fallbackIcon: Icons.child_care_outlined, + avatarUrl: photoUrl, + subtitleLines: subtitleLines, + actions: actions, + ); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart index 44c1585..a12ffe5 100644 --- a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart @@ -5,7 +5,7 @@ import 'package:p_tits_pas/models/parent_child_summary.dart'; import 'package:p_tits_pas/models/parent_model.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; -import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart'; import 'package:p_tits_pas/widgets/common/identity_block.dart'; import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; @@ -36,14 +36,17 @@ class _AdminParentEditModalState extends State { late String _statut; late List _children; + late final ScrollController _childrenScrollCtrl; bool _saving = false; bool _dirty = false; static const _statuts = ['actif', 'en_attente', 'suspendu', 'refuse']; static const double _modalWidth = 930; - /// Corps plus haut que les modales validation pour agrandir la liste enfants. - static const double _bodyHeight = 580; + /// Hauteur d'une ligne enfant (carte + marge) — viewport = 2,5 lignes visibles. + static const double _childListItemHeight = 58; + static const double _childrenListViewportHeight = + _childListItemHeight * 2.5 + 8; @override void initState() { @@ -60,6 +63,7 @@ class _AdminParentEditModalState extends State { _cpCtrl = TextEditingController(text: u.codePostal ?? ''); _statut = u.statut ?? 'en_attente'; _children = List.of(widget.parent.children); + _childrenScrollCtrl = ScrollController(); for (final c in [ _nomCtrl, _prenomCtrl, @@ -87,6 +91,7 @@ class _AdminParentEditModalState extends State { ]) { c.dispose(); } + _childrenScrollCtrl.dispose(); super.dispose(); } @@ -187,24 +192,15 @@ class _AdminParentEditModalState extends State { Future _reloadChildren() async { try { final refreshed = await UserService.getParent(widget.parent.user.id); - var kids = List.of(refreshed.children); - if (kids.any((c) => c.fullName == 'Enfant')) { - final all = await UserService.getEnfants(); - final byId = {for (final e in all) e.id: e}; - kids = kids - .map((c) { - if (c.fullName != 'Enfant') return c; - final e = byId[c.id]; - if (e == null) return c; - return ParentChildSummary( - id: c.id, - firstName: e.firstName, - lastName: e.lastName, - status: c.status.isNotEmpty ? c.status : e.status, - ); - }) - .toList(); - } + final all = await UserService.getEnfants(); + final byId = {for (final e in all) e.id: e}; + + final kids = refreshed.children.map((c) { + final full = byId[c.id]; + if (full != null) return ParentChildSummary.fromEnfant(full); + return c; + }).toList(); + if (!mounted) return; setState(() => _children = kids); } catch (_) {} @@ -237,12 +233,13 @@ class _AdminParentEditModalState extends State { if (confirmed != true || !mounted) return; try { - final updated = await UserService.detachEnfantFromParent( + await UserService.detachEnfantFromParent( parentUserId: widget.parent.user.id, enfantId: child.id, ); if (!mounted) return; - setState(() => _children = List.of(updated.children)); + await _reloadChildren(); + if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Enfant détaché')), ); @@ -294,12 +291,13 @@ class _AdminParentEditModalState extends State { if (selected == null || !mounted) return; try { - final updated = await UserService.attachEnfantToParent( + await UserService.attachEnfantToParent( parentUserId: widget.parent.user.id, enfantId: selected.id, ); if (!mounted) return; - setState(() => _children = List.of(updated.children)); + await _reloadChildren(); + if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Enfant rattaché')), ); @@ -361,49 +359,41 @@ class _AdminParentEditModalState extends State { Widget _childrenPanel() { return Container( - width: double.infinity, - decoration: ValidationFieldDecoration.container(), + height: _childrenListViewportHeight, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + ), + clipBehavior: Clip.antiAlias, child: _children.isEmpty ? const Center( - child: Padding( - padding: EdgeInsets.all(20), - child: Text( - 'Aucun enfant rattaché', - style: TextStyle(fontSize: 14, color: Colors.black54), - ), + child: Text( + 'Aucun enfant rattaché', + style: TextStyle(fontSize: 14, color: Colors.black54), ), ) - : ListView.separated( - padding: const EdgeInsets.symmetric(vertical: 4), + : ListView.builder( + controller: _childrenScrollCtrl, + padding: const EdgeInsets.fromLTRB(8, 8, 8, 4), + itemExtent: _childListItemHeight, itemCount: _children.length, - separatorBuilder: (_, __) => Divider( - height: 1, - color: Colors.grey.shade300, - ), itemBuilder: (_, i) { final c = _children[i]; - return ListTile( - dense: true, - title: InkWell( - onTap: () => _openChild(c), - child: Text( - c.fullName, - style: const TextStyle( - fontSize: 14, - color: Colors.black87, - decoration: TextDecoration.underline, - ), + return AdminEnfantUserCard.fromSummary( + c, + actions: [ + IconButton( + icon: const Icon(Icons.visibility_outlined), + tooltip: 'Voir / modifier', + onPressed: () => _openChild(c), ), - ), - subtitle: Text( - _childStatusLabel(c.status), - style: const TextStyle(fontSize: 12, color: Colors.black54), - ), - trailing: IconButton( - tooltip: 'Détacher', - icon: Icon(Icons.link_off, color: Colors.orange.shade800), - onPressed: () => _detachChild(c), - ), + IconButton( + tooltip: 'Détacher', + icon: Icon(Icons.link_off, color: Colors.orange.shade800), + onPressed: () => _detachChild(c), + ), + ], ); }, ), @@ -444,19 +434,20 @@ class _AdminParentEditModalState extends State { @override Widget build(BuildContext context) { - final maxH = MediaQuery.of(context).size.height * 0.85; - return Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: _modalWidth, maxHeight: maxH), + constraints: BoxConstraints( + maxWidth: _modalWidth, + maxHeight: MediaQuery.of(context).size.height * 0.85, + ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Row( children: [ const Padding( - padding: EdgeInsets.fromLTRB(18, 18, 0, 12), + padding: EdgeInsets.fromLTRB(18, 16, 0, 10), child: Text( 'Fiche parent', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700), @@ -473,32 +464,27 @@ class _AdminParentEditModalState extends State { ], ), const Divider(height: 1), - SizedBox( - height: _bodyHeight, - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _identityFields(), - Row( - children: [ - Text( - 'Nombre d\'enfants : ${_children.length}', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Colors.black87, - ), - ), - ], + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _identityFields(), + const SizedBox(height: 12), + Text( + 'Nombre d\'enfants : ${_children.length}', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black87, ), - const SizedBox(height: 8), - Expanded(child: _childrenPanel()), - const SizedBox(height: 24), - _buildFooter(), - ], - ), + ), + const SizedBox(height: 8), + _childrenPanel(), + const SizedBox(height: 16), + _buildFooter(), + ], ), ), ], @@ -506,17 +492,4 @@ class _AdminParentEditModalState extends State { ), ); } - - String _childStatusLabel(String status) { - switch (status) { - case 'a_naitre': - return 'À naître'; - case 'actif': - return 'Actif'; - case 'scolarise': - return 'Scolarisé'; - default: - return status; - } - } } diff --git a/frontend/lib/widgets/admin/enfant_management_widget.dart b/frontend/lib/widgets/admin/enfant_management_widget.dart index 3353897..cb11674 100644 --- a/frontend/lib/widgets/admin/enfant_management_widget.dart +++ b/frontend/lib/widgets/admin/enfant_management_widget.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/enfant_admin_model.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart'; import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; /// Onglet liste globale des enfants (doc 28 §6.2, ticket #137). @@ -52,19 +52,6 @@ class _EnfantManagementWidgetState extends State { } } - String _statusLabel(String status) { - switch (status) { - case 'a_naitre': - return 'À naître'; - case 'actif': - return 'Actif'; - case 'scolarise': - return 'Scolarisé'; - default: - return status; - } - } - Future _openEnfant(EnfantAdminModel enfant) async { await showDialog( context: context, @@ -93,19 +80,8 @@ class _EnfantManagementWidgetState extends State { itemCount: filtered.length, itemBuilder: (context, index) { final enfant = filtered[index]; - final parents = enfant.parentLinks - .map((l) => l.parentName ?? 'Parent') - .join(', '); - return AdminUserCard( - title: enfant.fullName, - fallbackIcon: Icons.child_care_outlined, - avatarUrl: enfant.photoUrl, - subtitleLines: [ - 'Statut : ${_statusLabel(enfant.status)}', - if (enfant.birthDate != null && enfant.birthDate!.isNotEmpty) - 'Naissance : ${enfant.birthDate}', - if (parents.isNotEmpty) 'Responsables : $parents', - ], + return AdminEnfantUserCard.fromEnfant( + enfant, actions: [ IconButton( icon: const Icon(Icons.visibility_outlined), From ebf794e1ac8db0d0dc177ed3d1b3fcea33c07ddc Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Wed, 24 Jun 2026 23:37:04 +0200 Subject: [PATCH 13/85] =?UTF-8?q?feat(#131):=20en-t=C3=AAte=20fiche=20pare?= =?UTF-8?q?nt=20avec=20nom=20et=20co-parent.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Affiche le prénom/nom du parent en titre et le co-parent en sous-titre ; note handoff back dans archive/temporaires. Co-authored-by: Cursor --- ...TEMP_112-back-reprise-alignement-front.md} | 0 .../TEMP_131-back-fiche-parent-co-parent.md | 124 ++++++++++++++++++ frontend/lib/models/parent_model.dart | 9 ++ .../admin/common/admin_parent_edit_modal.dart | 62 ++++++++- 4 files changed, 188 insertions(+), 7 deletions(-) rename docs/{tmp/112-back-reprise-alignement-front.md => archive/temporaires/TEMP_112-back-reprise-alignement-front.md} (100%) create mode 100644 docs/archive/temporaires/TEMP_131-back-fiche-parent-co-parent.md diff --git a/docs/tmp/112-back-reprise-alignement-front.md b/docs/archive/temporaires/TEMP_112-back-reprise-alignement-front.md similarity index 100% rename from docs/tmp/112-back-reprise-alignement-front.md rename to docs/archive/temporaires/TEMP_112-back-reprise-alignement-front.md diff --git a/docs/archive/temporaires/TEMP_131-back-fiche-parent-co-parent.md b/docs/archive/temporaires/TEMP_131-back-fiche-parent-co-parent.md new file mode 100644 index 0000000..43ac761 --- /dev/null +++ b/docs/archive/temporaires/TEMP_131-back-fiche-parent-co-parent.md @@ -0,0 +1,124 @@ +# #131 — En-tête fiche parent : co-parent (note front → back) + +**Ticket :** #131 (fiche parent dashboard, doc `28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1) +**Date :** 2026-06-01 +**Statut front :** livré (en-tête dynamique) +**Modif backend demandée :** **aucune** — ce document fixe le contrat attendu et invite à valider que l’existant le couvre. + +--- + +## 1. Comportement UI (front) + +Dans la modale **fiche parent** (`AdminParentEditModal`) : + +| Zone | Contenu | +|------|---------| +| **Titre** | `prenom` + `nom` du parent affiché (plus le libellé fixe « Fiche parent ») | +| **Sous-titre** | `Co-parent : {prenom} {nom}` — affiché **uniquement** si un co-parent est connu | + +Le titre se met à jour en direct pendant l’édition des champs nom/prénom. +Le sous-titre provient du co-parent **chargé depuis l’API** (pas saisi à la main dans la modale). + +--- + +## 2. Endpoints consommés + +| Méthode | Route | Usage front | +|---------|-------|-------------| +| `GET` | `/api/v1/parents` | Liste parents (onglet Parents) | +| `GET` | `/api/v1/parents/:userId` | Rechargement fiche après rattachement/détachement enfant | +| `PATCH` | `/api/v1/parents/:userId/fiche` | Sauvegarde identité + statut (inchangé) | + +Rôles : `super_admin`, `gestionnaire`, `administrateur` (selon route). + +--- + +## 3. Contrat JSON attendu pour `co_parent` + +Le front parse `ParentModel.fromJson` avec la clé **`co_parent`** (snake_case), objet utilisateur imbriqué. + +### Champs minimum utilisés pour le sous-titre + +| Clé JSON | Usage | +|----------|--------| +| `co_parent` | Objet ou absent/`null` | +| `co_parent.id` | Identifiant (futur lien cliquable éventuel) | +| `co_parent.prenom` | Affichage | +| `co_parent.nom` | Affichage | + +Affichage front : `'{prenom} {nom}'.trim()` → libellé `Co-parent : …`. + +### Exemple de fragment de réponse (`GET /parents/:id`) + +```json +{ + "user_id": "33333333-3333-3333-3333-333333333333", + "numero_dossier": "2026-000042", + "user": { + "id": "33333333-3333-3333-3333-333333333333", + "email": "parent1@example.com", + "prenom": "Paul", + "nom": "PARENT", + "statut": "actif", + "telephone": "0601020304" + }, + "co_parent": { + "id": "44444444-4444-4444-4444-444444444444", + "email": "coparent1@example.com", + "prenom": "Clara", + "nom": "COPARENT", + "role": "parent", + "statut": "actif" + }, + "parentChildren": [] +} +``` + +> **Note :** le front lit `user` (pas `utilisateur`). La doc `11_API.md` § Parents mentionne encore `utilisateur` / `id_co_parent` seul — le contrat **effectif** côté Nest/TypeORM est l’entité `Parents` sérialisée (`user`, `co_parent`, `parentChildren`, …). + +--- + +## 4. État backend (à valider, pas à refaire) + +D’après le code actuel (`parents.service.ts`) : + +- `findAll()` et `findOne(user_id)` chargent déjà la relation **`co_parent`** ; +- la FK métier est `parents.id_co_parent` → `utilisateurs.id` ; +- à l’inscription couple, les deux sens sont en principe renseignés (`auth.service.ts`). + +**Checklist validation back :** + +- [ ] `GET /parents/:id` renvoie bien `co_parent` peuplé quand `id_co_parent` est non null +- [ ] `GET /parents` (liste) inclut aussi `co_parent` (sous-titre disponible dès l’ouverture sans re-fetch) +- [ ] Les champs `prenom` / `nom` du co-parent sont présents dans la réponse JSON + +Si ces trois points passent en recette, **aucun changement backend n’est nécessaire** pour cette fonctionnalité. + +--- + +## 5. Points d’attention (hors périmètre immédiat) + +| Sujet | Détail | +|-------|--------| +| **Lien inverse** | Si le parent B est le co-parent de A (`A.id_co_parent = B`) mais que `B.id_co_parent` est `null`, le sous-titre **ne s’affichera pas** sur la fiche de B. Le front ne fait pas de résolution inverse. À traiter côté back **seulement si** des données legacy ont un lien à sens unique. | +| **Familles > 2 adultes** | Le sous-titre n’affiche que le co-parent direct (`id_co_parent`). Les autres responsables liés uniquement via `enfants_parents` ne sont pas listés ici (cf. doc 28 §6). | +| **Données sensibles** | Vérifier que la sérialisation de `co_parent` n’expose pas `password` / tokens (même remarque que pour `user`). | + +--- + +## 6. Fichiers front concernés + +| Fichier | Rôle | +|---------|------| +| `frontend/lib/models/parent_model.dart` | Parse `co_parent` → `AppUser? coParent` | +| `frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart` | Titre + sous-titre | +| `frontend/lib/services/user_service.dart` | `getParents()` / `getParent()` | + +--- + +## 7. Références + +- `docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1 +- `backend/src/routes/parents/parents.service.ts` — `findOne`, `findAll` +- `backend/src/entities/parents.entity.ts` — relation `co_parent` +- Ticket Gitea **#131** diff --git a/frontend/lib/models/parent_model.dart b/frontend/lib/models/parent_model.dart index 1e52b8d..3a0cd5e 100644 --- a/frontend/lib/models/parent_model.dart +++ b/frontend/lib/models/parent_model.dart @@ -3,11 +3,13 @@ import 'package:p_tits_pas/models/user.dart'; class ParentModel { final AppUser user; + final AppUser? coParent; final int childrenCount; final List children; ParentModel({ required this.user, + this.coParent, this.childrenCount = 0, this.children = const [], }); @@ -20,12 +22,19 @@ class ParentModel { } final user = AppUser.fromJson(userJson); + AppUser? coParent; + final coParentRaw = root['co_parent']; + if (coParentRaw is Map) { + coParent = AppUser.fromJson(Map.from(coParentRaw)); + } + final children = _parseChildren(root); final links = root['parentChildren'] ?? root['parent_children']; final linkCount = links is List ? links.length : 0; return ParentModel( user: user, + coParent: coParent, childrenCount: children.isNotEmpty ? children.length : linkCount, children: children, ); diff --git a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart index a12ffe5..41ceaa0 100644 --- a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart @@ -3,6 +3,7 @@ import 'package:p_tits_pas/models/enfant_admin_model.dart'; import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/models/parent_child_summary.dart'; import 'package:p_tits_pas/models/parent_model.dart'; +import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart'; @@ -36,6 +37,7 @@ class _AdminParentEditModalState extends State { late String _statut; late List _children; + AppUser? _coParent; late final ScrollController _childrenScrollCtrl; bool _saving = false; bool _dirty = false; @@ -62,6 +64,7 @@ class _AdminParentEditModalState extends State { _villeCtrl = TextEditingController(text: u.ville ?? ''); _cpCtrl = TextEditingController(text: u.codePostal ?? ''); _statut = u.statut ?? 'en_attente'; + _coParent = widget.parent.coParent; _children = List.of(widget.parent.children); _childrenScrollCtrl = ScrollController(); for (final c in [ @@ -75,9 +78,15 @@ class _AdminParentEditModalState extends State { ]) { c.addListener(_markDirty); } + _nomCtrl.addListener(_onNameFieldChanged); + _prenomCtrl.addListener(_onNameFieldChanged); _reloadChildren(); } + void _onNameFieldChanged() { + setState(() {}); + } + @override void dispose() { for (final c in [ @@ -99,6 +108,22 @@ class _AdminParentEditModalState extends State { if (!_dirty) setState(() => _dirty = true); } + String _headerTitle() { + final fn = _prenomCtrl.text.trim(); + final ln = _nomCtrl.text.trim(); + if (fn.isEmpty && ln.isEmpty) { + final fallback = widget.parent.user.fullName.trim(); + return fallback.isNotEmpty ? fallback : 'Parent'; + } + return '$fn $ln'.trim(); + } + + String? _coParentSubtitle() { + final name = _coParent?.fullName.trim() ?? ''; + if (name.isEmpty) return null; + return 'Co-parent : $name'; + } + String _displayStatus(String status) { switch (status) { case 'actif': @@ -202,7 +227,10 @@ class _AdminParentEditModalState extends State { }).toList(); if (!mounted) return; - setState(() => _children = kids); + setState(() { + _children = kids; + _coParent = refreshed.coParent; + }); } catch (_) {} } @@ -445,15 +473,35 @@ class _AdminParentEditModalState extends State { mainAxisSize: MainAxisSize.min, children: [ Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Padding( - padding: EdgeInsets.fromLTRB(18, 16, 0, 10), - child: Text( - 'Fiche parent', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(18, 16, 12, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _headerTitle(), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + if (_coParentSubtitle() != null) ...[ + const SizedBox(height: 4), + Text( + _coParentSubtitle()!, + style: const TextStyle( + fontSize: 13, + color: Colors.black54, + ), + ), + ], + ], + ), ), ), - const Spacer(), SizedBox(width: 160, child: _statusDropdown()), const SizedBox(width: 4), IconButton( From ce474797c488987d38b918912097e27cc5daf618 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Wed, 24 Jun 2026 23:38:07 +0200 Subject: [PATCH 14/85] =?UTF-8?q?fix(#131):=20co=5Fparent=20en=20r=C3=A9po?= =?UTF-8?q?nse=20parents=20+=20masquage=20secrets=20user?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contrat API fiche parent : co_parent peuplé (déjà chargé), sans password ni tokens sur user/co_parent. Doc tmp front→back. Co-authored-by: Cursor --- .../utils/sanitize-user-for-api.spec.ts | 32 +++++ .../src/common/utils/sanitize-user-for-api.ts | 23 ++++ .../src/routes/parents/parents.controller.ts | 39 +++--- .../src/routes/parents/parents.mapper.spec.ts | 38 ++++++ backend/src/routes/parents/parents.mapper.ts | 18 +++ .../131-fiche-parent-co-parent-header.md | 127 ++++++++++++++++++ 6 files changed, 262 insertions(+), 15 deletions(-) create mode 100644 backend/src/common/utils/sanitize-user-for-api.spec.ts create mode 100644 backend/src/common/utils/sanitize-user-for-api.ts create mode 100644 backend/src/routes/parents/parents.mapper.spec.ts create mode 100644 backend/src/routes/parents/parents.mapper.ts create mode 100644 docs/archive/temporaires/131-fiche-parent-co-parent-header.md diff --git a/backend/src/common/utils/sanitize-user-for-api.spec.ts b/backend/src/common/utils/sanitize-user-for-api.spec.ts new file mode 100644 index 0000000..c4ad2b2 --- /dev/null +++ b/backend/src/common/utils/sanitize-user-for-api.spec.ts @@ -0,0 +1,32 @@ +import { sanitizeUserForApi } from './sanitize-user-for-api'; +import { RoleType, StatutUtilisateurType, Users } from '../../entities/users.entity'; + +describe('sanitizeUserForApi', () => { + const base: Users = { + id: 'u1', + email: 'a@b.fr', + prenom: 'Paul', + nom: 'Parent', + role: RoleType.PARENT, + statut: StatutUtilisateurType.ACTIF, + password: 'hash', + token_creation_mdp: 'tok', + token_creation_mdp_expire_le: new Date(), + password_reset_token: 'rst', + password_reset_expires: new Date(), + } as Users; + + it('retire password et tokens', () => { + const out = sanitizeUserForApi(base)!; + expect(out.prenom).toBe('Paul'); + expect(out.nom).toBe('Parent'); + expect(out.password).toBeUndefined(); + expect(out.token_creation_mdp).toBeUndefined(); + expect(out.password_reset_token).toBeUndefined(); + }); + + it('retourne undefined si user absent', () => { + expect(sanitizeUserForApi(null)).toBeUndefined(); + expect(sanitizeUserForApi(undefined)).toBeUndefined(); + }); +}); diff --git a/backend/src/common/utils/sanitize-user-for-api.ts b/backend/src/common/utils/sanitize-user-for-api.ts new file mode 100644 index 0000000..dcd7e89 --- /dev/null +++ b/backend/src/common/utils/sanitize-user-for-api.ts @@ -0,0 +1,23 @@ +import { Users } from 'src/entities/users.entity'; + +/** Champs sensibles exclus des réponses API (ticket #131 — user / co_parent). */ +const SENSITIVE_USER_KEYS: (keyof Users)[] = [ + 'password', + 'token_creation_mdp', + 'token_creation_mdp_expire_le', + 'password_reset_token', + 'password_reset_expires', +]; + +/** + * Retourne une copie utilisateur sans secrets (hash MDP, tokens). + * Utilisé pour `user` et `co_parent` dans les réponses Parents. + */ +export function sanitizeUserForApi(user?: Users | null): Users | undefined { + if (!user) return undefined; + const safe = { ...user } as Users; + for (const key of SENSITIVE_USER_KEYS) { + delete safe[key]; + } + return safe; +} diff --git a/backend/src/routes/parents/parents.controller.ts b/backend/src/routes/parents/parents.controller.ts index 6f44bee..350eed7 100644 --- a/backend/src/routes/parents/parents.controller.ts +++ b/backend/src/routes/parents/parents.controller.ts @@ -23,6 +23,7 @@ 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'; +import { mapParentForApi, mapParentsForApi } from './parents.mapper'; @ApiTags('Parents') @Controller('parents') @@ -82,20 +83,23 @@ export class ParentsController { } @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) - @Get() + @ApiOperation({ summary: 'Liste des parents (user, co_parent, parentChildren) — ticket #131' }) @ApiResponse({ status: 200, type: [Parents], description: 'Liste des parents' }) @ApiResponse({ status: 403, description: 'Accès refusé !' }) - getAll(): Promise { - return this.parentsService.findAll(); + async getAll(): Promise { + const parents = await this.parentsService.findAll(); + return mapParentsForApi(parents); } @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @Get(':id') + @ApiOperation({ summary: 'Détail parent par user_id (inclut co_parent si id_co_parent renseigné) — ticket #131' }) @ApiResponse({ status: 200, type: Parents, description: 'Détails du parent par ID utilisateur' }) @ApiResponse({ status: 404, description: 'Parent non trouvé' }) @ApiResponse({ status: 403, description: 'Accès refusé !' }) - getOne(@Param('id') user_id: string): Promise { - return this.parentsService.findOne(user_id); + async getOne(@Param('id') user_id: string): Promise { + const parent = await this.parentsService.findOne(user_id); + return mapParentForApi(parent); } @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE) @@ -103,8 +107,9 @@ export class ParentsController { @ApiBody({ type: CreateParentDto }) @ApiResponse({ status: 201, type: Parents, description: 'Parent créé avec succès' }) @ApiResponse({ status: 403, description: 'Accès refusé !' }) - create(@Body() dto: CreateParentDto): Promise { - return this.parentsService.create(dto); + async create(@Body() dto: CreateParentDto): Promise { + const parent = await this.parentsService.create(dto); + return mapParentForApi(parent); } @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @@ -113,11 +118,12 @@ export class ParentsController { @ApiParam({ name: 'id', description: "UUID utilisateur du parent" }) @ApiBody({ type: UpdateParentFicheAdminDto }) @ApiResponse({ status: 200, type: Parents, description: 'Fiche parent mise à jour' }) - updateFicheAdmin( + async updateFicheAdmin( @Param('id') id: string, @Body() dto: UpdateParentFicheAdminDto, ): Promise { - return this.parentsService.updateFicheAdmin(id, dto); + const parent = await this.parentsService.updateFicheAdmin(id, dto); + return mapParentForApi(parent); } @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @@ -126,11 +132,12 @@ export class ParentsController { @ApiParam({ name: 'id', description: "UUID utilisateur du parent" }) @ApiParam({ name: 'enfantId', description: "UUID de l'enfant" }) @ApiResponse({ status: 200, type: Parents, description: 'Parent avec enfants mis à jour' }) - attachEnfant( + async attachEnfant( @Param('id') id: string, @Param('enfantId') enfantId: string, ): Promise { - return this.parentsService.attachEnfant(id, enfantId); + const parent = await this.parentsService.attachEnfant(id, enfantId); + return mapParentForApi(parent); } @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @@ -139,11 +146,12 @@ export class ParentsController { @ApiParam({ name: 'id', description: "UUID utilisateur du parent" }) @ApiParam({ name: 'enfantId', description: "UUID de l'enfant" }) @ApiResponse({ status: 200, type: Parents, description: 'Parent avec enfants mis à jour' }) - detachEnfant( + async detachEnfant( @Param('id') id: string, @Param('enfantId') enfantId: string, ): Promise { - return this.parentsService.detachEnfant(id, enfantId); + const parent = await this.parentsService.detachEnfant(id, enfantId); + return mapParentForApi(parent); } @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE) @@ -152,7 +160,8 @@ export class ParentsController { @ApiResponse({ status: 200, type: Parents, description: 'Parent mis à jour avec succès' }) @ApiResponse({ status: 404, description: 'Parent introuvable' }) @ApiResponse({ status: 403, description: 'Accès refusé !' }) - update(@Param('id') id: string, @Body() dto: UpdateParentsDto): Promise { - return this.parentsService.update(id, dto); + async update(@Param('id') id: string, @Body() dto: UpdateParentsDto): Promise { + const parent = await this.parentsService.update(id, dto); + return mapParentForApi(parent); } } diff --git a/backend/src/routes/parents/parents.mapper.spec.ts b/backend/src/routes/parents/parents.mapper.spec.ts new file mode 100644 index 0000000..e34ecc0 --- /dev/null +++ b/backend/src/routes/parents/parents.mapper.spec.ts @@ -0,0 +1,38 @@ +import { mapParentForApi } from './parents.mapper'; +import { Parents } from '../../entities/parents.entity'; +import { RoleType, StatutUtilisateurType, Users } from '../../entities/users.entity'; + +describe('mapParentForApi', () => { + it('expose co_parent avec prenom/nom sans secrets', () => { + const coParent = { + id: 'cp1', + email: 'co@b.fr', + prenom: 'Clara', + nom: 'Co', + role: RoleType.PARENT, + statut: StatutUtilisateurType.ACTIF, + password: 'secret', + } as Users; + + const parent = { + user_id: 'u1', + numero_dossier: '2026-000042', + user: { + id: 'u1', + email: 'p@b.fr', + prenom: 'Paul', + nom: 'Parent', + role: RoleType.PARENT, + password: 'secret', + } as Users, + co_parent: coParent, + parentChildren: [], + } as Parents; + + const out = mapParentForApi(parent); + expect(out.co_parent?.prenom).toBe('Clara'); + expect(out.co_parent?.nom).toBe('Co'); + expect(out.co_parent?.password).toBeUndefined(); + expect(out.user.password).toBeUndefined(); + }); +}); diff --git a/backend/src/routes/parents/parents.mapper.ts b/backend/src/routes/parents/parents.mapper.ts new file mode 100644 index 0000000..5c9e517 --- /dev/null +++ b/backend/src/routes/parents/parents.mapper.ts @@ -0,0 +1,18 @@ +import { Parents } from 'src/entities/parents.entity'; +import { sanitizeUserForApi } from '../../common/utils/sanitize-user-for-api'; + +/** + * Sérialisation API fiche parent — ticket #131. + * Garantit `user`, `co_parent` (si présent) et relations sans champs sensibles. + */ +export function mapParentForApi(parent: Parents): Parents { + return { + ...parent, + user: sanitizeUserForApi(parent.user)!, + co_parent: sanitizeUserForApi(parent.co_parent), + }; +} + +export function mapParentsForApi(parents: Parents[]): Parents[] { + return parents.map(mapParentForApi); +} diff --git a/docs/archive/temporaires/131-fiche-parent-co-parent-header.md b/docs/archive/temporaires/131-fiche-parent-co-parent-header.md new file mode 100644 index 0000000..6f8a3c8 --- /dev/null +++ b/docs/archive/temporaires/131-fiche-parent-co-parent-header.md @@ -0,0 +1,127 @@ +# #131 — En-tête fiche parent : co-parent (note front → back) + +**Ticket :** #131 (fiche parent dashboard, doc `28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1) +**Date :** 2026-06-01 +**Statut front :** livré (en-tête dynamique) +**Modif backend demandée :** **aucune fonctionnelle** — ce document fixe le contrat attendu ; le back valide `co_parent` et masque les champs sensibles. + +--- + +## 1. Comportement UI (front) + +Dans la modale **fiche parent** (`AdminParentEditModal`) : + +| Zone | Contenu | +|------|---------| +| **Titre** | `prenom` + `nom` du parent affiché (plus le libellé fixe « Fiche parent ») | +| **Sous-titre** | `Co-parent : {prenom} {nom}` — affiché **uniquement** si un co-parent est connu | + +Le titre se met à jour en direct pendant l’édition des champs nom/prénom. +Le sous-titre provient du co-parent **chargé depuis l’API** (pas saisi à la main dans la modale). + +--- + +## 2. Endpoints consommés + +| Méthode | Route | Usage front | +|---------|-------|-------------| +| `GET` | `/api/v1/parents` | Liste parents (onglet Parents) | +| `GET` | `/api/v1/parents/:userId` | Rechargement fiche après rattachement/détachement enfant | +| `PATCH` | `/api/v1/parents/:userId/fiche` | Sauvegarde identité + statut (inchangé) | + +Rôles : `super_admin`, `gestionnaire`, `administrateur` (selon route). + +--- + +## 3. Contrat JSON attendu pour `co_parent` + +Le front parse `ParentModel.fromJson` avec la clé **`co_parent`** (snake_case), objet utilisateur imbriqué. + +### Champs minimum utilisés pour le sous-titre + +| Clé JSON | Usage | +|----------|--------| +| `co_parent` | Objet ou absent/`null` | +| `co_parent.id` | Identifiant (futur lien cliquable éventuel) | +| `co_parent.prenom` | Affichage | +| `co_parent.nom` | Affichage | + +Affichage front : `'{prenom} {nom}'.trim()` → libellé `Co-parent : …`. + +### Exemple de fragment de réponse (`GET /parents/:id`) + +```json +{ + "user_id": "33333333-3333-3333-3333-333333333333", + "numero_dossier": "2026-000042", + "user": { + "id": "33333333-3333-3333-3333-333333333333", + "email": "parent1@example.com", + "prenom": "Paul", + "nom": "PARENT", + "statut": "actif", + "telephone": "0601020304" + }, + "co_parent": { + "id": "44444444-4444-4444-4444-444444444444", + "email": "coparent1@example.com", + "prenom": "Clara", + "nom": "COPARENT", + "role": "parent", + "statut": "actif" + }, + "parentChildren": [] +} +``` + +> **Note :** le front lit `user` (pas `utilisateur`). La doc `11_API.md` § Parents mentionne encore `utilisateur` / `id_co_parent` seul — le contrat **effectif** côté Nest/TypeORM est l’entité `Parents` sérialisée (`user`, `co_parent`, `parentChildren`, …). + +--- + +## 4. État backend + +### Relations (déjà en place) + +- `findAll()` et `findOne(user_id)` chargent **`co_parent`** ; +- FK : `parents.id_co_parent` → `utilisateurs.id` ; +- inscription couple : les deux sens renseignés en principe (`auth.service.ts`). + +### Livraison back (#131) + +- `mapParentForApi` / `sanitizeUserForApi` : réponses `GET/PATCH/POST/DELETE` parents **sans** `password`, `token_creation_mdp`, `password_reset_*` sur `user` et `co_parent`. + +**Checklist validation :** + +- [x] `GET /parents/:id` renvoie `co_parent` peuplé quand `id_co_parent` est non null +- [x] `GET /parents` (liste) inclut `co_parent` +- [x] `prenom` / `nom` du co-parent présents +- [x] Pas de fuite `password` / tokens sur `user` ni `co_parent` + +--- + +## 5. Points d’attention (hors périmètre immédiat) + +| Sujet | Détail | +|-------|--------| +| **Lien inverse** | Si B est co-parent de A (`A.id_co_parent = B`) mais `B.id_co_parent` est `null`, le sous-titre **ne s’affichera pas** sur la fiche de B. Pas de résolution inverse côté front. | +| **Familles > 2 adultes** | Sous-titre = co-parent direct (`id_co_parent`) uniquement. | +| **Trou AM ↔ enfants en garde** | Pas de lien AM–enfant aujourd’hui (à documenter / traiter plus tard). | + +--- + +## 6. Fichiers back concernés + +| Fichier | Rôle | +|---------|------| +| `backend/src/routes/parents/parents.service.ts` | `findOne`, `findAll` + relations | +| `backend/src/routes/parents/parents.controller.ts` | `mapParentForApi` sur les réponses | +| `backend/src/routes/parents/parents.mapper.ts` | Sérialisation API | +| `backend/src/common/utils/sanitize-user-for-api.ts` | Masquage secrets | +| `backend/src/entities/parents.entity.ts` | relation `co_parent` | + +--- + +## 7. Références + +- `docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1 +- Ticket Gitea **#131** From 2fd97ddecb184e9e8335ba8c7a4546ea83018c90 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 25 Jun 2026 16:32:56 +0200 Subject: [PATCH 15/85] =?UTF-8?q?fix(#131):=20aligner=20la=20g=C3=A9lule?= =?UTF-8?q?=20de=20statut=20sur=20l'en-t=C3=AAte=20parent.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le titre, le sélecteur de statut et le bouton fermer partagent le même padding : la gélule n'est plus collée au bord supérieur de la modale. Co-authored-by: Cursor --- .../admin/common/admin_parent_edit_modal.dart | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart index 41ceaa0..d4e63fd 100644 --- a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart @@ -472,12 +472,12 @@ class _AdminParentEditModalState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.fromLTRB(18, 16, 12, 10), + Padding( + padding: const EdgeInsets.fromLTRB(18, 16, 4, 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -501,15 +501,20 @@ class _AdminParentEditModalState extends State { ], ), ), - ), - SizedBox(width: 160, child: _statusDropdown()), - const SizedBox(width: 4), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.of(context).pop(), - tooltip: 'Fermer', - ), - ], + const SizedBox(width: 12), + SizedBox(width: 160, child: _statusDropdown()), + IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 40, + minHeight: 40, + ), + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + tooltip: 'Fermer', + ), + ], + ), ), const Divider(height: 1), Padding( From 479a32b4bf49ce9f9e6b2ac27ee5976c0d5765f7 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 25 Jun 2026 17:33:41 +0200 Subject: [PATCH 16/85] =?UTF-8?q?feat(#131):=20fiche=20AM=20=C3=A9ditable?= =?UTF-8?q?=20en=203=20onglets=20et=20widgets=20admin=20partag=C3=A9s.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modale admin AM (identité, dossier pro, enfants), API front avec repli, note back affiliation, et extraction gélule statut / liste enfants pour la fiche parent. Co-authored-by: Cursor --- .../TEMP_131-back-fiche-am-enfants.md | 132 ++++ .../models/assistante_maternelle_model.dart | 84 +- frontend/lib/services/user_service.dart | 145 +++- ...sistante_maternelle_management_widget.dart | 54 +- .../admin/common/admin_am_edit_modal.dart | 721 ++++++++++++++++++ .../admin/common/admin_am_photo_frame.dart | 112 +++ .../admin_children_affiliation_panel.dart | 71 ++ .../admin/common/admin_parent_edit_modal.dart | 130 +--- .../admin/common/admin_status_capsule.dart | 66 ++ .../common/validation_detail_section.dart | 83 +- 10 files changed, 1417 insertions(+), 181 deletions(-) create mode 100644 docs/archive/temporaires/TEMP_131-back-fiche-am-enfants.md create mode 100644 frontend/lib/widgets/admin/common/admin_am_edit_modal.dart create mode 100644 frontend/lib/widgets/admin/common/admin_am_photo_frame.dart create mode 100644 frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart create mode 100644 frontend/lib/widgets/admin/common/admin_status_capsule.dart diff --git a/docs/archive/temporaires/TEMP_131-back-fiche-am-enfants.md b/docs/archive/temporaires/TEMP_131-back-fiche-am-enfants.md new file mode 100644 index 0000000..870ef51 --- /dev/null +++ b/docs/archive/temporaires/TEMP_131-back-fiche-am-enfants.md @@ -0,0 +1,132 @@ +# #131 — Fiche AM éditable + affiliation enfants (note front → back) + +**Ticket :** #131 (partie AM, doc `28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1) +**Date :** 2026-06-01 +**Statut front :** modale livrée (2 onglets) — **API affiliation AM↔enfant à implémenter** + +--- + +## 1. Comportement UI (front) + +Modale `AdminAmEditModal` — même shell que la fiche parent (~930 px) : + +| Onglet | Contenu | +|--------|---------| +| **Identité & professionnel** | `IdentityBlock` éditable + grille pro (agrément, ville résidence, capacité, places, NIR/agrément date en lecture seule, biographie, switch disponible) + gélule statut | +| **Enfants accueillis** | Liste cartes enfants (réutilise `AdminChildrenAffiliationPanel` / `AdminEnfantUserCard`) + rattacher / détacher | + +En-tête : prénom nom · sous-titre `Zone · Agrément · Dossier`. + +--- + +## 2. Endpoints consommés + +### Déjà existants (partiels) + +| Méthode | Route | Usage | +|---------|-------|-------| +| `GET` | `/api/v1/assistantes-maternelles` | Liste AM | +| `GET` | `/api/v1/assistantes-maternelles/:userId` | Détail (403 possible pour `administrateur` → fallback liste) | +| `PATCH` | `/api/v1/users/:userId` | Identité + statut (admin / super_admin uniquement) | +| `PATCH` | `/api/v1/assistantes-maternelles/:userId` | Champs pro (gestionnaire / super_admin) | + +### À créer (recommandé — miroir parent #131 / #115) + +| Méthode | Route | Rôle | +|---------|-------|------| +| `PATCH` | `/api/v1/assistantes-maternelles/:userId/fiche` | Mise à jour unifiée identité + pro + statut (`super_admin`, `gestionnaire`, `administrateur`) | +| `POST` | `/api/v1/assistantes-maternelles/:userId/enfants/:enfantId` | Rattacher un enfant | +| `DELETE` | `/api/v1/assistantes-maternelles/:userId/enfants/:enfantId` | Détacher un enfant | +| `GET` | `/api/v1/assistantes-maternelles/:userId` | Inclure `amChildren[]` (relation enfant) | + +Le front appelle déjà ces routes ; en l’absence de `PATCH …/fiche`, il tente un fallback `PATCH users` + `PATCH assistantes-maternelles` (échoue selon le rôle connecté). + +--- + +## 3. Modèle de données affiliation AM ↔ enfant + +**À définir côté BDD** (pas de table dédiée aujourd’hui, contrairement à `enfants_parents`) : + +Proposition alignée parent : + +```sql +-- Piste : enfants_assistantes_maternelles +CREATE TABLE enfants_assistantes_maternelles ( + id_am UUID NOT NULL REFERENCES utilisateurs(id) ON DELETE CASCADE, + id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE, + PRIMARY KEY (id_am, id_enfant) +); +``` + +Réponse API attendue sur `GET /assistantes-maternelles/:id` : + +```json +{ + "user_id": "uuid-am", + "user": { "id": "…", "prenom": "Claire", "nom": "MARTIN", "statut": "actif" }, + "approval_number": "AGR-2024-12345", + "residence_city": "Bezons", + "max_children": 4, + "places_available": 2, + "available": true, + "amChildren": [ + { + "child": { + "id": "uuid-enfant", + "first_name": "Emma", + "last_name": "MARTIN", + "status": "actif", + "birth_date": "2023-02-15" + } + } + ] +} +``` + +Le front parse `amChildren` / `am_children` / `assistanteChildren` (même logique que `parentChildren`). + +--- + +## 4. Body `PATCH …/fiche` suggéré + +```json +{ + "nom": "MARTIN", + "prenom": "Claire", + "email": "claire@example.com", + "telephone": "0612345678", + "adresse": "5 place Bellecour", + "ville": "Lyon", + "code_postal": "69002", + "statut": "actif", + "approval_number": "AGR-2024-12345", + "residence_city": "Lyon", + "max_children": 4, + "places_available": 2, + "biography": "…", + "available": true +} +``` + +NIR et date d’agrément : lecture seule dans la modale (modification hors périmètre admin v1). + +--- + +## 5. Fichiers front concernés + +| Fichier | Rôle | +|---------|------| +| `frontend/lib/widgets/admin/common/admin_am_edit_modal.dart` | Modale 2 onglets | +| `frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart` | Liste enfants partagée parent/AM | +| `frontend/lib/widgets/admin/common/admin_status_capsule.dart` | Gélule statut partagée | +| `frontend/lib/models/assistante_maternelle_model.dart` | Parse champs pro + `amChildren` | +| `frontend/lib/services/user_service.dart` | `getAssistanteMaternelle`, `updateAmFiche`, `attachEnfantToAm`, `detachEnfantFromAm` | +| `frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart` | Ouverture modale au clic Modifier | + +--- + +## 6. Références + +- Fiche parent : `PATCH /parents/:id/fiche`, `POST|DELETE /parents/:id/enfants/:enfantId` +- Ticket Gitea **#131**, **#115** +- `docs/archive/temporaires/TEMP_131-back-fiche-parent-co-parent.md` diff --git a/frontend/lib/models/assistante_maternelle_model.dart b/frontend/lib/models/assistante_maternelle_model.dart index 2d53fb0..9a8cb96 100644 --- a/frontend/lib/models/assistante_maternelle_model.dart +++ b/frontend/lib/models/assistante_maternelle_model.dart @@ -1,30 +1,102 @@ +import 'package:p_tits_pas/models/parent_child_summary.dart'; import 'package:p_tits_pas/models/user.dart'; class AssistanteMaternelleModel { final AppUser user; final String? approvalNumber; + final String? nir; final String? residenceCity; final int? maxChildren; final int? placesAvailable; + final String? biography; + final bool? available; + final String? agreementDate; + final List children; AssistanteMaternelleModel({ required this.user, this.approvalNumber, + this.nir, this.residenceCity, this.maxChildren, this.placesAvailable, + this.biography, + this.available, + this.agreementDate, + this.children = const [], }); factory AssistanteMaternelleModel.fromJson(Map json) { - final userJson = json['user'] ?? json; + final root = Map.from(json); + final userJson = Map.from(root['user'] ?? root); + if (root['numero_dossier'] != null && userJson['numero_dossier'] == null) { + userJson['numero_dossier'] = root['numero_dossier']; + } final user = AppUser.fromJson(userJson); - + final children = _parseChildren(root); + return AssistanteMaternelleModel( user: user, - approvalNumber: json['numero_agrement'] as String?, - residenceCity: json['ville_residence'] as String?, - maxChildren: json['nb_max_enfants'] as int?, - placesAvailable: json['place_disponible'] as int?, + approvalNumber: _str( + root['approval_number'] ?? root['numero_agrement'], + ), + nir: _str(root['nir'] ?? root['nir_chiffre']), + residenceCity: _str( + root['residence_city'] ?? root['ville_residence'], + ), + maxChildren: _int(root['max_children'] ?? root['nb_max_enfants']), + placesAvailable: _int( + root['places_available'] ?? root['place_disponible'], + ), + biography: _str(root['biography'] ?? root['biographie']), + available: root['available'] as bool? ?? root['disponible'] as bool?, + agreementDate: _dateString( + root['agreement_date'] ?? root['date_agrement'], + ), + children: children, ); } + + static String? _str(dynamic v) { + if (v == null) return null; + final s = v.toString().trim(); + return s.isEmpty ? null : s; + } + + static int? _int(dynamic v) { + if (v == null) return null; + if (v is int) return v; + return int.tryParse(v.toString()); + } + + static String? _dateString(dynamic v) { + if (v == null) return null; + return v.toString().split('T').first; + } + + static List _parseChildren(Map json) { + final children = []; + final seen = {}; + + void add(ParentChildSummary? child) { + if (child == null || child.id.isEmpty || seen.contains(child.id)) return; + seen.add(child.id); + children.add(child); + } + + final links = json['amChildren'] ?? + json['am_children'] ?? + json['assistanteChildren'] ?? + json['assistante_children']; + if (links is List) { + for (final link in links) { + if (link is! Map) continue; + add(ParentChildSummary.fromParentChildLink( + Map.from(link), + )); + } + } + + return children; + } } diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index 1d1c87f..3aa8111 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -513,7 +513,150 @@ class UserService { } final List data = jsonDecode(response.body); - return data.map((e) => AssistanteMaternelleModel.fromJson(e)).toList(); + return data + .map((e) => AssistanteMaternelleModel.fromJson( + Map.from(e as Map), + )) + .toList(); + } + + static Future getAssistanteMaternelle( + String userId, + ) async { + final response = await http.get( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$userId'), + headers: await _headers(), + ); + if (response.statusCode == 200) { + final decoded = jsonDecode(response.body); + return AssistanteMaternelleModel.fromJson( + Map.from(decoded is Map ? decoded : {}), + ); + } + if (response.statusCode == 403 || response.statusCode == 404) { + final all = await getAssistantesMaternelles(); + return all.firstWhere( + (a) => a.user.id == userId, + orElse: () => throw Exception('Assistante maternelle introuvable'), + ); + } + final err = jsonDecode(response.body) as Map?; + throw Exception(_toStr(err?['message']) ?? 'Erreur chargement AM'); + } + + /// Mise à jour fiche AM (identité + champs pro). Ticket #131. + static Future updateAmFiche({ + required String amUserId, + required Map body, + }) async { + final ficheResponse = await http.patch( + Uri.parse( + '${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$amUserId/fiche', + ), + headers: await _headers(), + body: jsonEncode(body), + ); + if (ficheResponse.statusCode == 200) { + return _amModelFromBody(ficheResponse.body); + } + if (ficheResponse.statusCode != 404) { + throw Exception( + _extractErrorMessage(ficheResponse.body, 'Erreur mise à jour AM'), + ); + } + + final userFields = {}; + for (final k in [ + 'nom', + 'prenom', + 'email', + 'telephone', + 'adresse', + 'ville', + 'code_postal', + 'statut', + ]) { + if (body.containsKey(k)) userFields[k] = body[k]; + } + + final proFields = {}; + for (final entry in body.entries) { + if (!userFields.containsKey(entry.key)) { + proFields[entry.key] = entry.value; + } + } + + if (userFields.isNotEmpty) { + final userResponse = await http.patch( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$amUserId'), + headers: await _headers(), + body: jsonEncode(userFields), + ); + if (userResponse.statusCode != 200) { + throw Exception( + _extractErrorMessage(userResponse.body, 'Erreur mise à jour identité'), + ); + } + } + + if (proFields.isNotEmpty) { + final proResponse = await http.patch( + Uri.parse( + '${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$amUserId', + ), + headers: await _headers(), + body: jsonEncode(proFields), + ); + if (proResponse.statusCode != 200) { + throw Exception( + _extractErrorMessage(proResponse.body, 'Erreur mise à jour fiche pro'), + ); + } + } + + return getAssistanteMaternelle(amUserId); + } + + static Future attachEnfantToAm({ + required String amUserId, + required String enfantId, + }) async { + final response = await http.post( + Uri.parse( + '${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$amUserId/enfants/$enfantId', + ), + headers: await _headers(), + ); + if (response.statusCode != 200 && response.statusCode != 201) { + throw Exception(_extractErrorMessage(response.body, 'Erreur rattachement enfant')); + } + return _amModelFromBody(response.body); + } + + static Future detachEnfantFromAm({ + required String amUserId, + required String enfantId, + }) async { + final response = await http.delete( + Uri.parse( + '${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$amUserId/enfants/$enfantId', + ), + headers: await _headers(), + ); + if (response.statusCode != 200 && response.statusCode != 204) { + throw Exception(_extractErrorMessage(response.body, 'Erreur détachement enfant')); + } + if (response.body.isEmpty) { + return getAssistanteMaternelle(amUserId); + } + return _amModelFromBody(response.body); + } + + static AssistanteMaternelleModel _amModelFromBody(String body) { + final decoded = jsonDecode(body); + return AssistanteMaternelleModel.fromJson( + Map.from(decoded is Map ? decoded : {}), + ); } // Récupérer la liste des administrateurs (via /users filtré ou autre) diff --git a/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart b/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart index 432a782..cfc7826 100644 --- a/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart +++ b/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart @@ -1,8 +1,7 @@ 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_am_edit_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; @@ -102,55 +101,10 @@ class _AssistanteMaternelleManagementWidgetState void _openAssistanteDetails(AssistanteMaternelleModel assistante) { showDialog( context: context, - builder: (context) => AdminDetailModal( - title: assistante.user.fullName.isEmpty - ? 'Assistante maternelle' - : assistante.user.fullName, - subtitle: assistante.user.email, - fields: [ - AdminDetailField(label: 'ID', value: _v(assistante.user.id)), - AdminDetailField( - label: 'Numero agrement', - value: _v(assistante.approvalNumber), - ), - AdminDetailField( - label: 'Ville residence', - value: _v(assistante.residenceCity), - ), - AdminDetailField( - label: 'Capacite max', - value: assistante.maxChildren?.toString() ?? '-', - ), - AdminDetailField( - label: 'Places disponibles', - value: assistante.placesAvailable?.toString() ?? '-', - ), - AdminDetailField( - label: '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)), - AdminDetailField( - label: 'Code postal', - value: _v(assistante.user.codePostal), - ), - ], - onEdit: () { - Navigator.of(context).pop(); - ScaffoldMessenger.of(this.context).showSnackBar( - const SnackBar(content: Text('Action Modifier a implementer')), - ); - }, - onDelete: () { - Navigator.of(context).pop(); - ScaffoldMessenger.of(this.context).showSnackBar( - const SnackBar(content: Text('Action Supprimer a implementer')), - ); - }, + builder: (context) => AdminAmEditModal( + assistante: assistante, + onSaved: _loadAssistantes, ), ); } - - String _v(String? value) => (value == null || value.isEmpty) ? '-' : value; } diff --git a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart new file mode 100644 index 0000000..55d6de9 --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart @@ -0,0 +1,721 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; +import 'package:p_tits_pas/models/enfant_admin_model.dart'; +import 'package:p_tits_pas/models/parent_child_summary.dart'; +import 'package:p_tits_pas/utils/date_display_utils.dart'; +import 'package:p_tits_pas/utils/nir_utils.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_am_photo_frame.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_children_affiliation_panel.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart'; +import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; +import 'package:p_tits_pas/widgets/common/identity_block.dart'; +import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; + +/// Fiche AM éditable (ticket #131) — identité | fiche pro (photo) | enfants. +class AdminAmEditModal extends StatefulWidget { + final AssistanteMaternelleModel assistante; + final VoidCallback? onSaved; + + const AdminAmEditModal({ + super.key, + required this.assistante, + this.onSaved, + }); + + @override + State createState() => _AdminAmEditModalState(); +} + +class _AdminAmEditModalState extends State + with SingleTickerProviderStateMixin { + late final TabController _tabCtrl; + late final TextEditingController _nomCtrl; + late final TextEditingController _prenomCtrl; + late final TextEditingController _emailCtrl; + late final TextEditingController _telCtrl; + late final TextEditingController _adresseCtrl; + late final TextEditingController _villeCtrl; + late final TextEditingController _cpCtrl; + late final TextEditingController _agrementCtrl; + late final TextEditingController _capaciteCtrl; + late final TextEditingController _placesCtrl; + + late String _statut; + late bool _disponible; + late List _children; + late final ScrollController _childrenScrollCtrl; + + bool _saving = false; + bool _dirty = false; + + static const double _modalWidth = 930; + static const double _photoProGap = 24; + static const double _proColumnMinWidth = 260; + static const double _photoColumnMinWidth = 160; + static const double _proTabHeight = 300; + static const List _photoProRowLayout = [2, 2, 2]; + + double _tabViewHeight(int index) { + switch (index) { + case 1: + return _proTabHeight; + case 2: + return 280; + default: + return 292; + } + } + + @override + void initState() { + super.initState(); + _tabCtrl = TabController(length: 3, vsync: this); + final u = widget.assistante.user; + final am = widget.assistante; + + _nomCtrl = TextEditingController(text: u.nom ?? ''); + _prenomCtrl = TextEditingController(text: u.prenom ?? ''); + _emailCtrl = TextEditingController(text: u.email); + _telCtrl = TextEditingController( + text: formatPhoneForDisplay(u.telephone ?? ''), + ); + _adresseCtrl = TextEditingController(text: u.adresse ?? ''); + _villeCtrl = TextEditingController(text: u.ville ?? ''); + _cpCtrl = TextEditingController(text: u.codePostal ?? ''); + _agrementCtrl = TextEditingController(text: am.approvalNumber ?? ''); + _capaciteCtrl = TextEditingController( + text: am.maxChildren?.toString() ?? '', + ); + _placesCtrl = TextEditingController( + text: am.placesAvailable?.toString() ?? '', + ); + _statut = u.statut ?? 'en_attente'; + _disponible = am.available ?? true; + _children = List.of(am.children); + _childrenScrollCtrl = ScrollController(); + + for (final c in [ + _nomCtrl, + _prenomCtrl, + _emailCtrl, + _telCtrl, + _adresseCtrl, + _villeCtrl, + _cpCtrl, + _agrementCtrl, + _capaciteCtrl, + _placesCtrl, + ]) { + c.addListener(_markDirty); + } + _nomCtrl.addListener(_onNameFieldChanged); + _prenomCtrl.addListener(_onNameFieldChanged); + _tabCtrl.addListener(_onTabChanged); + _reloadChildren(); + } + + void _onTabChanged() { + if (!_tabCtrl.indexIsChanging) setState(() {}); + } + + void _onNameFieldChanged() => setState(() {}); + + @override + void dispose() { + _tabCtrl.dispose(); + for (final c in [ + _nomCtrl, + _prenomCtrl, + _emailCtrl, + _telCtrl, + _adresseCtrl, + _villeCtrl, + _cpCtrl, + _agrementCtrl, + _capaciteCtrl, + _placesCtrl, + ]) { + c.dispose(); + } + _childrenScrollCtrl.dispose(); + super.dispose(); + } + + void _markDirty() { + if (!_dirty) setState(() => _dirty = true); + } + + String _headerTitle() { + final fn = _prenomCtrl.text.trim(); + final ln = _nomCtrl.text.trim(); + if (fn.isEmpty && ln.isEmpty) { + final fallback = widget.assistante.user.fullName.trim(); + return fallback.isNotEmpty ? fallback : 'Assistante maternelle'; + } + return '$fn $ln'.trim(); + } + + String? _headerSubtitle() { + final parts = []; + final zone = (widget.assistante.residenceCity ?? '').trim(); + if (zone.isNotEmpty) parts.add('Zone : $zone'); + final agrement = _agrementCtrl.text.trim(); + if (agrement.isNotEmpty) parts.add('Agrément : $agrement'); + final dossier = widget.assistante.user.numeroDossier?.trim(); + if (dossier != null && dossier.isNotEmpty) { + parts.add('Dossier $dossier'); + } + if (parts.isEmpty) return null; + return parts.join(' · '); + } + + String _formatNirDisplay() { + final raw = widget.assistante.nir?.trim() ?? ''; + if (raw.isEmpty) return '–'; + final digits = nirToRaw(raw).toUpperCase(); + return digits.length == 15 ? formatNir(digits) : raw; + } + + int? _parseIntField(TextEditingController c) { + final t = c.text.trim(); + if (t.isEmpty) return null; + return int.tryParse(t); + } + + Future _save() async { + if (!_dirty) return; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Confirmer'), + content: const Text( + 'Enregistrer les modifications de la fiche assistante maternelle ?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Sauvegarder'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + + setState(() => _saving = true); + try { + await UserService.updateAmFiche( + amUserId: widget.assistante.user.id, + body: { + 'nom': _nomCtrl.text.trim(), + 'prenom': _prenomCtrl.text.trim(), + 'email': _emailCtrl.text.trim(), + 'telephone': normalizePhone(_telCtrl.text), + 'adresse': _adresseCtrl.text.trim(), + 'ville': _villeCtrl.text.trim(), + 'code_postal': _cpCtrl.text.trim(), + 'statut': _statut, + 'approval_number': _agrementCtrl.text.trim(), + if (_parseIntField(_capaciteCtrl) != null) + 'max_children': _parseIntField(_capaciteCtrl), + if (_parseIntField(_placesCtrl) != null) + 'places_available': _parseIntField(_placesCtrl), + 'available': _disponible, + }, + ); + if (!mounted) return; + setState(() { + _dirty = false; + _saving = false; + }); + widget.onSaved?.call(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Fiche assistante maternelle enregistrée')), + ); + } catch (e) { + if (!mounted) return; + setState(() => _saving = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } + } + + Future _reloadChildren() async { + try { + final refreshed = + await UserService.getAssistanteMaternelle(widget.assistante.user.id); + final all = await UserService.getEnfants(); + final byId = {for (final e in all) e.id: e}; + + final kids = refreshed.children.map((c) { + final full = byId[c.id]; + if (full != null) return ParentChildSummary.fromEnfant(full); + return c; + }).toList(); + + if (!mounted) return; + setState(() => _children = kids); + } catch (_) {} + } + + Future _openChild(ParentChildSummary child) async { + try { + final enfant = await UserService.getEnfant(child.id); + if (!mounted) return; + await showDialog( + context: context, + builder: (ctx) => AdminChildDetailModal( + enfant: enfant, + onSaved: _reloadChildren, + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } + } + + Future _detachChild(ParentChildSummary child) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Détacher l\'enfant'), + content: Text( + 'Retirer ${child.fullName} de la fiche de cette assistante ?\n' + '(L\'enfant ne sera pas supprimé.)', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red.shade700, + ), + child: const Text('Détacher'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + + try { + await UserService.detachEnfantFromAm( + amUserId: widget.assistante.user.id, + enfantId: child.id, + ); + if (!mounted) return; + await _reloadChildren(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Enfant détaché')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } + } + + Future _attachChild() async { + List all; + try { + all = await UserService.getEnfants(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + return; + } + + final linkedIds = _children.map((c) => c.id).toSet(); + final candidates = all.where((e) => !linkedIds.contains(e.id)).toList(); + if (candidates.isEmpty) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Aucun enfant disponible à rattacher')), + ); + return; + } + + if (!mounted) return; + final selected = await showDialog( + context: context, + builder: (ctx) => SimpleDialog( + title: const Text('Rattacher un enfant'), + children: candidates + .map( + (e) => SimpleDialogOption( + onPressed: () => Navigator.pop(ctx, e), + child: Text(e.fullName), + ), + ) + .toList(), + ), + ); + if (selected == null || !mounted) return; + + try { + await UserService.attachEnfantToAm( + amUserId: widget.assistante.user.id, + enfantId: selected.id, + ); + if (!mounted) return; + await _reloadChildren(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Enfant rattaché')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } + } + + String _v(String? s) => + (s != null && s.trim().isNotEmpty) ? s.trim() : '–'; + + Widget _identityTab() { + return SingleChildScrollView( + child: IdentityBlock.editable( + title: 'Identité et coordonnées', + nomController: _nomCtrl, + prenomController: _prenomCtrl, + telephoneController: _telCtrl, + emailController: _emailCtrl, + adresseController: _adresseCtrl, + codePostalController: _cpCtrl, + villeController: _villeCtrl, + ), + ); + } + + Widget _proFieldsGrid() { + final u = widget.assistante.user; + return ValidationFormGrid( + title: 'Dossier professionnel', + rowLayout: _photoProRowLayout, + compact: true, + fields: [ + ValidationLabeledField( + label: 'NIR', + field: ValidationReadOnlyField( + value: _formatNirDisplay(), + compact: true, + ), + ), + ValidationLabeledField( + label: 'Date de naissance', + field: ValidationReadOnlyField( + value: formatIsoDateFr(u.dateNaissance), + compact: true, + ), + ), + ValidationLabeledField( + label: 'Ville de naissance', + field: ValidationReadOnlyField( + value: _v(u.lieuNaissanceVille), + compact: true, + ), + ), + ValidationLabeledField( + label: 'Pays de naissance', + field: ValidationReadOnlyField( + value: _v(u.lieuNaissancePays), + compact: true, + ), + ), + ValidationLabeledField( + label: 'N° Agrément', + field: ValidationEditableField( + controller: _agrementCtrl, + compact: true, + ), + ), + ValidationLabeledField( + label: 'Date d\'agrément', + field: ValidationReadOnlyField( + value: formatIsoDateFr(widget.assistante.agreementDate), + compact: true, + ), + ), + ], + ); + } + + Widget _proTab() { + return LayoutBuilder( + builder: (context, c) { + final maxRowW = c.maxWidth; + final maxRowH = c.maxHeight; + final bodyH = maxRowH; + final idealPhotoW = bodyH * AdminAmPhotoFrame.idPhotoAspectRatio + 16; + final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth) + .clamp(0.0, double.infinity); + var photoW = idealPhotoW.clamp(_photoColumnMinWidth, 220.0); + if (photoW > maxPhotoW) photoW = maxPhotoW; + + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: photoW, + child: AdminAmPhotoFrame( + photoUrl: widget.assistante.user.photoUrl, + ), + ), + const SizedBox(width: _photoProGap), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _proFieldsGrid(), + SwitchListTile( + contentPadding: EdgeInsets.zero, + dense: true, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + title: const Text( + 'Disponible pour accueillir', + style: TextStyle(fontSize: 13), + ), + value: _disponible, + onChanged: (v) => setState(() { + _disponible = v; + _dirty = true; + }), + ), + ], + ), + ), + ), + ], + ); + }, + ); + } + + Widget _childrenCapacityFields() { + return ValidationEditableSection( + compact: true, + rowLayout: const [2], + fields: [ + ValidationLabeledField( + label: 'Capacité max (enfants)', + field: ValidationEditableField( + controller: _capaciteCtrl, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + compact: true, + ), + ), + ValidationLabeledField( + label: 'Places disponibles', + field: ValidationEditableField( + controller: _placesCtrl, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + compact: true, + ), + ), + ], + ); + } + + Widget _childrenTab() { + return LayoutBuilder( + builder: (context, constraints) { + const headerBlock = 88.0; + const capacityBlock = 56.0; + const gaps = 20.0; + final listHeight = (constraints.maxHeight - + headerBlock - + capacityBlock - + gaps) + .clamp(72.0, AdminChildrenAffiliationPanel.defaultViewportHeight); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _childrenCapacityFields(), + const SizedBox(height: 10), + Text( + 'Enfants accueillis : ${_children.length}', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + const SizedBox(height: 2), + Text( + 'Enfants rattachés à cette assistante (accueil / garde).', + style: TextStyle(fontSize: 12, color: Colors.grey.shade700), + ), + const SizedBox(height: 8), + Expanded( + child: AdminChildrenAffiliationPanel( + height: listHeight, + children: _children, + scrollController: _childrenScrollCtrl, + onOpen: _openChild, + onDetach: _detachChild, + emptyMessage: 'Aucun enfant rattaché à cette assistante', + ), + ), + ], + ); + }, + ); + } + + Widget _buildFooter() { + final isChildrenTab = _tabCtrl.index == 2; + return Row( + children: [ + TextButton( + onPressed: _saving ? null : () => Navigator.of(context).pop(), + child: const Text('Fermer'), + ), + const Spacer(), + if (isChildrenTab) + TextButton.icon( + onPressed: _attachChild, + icon: const Icon(Icons.link, size: 18), + label: const Text('Rattacher un enfant'), + ), + if (isChildrenTab) const SizedBox(width: 12), + ElevatedButton( + style: ValidationModalTheme.primaryElevatedStyle, + onPressed: !_dirty || _saving ? null : _save, + child: _saving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text(_dirty ? 'Sauvegarder' : 'Aucune modification'), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: _modalWidth), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(18, 16, 4, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _headerTitle(), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + if (_headerSubtitle() != null) ...[ + const SizedBox(height: 4), + Text( + _headerSubtitle()!, + style: const TextStyle( + fontSize: 13, + color: Colors.black54, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 160, + child: AdminStatusCapsule( + statut: _statut, + onChanged: (v) => setState(() { + _statut = v; + _dirty = true; + }), + ), + ), + IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 40, + minHeight: 40, + ), + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + tooltip: 'Fermer', + ), + ], + ), + ), + TabBar( + controller: _tabCtrl, + onTap: (_) => setState(() {}), + labelColor: ValidationModalTheme.primaryActionBackground, + unselectedLabelColor: Colors.black54, + indicatorColor: ValidationModalTheme.primaryActionBackground, + tabs: const [ + Tab(text: 'Identité'), + Tab(text: 'Fiche professionnelle'), + Tab(text: 'Enfants accueillis'), + ], + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), + child: SizedBox( + height: _tabViewHeight(_tabCtrl.index), + child: TabBarView( + controller: _tabCtrl, + children: [ + _identityTab(), + _proTab(), + _childrenTab(), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 12), + child: _buildFooter(), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_am_photo_frame.dart b/frontend/lib/widgets/admin/common/admin_am_photo_frame.dart new file mode 100644 index 0000000..9d53a7f --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_am_photo_frame.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/services/api/api_config.dart'; +import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; + +/// Cadre photo identité AM (35×45 mm) — même logique que [ValidationAmWizard]. +class AdminAmPhotoFrame extends StatelessWidget { + final String? photoUrl; + + static const double idPhotoAspectRatio = 35 / 45; + + const AdminAmPhotoFrame({super.key, this.photoUrl}); + + /// Largeur colonne photo pour remplir [height] (cadre inclus). + static double columnWidthForHeight(double height) { + const frame = 16.0; + final innerH = (height - frame).clamp(0.0, double.infinity); + return innerH * idPhotoAspectRatio + frame; + } + + @override + Widget build(BuildContext context) { + final fullUrl = ApiConfig.absoluteMediaUrl(photoUrl); + return LayoutBuilder( + builder: (context, c) { + 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 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: Align( + alignment: Alignment.topCenter, + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: SizedBox( + width: pw, + height: ph, + child: _photoContent(fullUrl, pw, ph), + ), + ), + ), + ), + ); + }, + ); + } + + Widget _photoContent(String fullUrl, double pw, double ph) { + if (fullUrl.isEmpty) { + return ColoredBox( + color: Colors.grey.shade200, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.person_off_outlined, size: 36, color: Colors.grey.shade400), + const SizedBox(height: 6), + Text( + 'Aucune photo', + style: TextStyle(color: Colors.grey.shade600, fontSize: 11), + ), + ], + ), + ); + } + return AuthNetworkImage( + url: fullUrl, + width: pw, + height: ph, + fit: BoxFit.cover, + loadingBuilder: (_, child, progress) { + if (progress == null) return child; + return ColoredBox( + color: Colors.grey.shade200, + child: Center( + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + value: progress.expectedTotalBytes != null + ? progress.cumulativeBytesLoaded / + (progress.expectedTotalBytes!) + : null, + ), + ), + ), + ); + }, + errorBuilder: (_, __, ___) => ColoredBox( + color: Colors.grey.shade200, + child: Icon(Icons.broken_image_outlined, size: 36, color: Colors.grey.shade400), + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart b/frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart new file mode 100644 index 0000000..8a534bb --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/parent_child_summary.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart'; + +/// Liste scrollable d'enfants rattachés (fiche parent / fiche AM). +class AdminChildrenAffiliationPanel extends StatelessWidget { + final List children; + final ScrollController scrollController; + final void Function(ParentChildSummary child) onOpen; + final void Function(ParentChildSummary child) onDetach; + final String emptyMessage; + final double? height; + + static const double _itemHeight = 58; + static const double defaultViewportHeight = _itemHeight * 2.5 + 8; + + const AdminChildrenAffiliationPanel({ + super.key, + required this.children, + required this.scrollController, + required this.onOpen, + required this.onDetach, + this.emptyMessage = 'Aucun enfant rattaché', + this.height, + }); + + @override + Widget build(BuildContext context) { + final panelHeight = height ?? defaultViewportHeight; + return Container( + height: panelHeight, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + ), + clipBehavior: Clip.antiAlias, + child: children.isEmpty + ? Center( + child: Text( + emptyMessage, + style: const TextStyle(fontSize: 14, color: Colors.black54), + ), + ) + : ListView.builder( + controller: scrollController, + padding: const EdgeInsets.fromLTRB(8, 8, 8, 4), + itemExtent: _itemHeight, + itemCount: children.length, + itemBuilder: (_, i) { + final c = children[i]; + return AdminEnfantUserCard.fromSummary( + c, + actions: [ + IconButton( + icon: const Icon(Icons.visibility_outlined), + tooltip: 'Voir / modifier', + onPressed: () => onOpen(c), + ), + IconButton( + tooltip: 'Détacher', + icon: Icon(Icons.link_off, color: Colors.orange.shade800), + onPressed: () => onDetach(c), + ), + ], + ); + }, + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart index d4e63fd..f0f66ed 100644 --- a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart @@ -6,7 +6,8 @@ import 'package:p_tits_pas/models/parent_model.dart'; import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_children_affiliation_panel.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart'; import 'package:p_tits_pas/widgets/common/identity_block.dart'; import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; @@ -42,13 +43,7 @@ class _AdminParentEditModalState extends State { bool _saving = false; bool _dirty = false; - static const _statuts = ['actif', 'en_attente', 'suspendu', 'refuse']; - static const double _modalWidth = 930; - /// Hauteur d'une ligne enfant (carte + marge) — viewport = 2,5 lignes visibles. - static const double _childListItemHeight = 58; - static const double _childrenListViewportHeight = - _childListItemHeight * 2.5 + 8; @override void initState() { @@ -124,21 +119,6 @@ class _AdminParentEditModalState extends State { return 'Co-parent : $name'; } - String _displayStatus(String status) { - switch (status) { - case 'actif': - return 'Actif'; - case 'en_attente': - return 'En attente'; - case 'suspendu': - return 'Suspendu'; - case 'refuse': - return 'Refusé'; - default: - return status; - } - } - Future _save() async { if (!_dirty) return; final confirmed = await showDialog( @@ -337,39 +317,12 @@ class _AdminParentEditModalState extends State { } } - Widget _statusDropdown() { - return Container( - height: 34, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(18), - border: Border.all(color: Colors.black26), - ), - padding: const EdgeInsets.symmetric(horizontal: 12), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: _statuts.contains(_statut) ? _statut : _statuts.first, - isExpanded: true, - isDense: true, - style: const TextStyle(fontSize: 13, color: Colors.black87), - icon: const Icon(Icons.arrow_drop_down, size: 20), - items: _statuts - .map( - (s) => DropdownMenuItem( - value: s, - child: Text(_displayStatus(s)), - ), - ) - .toList(), - onChanged: (v) { - if (v == null) return; - setState(() { - _statut = v; - _dirty = true; - }); - }, - ), - ), + Widget _childrenPanel() { + return AdminChildrenAffiliationPanel( + children: _children, + scrollController: _childrenScrollCtrl, + onOpen: _openChild, + onDetach: _detachChild, ); } @@ -385,49 +338,6 @@ class _AdminParentEditModalState extends State { ); } - Widget _childrenPanel() { - return Container( - height: _childrenListViewportHeight, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.grey.shade300), - ), - clipBehavior: Clip.antiAlias, - child: _children.isEmpty - ? const Center( - child: Text( - 'Aucun enfant rattaché', - style: TextStyle(fontSize: 14, color: Colors.black54), - ), - ) - : ListView.builder( - controller: _childrenScrollCtrl, - padding: const EdgeInsets.fromLTRB(8, 8, 8, 4), - itemExtent: _childListItemHeight, - itemCount: _children.length, - itemBuilder: (_, i) { - final c = _children[i]; - return AdminEnfantUserCard.fromSummary( - c, - actions: [ - IconButton( - icon: const Icon(Icons.visibility_outlined), - tooltip: 'Voir / modifier', - onPressed: () => _openChild(c), - ), - IconButton( - tooltip: 'Détacher', - icon: Icon(Icons.link_off, color: Colors.orange.shade800), - onPressed: () => _detachChild(c), - ), - ], - ); - }, - ), - ); - } - Widget _buildFooter() { return Row( children: [ @@ -465,10 +375,7 @@ class _AdminParentEditModalState extends State { return Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: _modalWidth, - maxHeight: MediaQuery.of(context).size.height * 0.85, - ), + constraints: const BoxConstraints(maxWidth: _modalWidth), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -502,7 +409,16 @@ class _AdminParentEditModalState extends State { ), ), const SizedBox(width: 12), - SizedBox(width: 160, child: _statusDropdown()), + SizedBox( + width: 160, + child: AdminStatusCapsule( + statut: _statut, + onChanged: (v) => setState(() { + _statut = v; + _dirty = true; + }), + ), + ), IconButton( padding: EdgeInsets.zero, constraints: const BoxConstraints( @@ -518,13 +434,13 @@ class _AdminParentEditModalState extends State { ), const Divider(height: 1), Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 16), + padding: const EdgeInsets.fromLTRB(20, 12, 20, 12), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _identityFields(), - const SizedBox(height: 12), + const SizedBox(height: 10), Text( 'Nombre d\'enfants : ${_children.length}', style: const TextStyle( @@ -533,9 +449,9 @@ class _AdminParentEditModalState extends State { color: Colors.black87, ), ), - const SizedBox(height: 8), + const SizedBox(height: 6), _childrenPanel(), - const SizedBox(height: 16), + const SizedBox(height: 12), _buildFooter(), ], ), diff --git a/frontend/lib/widgets/admin/common/admin_status_capsule.dart b/frontend/lib/widgets/admin/common/admin_status_capsule.dart new file mode 100644 index 0000000..a42b015 --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_status_capsule.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +/// Gélule de sélection du statut utilisateur (fiches admin parent / AM). +class AdminStatusCapsule extends StatelessWidget { + final String statut; + final ValueChanged? onChanged; + + static const statuts = ['actif', 'en_attente', 'suspendu', 'refuse']; + + const AdminStatusCapsule({ + super.key, + required this.statut, + this.onChanged, + }); + + static String displayStatus(String status) { + switch (status) { + case 'actif': + return 'Actif'; + case 'en_attente': + return 'En attente'; + case 'suspendu': + return 'Suspendu'; + case 'refuse': + return 'Refusé'; + default: + return status; + } + } + + @override + Widget build(BuildContext context) { + final value = statuts.contains(statut) ? statut : statuts.first; + return Container( + height: 34, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: Colors.black26), + ), + padding: const EdgeInsets.symmetric(horizontal: 12), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: value, + isExpanded: true, + isDense: true, + style: const TextStyle(fontSize: 13, color: Colors.black87), + icon: const Icon(Icons.arrow_drop_down, size: 20), + items: statuts + .map( + (s) => DropdownMenuItem( + value: s, + child: Text(displayStatus(s)), + ), + ) + .toList(), + onChanged: onChanged == null + ? null + : (v) { + if (v != null) onChanged!(v); + }, + ), + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/common/validation_detail_section.dart b/frontend/lib/widgets/admin/common/validation_detail_section.dart index ce1a3ba..24974c0 100644 --- a/frontend/lib/widgets/admin/common/validation_detail_section.dart +++ b/frontend/lib/widgets/admin/common/validation_detail_section.dart @@ -48,6 +48,7 @@ class ValidationFormGrid extends StatelessWidget { final List fields; final List? rowLayout; final Map>? rowFlex; + final bool compact; const ValidationFormGrid({ super.key, @@ -55,6 +56,7 @@ class ValidationFormGrid extends StatelessWidget { required this.fields, this.rowLayout, this.rowFlex, + this.compact = false, }); @override @@ -72,17 +74,17 @@ class ValidationFormGrid extends StatelessWidget { rowIndex++; if (count == 1) { rows.add(Padding( - padding: const EdgeInsets.only(bottom: 12), + padding: EdgeInsets.only(bottom: compact ? 8 : 12), child: rowFields.first, )); } else { rows.add(Padding( - padding: const EdgeInsets.only(bottom: 12), + padding: EdgeInsets.only(bottom: compact ? 8 : 12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (int i = 0; i < rowFields.length; i++) ...[ - if (i > 0) const SizedBox(width: 16), + if (i > 0) SizedBox(width: compact ? 12 : 16), Expanded( flex: (flexForRow != null && i < flexForRow.length) ? flexForRow[i] @@ -103,13 +105,13 @@ class ValidationFormGrid extends StatelessWidget { if (showTitle) ...[ Text( title!.trim(), - style: const TextStyle( - fontSize: 16, + style: TextStyle( + fontSize: compact ? 15 : 16, fontWeight: FontWeight.w600, color: Colors.black87, ), ), - const SizedBox(height: 12), + SizedBox(height: compact ? 8 : 12), ], ...rows, ], @@ -121,13 +123,16 @@ class ValidationFormGrid extends StatelessWidget { class ValidationFieldDecoration { ValidationFieldDecoration._(); - static InputDecoration input({String? hint}) { + static InputDecoration input({String? hint, bool compact = false}) { return InputDecoration( isDense: true, filled: true, fillColor: Colors.grey.shade50, hintText: hint, - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + contentPadding: EdgeInsets.symmetric( + horizontal: compact ? 10 : 12, + vertical: compact ? 7 : 10, + ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(6), borderSide: BorderSide(color: Colors.grey.shade300), @@ -191,6 +196,7 @@ class ValidationEditableField extends StatelessWidget { final List? inputFormatters; final String? hintText; final int maxLines; + final bool compact; const ValidationEditableField({ super.key, @@ -199,17 +205,44 @@ class ValidationEditableField extends StatelessWidget { this.inputFormatters, this.hintText, this.maxLines = 1, + this.compact = false, }); + static const double _compactFieldHeight = 34; + @override Widget build(BuildContext context) { - return TextField( - controller: controller, - keyboardType: keyboardType, - inputFormatters: inputFormatters, - maxLines: maxLines, - style: const TextStyle(color: Colors.black87, fontSize: 14), - decoration: ValidationFieldDecoration.input(hint: hintText), + if (!compact || maxLines > 1) { + return TextField( + controller: controller, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + maxLines: maxLines, + style: TextStyle( + color: Colors.black87, + fontSize: compact ? 13 : 14, + ), + decoration: ValidationFieldDecoration.input( + hint: hintText, + compact: compact, + ), + ); + } + return SizedBox( + height: _compactFieldHeight, + child: TextField( + controller: controller, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + maxLines: 1, + style: const TextStyle(color: Colors.black87, fontSize: 13, height: 1.15), + decoration: ValidationFieldDecoration.input( + hint: hintText, + compact: true, + ).copyWith( + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), + ), + ), ); } } @@ -219,12 +252,14 @@ class ValidationEditableSection extends StatelessWidget { final List fields; final List? rowLayout; final Map>? rowFlex; + final bool compact; const ValidationEditableSection({ super.key, required this.fields, this.rowLayout, this.rowFlex, + this.compact = false, }); @override @@ -232,6 +267,7 @@ class ValidationEditableSection extends StatelessWidget { return ValidationFormGrid( rowLayout: rowLayout, rowFlex: rowFlex, + compact: compact, fields: fields, ); } @@ -241,22 +277,35 @@ class ValidationEditableSection extends StatelessWidget { class ValidationReadOnlyField extends StatelessWidget { final String value; final int? maxLines; + final bool compact; const ValidationReadOnlyField({ super.key, required this.value, this.maxLines = 1, + this.compact = false, }); + static const double _compactFieldHeight = 34; + @override Widget build(BuildContext context) { return Container( width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + height: compact && maxLines == 1 ? _compactFieldHeight : null, + alignment: compact ? Alignment.centerLeft : null, + padding: EdgeInsets.symmetric( + horizontal: compact ? 10 : 12, + vertical: compact ? 7 : 10, + ), decoration: ValidationFieldDecoration.container(), child: Text( value, - style: const TextStyle(color: Colors.black87, fontSize: 14), + style: TextStyle( + color: Colors.black87, + fontSize: compact ? 13 : 14, + height: compact ? 1.2 : null, + ), maxLines: maxLines, overflow: TextOverflow.ellipsis, ), From 4985726bc6f79af69dd087bf4f88fcdc28dacc2c Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Mon, 6 Jul 2026 17:37:21 +0200 Subject: [PATCH 17/85] =?UTF-8?q?fix(#131):=20polish=20fiche=20AM=20?= =?UTF-8?q?=E2=80=94=20champs=20pro,=20places,=20vigilance=20et=20photo.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fiche pro entièrement éditable, places déclarées en lecture seule avec alerte rouge, icône vigilance sur la liste AM, et cadrage photo aligné sur le wizard validation. Co-authored-by: Cursor --- frontend/lib/services/user_service.dart | 3 + frontend/lib/utils/am_vigilance.dart | 45 ++++ frontend/lib/utils/date_display_utils.dart | 20 +- ...sistante_maternelle_management_widget.dart | 3 + .../admin/common/admin_am_edit_modal.dart | 241 +++++++++++------- .../admin/common/admin_am_photo_frame.dart | 25 +- .../admin_children_affiliation_panel.dart | 15 +- .../widgets/admin/common/admin_user_card.dart | 13 + .../common/validation_detail_section.dart | 59 ++++- .../widgets/common/auth_network_image.dart | 4 + 10 files changed, 315 insertions(+), 113 deletions(-) create mode 100644 frontend/lib/utils/am_vigilance.dart diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index 3aa8111..31ea722 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -575,6 +575,9 @@ class UserService { 'ville', 'code_postal', 'statut', + 'date_naissance', + 'lieu_naissance_ville', + 'lieu_naissance_pays', ]) { if (body.containsKey(k)) userFields[k] = body[k]; } diff --git a/frontend/lib/utils/am_vigilance.dart b/frontend/lib/utils/am_vigilance.dart new file mode 100644 index 0000000..944dc93 --- /dev/null +++ b/frontend/lib/utils/am_vigilance.dart @@ -0,0 +1,45 @@ +import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; + +/// Places disponibles attendues : capacité max − enfants rattachés. +int? amExpectedPlacesAvailable({ + required int? maxChildren, + required int childrenCount, +}) { + if (maxChildren == null) return null; + return (maxChildren - childrenCount).clamp(0, maxChildren); +} + +/// True si la valeur déclarée par l'AM ne correspond pas au calcul métier. +bool amHasPlacesInconsistency({ + required int? maxChildren, + required int? placesAvailable, + required int childrenCount, +}) { + final expected = amExpectedPlacesAvailable( + maxChildren: maxChildren, + childrenCount: childrenCount, + ); + if (expected == null) return false; + if (placesAvailable == null) return childrenCount > (maxChildren ?? 0); + return placesAvailable != expected; +} + +String? amPlacesVigilanceMessage(AssistanteMaternelleModel am) { + if (!amHasPlacesInconsistency( + maxChildren: am.maxChildren, + placesAvailable: am.placesAvailable, + childrenCount: am.children.length, + )) { + return null; + } + final stored = am.placesAvailable; + final expected = amExpectedPlacesAvailable( + maxChildren: am.maxChildren, + childrenCount: am.children.length, + ); + final storedLabel = stored?.toString() ?? 'non renseigné'; + final expectedLabel = expected?.toString() ?? '–'; + return 'Point de vigilance : l\'AM déclare $storedLabel place(s) disponible(s), ' + 'alors que capacité ${am.maxChildren ?? '–'} − ' + '${am.children.length} enfant(s) rattaché(s) = $expectedLabel.'; +} diff --git a/frontend/lib/utils/date_display_utils.dart b/frontend/lib/utils/date_display_utils.dart index 81318e3..9836e82 100644 --- a/frontend/lib/utils/date_display_utils.dart +++ b/frontend/lib/utils/date_display_utils.dart @@ -10,7 +10,25 @@ String formatIsoDateFr(String? s, {String ifEmpty = '–'}) { } } -/// Parties d'âge calendaire entre [birth] et [reference] (date-only). +/// Convertit `dd/MM/yyyy` (ou ISO) en `yyyy-MM-dd` pour l'API. +String? parseFrDateToIso(String text) { + final t = text.trim(); + if (t.isEmpty) return null; + try { + return DateFormat('dd/MM/yyyy') + .parseStrict(t) + .toIso8601String() + .split('T') + .first; + } catch (_) { + try { + return DateTime.parse(t).toIso8601String().split('T').first; + } catch (_) { + return null; + } + } +} + ({int years, int months, int days}) computeChildAgeParts( DateTime birth, DateTime reference, diff --git a/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart b/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart index cfc7826..5ee5ed1 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/am_vigilance.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; @@ -76,10 +77,12 @@ class _AssistanteMaternelleManagementWidgetState itemCount: filteredAssistantes.length, itemBuilder: (context, index) { final assistante = filteredAssistantes[index]; + final vigilance = amPlacesVigilanceMessage(assistante); return AdminUserCard( title: assistante.user.fullName, avatarUrl: assistante.user.photoUrl, fallbackIcon: Icons.face, + vigilanceTooltip: vigilance, subtitleLines: [ assistante.user.email, 'Zone : ${assistante.residenceCity ?? 'N/A'} | Capacité : ${assistante.maxChildren ?? 0}', diff --git a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart index 55d6de9..95e85e2 100644 --- a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; import 'package:p_tits_pas/models/enfant_admin_model.dart'; import 'package:p_tits_pas/models/parent_child_summary.dart'; +import 'package:p_tits_pas/utils/am_vigilance.dart'; import 'package:p_tits_pas/utils/date_display_utils.dart'; import 'package:p_tits_pas/utils/nir_utils.dart'; import 'package:p_tits_pas/utils/phone_utils.dart'; @@ -41,8 +42,12 @@ class _AdminAmEditModalState extends State late final TextEditingController _villeCtrl; late final TextEditingController _cpCtrl; late final TextEditingController _agrementCtrl; + late final TextEditingController _nirCtrl; + late final TextEditingController _dateNaissanceCtrl; + late final TextEditingController _lieuNaissanceVilleCtrl; + late final TextEditingController _lieuNaissancePaysCtrl; + late final TextEditingController _dateAgrementCtrl; late final TextEditingController _capaciteCtrl; - late final TextEditingController _placesCtrl; late String _statut; late bool _disponible; @@ -87,12 +92,22 @@ class _AdminAmEditModalState extends State _villeCtrl = TextEditingController(text: u.ville ?? ''); _cpCtrl = TextEditingController(text: u.codePostal ?? ''); _agrementCtrl = TextEditingController(text: am.approvalNumber ?? ''); + _nirCtrl = TextEditingController(text: _formatNirDisplay()); + _dateNaissanceCtrl = TextEditingController( + text: formatIsoDateFr(u.dateNaissance, ifEmpty: ''), + ); + _lieuNaissanceVilleCtrl = TextEditingController( + text: u.lieuNaissanceVille ?? '', + ); + _lieuNaissancePaysCtrl = TextEditingController( + text: u.lieuNaissancePays ?? '', + ); + _dateAgrementCtrl = TextEditingController( + text: formatIsoDateFr(am.agreementDate, ifEmpty: ''), + ); _capaciteCtrl = TextEditingController( text: am.maxChildren?.toString() ?? '', ); - _placesCtrl = TextEditingController( - text: am.placesAvailable?.toString() ?? '', - ); _statut = u.statut ?? 'en_attente'; _disponible = am.available ?? true; _children = List.of(am.children); @@ -107,11 +122,16 @@ class _AdminAmEditModalState extends State _villeCtrl, _cpCtrl, _agrementCtrl, + _nirCtrl, + _dateNaissanceCtrl, + _lieuNaissanceVilleCtrl, + _lieuNaissancePaysCtrl, + _dateAgrementCtrl, _capaciteCtrl, - _placesCtrl, ]) { c.addListener(_markDirty); } + _capaciteCtrl.addListener(_onCapacityChanged); _nomCtrl.addListener(_onNameFieldChanged); _prenomCtrl.addListener(_onNameFieldChanged); _tabCtrl.addListener(_onTabChanged); @@ -124,6 +144,8 @@ class _AdminAmEditModalState extends State void _onNameFieldChanged() => setState(() {}); + void _onCapacityChanged() => setState(() {}); + @override void dispose() { _tabCtrl.dispose(); @@ -136,8 +158,12 @@ class _AdminAmEditModalState extends State _villeCtrl, _cpCtrl, _agrementCtrl, + _nirCtrl, + _dateNaissanceCtrl, + _lieuNaissanceVilleCtrl, + _lieuNaissancePaysCtrl, + _dateAgrementCtrl, _capaciteCtrl, - _placesCtrl, ]) { c.dispose(); } @@ -175,11 +201,43 @@ class _AdminAmEditModalState extends State String _formatNirDisplay() { final raw = widget.assistante.nir?.trim() ?? ''; - if (raw.isEmpty) return '–'; + if (raw.isEmpty) return ''; final digits = nirToRaw(raw).toUpperCase(); return digits.length == 15 ? formatNir(digits) : raw; } + String? _frDateToIso(String text) => parseFrDateToIso(text); + + int? _capaciteMax() => _parseIntField(_capaciteCtrl); + + int? _computedPlacesAvailable() => amExpectedPlacesAvailable( + maxChildren: _capaciteMax(), + childrenCount: _children.length, + ); + + bool _placesInconsistent() => amHasPlacesInconsistency( + maxChildren: _capaciteMax(), + placesAvailable: widget.assistante.placesAvailable, + childrenCount: _children.length, + ); + + String _placesDisplayValue() { + final stored = widget.assistante.placesAvailable; + if (stored != null) return stored.toString(); + return '–'; + } + + String? _placesInconsistencyMessage() { + if (!_placesInconsistent()) return null; + final stored = widget.assistante.placesAvailable; + final expected = _computedPlacesAvailable(); + final storedLabel = stored?.toString() ?? 'non renseigné'; + final expectedLabel = expected?.toString() ?? '–'; + return 'Incohérence : l\'AM déclare $storedLabel place(s) disponible(s), ' + 'le calcul (capacité ${_capaciteMax() ?? '–'} − ${_children.length} ' + 'enfant(s) rattaché(s)) donne $expectedLabel.'; + } + int? _parseIntField(TextEditingController c) { final t = c.text.trim(); if (t.isEmpty) return null; @@ -223,10 +281,14 @@ class _AdminAmEditModalState extends State 'code_postal': _cpCtrl.text.trim(), 'statut': _statut, 'approval_number': _agrementCtrl.text.trim(), - if (_parseIntField(_capaciteCtrl) != null) - 'max_children': _parseIntField(_capaciteCtrl), - if (_parseIntField(_placesCtrl) != null) - 'places_available': _parseIntField(_placesCtrl), + 'nir': nirToRaw(_nirCtrl.text), + if (_frDateToIso(_dateNaissanceCtrl.text) != null) + 'date_naissance': _frDateToIso(_dateNaissanceCtrl.text), + 'lieu_naissance_ville': _lieuNaissanceVilleCtrl.text.trim(), + 'lieu_naissance_pays': _lieuNaissancePaysCtrl.text.trim(), + if (_frDateToIso(_dateAgrementCtrl.text) != null) + 'agreement_date': _frDateToIso(_dateAgrementCtrl.text), + if (_capaciteMax() != null) 'max_children': _capaciteMax(), 'available': _disponible, }, ); @@ -388,8 +450,6 @@ class _AdminAmEditModalState extends State } } - String _v(String? s) => - (s != null && s.trim().isNotEmpty) ? s.trim() : '–'; Widget _identityTab() { return SingleChildScrollView( @@ -407,7 +467,6 @@ class _AdminAmEditModalState extends State } Widget _proFieldsGrid() { - final u = widget.assistante.user; return ValidationFormGrid( title: 'Dossier professionnel', rowLayout: _photoProRowLayout, @@ -415,29 +474,34 @@ class _AdminAmEditModalState extends State fields: [ ValidationLabeledField( label: 'NIR', - field: ValidationReadOnlyField( - value: _formatNirDisplay(), + field: ValidationEditableField( + controller: _nirCtrl, compact: true, + hintText: '15 chiffres', + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[\d\s]')), + ], ), ), ValidationLabeledField( label: 'Date de naissance', - field: ValidationReadOnlyField( - value: formatIsoDateFr(u.dateNaissance), + field: ValidationEditableField( + controller: _dateNaissanceCtrl, compact: true, + hintText: 'jj/mm/aaaa', ), ), ValidationLabeledField( label: 'Ville de naissance', - field: ValidationReadOnlyField( - value: _v(u.lieuNaissanceVille), + field: ValidationEditableField( + controller: _lieuNaissanceVilleCtrl, compact: true, ), ), ValidationLabeledField( label: 'Pays de naissance', - field: ValidationReadOnlyField( - value: _v(u.lieuNaissancePays), + field: ValidationEditableField( + controller: _lieuNaissancePaysCtrl, compact: true, ), ), @@ -450,9 +514,10 @@ class _AdminAmEditModalState extends State ), ValidationLabeledField( label: 'Date d\'agrément', - field: ValidationReadOnlyField( - value: formatIsoDateFr(widget.assistante.agreementDate), + field: ValidationEditableField( + controller: _dateAgrementCtrl, compact: true, + hintText: 'jj/mm/aaaa', ), ), ], @@ -513,76 +578,78 @@ class _AdminAmEditModalState extends State } Widget _childrenCapacityFields() { - return ValidationEditableSection( - compact: true, - rowLayout: const [2], - fields: [ - ValidationLabeledField( - label: 'Capacité max (enfants)', - field: ValidationEditableField( - controller: _capaciteCtrl, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - compact: true, - ), + final inconsistent = _placesInconsistent(); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ValidationEditableSection( + compact: true, + rowLayout: const [2], + fields: [ + ValidationLabeledField( + label: 'Capacité max (enfants)', + field: ValidationEditableField( + controller: _capaciteCtrl, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + compact: true, + ), + ), + ValidationLabeledField( + label: 'Places disponibles', + field: ValidationReadOnlyField( + value: _placesDisplayValue(), + compact: true, + error: inconsistent, + ), + ), + ], ), - ValidationLabeledField( - label: 'Places disponibles', - field: ValidationEditableField( - controller: _placesCtrl, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - compact: true, + if (inconsistent) ...[ + const SizedBox(height: 6), + Text( + _placesInconsistencyMessage()!, + style: TextStyle( + fontSize: 12, + color: Colors.red.shade700, + height: 1.3, + ), ), - ), + ], ], ); } Widget _childrenTab() { - return LayoutBuilder( - builder: (context, constraints) { - const headerBlock = 88.0; - const capacityBlock = 56.0; - const gaps = 20.0; - final listHeight = (constraints.maxHeight - - headerBlock - - capacityBlock - - gaps) - .clamp(72.0, AdminChildrenAffiliationPanel.defaultViewportHeight); - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _childrenCapacityFields(), - const SizedBox(height: 10), - Text( - 'Enfants accueillis : ${_children.length}', - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - color: Colors.black87, - ), - ), - const SizedBox(height: 2), - Text( - 'Enfants rattachés à cette assistante (accueil / garde).', - style: TextStyle(fontSize: 12, color: Colors.grey.shade700), - ), - const SizedBox(height: 8), - Expanded( - child: AdminChildrenAffiliationPanel( - height: listHeight, - children: _children, - scrollController: _childrenScrollCtrl, - onOpen: _openChild, - onDetach: _detachChild, - emptyMessage: 'Aucun enfant rattaché à cette assistante', - ), - ), - ], - ); - }, + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _childrenCapacityFields(), + const SizedBox(height: 10), + Text( + 'Enfants accueillis : ${_children.length}', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + const SizedBox(height: 2), + Text( + 'Enfants rattachés à cette assistante (accueil / garde).', + style: TextStyle(fontSize: 12, color: Colors.grey.shade700), + ), + const SizedBox(height: 8), + Expanded( + child: AdminChildrenAffiliationPanel( + children: _children, + scrollController: _childrenScrollCtrl, + onOpen: _openChild, + onDetach: _detachChild, + emptyMessage: 'Aucun enfant rattaché à cette assistante', + ), + ), + ], ); } diff --git a/frontend/lib/widgets/admin/common/admin_am_photo_frame.dart b/frontend/lib/widgets/admin/common/admin_am_photo_frame.dart index 9d53a7f..17ac62e 100644 --- a/frontend/lib/widgets/admin/common/admin_am_photo_frame.dart +++ b/frontend/lib/widgets/admin/common/admin_am_photo_frame.dart @@ -36,17 +36,19 @@ class AdminAmPhotoFrame extends StatelessWidget { ph = pw / ar; } - return 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: Align( - alignment: Alignment.topCenter, + // Cadre gris = taille photo + padding uniforme ; centré dans la colonne + // (évite le vide blanc en bas quand le conteneur parent est plus haut). + return Align( + alignment: Alignment.topCenter, + 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( @@ -84,6 +86,7 @@ class AdminAmPhotoFrame extends StatelessWidget { width: pw, height: ph, fit: BoxFit.cover, + alignment: Alignment.topCenter, loadingBuilder: (_, child, progress) { if (progress == null) return child; return ColoredBox( diff --git a/frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart b/frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart index 8a534bb..e984e66 100644 --- a/frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart +++ b/frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart @@ -26,7 +26,20 @@ class AdminChildrenAffiliationPanel extends StatelessWidget { @override Widget build(BuildContext context) { - final panelHeight = height ?? defaultViewportHeight; + if (height != null) { + return _panel(height!); + } + return LayoutBuilder( + builder: (context, constraints) { + final h = constraints.maxHeight.isFinite && constraints.maxHeight > 0 + ? constraints.maxHeight + : defaultViewportHeight; + return _panel(h); + }, + ); + } + + Widget _panel(double panelHeight) { return Container( height: panelHeight, decoration: BoxDecoration( diff --git a/frontend/lib/widgets/admin/common/admin_user_card.dart b/frontend/lib/widgets/admin/common/admin_user_card.dart index 0e6c5ab..7caafe1 100644 --- a/frontend/lib/widgets/admin/common/admin_user_card.dart +++ b/frontend/lib/widgets/admin/common/admin_user_card.dart @@ -12,6 +12,7 @@ class AdminUserCard extends StatefulWidget { final Color? backgroundColor; final Color? titleColor; final Color? infoColor; + final String? vigilanceTooltip; const AdminUserCard({ super.key, @@ -24,6 +25,7 @@ class AdminUserCard extends StatefulWidget { this.backgroundColor, this.titleColor, this.infoColor, + this.vigilanceTooltip, }); @override @@ -65,6 +67,17 @@ class _AdminUserCardState extends State { children: [ _buildAvatar(avatarUrl), const SizedBox(width: 10), + if (widget.vigilanceTooltip != null) ...[ + Tooltip( + message: widget.vigilanceTooltip!, + child: Icon( + Icons.error_outline, + size: 20, + color: Colors.orange.shade800, + ), + ), + const SizedBox(width: 6), + ], Expanded( child: Row( children: [ diff --git a/frontend/lib/widgets/admin/common/validation_detail_section.dart b/frontend/lib/widgets/admin/common/validation_detail_section.dart index 24974c0..0ece3fd 100644 --- a/frontend/lib/widgets/admin/common/validation_detail_section.dart +++ b/frontend/lib/widgets/admin/common/validation_detail_section.dart @@ -210,6 +210,28 @@ class ValidationEditableField extends StatelessWidget { static const double _compactFieldHeight = 34; + static BoxDecoration _compactDecoration({bool error = false}) { + return BoxDecoration( + color: error ? Colors.red.shade50 : Colors.grey.shade50, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: error ? Colors.red.shade400 : Colors.grey.shade300, + ), + ); + } + + static InputDecoration _compactInputDecoration({String? hint}) { + return InputDecoration( + isDense: true, + filled: false, + hintText: hint, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), + ); + } + @override Widget build(BuildContext context) { if (!compact || maxLines > 1) { @@ -230,17 +252,19 @@ class ValidationEditableField extends StatelessWidget { } return SizedBox( height: _compactFieldHeight, - child: TextField( - controller: controller, - keyboardType: keyboardType, - inputFormatters: inputFormatters, - maxLines: 1, - style: const TextStyle(color: Colors.black87, fontSize: 13, height: 1.15), - decoration: ValidationFieldDecoration.input( - hint: hintText, - compact: true, - ).copyWith( - contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), + child: DecoratedBox( + decoration: _compactDecoration(), + child: TextField( + controller: controller, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + maxLines: 1, + style: const TextStyle( + color: Colors.black87, + fontSize: 13, + height: 1.15, + ), + decoration: _compactInputDecoration(hint: hintText), ), ), ); @@ -278,12 +302,14 @@ class ValidationReadOnlyField extends StatelessWidget { final String value; final int? maxLines; final bool compact; + final bool error; const ValidationReadOnlyField({ super.key, required this.value, this.maxLines = 1, this.compact = false, + this.error = false, }); static const double _compactFieldHeight = 34; @@ -298,13 +324,20 @@ class ValidationReadOnlyField extends StatelessWidget { horizontal: compact ? 10 : 12, vertical: compact ? 7 : 10, ), - decoration: ValidationFieldDecoration.container(), + decoration: error + ? BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.red.shade400), + ) + : ValidationFieldDecoration.container(), child: Text( value, style: TextStyle( - color: Colors.black87, + color: error ? Colors.red.shade800 : Colors.black87, fontSize: compact ? 13 : 14, height: compact ? 1.2 : null, + fontWeight: error ? FontWeight.w600 : null, ), maxLines: maxLines, overflow: TextOverflow.ellipsis, diff --git a/frontend/lib/widgets/common/auth_network_image.dart b/frontend/lib/widgets/common/auth_network_image.dart index 60ff0f2..10221ed 100644 --- a/frontend/lib/widgets/common/auth_network_image.dart +++ b/frontend/lib/widgets/common/auth_network_image.dart @@ -13,6 +13,7 @@ class AuthNetworkImage extends StatefulWidget { this.width, this.height, this.fit = BoxFit.cover, + this.alignment = Alignment.center, this.loadingBuilder, this.errorBuilder, }); @@ -21,6 +22,7 @@ class AuthNetworkImage extends StatefulWidget { final double? width; final double? height; final BoxFit fit; + final AlignmentGeometry alignment; final ImageLoadingBuilder? loadingBuilder; final ImageErrorWidgetBuilder? errorBuilder; @@ -75,6 +77,7 @@ class _AuthNetworkImageState extends State { width: widget.width, height: widget.height, fit: widget.fit, + alignment: widget.alignment, loadingBuilder: widget.loadingBuilder, errorBuilder: err, ); @@ -105,6 +108,7 @@ class _AuthNetworkImageState extends State { width: widget.width, height: widget.height, fit: widget.fit, + alignment: widget.alignment, headers: headers, loadingBuilder: widget.loadingBuilder, errorBuilder: err, From 52e40d000109c8abd0b8230ceb3a227037c8d487 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 7 Jul 2026 14:05:39 +0200 Subject: [PATCH 18/85] =?UTF-8?q?feat(#131):=20back=20placement=20AM?= =?UTF-8?q?=E2=86=94enfant=20+=20BDD=20garde/sans=5Fgarde?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API PATCH …/fiche, POST/DELETE …/enfants/:enfantId, GET avec amChildren - Table enfants_assistantes_maternelles (option D, 1 garde active/enfant) - Statuts enfant: garde/sans_garde remplacent actif (inscription → sans_garde) - BDD.sql canonique réécrit; migration pour BDD existantes - Seed test: hash bcrypt corrigé pour mot de passe « password » Co-authored-by: Cursor --- backend/src/entities/am_children.entity.ts | 47 ++++ .../assistantes_maternelles.entity.ts | 6 +- backend/src/entities/children.entity.ts | 7 +- .../assistantes_maternelles.controller.ts | 78 ++++-- .../assistantes_maternelles.mapper.ts | 28 +++ .../assistantes_maternelles.module.ts | 4 +- .../assistantes_maternelles.service.ts | 163 ++++++++++++- .../dto/update-am-fiche-admin.dto.ts | 97 ++++++++ backend/src/routes/auth/auth.service.spec.ts | 2 +- backend/src/routes/auth/auth.service.ts | 4 +- .../routes/enfants/dto/create_enfants.dto.ts | 2 +- backend/src/routes/enfants/enfants.service.ts | 2 +- database/BDD.sql | 228 +++++++++--------- database/README.md | 11 +- database/bdd/data_test/enfants.csv | 20 +- database/docker-compose.dev.yml | 5 +- database/docs/ENUMS.md | 9 +- database/docs/FK_POLICIES.md | 3 + .../2026_enfants_assistantes_maternelles.sql | 36 +++ database/seed/02_seed.sql | 4 +- database/seed/03_seed_test_data.sql | 30 +-- 21 files changed, 608 insertions(+), 178 deletions(-) create mode 100644 backend/src/entities/am_children.entity.ts create mode 100644 backend/src/routes/assistantes_maternelles/assistantes_maternelles.mapper.ts create mode 100644 backend/src/routes/assistantes_maternelles/dto/update-am-fiche-admin.dto.ts create mode 100644 database/migrations/2026_enfants_assistantes_maternelles.sql diff --git a/backend/src/entities/am_children.entity.ts b/backend/src/entities/am_children.entity.ts new file mode 100644 index 0000000..06d209b --- /dev/null +++ b/backend/src/entities/am_children.entity.ts @@ -0,0 +1,47 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, +} from 'typeorm'; +import { AssistanteMaternelle } from './assistantes_maternelles.entity'; +import { Children } from './children.entity'; +import { Users } from './users.entity'; + +@Entity('enfants_assistantes_maternelles', { schema: 'public' }) +export class AmChildren { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'id_am', type: 'uuid' }) + amId: string; + + @Column({ name: 'id_enfant', type: 'uuid' }) + enfantId: string; + + @Column({ name: 'date_debut', type: 'date' }) + date_debut: Date; + + @Column({ name: 'date_fin', type: 'date', nullable: true }) + date_fin?: Date; + + @CreateDateColumn({ name: 'cree_le', type: 'timestamptz' }) + cree_le: Date; + + @Column({ name: 'cree_par', type: 'uuid', nullable: true }) + cree_par?: string; + + @ManyToOne(() => AssistanteMaternelle, (am) => am.amChildren, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'id_am', referencedColumnName: 'user_id' }) + am: AssistanteMaternelle; + + @ManyToOne(() => Children, (c) => c.amLinks, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'id_enfant', referencedColumnName: 'id' }) + child: Children; + + @ManyToOne(() => Users, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'cree_par', referencedColumnName: 'id' }) + createdBy?: Users; +} diff --git a/backend/src/entities/assistantes_maternelles.entity.ts b/backend/src/entities/assistantes_maternelles.entity.ts index 77db5f1..65d46b8 100644 --- a/backend/src/entities/assistantes_maternelles.entity.ts +++ b/backend/src/entities/assistantes_maternelles.entity.ts @@ -1,5 +1,6 @@ -import { Entity, PrimaryColumn, Column, OneToOne, JoinColumn } from 'typeorm'; +import { Entity, PrimaryColumn, Column, OneToOne, OneToMany, JoinColumn } from 'typeorm'; import { Users } from './users.entity'; +import { AmChildren } from './am_children.entity'; @Entity('assistantes_maternelles') export class AssistanteMaternelle { @@ -51,4 +52,7 @@ export class AssistanteMaternelle { /** 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; + + @OneToMany(() => AmChildren, (ac) => ac.am) + amChildren: AmChildren[]; } diff --git a/backend/src/entities/children.entity.ts b/backend/src/entities/children.entity.ts index 3dd691f..3c1db67 100644 --- a/backend/src/entities/children.entity.ts +++ b/backend/src/entities/children.entity.ts @@ -4,12 +4,14 @@ import { } from 'typeorm'; import { Parents } from './parents.entity'; import { ParentsChildren } from './parents_children.entity'; +import { AmChildren } from './am_children.entity'; import { Dossier } from './dossiers.entity'; export enum StatutEnfantType { A_NAITRE = 'a_naitre', - ACTIF = 'actif', SCOLARISE = 'scolarise', + GARDE = 'garde', + SANS_GARDE = 'sans_garde', } export enum GenreType { @@ -68,6 +70,9 @@ export class Children { @OneToMany(() => ParentsChildren, pc => pc.child) parentLinks: ParentsChildren[]; + @OneToMany(() => AmChildren, (ac) => ac.child) + amLinks: AmChildren[]; + // Relation avec Dossier @OneToMany(() => Dossier, d => d.child) dossiers: Dossier[]; diff --git a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts index 47d51eb..540c73e 100644 --- a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts +++ b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts @@ -12,11 +12,14 @@ import { AssistantesMaternellesService } from './assistantes_maternelles.service import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { Roles } from 'src/common/decorators/roles.decorator'; -import { RoleType } from 'src/entities/users.entity'; +import { RoleType, Users } from 'src/entities/users.entity'; import { CreateAssistanteDto } from '../user/dto/create_assistante.dto'; import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto'; +import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto'; import { RolesGuard } from 'src/common/guards/roles.guard'; import { AuthGuard } from 'src/common/guards/auth.guard'; +import { User } from 'src/common/decorators/user.decorator'; +import { mapAmForApi, mapAmsForApi } from './assistantes_maternelles.mapper'; @ApiTags("Assistantes Maternelles") @ApiBearerAuth('access-token') @@ -31,28 +34,74 @@ export class AssistantesMaternellesController { @ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins et gestionnaires' }) @ApiBody({ type: CreateAssistanteDto }) @Post() - create(@Body() dto: CreateAssistanteDto): Promise { - return this.assistantesMaternellesService.create(dto); + async create(@Body() dto: CreateAssistanteDto): Promise { + const am = await this.assistantesMaternellesService.create(dto); + return mapAmForApi(am); } @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @Get() - @ApiOperation({ summary: 'Récupérer la liste des nounous' }) + @ApiOperation({ summary: 'Récupérer la liste des nounous (inclut amChildren actifs) — ticket #131' }) @ApiResponse({ status: 200, description: 'Liste des nounous' }) @ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins et gestionnaires' }) - getAll(): Promise { - return this.assistantesMaternellesService.findAll(); + async getAll(): Promise { + const ams = await this.assistantesMaternellesService.findAll(); + return mapAmsForApi(ams); } - @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE) + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @Get(':id') @ApiParam({ name: 'id', description: "UUID de la nounou" }) - @ApiOperation({ summary: 'Récupérer une nounou par id' }) + @ApiOperation({ summary: 'Récupérer une nounou par id (inclut amChildren) — ticket #131' }) @ApiResponse({ status: 200, description: 'Détails de la nounou' }) @ApiResponse({ status: 404, description: 'Nounou non trouvée' }) - @ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins et gestionnaires' }) - getOne(@Param('id') user_id: string): Promise { - return this.assistantesMaternellesService.findOne(user_id); + @ApiResponse({ status: 403, description: 'Accès refusé' }) + async getOne(@Param('id') user_id: string): Promise { + const am = await this.assistantesMaternellesService.findOne(user_id); + return mapAmForApi(am); + } + + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) + @Patch(':id/fiche') + @ApiBody({ type: UpdateAmFicheAdminDto }) + @ApiOperation({ summary: 'Mettre à jour la fiche AM (identité + pro) — ticket #131' }) + @ApiParam({ name: 'id', description: "UUID utilisateur de l'AM" }) + @ApiResponse({ status: 200, description: 'Fiche AM mise à jour' }) + async updateFicheAdmin( + @Param('id') id: string, + @Body() dto: UpdateAmFicheAdminDto, + ): Promise { + const am = await this.assistantesMaternellesService.updateFicheAdmin(id, dto); + return mapAmForApi(am); + } + + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) + @Post(':id/enfants/:enfantId') + @ApiOperation({ summary: 'Rattacher un enfant à une AM (statut enfant → garde) — ticket #131' }) + @ApiParam({ name: 'id', description: "UUID utilisateur de l'AM" }) + @ApiParam({ name: 'enfantId', description: "UUID de l'enfant" }) + @ApiResponse({ status: 200, description: 'AM avec enfants mis à jour' }) + async attachEnfant( + @Param('id') id: string, + @Param('enfantId') enfantId: string, + @User() currentUser: Users, + ): Promise { + const am = await this.assistantesMaternellesService.attachEnfant(id, enfantId, currentUser); + return mapAmForApi(am); + } + + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) + @Delete(':id/enfants/:enfantId') + @ApiOperation({ summary: "Clôturer le placement d'un enfant chez une AM — ticket #131" }) + @ApiParam({ name: 'id', description: "UUID utilisateur de l'AM" }) + @ApiParam({ name: 'enfantId', description: "UUID de l'enfant" }) + @ApiResponse({ status: 200, description: 'AM avec enfants mis à jour' }) + async detachEnfant( + @Param('id') id: string, + @Param('enfantId') enfantId: string, + ): Promise { + const am = await this.assistantesMaternellesService.detachEnfant(id, enfantId); + return mapAmForApi(am); } @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE) @@ -63,14 +112,15 @@ export class AssistantesMaternellesController { @ApiResponse({ status: 404, description: 'Nounou non trouvée' }) @ApiParam({ name: 'id', description: "UUID de la nounou" }) @Patch(':id') - update(@Param('id') id: string, @Body() dto: UpdateAssistanteDto): Promise { - return this.assistantesMaternellesService.update(id, dto); + async update(@Param('id') id: string, @Body() dto: UpdateAssistanteDto): Promise { + const am = await this.assistantesMaternellesService.update(id, dto); + return mapAmForApi(am); } @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @ApiOperation({ summary: 'Supprimer une nounou' }) @ApiResponse({ status: 200, description: 'Nounou supprimée avec succès' }) - @ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins, gestionnaires et administrateurs' }) + @ApiResponse({ status: 403, description: 'Accès refusé' }) @ApiResponse({ status: 404, description: 'Nounou non trouvée' }) @ApiParam({ name: 'id', description: "UUID de la nounou" }) @Delete(':id') diff --git a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.mapper.ts b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.mapper.ts new file mode 100644 index 0000000..cc33c4c --- /dev/null +++ b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.mapper.ts @@ -0,0 +1,28 @@ +import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; +import { AmChildren } from 'src/entities/am_children.entity'; +import { sanitizeUserForApi } from '../../common/utils/sanitize-user-for-api'; + +/** + * Sérialisation API fiche AM — ticket #131. + * Expose `amChildren` actifs (date_fin null) avec enfant imbriqué, sans secrets user. + */ +export function mapAmForApi(am: AssistanteMaternelle): AssistanteMaternelle { + const activeChildren = (am.amChildren ?? []).filter((link) => !link.date_fin); + + return { + ...am, + user: sanitizeUserForApi(am.user)!, + amChildren: activeChildren.map((link) => ({ + ...link, + child: link.child, + })), + }; +} + +export function mapAmsForApi(ams: AssistanteMaternelle[]): AssistanteMaternelle[] { + return ams.map(mapAmForApi); +} + +export function filterActiveAmChildren(links: AmChildren[] | undefined): AmChildren[] { + return (links ?? []).filter((link) => !link.date_fin); +} diff --git a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.module.ts b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.module.ts index 9e2e231..54a10ae 100644 --- a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.module.ts +++ b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.module.ts @@ -2,12 +2,14 @@ import { Module } from '@nestjs/common'; import { AssistantesMaternellesService } from './assistantes_maternelles.service'; import { AssistantesMaternellesController } from './assistantes_maternelles.controller'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; +import { AmChildren } from 'src/entities/am_children.entity'; +import { Children } from 'src/entities/children.entity'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Users } from 'src/entities/users.entity'; import { AuthModule } from '../auth/auth.module'; @Module({ - imports: [TypeOrmModule.forFeature([AssistanteMaternelle, Users]), + imports: [TypeOrmModule.forFeature([AssistanteMaternelle, AmChildren, Children, Users]), AuthModule ], controllers: [AssistantesMaternellesController], diff --git a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.service.ts b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.service.ts index c9ba68f..9949126 100644 --- a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.service.ts +++ b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.service.ts @@ -5,11 +5,16 @@ import { NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { IsNull, Repository } from 'typeorm'; import { RoleType, Users } from 'src/entities/users.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; +import { AmChildren } from 'src/entities/am_children.entity'; +import { Children, StatutEnfantType } from 'src/entities/children.entity'; import { CreateAssistanteDto } from '../user/dto/create_assistante.dto'; import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto'; +import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto'; + +const AM_CHILDREN_RELATIONS = ['user', 'amChildren', 'amChildren.child'] as const; @Injectable() export class AssistantesMaternellesService { @@ -17,10 +22,13 @@ export class AssistantesMaternellesService { @InjectRepository(AssistanteMaternelle) private readonly assistantesMaternelleRepository: Repository, @InjectRepository(Users) - private readonly usersRepository: Repository + private readonly usersRepository: Repository, + @InjectRepository(AmChildren) + private readonly amChildrenRepository: Repository, + @InjectRepository(Children) + private readonly childrenRepository: Repository, ) {} - // Création d’une assistante maternelle async create(dto: CreateAssistanteDto): Promise { const user = await this.usersRepository.findOneBy({ id: dto.user_id }); if (!user) throw new NotFoundException('Utilisateur introuvable'); @@ -49,30 +57,167 @@ export class AssistantesMaternellesService { return this.assistantesMaternelleRepository.save(entity); } - // Liste des assistantes maternelles async findAll(): Promise { return this.assistantesMaternelleRepository.find({ - relations: ['user'], + relations: [...AM_CHILDREN_RELATIONS], }); } - // Récupérer une assistante maternelle par user_id async findOne(user_id: string): Promise { const assistante = await this.assistantesMaternelleRepository.findOne({ where: { user_id }, - relations: ['user'], + relations: [...AM_CHILDREN_RELATIONS], }); if (!assistante) throw new NotFoundException('Assistante maternelle introuvable'); return assistante; } - // Mise à jour async update(id: string, dto: UpdateAssistanteDto): Promise { await this.assistantesMaternelleRepository.update(id, dto); return this.findOne(id); } - // Suppression d’une assistante maternelle + /** + * Mise à jour fiche AM (identité + champs pro) par admin/gestionnaire. Ticket #131. + */ + async updateFicheAdmin(amUserId: string, dto: UpdateAmFicheAdminDto): Promise { + const am = await this.findOne(amUserId); + const user = am.user; + + if (dto.email && dto.email !== user.email) { + const existing = await this.usersRepository.findOne({ where: { email: dto.email } }); + if (existing && existing.id !== user.id) { + throw new ConflictException('Cet email est déjà utilisé'); + } + user.email = dto.email; + } + + if (dto.nom !== undefined) user.nom = dto.nom; + if (dto.prenom !== undefined) user.prenom = dto.prenom; + 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.statut !== undefined) user.statut = dto.statut; + + await this.usersRepository.save(user); + + const amPatch: Partial = {}; + if (dto.approval_number !== undefined) amPatch.approval_number = dto.approval_number; + if (dto.residence_city !== undefined) amPatch.residence_city = dto.residence_city; + if (dto.max_children !== undefined) amPatch.max_children = dto.max_children; + if (dto.places_available !== undefined) amPatch.places_available = dto.places_available; + if (dto.biography !== undefined) amPatch.biography = dto.biography; + if (dto.available !== undefined) amPatch.available = dto.available; + + if (Object.keys(amPatch).length > 0) { + await this.assistantesMaternelleRepository.update(amUserId, amPatch); + } + + return this.findOne(amUserId); + } + + /** + * Rattacher un enfant à une AM (placement actif). Ticket #131. + * Passe le statut enfant à `garde` (sauf a_naitre / scolarise). + */ + async attachEnfant(amUserId: string, enfantId: string, createdBy?: Users): Promise { + const am = await this.findOne(amUserId); + + const existingForAm = await this.amChildrenRepository.findOne({ + where: { amId: amUserId, enfantId, date_fin: IsNull() }, + }); + if (existingForAm) { + throw new ConflictException('Cet enfant est déjà rattaché à cette assistante maternelle'); + } + + const child = await this.childrenRepository.findOne({ where: { id: enfantId } }); + if (!child) { + throw new NotFoundException('Enfant introuvable'); + } + + const activeForChild = await this.amChildrenRepository.findOne({ + where: { enfantId, date_fin: IsNull() }, + }); + if (activeForChild && activeForChild.amId !== amUserId) { + throw new ConflictException( + 'Cet enfant est déjà en garde chez une autre assistante maternelle', + ); + } + + const activeCount = await this.amChildrenRepository.count({ + where: { amId: amUserId, date_fin: IsNull() }, + }); + if (am.max_children != null && activeCount >= am.max_children) { + throw new BadRequestException( + `Capacité maximale atteinte (${am.max_children} enfant(s))`, + ); + } + + await this.amChildrenRepository.save( + this.amChildrenRepository.create({ + amId: amUserId, + enfantId, + date_debut: new Date(), + cree_par: createdBy?.id, + }), + ); + + await this.applyGardeStatusOnAttach(child); + + return this.findOne(amUserId); + } + + /** + * Clôturer le placement AM ↔ enfant. Ticket #131. + * Repasse l'enfant en `sans_garde` s'il n'a plus de placement actif. + */ + async detachEnfant(amUserId: string, enfantId: string): Promise { + await this.findOne(amUserId); + + const link = await this.amChildrenRepository.findOne({ + where: { amId: amUserId, enfantId, date_fin: IsNull() }, + relations: ['child'], + }); + if (!link) { + throw new NotFoundException('Lien assistante maternelle-enfant introuvable'); + } + + link.date_fin = new Date(); + await this.amChildrenRepository.save(link); + + const remaining = await this.amChildrenRepository.count({ + where: { enfantId, date_fin: IsNull() }, + }); + if (remaining === 0 && link.child) { + await this.applySansGardeStatusOnDetach(link.child); + } + + return this.findOne(amUserId); + } + + private async applyGardeStatusOnAttach(child: Children): Promise { + if ( + child.status === StatutEnfantType.A_NAITRE || + child.status === StatutEnfantType.SCOLARISE + ) { + return; + } + child.status = StatutEnfantType.GARDE; + await this.childrenRepository.save(child); + } + + private async applySansGardeStatusOnDetach(child: Children): Promise { + if ( + child.status === StatutEnfantType.A_NAITRE || + child.status === StatutEnfantType.SCOLARISE + ) { + return; + } + child.status = StatutEnfantType.SANS_GARDE; + await this.childrenRepository.save(child); + } + async remove(id: string): Promise<{ message: string }> { await this.assistantesMaternelleRepository.delete(id); return { message: 'Assistante maternelle supprimée' }; diff --git a/backend/src/routes/assistantes_maternelles/dto/update-am-fiche-admin.dto.ts b/backend/src/routes/assistantes_maternelles/dto/update-am-fiche-admin.dto.ts new file mode 100644 index 0000000..81a665d --- /dev/null +++ b/backend/src/routes/assistantes_maternelles/dto/update-am-fiche-admin.dto.ts @@ -0,0 +1,97 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsBoolean, + IsEmail, + IsEnum, + IsInt, + IsOptional, + IsString, + Max, + MaxLength, + Min, +} from 'class-validator'; +import { StatutUtilisateurType } from 'src/entities/users.entity'; + +/** Mise à jour fiche AM par admin/gestionnaire (doc 28 §6.1, ticket #131). */ +export class UpdateAmFicheAdminDto { + @ApiPropertyOptional({ example: 'MARTIN' }) + @IsOptional() + @IsString() + @MaxLength(100) + nom?: string; + + @ApiPropertyOptional({ example: 'Claire' }) + @IsOptional() + @IsString() + @MaxLength(100) + prenom?: string; + + @ApiPropertyOptional({ example: 'claire@example.com' }) + @IsOptional() + @IsEmail() + email?: string; + + @ApiPropertyOptional({ example: '0612345678' }) + @IsOptional() + @IsString() + @MaxLength(20) + telephone?: string; + + @ApiPropertyOptional({ example: '5 place Bellecour' }) + @IsOptional() + @IsString() + adresse?: string; + + @ApiPropertyOptional({ example: 'Lyon' }) + @IsOptional() + @IsString() + @MaxLength(150) + ville?: string; + + @ApiPropertyOptional({ example: '69002' }) + @IsOptional() + @IsString() + @MaxLength(10) + code_postal?: string; + + @ApiPropertyOptional({ enum: StatutUtilisateurType }) + @IsOptional() + @IsEnum(StatutUtilisateurType) + statut?: StatutUtilisateurType; + + @ApiPropertyOptional({ example: 'AGR-2024-12345' }) + @IsOptional() + @IsString() + @MaxLength(50) + approval_number?: string; + + @ApiPropertyOptional({ example: 'Lyon' }) + @IsOptional() + @IsString() + @MaxLength(100) + residence_city?: string; + + @ApiPropertyOptional({ example: 4 }) + @IsOptional() + @IsInt() + @Min(1) + @Max(10) + max_children?: number; + + @ApiPropertyOptional({ example: 2 }) + @IsOptional() + @IsInt() + @Min(0) + @Max(10) + places_available?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + biography?: string; + + @ApiPropertyOptional({ example: true }) + @IsOptional() + @IsBoolean() + available?: boolean; +} diff --git a/backend/src/routes/auth/auth.service.spec.ts b/backend/src/routes/auth/auth.service.spec.ts index e373abd..f24b719 100644 --- a/backend/src/routes/auth/auth.service.spec.ts +++ b/backend/src/routes/auth/auth.service.spec.ts @@ -176,7 +176,7 @@ describe('AuthService (#118 create-password API)', () => { parentsServiceMock.getDossierFamilleByNumero.mockResolvedValue({ numero_dossier: '2026-000021', parents: [{ user_id: 'p1', email: 'claire@test.fr', statut: StatutUtilisateurType.REFUSE }], - enfants: [{ id: 'e1', first_name: 'Emma', status: 'actif' }], + enfants: [{ id: 'e1', first_name: 'Emma', status: 'sans_garde' }], texte_motivation: 'Motivation test', }); diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index f78a06e..308bb81 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -551,7 +551,7 @@ export class AuthService { ? new Date(enfantDto.date_previsionnelle_naissance) : undefined; enfant.photo_url = urlPhoto || undefined; - enfant.status = enfantDto.date_naissance ? StatutEnfantType.ACTIF : StatutEnfantType.A_NAITRE; + enfant.status = enfantDto.date_naissance ? StatutEnfantType.SANS_GARDE : StatutEnfantType.A_NAITRE; enfant.consent_photo = false; enfant.is_multiple = enfantDto.grossesse_multiple || false; @@ -1063,7 +1063,7 @@ export class AuthService { if (enfantDto.genre !== undefined) enfant.gender = enfantDto.genre; if (enfantDto.date_naissance !== undefined) { enfant.birth_date = new Date(enfantDto.date_naissance); - enfant.status = StatutEnfantType.ACTIF; + enfant.status = StatutEnfantType.SANS_GARDE; } if (enfantDto.date_previsionnelle_naissance !== undefined) { enfant.due_date = new Date(enfantDto.date_previsionnelle_naissance); diff --git a/backend/src/routes/enfants/dto/create_enfants.dto.ts b/backend/src/routes/enfants/dto/create_enfants.dto.ts index 3ede09d..7d69067 100644 --- a/backend/src/routes/enfants/dto/create_enfants.dto.ts +++ b/backend/src/routes/enfants/dto/create_enfants.dto.ts @@ -12,7 +12,7 @@ import { import { GenreType, StatutEnfantType } from 'src/entities/children.entity'; export class CreateEnfantsDto { - @ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.ACTIF }) + @ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.SANS_GARDE }) @IsEnum(StatutEnfantType) @IsNotEmpty() status: StatutEnfantType; diff --git a/backend/src/routes/enfants/enfants.service.ts b/backend/src/routes/enfants/enfants.service.ts index fb900a1..e4b46c4 100644 --- a/backend/src/routes/enfants/enfants.service.ts +++ b/backend/src/routes/enfants/enfants.service.ts @@ -34,7 +34,7 @@ export class EnfantsService { // Vérif métier simple if (dto.status !== StatutEnfantType.A_NAITRE && !dto.birth_date) { - throw new BadRequestException('Un enfant actif doit avoir une date de naissance'); + throw new BadRequestException('Un enfant né doit avoir une date de naissance'); } // Vérif doublon éventuel (ex: même prénom + date de naissance pour ce parent) diff --git a/database/BDD.sql b/database/BDD.sql index b7cd431..9cd9f5e 100644 --- a/database/BDD.sql +++ b/database/BDD.sql @@ -1,3 +1,9 @@ +-- ========================================================== +-- P'titsPas — Schéma PostgreSQL (création from scratch) +-- Fichier canonique : toute nouvelle BDD doit partir d'ici. +-- Migrations dans database/migrations/ : BDD existantes uniquement. +-- ========================================================== + CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- ========================================================== @@ -5,40 +11,63 @@ CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- ========================================================== DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'role_type') THEN - CREATE TYPE role_type AS ENUM ('parent', 'gestionnaire', 'super_admin', 'administrateur', 'assistante_maternelle'); + 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', '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'); + 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'); + CREATE TYPE statut_enfant_type AS ENUM ('a_naitre', 'garde', 'sans_garde', 'scolarise'); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_dossier_type') THEN - CREATE TYPE statut_dossier_type AS ENUM ('envoye','accepte','refuse'); + CREATE TYPE statut_dossier_type AS ENUM ('envoye', 'accepte', 'refuse'); END IF; - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_contrat_type') THEN - CREATE TYPE statut_contrat_type AS ENUM ('brouillon','en_attente_signature','valide','resilie'); + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_contrat_type') THEN + CREATE TYPE statut_contrat_type AS ENUM ( + 'brouillon', 'en_attente_signature', 'valide', 'resilie' + ); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_avenant_type') THEN - CREATE TYPE statut_avenant_type AS ENUM ('propose','accepte','refuse'); + CREATE TYPE statut_avenant_type AS ENUM ('propose', 'accepte', 'refuse'); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'type_evenement_type') THEN - CREATE TYPE type_evenement_type AS ENUM ('absence_enfant','conge_am','conge_parent','arret_maladie_am','evenement_rpe'); + CREATE TYPE type_evenement_type AS ENUM ( + 'absence_enfant', 'conge_am', 'conge_parent', 'arret_maladie_am', 'evenement_rpe' + ); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_evenement_type') THEN - CREATE TYPE statut_evenement_type AS ENUM ('propose','valide','refuse'); + CREATE TYPE statut_evenement_type AS ENUM ('propose', 'valide', 'refuse'); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_validation_type') THEN - CREATE TYPE statut_validation_type AS ENUM ('en_attente','valide','refuse'); + CREATE TYPE statut_validation_type AS ENUM ('en_attente', 'valide', 'refuse'); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'situation_familiale_type') THEN - CREATE TYPE situation_familiale_type AS ENUM ('celibataire','marie','concubinage','pacse','separe','divorce','veuf','parent_isole'); + CREATE TYPE situation_familiale_type AS ENUM ( + 'celibataire', 'marie', 'concubinage', 'pacse', 'separe', 'divorce', 'veuf', 'parent_isole' + ); END IF; END $$; +-- ========================================================== +-- Table : relais (avant utilisateurs — FK relais_id) +-- ========================================================== +CREATE TABLE relais ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + nom VARCHAR(255) NOT NULL, + adresse TEXT NOT NULL, + horaires_ouverture JSONB, + ligne_fixe VARCHAR(20), + actif BOOLEAN DEFAULT true, + notes TEXT, + cree_le TIMESTAMPTZ DEFAULT now(), + modifie_le TIMESTAMPTZ DEFAULT now() +); + -- ========================================================== -- Table : utilisateurs -- ========================================================== @@ -46,26 +75,33 @@ CREATE TABLE utilisateurs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email VARCHAR(255) NOT NULL UNIQUE, CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'), - password TEXT, -- NULL avant création via token + password TEXT, prenom VARCHAR(100), nom VARCHAR(100), genre genre_type, role role_type NOT NULL, statut statut_utilisateur_type DEFAULT 'en_attente', - telephone VARCHAR(20), -- Unifié (mobile privilégié) + telephone VARCHAR(20), adresse TEXT, date_naissance DATE, lieu_naissance_ville VARCHAR(100), lieu_naissance_pays VARCHAR(100), - photo_url TEXT, -- Obligatoire pour AM, non utilisé pour parents + photo_url TEXT, consentement_photo BOOLEAN DEFAULT false, date_consentement_photo TIMESTAMPTZ, - token_creation_mdp VARCHAR(255), -- Token pour créer MDP après validation - token_creation_mdp_expire_le TIMESTAMPTZ, -- Expiration 7 jours - -- Ticket #127 : réinitialisation mot de passe oublié (distinct de token_creation_mdp) - password_reset_token VARCHAR(255) NULL, - password_reset_expires TIMESTAMPTZ NULL, + token_creation_mdp VARCHAR(255), + token_creation_mdp_expire_le TIMESTAMPTZ, + password_reset_token VARCHAR(255), + password_reset_expires TIMESTAMPTZ, + token_reprise VARCHAR(255), + token_reprise_expire_le TIMESTAMPTZ, changement_mdp_obligatoire BOOLEAN DEFAULT false, + numero_dossier VARCHAR(20), + cgu_version_acceptee INTEGER, + cgu_acceptee_le TIMESTAMPTZ, + privacy_version_acceptee INTEGER, + privacy_acceptee_le TIMESTAMPTZ, + relais_id UUID REFERENCES relais(id) ON DELETE SET NULL, cree_le TIMESTAMPTZ DEFAULT now(), modifie_le TIMESTAMPTZ DEFAULT now(), ville VARCHAR(150), @@ -74,15 +110,22 @@ CREATE TABLE utilisateurs ( situation_familiale situation_familiale_type ); --- Index pour recherche par token -CREATE INDEX idx_utilisateurs_token_creation_mdp - ON utilisateurs(token_creation_mdp) +CREATE INDEX idx_utilisateurs_token_creation_mdp + ON utilisateurs(token_creation_mdp) WHERE token_creation_mdp IS NOT NULL; CREATE INDEX idx_utilisateurs_password_reset_token ON utilisateurs(password_reset_token) WHERE password_reset_token IS NOT NULL; +CREATE INDEX idx_utilisateurs_token_reprise + ON utilisateurs(token_reprise) + WHERE token_reprise IS NOT NULL; + +CREATE INDEX idx_utilisateurs_numero_dossier + ON utilisateurs(numero_dossier) + WHERE numero_dossier IS NOT NULL; + -- ========================================================== -- Table : assistantes_maternelles -- ========================================================== @@ -97,17 +140,27 @@ CREATE TABLE assistantes_maternelles ( date_agrement DATE, annee_experience SMALLINT, specialite VARCHAR(100), - place_disponible INT + place_disponible INT, + numero_dossier VARCHAR(20) ); +CREATE INDEX idx_assistantes_maternelles_numero_dossier + ON assistantes_maternelles(numero_dossier) + WHERE numero_dossier IS NOT NULL; + -- ========================================================== -- Table : parents -- ========================================================== CREATE TABLE parents ( id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE, - id_co_parent UUID REFERENCES utilisateurs(id) + id_co_parent UUID REFERENCES utilisateurs(id), + numero_dossier VARCHAR(20) ); +CREATE INDEX idx_parents_numero_dossier + ON parents(numero_dossier) + WHERE numero_dossier IS NOT NULL; + -- ========================================================== -- Table : enfants -- ========================================================== @@ -116,7 +169,7 @@ CREATE TABLE enfants ( statut statut_enfant_type, prenom VARCHAR(100), nom VARCHAR(100), - genre genre_type NOT NULL, -- Obligatoire selon CDC + genre genre_type NOT NULL, date_naissance DATE, date_prevue_naissance DATE, photo_url TEXT, @@ -135,7 +188,27 @@ CREATE TABLE enfants_parents ( ); -- ========================================================== --- Table : dossier_famille (inscription parent, schéma simplifié — ticket #119) +-- Table : enfants_assistantes_maternelles (placement AM ↔ enfant — #131) +-- ========================================================== +CREATE TABLE enfants_assistantes_maternelles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + id_am UUID NOT NULL REFERENCES assistantes_maternelles(id_utilisateur) ON DELETE CASCADE, + id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE, + date_debut DATE NOT NULL DEFAULT CURRENT_DATE, + date_fin DATE NULL, + cree_le TIMESTAMPTZ NOT NULL DEFAULT now(), + cree_par UUID REFERENCES utilisateurs(id) ON DELETE SET NULL +); + +CREATE INDEX idx_eam_am ON enfants_assistantes_maternelles(id_am); +CREATE INDEX idx_eam_enfant ON enfants_assistantes_maternelles(id_enfant); + +CREATE UNIQUE INDEX uq_enfant_garde_active + ON enfants_assistantes_maternelles (id_enfant) + WHERE date_fin IS NULL; + +-- ========================================================== +-- Table : dossier_famille (inscription parent — ticket #119) -- ========================================================== CREATE TABLE dossier_famille ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -276,10 +349,6 @@ CREATE TABLE notifications ( -- ========================================================== -- Table : validations -- ========================================================== --- Historique des décisions (validation / refus / suspension de compte). --- Colonnes commentaire + valide_par : requises par l’API Nest (TypeORM). --- FK en ON DELETE SET NULL : conserver la ligne si l’utilisateur référencé --- est supprimé (voir database/docs/FK_POLICIES.md). CREATE TABLE validations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id_utilisateur UUID REFERENCES utilisateurs(id) ON DELETE SET NULL, @@ -305,13 +374,10 @@ CREATE TABLE configuration ( modifie_par UUID REFERENCES utilisateurs(id) ); --- Index pour performance CREATE INDEX idx_configuration_cle ON configuration(cle); CREATE INDEX idx_configuration_categorie ON configuration(categorie); --- Seed initial de configuration INSERT INTO configuration (cle, valeur, type, categorie, description) VALUES --- === Configuration Email (SMTP) === ('smtp_host', 'localhost', 'string', 'email', 'Serveur SMTP (ex: mail.mairie-bezons.fr, smtp.gmail.com)'), ('smtp_port', '25', 'number', 'email', 'Port SMTP (25, 465, 587)'), ('smtp_secure', 'false', 'boolean', 'email', 'Utiliser SSL/TLS (true pour port 465)'), @@ -320,14 +386,10 @@ INSERT INTO configuration (cle, valeur, type, categorie, description) VALUES ('smtp_password', '', 'encrypted', 'email', 'Mot de passe SMTP (chiffré en AES-256)'), ('email_from_name', 'P''titsPas', 'string', 'email', 'Nom de l''expéditeur affiché dans les emails'), ('email_from_address', 'no-reply@ptits-pas.fr', 'string', 'email', 'Adresse email de l''expéditeur'), - --- === Configuration Application === ('app_name', 'P''titsPas', 'string', 'app', 'Nom de l''application (affiché dans l''interface)'), ('app_url', 'https://app.ptits-pas.fr', 'string', 'app', 'URL publique de l''application (pour les liens dans emails)'), ('app_logo_url', '/assets/logo.png', 'string', 'app', 'URL du logo de l''application'), ('setup_completed', 'false', 'boolean', 'app', 'Configuration initiale terminée'), - --- === Configuration Sécurité === ('password_reset_token_expiry_days', '7', 'number', 'security', 'Durée de validité des tokens de création/réinitialisation de mot de passe (en jours)'), ('jwt_expiry_hours', '24', 'number', 'security', 'Durée de validité des sessions JWT (en heures)'), ('max_upload_size_mb', '5', 'number', 'security', 'Taille maximale des fichiers uploadés (en MB)'), @@ -338,19 +400,18 @@ INSERT INTO configuration (cle, valeur, type, categorie, description) VALUES -- ========================================================== CREATE TABLE documents_legaux ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - type VARCHAR(50) NOT NULL, -- 'cgu' ou 'privacy' - version INTEGER NOT NULL, -- Numéro de version (auto-incrémenté) - fichier_nom VARCHAR(255) NOT NULL, -- Nom original du fichier - fichier_path VARCHAR(500) NOT NULL, -- Chemin de stockage - fichier_hash VARCHAR(64) NOT NULL, -- Hash SHA-256 pour intégrité - actif BOOLEAN DEFAULT false, -- Version actuellement active - televerse_par UUID REFERENCES utilisateurs(id), -- Qui a uploadé - televerse_le TIMESTAMPTZ DEFAULT now(), -- Date d'upload - active_le TIMESTAMPTZ, -- Date d'activation - UNIQUE(type, version) -- Pas de doublon version + type VARCHAR(50) NOT NULL, + version INTEGER NOT NULL, + fichier_nom VARCHAR(255) NOT NULL, + fichier_path VARCHAR(500) NOT NULL, + fichier_hash VARCHAR(64) NOT NULL, + actif BOOLEAN DEFAULT false, + televerse_par UUID REFERENCES utilisateurs(id), + televerse_le TIMESTAMPTZ DEFAULT now(), + active_le TIMESTAMPTZ, + UNIQUE(type, version) ); --- Index pour performance CREATE INDEX idx_documents_legaux_type_actif ON documents_legaux(type, actif); CREATE INDEX idx_documents_legaux_version ON documents_legaux(type, version DESC); @@ -361,73 +422,23 @@ CREATE TABLE acceptations_documents ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id_utilisateur UUID REFERENCES utilisateurs(id) ON DELETE CASCADE, id_document UUID REFERENCES documents_legaux(id), - type_document VARCHAR(50) NOT NULL, -- 'cgu' ou 'privacy' - version_document INTEGER NOT NULL, -- Version acceptée - accepte_le TIMESTAMPTZ DEFAULT now(), -- Date d'acceptation - ip_address INET, -- IP de l'utilisateur (RGPD) - user_agent TEXT -- Navigateur (preuve) + type_document VARCHAR(50) NOT NULL, + version_document INTEGER NOT NULL, + accepte_le TIMESTAMPTZ DEFAULT now(), + ip_address INET, + user_agent TEXT ); --- Index pour performance CREATE INDEX idx_acceptations_utilisateur ON acceptations_documents(id_utilisateur); CREATE INDEX idx_acceptations_document ON acceptations_documents(id_document); --- ========================================================== --- Table : relais --- ========================================================== -CREATE TABLE relais ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - nom VARCHAR(255) NOT NULL, - adresse TEXT NOT NULL, - horaires_ouverture JSONB, - ligne_fixe VARCHAR(20), - actif BOOLEAN DEFAULT true, - notes TEXT, - cree_le TIMESTAMPTZ DEFAULT now(), - modifie_le TIMESTAMPTZ DEFAULT now() -); - --- ========================================================== --- Modification Table : utilisateurs (ajout colonnes documents et relais) --- ========================================================== -ALTER TABLE utilisateurs - ADD COLUMN IF NOT EXISTS cgu_version_acceptee INTEGER, - ADD COLUMN IF NOT EXISTS cgu_acceptee_le TIMESTAMPTZ, - ADD COLUMN IF NOT EXISTS privacy_version_acceptee INTEGER, - 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 +CREATE TABLE 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; - --- ========================================================== --- 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; - --- ========================================================== --- Ticket #127 : Mot de passe oublié (token dédié, ≠ inscription) --- ========================================================== -ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS password_reset_token VARCHAR(255) NULL; -ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS password_reset_expires TIMESTAMPTZ NULL; -CREATE INDEX IF NOT EXISTS idx_utilisateurs_password_reset_token ON utilisateurs(password_reset_token) WHERE password_reset_token IS NOT NULL; - --- Lieu de naissance (aligné CREATE TABLE utilisateurs — idempotent si colonnes déjà présentes) -ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS lieu_naissance_ville VARCHAR(100) NULL; -ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS lieu_naissance_pays VARCHAR(100) NULL; -- ========================================================== -- Seed : Documents légaux génériques v1 @@ -438,10 +449,7 @@ INSERT INTO documents_legaux (type, version, fichier_nom, fichier_path, fichier_ -- ========================================================== -- Seed : Super Administrateur par défaut --- ========================================================== --- Email: admin@ptits-pas.fr --- Mot de passe: 4dm1n1strateur (hashé bcrypt) --- IMPORTANT: Changer ce mot de passe en production ! +-- Email: admin@ptits-pas.fr | Mot de passe: 4dm1n1strateur -- ========================================================== INSERT INTO utilisateurs ( email, diff --git a/database/README.md b/database/README.md index 98200b5..16846eb 100644 --- a/database/README.md +++ b/database/README.md @@ -15,12 +15,13 @@ Ce projet contient la **base de données** pour l'application PtitsPas, avec scr ## Structure du projet -- `migrations/` : scripts SQL pour la création et l'import de la base -- `bdd/data_test/` : fichiers CSV pour l'import de données de test -- `docs/` : documentation métier et technique -- `seed/` : scripts de seed +- **`BDD.sql`** : schéma canonique (création from scratch — utilisé par `docker-compose.yml` racine) +- `migrations/` : scripts SQL pour **mettre à jour** une BDD déjà en service +- `bdd/data_test/` : fichiers CSV (import legacy) +- `docs/` : documentation métier et technique (`ENUMS.md`, `FK_POLICIES.md`) +- `seed/` : scripts de seed (`03_seed_test_data.sql` — jeu dashboard admin) - `tests/` : tests SQL -- `docker-compose.dev.yml` : configuration Docker pour le développement +- `docker-compose.dev.yml` : Postgres standalone (BDD.sql + seed auto) --- diff --git a/database/bdd/data_test/enfants.csv b/database/bdd/data_test/enfants.csv index 1cf2ab6..43a2275 100644 --- a/database/bdd/data_test/enfants.csv +++ b/database/bdd/data_test/enfants.csv @@ -1,10 +1,10 @@ -"id","statut","prenom","nom","genre","date_naissance","date_prevue_naissance","photo_url","consentement_photo","date_consentement_photo","est_multiple" -"5e8574b7-63e6-4d48-9af3-8d3bf7a6a6cf","actif","Emma","Dupont","F","2020-06-01",,,False,,False -"a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d","actif",,,,"2020-01-01","2025-01-01",,False,,False -"e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c","actif","Emma","Martin",,"2023-02-15",,,False,,False -"e2b3c4d5-e6f7-4a8b-9c1d-2e3f4a5b6c7d","actif","Noah","Martin",,"2023-02-15",,,False,,False -"e3c4d5e6-f7a8-4b9c-1d2e-3f4a5b6c7d8e","actif","Léa","Martin",,"2023-02-15",,,False,,False -"e4d5e6f7-a8b9-4c1d-2e3f-4a5b6c7d8e9f","actif","Chloé","Rousseau",,"2022-04-20",,,False,,False -"e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a","actif","Hugo","Rousseau",,"2024-03-10",,,False,,False -"e6f7a8b9-c1d2-4e3f-5a6b-7c8d9e0f1a2b","actif","Maxime","Lecomte",,"2023-04-15",,,False,,False -"edd19cd1-bb67-4f14-8a37-c66b75c94537","scolarise","Lucas","Durand","H","2018-09-15",,,False,,False +"id","statut","prenom","nom","genre","date_naissance","date_prevue_naissance","photo_url","consentement_photo","date_consentement_photo","est_multiple" +"5e8574b7-63e6-4d48-9af3-8d3bf7a6a6cf","sans_garde","Emma","Dupont","F","2020-06-01",,,False,,False +"a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d","sans_garde",,,,"2020-01-01","2025-01-01",,False,,False +"e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c","sans_garde","Emma","Martin",,"2023-02-15",,,False,,False +"e2b3c4d5-e6f7-4a8b-9c1d-2e3f4a5b6c7d","sans_garde","Noah","Martin",,"2023-02-15",,,False,,False +"e3c4d5e6-f7a8-4b9c-1d2e-3f4a5b6c7d8e","sans_garde","Léa","Martin",,"2023-02-15",,,False,,False +"e4d5e6f7-a8b9-4c1d-2e3f-4a5b6c7d8e9f","sans_garde","Chloé","Rousseau",,"2022-04-20",,,False,,False +"e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a","sans_garde","Hugo","Rousseau",,"2024-03-10",,,False,,False +"e6f7a8b9-c1d2-4e3f-5a6b-7c8d9e0f1a2b","sans_garde","Maxime","Lecomte",,"2023-04-15",,,False,,False +"edd19cd1-bb67-4f14-8a37-c66b75c94537","scolarise","Lucas","Durand","H","2018-09-15",,,False,,False diff --git a/database/docker-compose.dev.yml b/database/docker-compose.dev.yml index 6c608be..74d6db2 100644 --- a/database/docker-compose.dev.yml +++ b/database/docker-compose.dev.yml @@ -14,9 +14,8 @@ services: ports: - "5433:5432" volumes: - - ./migrations/01_init.sql:/docker-entrypoint-initdb.d/01_init.sql - - ./migrations/07_import.sql:/docker-entrypoint-initdb.d/07_import.sql - - ./bdd/data_test:/bdd/data_test + - ./BDD.sql:/docker-entrypoint-initdb.d/01_init.sql + - ./seed/03_seed_test_data.sql:/docker-entrypoint-initdb.d/02_seed_test_data.sql - postgres_standalone_data:/var/lib/postgresql/data networks: - ptitspas_dev diff --git a/database/docs/ENUMS.md b/database/docs/ENUMS.md index c24e516..bda4d68 100644 --- a/database/docs/ENUMS.md +++ b/database/docs/ENUMS.md @@ -51,12 +51,17 @@ Ce document recense **toutes les valeurs énumérées** utilisées dans la base | Valeur | Description | |---|---| | `a_naitre` | Enfant à naître (date prévue renseignée) | -| `actif` | Enfant pris en charge / en cours de garde | +| `sans_garde` | Enfant né, pas encore placé chez une AM (défaut à l'inscription) | +| `garde` | Enfant placé chez une AM (`enfants_assistantes_maternelles` actif) | | `scolarise` | Enfant scolarisé, garde potentiellement périscolaire | **Contraintes associées** : - `a_naitre` → **`date_prevue_naissance` obligatoire** -- `actif`/`scolarise` → **`date_naissance` obligatoire** +- `sans_garde` / `garde` / `scolarise` → **`date_naissance` obligatoire** + +**Transitions** (API admin #131) : +- Rattachement AM ↔ enfant → `garde` (sauf `a_naitre` / `scolarise`) +- Détachement sans autre placement actif → `sans_garde` --- diff --git a/database/docs/FK_POLICIES.md b/database/docs/FK_POLICIES.md index d4686b1..2d861fa 100644 --- a/database/docs/FK_POLICIES.md +++ b/database/docs/FK_POLICIES.md @@ -32,6 +32,9 @@ Documenter, de façon unique et partagée, les règles de suppression/mise à jo | **parents(id_co_parent)** → `utilisateurs(id)` | **SET NULL** | Conserver le parent principal si co-parent disparaît | | **enfants_parents(id_parent)** → `parents(id_utilisateur)` | **CASCADE** | Nettoyage liaisons N:N | | **enfants_parents(id_enfant)** → `enfants(id)` | **CASCADE** | Idem | +| **enfants_assistantes_maternelles(id_am)** → `assistantes_maternelles(id_utilisateur)` | **CASCADE** | Placement supprimé avec l’AM | +| **enfants_assistantes_maternelles(id_enfant)** → `enfants(id)` | **CASCADE** | Idem | +| **enfants_assistantes_maternelles(cree_par)** → `utilisateurs(id)` | **SET NULL** | Historique placement conservé | | **dossiers(id_parent)** → `parents(id_utilisateur)` | **CASCADE** | Dossier n’a pas de sens sans parent | | **dossiers(id_enfant)** → `enfants(id)` | **CASCADE** | Dossier n’a pas de sens sans enfant | | **messages(id_dossier)** → `dossiers(id)` | **CASCADE** | Messages détruits avec le dossier | diff --git a/database/migrations/2026_enfants_assistantes_maternelles.sql b/database/migrations/2026_enfants_assistantes_maternelles.sql new file mode 100644 index 0000000..9175051 --- /dev/null +++ b/database/migrations/2026_enfants_assistantes_maternelles.sql @@ -0,0 +1,36 @@ +-- Ticket #131 / #141 — Placement AM ↔ enfant + statuts garde/sans_garde +-- ⚠️ BDD EXISTANTES UNIQUEMENT — ne pas exécuter sur une base créée via BDD.sql à jour. +-- Idempotent : safe à rejouer sur recette / prod. + +-- 1) Nouveaux statuts enfant +DO $$ BEGIN + ALTER TYPE statut_enfant_type ADD VALUE IF NOT EXISTS 'garde'; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TYPE statut_enfant_type ADD VALUE IF NOT EXISTS 'sans_garde'; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- actif → sans_garde (enfants sans placement AM connu au déploiement) +UPDATE enfants SET statut = 'sans_garde' WHERE statut = 'actif'; + +-- 2) Table de placement AM ↔ enfant +CREATE TABLE IF NOT EXISTS enfants_assistantes_maternelles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + id_am UUID NOT NULL REFERENCES assistantes_maternelles(id_utilisateur) ON DELETE CASCADE, + id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE, + date_debut DATE NOT NULL DEFAULT CURRENT_DATE, + date_fin DATE NULL, + cree_le TIMESTAMPTZ NOT NULL DEFAULT now(), + cree_par UUID REFERENCES utilisateurs(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_eam_am ON enfants_assistantes_maternelles(id_am); +CREATE INDEX IF NOT EXISTS idx_eam_enfant ON enfants_assistantes_maternelles(id_enfant); + +-- Au plus une garde active par enfant +CREATE UNIQUE INDEX IF NOT EXISTS uq_enfant_garde_active + ON enfants_assistantes_maternelles (id_enfant) + WHERE date_fin IS NULL; diff --git a/database/seed/02_seed.sql b/database/seed/02_seed.sql index a0a109c..bd95d05 100644 --- a/database/seed/02_seed.sql +++ b/database/seed/02_seed.sql @@ -65,12 +65,12 @@ ON CONFLICT (id_utilisateur) DO NOTHING; -- ------------------------------------------------------------ -- Enfants --- - child A : déjà né (statut = 'actif' et date_naissance requise) +-- - child A : déjà né (statut = 'sans_garde' et date_naissance requise) -- - child B : à naître (statut = 'a_naitre' et date_prevue_naissance requise) -- ------------------------------------------------------------ INSERT INTO enfants (id, prenom, nom, statut, date_naissance, jumeau_multiple) -VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Léo', 'Parent', 'actif', '2022-04-12', false) +VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Léo', 'Parent', 'sans_garde', '2022-04-12', false) ON CONFLICT (id) DO NOTHING; INSERT INTO enfants (id, prenom, nom, statut, date_prevue_naissance, jumeau_multiple) diff --git a/database/seed/03_seed_test_data.sql b/database/seed/03_seed_test_data.sql index 6d54592..8a54c7f 100644 --- a/database/seed/03_seed_test_data.sql +++ b/database/seed/03_seed_test_data.sql @@ -18,15 +18,15 @@ BEGIN; INSERT INTO utilisateurs (id, email, password, prenom, nom, role, statut, telephone, adresse, ville, code_postal, profession, situation_familiale, date_naissance, consentement_photo) VALUES - ('a0000001-0001-0001-0001-000000000001', 'sophie.bernard@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Sophie', 'BERNARD', 'administrateur', 'actif', '0678123456', '12 Avenue Gabriel Péri', 'Bezons', '95870', 'Responsable administrative', 'marie', '1978-03-15', false), - ('a0000002-0002-0002-0002-000000000002', 'lucas.moreau@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Lucas', 'MOREAU', 'gestionnaire', 'actif', '0687234567', '8 Rue Jean Jaurès', 'Bezons', '95870', 'Gestionnaire des placements', 'celibataire', '1985-09-22', false), - ('a0000003-0003-0003-0003-000000000003', 'marie.dubois@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Marie', 'DUBOIS', 'assistante_maternelle', 'actif', '0696345678', '25 Rue de la République', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1980-06-08', true), - ('a0000004-0004-0004-0004-000000000004', 'fatima.elmansouri@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Fatima', 'EL MANSOURI', 'assistante_maternelle', 'actif', '0675456789', '17 Boulevard Aristide Briand', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1975-11-12', true), - ('a0000005-0005-0005-0005-000000000005', 'claire.martin@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Claire', 'MARTIN', 'parent', 'actif', '0689567890', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Infirmière', 'marie', '1990-04-03', false), - ('a0000006-0006-0006-0006-000000000006', 'thomas.martin@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Thomas', 'MARTIN', 'parent', 'actif', '0678456789', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Ingénieur', 'marie', '1988-07-18', false), - ('a0000007-0007-0007-0007-000000000007', 'amelie.durand@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Amélie', 'DURAND', 'parent', 'actif', '0667788990', '23 Rue Victor Hugo', 'Bezons', '95870', 'Comptable', 'divorce', '1987-12-14', false), - ('a0000008-0008-0008-0008-000000000008', 'julien.rousseau@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Julien', 'ROUSSEAU', 'parent', 'actif', '0656677889', '14 Rue Pasteur', 'Bezons', '95870', 'Commercial', 'divorce', '1985-08-29', false), - ('a0000009-0009-0009-0009-000000000009', 'david.lecomte@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'David', 'LECOMTE', 'parent', 'actif', '0645566778', '31 Rue Émile Zola', 'Bezons', '95870', 'Développeur web', 'parent_isole', '1992-10-07', false) + ('a0000001-0001-0001-0001-000000000001', 'sophie.bernard@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Sophie', 'BERNARD', 'administrateur', 'actif', '0678123456', '12 Avenue Gabriel Péri', 'Bezons', '95870', 'Responsable administrative', 'marie', '1978-03-15', false), + ('a0000002-0002-0002-0002-000000000002', 'lucas.moreau@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Lucas', 'MOREAU', 'gestionnaire', 'actif', '0687234567', '8 Rue Jean Jaurès', 'Bezons', '95870', 'Gestionnaire des placements', 'celibataire', '1985-09-22', false), + ('a0000003-0003-0003-0003-000000000003', 'marie.dubois@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Marie', 'DUBOIS', 'assistante_maternelle', 'actif', '0696345678', '25 Rue de la République', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1980-06-08', true), + ('a0000004-0004-0004-0004-000000000004', 'fatima.elmansouri@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Fatima', 'EL MANSOURI', 'assistante_maternelle', 'actif', '0675456789', '17 Boulevard Aristide Briand', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1975-11-12', true), + ('a0000005-0005-0005-0005-000000000005', 'claire.martin@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Claire', 'MARTIN', 'parent', 'actif', '0689567890', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Infirmière', 'marie', '1990-04-03', false), + ('a0000006-0006-0006-0006-000000000006', 'thomas.martin@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Thomas', 'MARTIN', 'parent', 'actif', '0678456789', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Ingénieur', 'marie', '1988-07-18', false), + ('a0000007-0007-0007-0007-000000000007', 'amelie.durand@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Amélie', 'DURAND', 'parent', 'actif', '0667788990', '23 Rue Victor Hugo', 'Bezons', '95870', 'Comptable', 'divorce', '1987-12-14', false), + ('a0000008-0008-0008-0008-000000000008', 'julien.rousseau@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Julien', 'ROUSSEAU', 'parent', 'actif', '0656677889', '14 Rue Pasteur', 'Bezons', '95870', 'Commercial', 'divorce', '1985-08-29', false), + ('a0000009-0009-0009-0009-000000000009', 'david.lecomte@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'David', 'LECOMTE', 'parent', 'actif', '0645566778', '31 Rue Émile Zola', 'Bezons', '95870', 'Développeur web', 'parent_isole', '1992-10-07', false) ON CONFLICT (email) DO NOTHING; -- ========== PARENTS (avec co-parent pour le couple Martin) ========== @@ -51,12 +51,12 @@ ON CONFLICT (id_utilisateur) DO NOTHING; -- ========== ENFANTS ========== INSERT INTO enfants (id, prenom, nom, genre, date_naissance, statut, est_multiple) VALUES - ('e0000001-0001-0001-0001-000000000001', 'Emma', 'MARTIN', 'F', '2023-02-15', 'actif', true), - ('e0000002-0002-0002-0002-000000000002', 'Noah', 'MARTIN', 'H', '2023-02-15', 'actif', true), - ('e0000003-0003-0003-0003-000000000003', 'Léa', 'MARTIN', 'F', '2023-02-15', 'actif', true), - ('e0000004-0004-0004-0004-000000000004', 'Chloé', 'ROUSSEAU', 'F', '2022-04-20', 'actif', false), - ('e0000005-0005-0005-0005-000000000005', 'Hugo', 'ROUSSEAU', 'H', '2024-03-10', 'actif', false), - ('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'actif', false) + ('e0000001-0001-0001-0001-000000000001', 'Emma', 'MARTIN', 'F', '2023-02-15', 'sans_garde', true), + ('e0000002-0002-0002-0002-000000000002', 'Noah', 'MARTIN', 'H', '2023-02-15', 'sans_garde', true), + ('e0000003-0003-0003-0003-000000000003', 'Léa', 'MARTIN', 'F', '2023-02-15', 'sans_garde', true), + ('e0000004-0004-0004-0004-000000000004', 'Chloé', 'ROUSSEAU', 'F', '2022-04-20', 'sans_garde', false), + ('e0000005-0005-0005-0005-000000000005', 'Hugo', 'ROUSSEAU', 'H', '2024-03-10', 'sans_garde', false), + ('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'sans_garde', false) ON CONFLICT (id) DO NOTHING; -- ========== ENFANTS_PARENTS (liaison N:N) ========== From 53721ffbb393514faaed801ad14e41f074d8fd7f Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 7 Jul 2026 22:07:24 +0200 Subject: [PATCH 19/85] fix(#131): ajouter @Get() manquant sur liste parents Co-authored-by: Cursor --- backend/src/routes/parents/parents.controller.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/routes/parents/parents.controller.ts b/backend/src/routes/parents/parents.controller.ts index 350eed7..396ac2f 100644 --- a/backend/src/routes/parents/parents.controller.ts +++ b/backend/src/routes/parents/parents.controller.ts @@ -82,6 +82,7 @@ export class ParentsController { return validated; } + @Get() @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @ApiOperation({ summary: 'Liste des parents (user, co_parent, parentChildren) — ticket #131' }) @ApiResponse({ status: 200, type: [Parents], description: 'Liste des parents' }) From 66c7f222807be6e0eb594ba226a0d9a7f506c120 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 7 Jul 2026 23:01:29 +0200 Subject: [PATCH 20/85] feat(#131): grille enfants AM, statuts garde et polish champs admin. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligne le front sur les statuts enfant back (garde/sans_garde), refond l’onglet Enfants accueillis en grille 2×2 avec correction des places, et unifie les champs validation éditables/lecture seule. Co-authored-by: Cursor --- frontend/lib/models/dossier_unifie.dart | 3 +- frontend/lib/models/enfant_admin_model.dart | 3 +- frontend/lib/models/parent_child_summary.dart | 5 +- frontend/lib/utils/date_display_utils.dart | 3 +- frontend/lib/utils/enfant_status_utils.dart | 51 +++ .../admin_am_children_capacity_grid.dart | 301 ++++++++++++++++++ .../admin/common/admin_am_edit_modal.dart | 110 ++++--- .../common/admin_child_detail_modal.dart | 29 +- .../admin/common/admin_enfant_user_card.dart | 23 +- .../admin/common/admin_parent_edit_modal.dart | 4 +- .../common/validation_detail_section.dart | 123 +++++-- .../admin/enfant_management_widget.dart | 6 +- .../widgets/admin/user_management_panel.dart | 11 +- .../admin/validation_family_wizard.dart | 16 +- 14 files changed, 557 insertions(+), 131 deletions(-) create mode 100644 frontend/lib/utils/enfant_status_utils.dart create mode 100644 frontend/lib/widgets/admin/common/admin_am_children_capacity_grid.dart diff --git a/frontend/lib/models/dossier_unifie.dart b/frontend/lib/models/dossier_unifie.dart index 07b2d56..78b2fb3 100644 --- a/frontend/lib/models/dossier_unifie.dart +++ b/frontend/lib/models/dossier_unifie.dart @@ -1,4 +1,5 @@ import 'package:p_tits_pas/models/user.dart'; +import 'package:p_tits_pas/utils/enfant_status_utils.dart'; /// Réponse unifiée GET /dossiers/:numeroDossier. Ticket #119, #107. class DossierUnifie { @@ -220,7 +221,7 @@ class EnfantDossier { lastName: (json['last_name'] ?? json['nom'])?.toString(), birthDate: json['birth_date']?.toString(), gender: (json['gender'] ?? json['genre'])?.toString(), - status: json['status']?.toString(), + status: normalizeEnfantStatus(json['status']?.toString()), dueDate: json['due_date']?.toString(), photoUrl: resolvedPhoto, consentPhoto: diff --git a/frontend/lib/models/enfant_admin_model.dart b/frontend/lib/models/enfant_admin_model.dart index 396e263..5c7c84d 100644 --- a/frontend/lib/models/enfant_admin_model.dart +++ b/frontend/lib/models/enfant_admin_model.dart @@ -1,4 +1,5 @@ import 'package:p_tits_pas/models/user.dart'; +import 'package:p_tits_pas/utils/enfant_status_utils.dart'; /// Enfant tel que renvoyé par `GET /enfants` (dashboard admin). class EnfantAdminModel { @@ -54,7 +55,7 @@ class EnfantAdminModel { gender: json['gender'] as String?, birthDate: _dateString(json['birth_date']), dueDate: _dateString(json['due_date']), - status: (json['status'] ?? '').toString(), + status: normalizeEnfantStatus(json['status']?.toString()), photoUrl: json['photo_url'] as String?, consentPhoto: json['consent_photo'] == true, isMultiple: json['is_multiple'] == true, diff --git a/frontend/lib/models/parent_child_summary.dart b/frontend/lib/models/parent_child_summary.dart index 837ff4b..3a30bcb 100644 --- a/frontend/lib/models/parent_child_summary.dart +++ b/frontend/lib/models/parent_child_summary.dart @@ -1,4 +1,5 @@ import 'package:p_tits_pas/models/enfant_admin_model.dart'; +import 'package:p_tits_pas/utils/enfant_status_utils.dart'; /// Résumé enfant affiché dans la fiche parent (dashboard admin). class ParentChildSummary { @@ -33,7 +34,9 @@ class ParentChildSummary { id: (json['id'] ?? '').toString(), firstName: (json['first_name'] ?? json['prenom'])?.toString(), lastName: (json['last_name'] ?? json['nom'])?.toString(), - status: (json['status'] ?? json['statut'] ?? '').toString(), + status: normalizeEnfantStatus( + (json['status'] ?? json['statut'])?.toString(), + ), photoUrl: json['photo_url']?.toString(), birthDate: _dateString(json['birth_date']), dueDate: _dateString(json['due_date']), diff --git a/frontend/lib/utils/date_display_utils.dart b/frontend/lib/utils/date_display_utils.dart index 9836e82..61ecb5e 100644 --- a/frontend/lib/utils/date_display_utils.dart +++ b/frontend/lib/utils/date_display_utils.dart @@ -1,4 +1,5 @@ import 'package:intl/intl.dart'; +import 'package:p_tits_pas/utils/enfant_status_utils.dart'; /// Affiche une date ISO / parseable en `dd/MM/yyyy`, avec repli sur la chaîne ou [ifEmpty]. String formatIsoDateFr(String? s, {String ifEmpty = '–'}) { @@ -63,7 +64,7 @@ String formatChildAgeLabel({ String? dueDate, String? status, }) { - if (status == 'a_naitre') { + if (status == 'a_naitre' || normalizeEnfantStatus(status) == 'a_naitre') { final due = formatIsoDateFr(dueDate, ifEmpty: ''); if (due.isNotEmpty) return 'Naissance prévue : $due'; return 'À naître'; diff --git a/frontend/lib/utils/enfant_status_utils.dart b/frontend/lib/utils/enfant_status_utils.dart new file mode 100644 index 0000000..9ea84b5 --- /dev/null +++ b/frontend/lib/utils/enfant_status_utils.dart @@ -0,0 +1,51 @@ +/// Statuts enfant alignés sur `statut_enfant_type` (BDD / back #131). +const enfantStatusValues = [ + 'a_naitre', + 'sans_garde', + 'garde', + 'scolarise', +]; + +/// Normalise une valeur API (legacy `actif` → `sans_garde`). +String normalizeEnfantStatus(String? raw) { + final s = (raw ?? '').trim().toLowerCase(); + if (s.isEmpty) return 'sans_garde'; + if (s == 'actif') return 'sans_garde'; + return s; +} + +String _scolariseAccordeAuGenre(String? gender) { + final g = (gender ?? '').trim().toUpperCase(); + if (g == 'F') return 'Scolarisée'; + return 'Scolarisé'; +} + +/// Libellé affiché pour un statut enfant. +String enfantStatusLabel(String? status, {String? gender}) { + switch (normalizeEnfantStatus(status)) { + case 'a_naitre': + return 'À naître'; + case 'sans_garde': + return 'Sans garde'; + case 'garde': + return 'En garde'; + case 'scolarise': + return _scolariseAccordeAuGenre(gender); + default: + return status?.trim().isNotEmpty == true ? status!.trim() : '–'; + } +} + +/// Libellé court pour colonnes validation (null = pas de bandeau). +String? enfantColumnStatusLabel({String? status, String? gender}) { + final s = normalizeEnfantStatus(status); + switch (s) { + case 'a_naitre': + case 'sans_garde': + case 'garde': + case 'scolarise': + return enfantStatusLabel(s, gender: gender); + default: + return null; + } +} diff --git a/frontend/lib/widgets/admin/common/admin_am_children_capacity_grid.dart b/frontend/lib/widgets/admin/common/admin_am_children_capacity_grid.dart new file mode 100644 index 0000000..b77871c --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_am_children_capacity_grid.dart @@ -0,0 +1,301 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/parent_child_summary.dart'; +import 'package:p_tits_pas/services/api/api_config.dart'; +import 'package:p_tits_pas/utils/date_display_utils.dart'; +import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; + +/// Grille 2×2 des places d'accueil AM (max 4, limitée à [capacity]). +class AdminAmChildrenCapacityGrid extends StatelessWidget { + static const int _gridSlots = 4; + static const double _slotHeight = 44; + static const double _gridPadding = 10; + static const double _gridGap = 8; + static const double _borderWidth = 1; + static const double fixedHeight = _borderWidth * 2 + + _gridPadding * 2 + + _slotHeight * 2 + + _gridGap; + + final List children; + final int capacity; + final void Function(ParentChildSummary child) onOpen; + final void Function(ParentChildSummary child) onDetach; + + const AdminAmChildrenCapacityGrid({ + super.key, + required this.children, + required this.capacity, + required this.onOpen, + required this.onDetach, + }); + + @override + Widget build(BuildContext context) { + final maxSlots = capacity.clamp(0, _gridSlots); + + return Container( + height: fixedHeight, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300, width: _borderWidth), + ), + padding: const EdgeInsets.all(_gridPadding), + child: Column( + children: [ + _buildRow(0, 1, maxSlots), + const SizedBox(height: _gridGap), + _buildRow(2, 3, maxSlots), + ], + ), + ); + } + + Widget _buildRow(int leftIndex, int rightIndex, int maxSlots) { + return Row( + children: [ + Expanded(child: _buildSlot(leftIndex, maxSlots)), + const SizedBox(width: _gridGap), + Expanded(child: _buildSlot(rightIndex, maxSlots)), + ], + ); + } + + Widget _buildSlot(int index, int maxSlots) { + return SizedBox( + height: _slotHeight, + child: _buildSlotContent(index, maxSlots), + ); + } + + Widget _buildSlotContent(int index, int maxSlots) { + if (index < children.length) { + return _OccupiedSlot( + child: children[index], + overCapacity: index >= maxSlots, + onOpen: () => onOpen(children[index]), + onDetach: () => onDetach(children[index]), + ); + } + if (index < maxSlots) { + return const _EmptySlot(); + } + return const _UnavailableSlot(); + } +} + +class _SlotShell extends StatelessWidget { + final Color backgroundColor; + final Color borderColor; + final Widget child; + + const _SlotShell({ + required this.backgroundColor, + required this.borderColor, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return SizedBox.expand( + child: DecoratedBox( + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: borderColor), + ), + child: child, + ), + ); + } +} + +class _UnavailableSlot extends StatelessWidget { + const _UnavailableSlot(); + + @override + Widget build(BuildContext context) { + return _SlotShell( + backgroundColor: Colors.grey.shade100, + borderColor: Colors.grey.shade200, + child: Center( + child: Icon( + Icons.block_outlined, + size: 20, + color: Colors.grey.shade400, + ), + ), + ); + } +} + +class _EmptySlot extends StatelessWidget { + const _EmptySlot(); + + @override + Widget build(BuildContext context) { + return _SlotShell( + backgroundColor: Colors.grey.shade50, + borderColor: Colors.grey.shade300, + child: Center( + child: Text( + 'Place libre', + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + ), + ); + } +} + +class _OccupiedSlot extends StatefulWidget { + final ParentChildSummary child; + final bool overCapacity; + final VoidCallback onOpen; + final VoidCallback onDetach; + + const _OccupiedSlot({ + required this.child, + required this.overCapacity, + required this.onOpen, + required this.onDetach, + }); + + @override + State<_OccupiedSlot> createState() => _OccupiedSlotState(); +} + +class _OccupiedSlotState extends State<_OccupiedSlot> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final age = formatChildAgeLabel( + birthDate: widget.child.birthDate, + dueDate: widget.child.dueDate, + status: widget.child.status, + ); + final avatarUrl = ApiConfig.absoluteMediaUrl(widget.child.photoUrl ?? ''); + + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: _SlotShell( + backgroundColor: widget.overCapacity + ? Colors.red.shade50 + : const Color(0xFFF8F5FC), + borderColor: widget.overCapacity + ? Colors.red.shade300 + : const Color(0xFFD8CCE8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _buildAvatar(avatarUrl), + const SizedBox(width: 8), + Expanded( + child: Row( + children: [ + Flexible( + fit: FlexFit.loose, + child: Text( + widget.child.fullName, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (age.isNotEmpty) ...[ + const SizedBox(width: 8), + Expanded( + child: Text( + age, + style: const TextStyle( + fontSize: 11, + color: Colors.black54, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ], + ), + ), + AnimatedOpacity( + duration: const Duration(milliseconds: 120), + opacity: _hovered ? 1 : 0, + child: IgnorePointer( + ignoring: !_hovered, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'Voir / modifier', + icon: const Icon(Icons.visibility_outlined, size: 20), + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + onPressed: widget.onOpen, + ), + IconButton( + tooltip: 'Détacher', + icon: Icon( + Icons.link_off, + size: 20, + color: Colors.orange.shade800, + ), + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + onPressed: widget.onDetach, + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildAvatar(String url) { + const size = 28.0; + const bg = Color(0xFFEDE5FA); + const iconColor = Color(0xFF6B3FA0); + + if (url.isEmpty) { + return CircleAvatar( + radius: 14, + backgroundColor: bg, + child: const Icon(Icons.child_care_outlined, size: 15, color: iconColor), + ); + } + + return ClipOval( + child: SizedBox( + width: size, + height: size, + child: AuthNetworkImage( + url: url, + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => ColoredBox( + color: bg, + child: const Icon(Icons.child_care_outlined, size: 15, color: iconColor), + ), + ), + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart index 95e85e2..082356e 100644 --- a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart @@ -8,9 +8,9 @@ import 'package:p_tits_pas/utils/date_display_utils.dart'; import 'package:p_tits_pas/utils/nir_utils.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_am_children_capacity_grid.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_children_affiliation_panel.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; import 'package:p_tits_pas/widgets/common/identity_block.dart'; @@ -51,8 +51,8 @@ class _AdminAmEditModalState extends State late String _statut; late bool _disponible; + late int? _placesAvailable; late List _children; - late final ScrollController _childrenScrollCtrl; bool _saving = false; bool _dirty = false; @@ -64,12 +64,24 @@ class _AdminAmEditModalState extends State static const double _proTabHeight = 300; static const List _photoProRowLayout = [2, 2, 2]; + /// Hauteur onglet enfants : champs + titre + grille 2×2 (+ alerte places si besoin). + double _childrenTabHeight() { + const capacityFields = 72.0; + const titleSection = 40.0; + const inconsistencyExtra = 46.0; + var h = capacityFields + + titleSection + + AdminAmChildrenCapacityGrid.fixedHeight; + if (_placesInconsistent()) h += inconsistencyExtra; + return h + 4; + } + double _tabViewHeight(int index) { switch (index) { case 1: return _proTabHeight; case 2: - return 280; + return _childrenTabHeight(); default: return 292; } @@ -110,8 +122,8 @@ class _AdminAmEditModalState extends State ); _statut = u.statut ?? 'en_attente'; _disponible = am.available ?? true; + _placesAvailable = am.placesAvailable; _children = List.of(am.children); - _childrenScrollCtrl = ScrollController(); for (final c in [ _nomCtrl, @@ -167,7 +179,6 @@ class _AdminAmEditModalState extends State ]) { c.dispose(); } - _childrenScrollCtrl.dispose(); super.dispose(); } @@ -217,19 +228,27 @@ class _AdminAmEditModalState extends State bool _placesInconsistent() => amHasPlacesInconsistency( maxChildren: _capaciteMax(), - placesAvailable: widget.assistante.placesAvailable, + placesAvailable: _placesAvailable, childrenCount: _children.length, ); String _placesDisplayValue() { - final stored = widget.assistante.placesAvailable; - if (stored != null) return stored.toString(); + if (_placesAvailable != null) return _placesAvailable.toString(); return '–'; } + void _applyPlacesCorrection() { + final expected = _computedPlacesAvailable(); + if (expected == null) return; + setState(() { + _placesAvailable = expected; + _dirty = true; + }); + } + String? _placesInconsistencyMessage() { if (!_placesInconsistent()) return null; - final stored = widget.assistante.placesAvailable; + final stored = _placesAvailable; final expected = _computedPlacesAvailable(); final storedLabel = stored?.toString() ?? 'non renseigné'; final expectedLabel = expected?.toString() ?? '–'; @@ -289,6 +308,8 @@ class _AdminAmEditModalState extends State if (_frDateToIso(_dateAgrementCtrl.text) != null) 'agreement_date': _frDateToIso(_dateAgrementCtrl.text), if (_capaciteMax() != null) 'max_children': _capaciteMax(), + if (_placesAvailable != widget.assistante.placesAvailable) + 'places_available': _placesAvailable, 'available': _disponible, }, ); @@ -363,9 +384,7 @@ class _AdminAmEditModalState extends State ), ElevatedButton( onPressed: () => Navigator.pop(ctx, true), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red.shade700, - ), + style: ValidationModalTheme.primaryElevatedStyle, child: const Text('Détacher'), ), ], @@ -470,13 +489,11 @@ class _AdminAmEditModalState extends State return ValidationFormGrid( title: 'Dossier professionnel', rowLayout: _photoProRowLayout, - compact: true, fields: [ ValidationLabeledField( label: 'NIR', field: ValidationEditableField( controller: _nirCtrl, - compact: true, hintText: '15 chiffres', inputFormatters: [ FilteringTextInputFormatter.allow(RegExp(r'[\d\s]')), @@ -487,7 +504,6 @@ class _AdminAmEditModalState extends State label: 'Date de naissance', field: ValidationEditableField( controller: _dateNaissanceCtrl, - compact: true, hintText: 'jj/mm/aaaa', ), ), @@ -495,28 +511,24 @@ class _AdminAmEditModalState extends State label: 'Ville de naissance', field: ValidationEditableField( controller: _lieuNaissanceVilleCtrl, - compact: true, ), ), ValidationLabeledField( label: 'Pays de naissance', field: ValidationEditableField( controller: _lieuNaissancePaysCtrl, - compact: true, ), ), ValidationLabeledField( label: 'N° Agrément', field: ValidationEditableField( controller: _agrementCtrl, - compact: true, ), ), ValidationLabeledField( label: 'Date d\'agrément', field: ValidationEditableField( controller: _dateAgrementCtrl, - compact: true, hintText: 'jj/mm/aaaa', ), ), @@ -583,7 +595,6 @@ class _AdminAmEditModalState extends State crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ValidationEditableSection( - compact: true, rowLayout: const [2], fields: [ ValidationLabeledField( @@ -592,14 +603,12 @@ class _AdminAmEditModalState extends State controller: _capaciteCtrl, keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], - compact: true, ), ), ValidationLabeledField( label: 'Places disponibles', field: ValidationReadOnlyField( value: _placesDisplayValue(), - compact: true, error: inconsistent, ), ), @@ -607,13 +616,37 @@ class _AdminAmEditModalState extends State ), if (inconsistent) ...[ const SizedBox(height: 6), - Text( - _placesInconsistencyMessage()!, - style: TextStyle( - fontSize: 12, - color: Colors.red.shade700, - height: 1.3, - ), + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 6, + runSpacing: 4, + children: [ + Text( + _placesInconsistencyMessage()!, + style: TextStyle( + fontSize: 12, + color: Colors.red.shade700, + height: 1.3, + ), + ), + OutlinedButton( + onPressed: _saving ? null : _applyPlacesCorrection, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red.shade800, + disabledForegroundColor: Colors.red.shade300, + side: BorderSide(color: Colors.red.shade400), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + minimumSize: const Size(0, 28), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + textStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + child: const Text('Mettre à jour'), + ), + ], ), ], ], @@ -621,6 +654,7 @@ class _AdminAmEditModalState extends State } Widget _childrenTab() { + final capacity = (_capaciteMax() ?? 4).clamp(1, 4); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -634,20 +668,12 @@ class _AdminAmEditModalState extends State color: Colors.black87, ), ), - const SizedBox(height: 2), - Text( - 'Enfants rattachés à cette assistante (accueil / garde).', - style: TextStyle(fontSize: 12, color: Colors.grey.shade700), - ), const SizedBox(height: 8), - Expanded( - child: AdminChildrenAffiliationPanel( - children: _children, - scrollController: _childrenScrollCtrl, - onOpen: _openChild, - onDetach: _detachChild, - emptyMessage: 'Aucun enfant rattaché à cette assistante', - ), + AdminAmChildrenCapacityGrid( + children: _children, + capacity: capacity, + onOpen: _openChild, + onDetach: _detachChild, ), ], ); diff --git a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart index 8e0cc97..30941ed 100644 --- a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/enfant_admin_model.dart'; import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/utils/enfant_status_utils.dart'; /// Fiche enfant consultation / édition (ticket #138). class AdminChildDetailModal extends StatefulWidget { @@ -29,7 +30,6 @@ class _AdminChildDetailModalState extends State { bool _dirty = false; bool _saving = false; - static const _statuses = ['a_naitre', 'actif', 'scolarise']; static const _genders = ['H', 'F', 'Autre']; static const _labelStyle = TextStyle( @@ -46,7 +46,10 @@ class _AdminChildDetailModalState extends State { _nomCtrl = TextEditingController(text: e.lastName ?? ''); _birthCtrl = TextEditingController(text: e.birthDate ?? ''); _dueCtrl = TextEditingController(text: e.dueDate ?? ''); - _status = _statuses.contains(e.status) ? e.status : 'actif'; + _status = normalizeEnfantStatus(e.status); + if (!enfantStatusValues.contains(_status)) { + _status = 'sans_garde'; + } _gender = _normalizeGender(e.gender); _consentPhoto = e.consentPhoto; _isMultiple = e.isMultiple; @@ -213,8 +216,13 @@ class _AdminChildDetailModalState extends State { child: _labeledDropdown( 'Statut', _status, - _statuses - .map((s) => MapEntry(s, _statusLabel(s))) + enfantStatusValues + .map( + (s) => MapEntry( + s, + enfantStatusLabel(s, gender: _gender), + ), + ) .toList(), (v) => setState(() { _status = v; @@ -388,19 +396,6 @@ class _AdminChildDetailModalState extends State { ); } - String _statusLabel(String status) { - switch (status) { - case 'a_naitre': - return 'À naître'; - case 'actif': - return 'Actif'; - case 'scolarise': - return 'Scolarisé'; - default: - return status; - } - } - String _genderLabel(String gender) { switch (gender) { case 'H': diff --git a/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart b/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart index f45f059..a6349b8 100644 --- a/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart +++ b/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart @@ -2,36 +2,26 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/enfant_admin_model.dart'; import 'package:p_tits_pas/models/parent_child_summary.dart'; import 'package:p_tits_pas/utils/date_display_utils.dart'; +import 'package:p_tits_pas/utils/enfant_status_utils.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; -String enfantStatusLabel(String status) { - switch (status) { - case 'a_naitre': - return 'À naître'; - case 'actif': - return 'Actif'; - case 'scolarise': - return 'Scolarisé'; - default: - return status; - } -} - List enfantAdminSubtitleLines({ required String status, String? birthDate, String? dueDate, + String? gender, List extra = const [], }) { final lines = []; final age = formatChildAgeLabel( birthDate: birthDate, dueDate: dueDate, - status: status, + status: normalizeEnfantStatus(status), ); if (age.isNotEmpty) lines.add(age); - if (status.isNotEmpty) { - lines.add('Statut : ${enfantStatusLabel(status)}'); + final normalized = normalizeEnfantStatus(status); + if (normalized.isNotEmpty) { + lines.add('Statut : ${enfantStatusLabel(normalized, gender: gender)}'); } lines.addAll(extra); return lines; @@ -67,6 +57,7 @@ class AdminEnfantUserCard extends StatelessWidget { status: enfant.status, birthDate: enfant.birthDate, dueDate: enfant.dueDate, + gender: enfant.gender, extra: [ if (parents.isNotEmpty) 'Responsables : $parents', ...extraSubtitleLines, diff --git a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart index f0f66ed..b1fe436 100644 --- a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart @@ -230,9 +230,7 @@ class _AdminParentEditModalState extends State { ), ElevatedButton( onPressed: () => Navigator.pop(ctx, true), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red.shade700, - ), + style: ValidationModalTheme.primaryElevatedStyle, child: const Text('Détacher'), ), ], diff --git a/frontend/lib/widgets/admin/common/validation_detail_section.dart b/frontend/lib/widgets/admin/common/validation_detail_section.dart index 0ece3fd..610dd98 100644 --- a/frontend/lib/widgets/admin/common/validation_detail_section.dart +++ b/frontend/lib/widgets/admin/common/validation_detail_section.dart @@ -148,11 +148,30 @@ class ValidationFieldDecoration { ); } - static BoxDecoration container() { + static InputDecoration readOnly({bool error = false, bool compact = false}) { + final borderColor = error ? Colors.red.shade400 : Colors.grey.shade300; + final fillColor = error ? Colors.red.shade50 : Colors.grey.shade50; + return input(compact: compact).copyWith( + filled: true, + fillColor: fillColor, + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: BorderSide(color: borderColor), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: BorderSide(color: borderColor), + ), + ); + } + + static BoxDecoration container({bool error = false}) { return BoxDecoration( - color: Colors.grey.shade50, + color: error ? Colors.red.shade50 : Colors.grey.shade50, borderRadius: BorderRadius.circular(6), - border: Border.all(color: Colors.grey.shade300), + border: Border.all( + color: error ? Colors.red.shade400 : Colors.grey.shade300, + ), ); } } @@ -234,20 +253,24 @@ class ValidationEditableField extends StatelessWidget { @override Widget build(BuildContext context) { - if (!compact || maxLines > 1) { + if (maxLines > 1) { return TextField( controller: controller, keyboardType: keyboardType, inputFormatters: inputFormatters, maxLines: maxLines, - style: TextStyle( - color: Colors.black87, - fontSize: compact ? 13 : 14, - ), - decoration: ValidationFieldDecoration.input( - hint: hintText, - compact: compact, - ), + style: const TextStyle(color: Colors.black87, fontSize: 14), + decoration: ValidationFieldDecoration.input(hint: hintText), + ); + } + if (!compact) { + return TextField( + controller: controller, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + maxLines: 1, + style: const TextStyle(color: Colors.black87, fontSize: 14), + decoration: ValidationFieldDecoration.input(hint: hintText), ); } return SizedBox( @@ -259,10 +282,11 @@ class ValidationEditableField extends StatelessWidget { keyboardType: keyboardType, inputFormatters: inputFormatters, maxLines: 1, + textAlignVertical: TextAlignVertical.center, style: const TextStyle( color: Colors.black87, fontSize: 13, - height: 1.15, + height: 1.0, ), decoration: _compactInputDecoration(hint: hintText), ), @@ -297,8 +321,8 @@ class ValidationEditableSection extends StatelessWidget { } } -/// Champ texte en lecture seule, style formulaire (fond gris léger, bordure). -class ValidationReadOnlyField extends StatelessWidget { +/// Champ texte en lecture seule, même coque [TextField] que [ValidationEditableField]. +class ValidationReadOnlyField extends StatefulWidget { final String value; final int? maxLines; final bool compact; @@ -312,34 +336,69 @@ class ValidationReadOnlyField extends StatelessWidget { this.error = false, }); + @override + State createState() => _ValidationReadOnlyFieldState(); +} + +class _ValidationReadOnlyFieldState extends State { + late final TextEditingController _controller; + static const double _compactFieldHeight = 34; + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.value); + } + + @override + void didUpdateWidget(ValidationReadOnlyField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.value != widget.value) { + _controller.text = widget.value; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { + if (!widget.compact && widget.maxLines == 1) { + return TextField( + controller: _controller, + readOnly: true, + enableInteractiveSelection: false, + style: TextStyle( + color: widget.error ? Colors.red.shade800 : Colors.black87, + fontSize: 14, + fontWeight: widget.error ? FontWeight.w600 : null, + ), + decoration: ValidationFieldDecoration.readOnly(error: widget.error), + ); + } + return Container( width: double.infinity, - height: compact && maxLines == 1 ? _compactFieldHeight : null, - alignment: compact ? Alignment.centerLeft : null, + height: widget.compact && widget.maxLines == 1 ? _compactFieldHeight : null, + alignment: widget.compact ? Alignment.centerLeft : null, padding: EdgeInsets.symmetric( - horizontal: compact ? 10 : 12, - vertical: compact ? 7 : 10, + horizontal: widget.compact ? 10 : 12, + vertical: widget.compact ? 7 : 10, ), - decoration: error - ? BoxDecoration( - color: Colors.red.shade50, - borderRadius: BorderRadius.circular(6), - border: Border.all(color: Colors.red.shade400), - ) - : ValidationFieldDecoration.container(), + decoration: ValidationFieldDecoration.container(error: widget.error), child: Text( - value, + widget.value, style: TextStyle( - color: error ? Colors.red.shade800 : Colors.black87, - fontSize: compact ? 13 : 14, - height: compact ? 1.2 : null, - fontWeight: error ? FontWeight.w600 : null, + color: widget.error ? Colors.red.shade800 : Colors.black87, + fontSize: widget.compact ? 13 : 14, + height: widget.compact ? 1.0 : null, + fontWeight: widget.error ? FontWeight.w600 : null, ), - maxLines: maxLines, + maxLines: widget.maxLines, overflow: TextOverflow.ellipsis, ), ); diff --git a/frontend/lib/widgets/admin/enfant_management_widget.dart b/frontend/lib/widgets/admin/enfant_management_widget.dart index cb11674..bc90aaf 100644 --- a/frontend/lib/widgets/admin/enfant_management_widget.dart +++ b/frontend/lib/widgets/admin/enfant_management_widget.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:p_tits_pas/utils/enfant_status_utils.dart'; import 'package:p_tits_pas/models/enfant_admin_model.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; @@ -67,8 +68,9 @@ class _EnfantManagementWidgetState extends State { final query = widget.searchQuery.toLowerCase(); final filtered = _enfants.where((e) { final matchesName = e.fullName.toLowerCase().contains(query); - final matchesStatus = - widget.statusFilter == null || e.status == widget.statusFilter; + final matchesStatus = widget.statusFilter == null || + normalizeEnfantStatus(e.status) == + normalizeEnfantStatus(widget.statusFilter); return matchesName && matchesStatus; }).toList(); diff --git a/frontend/lib/widgets/admin/user_management_panel.dart b/frontend/lib/widgets/admin/user_management_panel.dart index 51cc207..9de03bc 100644 --- a/frontend/lib/widgets/admin/user_management_panel.dart +++ b/frontend/lib/widgets/admin/user_management_panel.dart @@ -205,10 +205,17 @@ class _UserManagementPanelState extends State { ), ), DropdownMenuItem( - value: 'actif', + value: 'sans_garde', child: Padding( padding: EdgeInsets.only(left: 10), - child: Text('Actif', style: TextStyle(fontSize: 12)), + child: Text('Sans garde', style: TextStyle(fontSize: 12)), + ), + ), + DropdownMenuItem( + value: 'garde', + child: Padding( + padding: EdgeInsets.only(left: 10), + child: Text('En garde', style: TextStyle(fontSize: 12)), ), ), DropdownMenuItem( diff --git a/frontend/lib/widgets/admin/validation_family_wizard.dart b/frontend/lib/widgets/admin/validation_family_wizard.dart index 533f25d..76ac741 100644 --- a/frontend/lib/widgets/admin/validation_family_wizard.dart +++ b/frontend/lib/widgets/admin/validation_family_wizard.dart @@ -3,6 +3,7 @@ import 'package:flutter/gestures.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart'; import 'package:p_tits_pas/utils/date_display_utils.dart'; +import 'package:p_tits_pas/utils/enfant_status_utils.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; @@ -397,20 +398,9 @@ class _ValidationFamilyWizardState extends State { ); } - /// « 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. + /// Statut dans la colonne 2/3 (scolarisé·e, à naître, sans garde, en garde). 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; + return enfantColumnStatusLabel(status: e.status, gender: e.gender); } /// Nom ; date de naissance et genre sur une ligne (prénom au-dessus, pleine largeur). From d03a8e6c8b4363e9443e4a64711a7c68a21c5700 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 7 Jul 2026 23:11:24 +0200 Subject: [PATCH 21/85] fix(#131): activer Sauvegarder sur statut, enfants et champs AM. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compare l'état de la fiche à un snapshot initial (liste enfants, statut, places, formulaire) et corrige la gélule statut pour les valeurs hors liste. Co-authored-by: Cursor --- .../admin/common/admin_am_edit_modal.dart | 111 +++++++++++++++--- .../admin/common/admin_status_capsule.dart | 7 +- 2 files changed, 100 insertions(+), 18 deletions(-) diff --git a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart index 082356e..d536a29 100644 --- a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; @@ -57,6 +58,25 @@ class _AdminAmEditModalState extends State bool _saving = false; bool _dirty = false; + late String _baseStatut; + late bool _baseDisponible; + late int? _basePlacesAvailable; + late List _baseChildIds; + late String _baseNom; + late String _basePrenom; + late String _baseEmail; + late String _baseTelephone; + late String _baseAdresse; + late String _baseVille; + late String _baseCodePostal; + late String _baseAgrement; + late String _baseNir; + late String? _baseDateNaissanceIso; + late String _baseLieuNaissanceVille; + late String _baseLieuNaissancePays; + late String? _baseAgreementDateIso; + late int? _baseCapacite; + static const double _modalWidth = 930; static const double _photoProGap = 24; static const double _proColumnMinWidth = 260; @@ -147,16 +167,77 @@ class _AdminAmEditModalState extends State _nomCtrl.addListener(_onNameFieldChanged); _prenomCtrl.addListener(_onNameFieldChanged); _tabCtrl.addListener(_onTabChanged); + _captureBaseline(); _reloadChildren(); } + void _captureBaseline() { + _baseStatut = _statut; + _baseDisponible = _disponible; + _basePlacesAvailable = _placesAvailable; + _baseChildIds = _children.map((c) => c.id).toList()..sort(); + _baseNom = _nomCtrl.text.trim(); + _basePrenom = _prenomCtrl.text.trim(); + _baseEmail = _emailCtrl.text.trim(); + _baseTelephone = normalizePhone(_telCtrl.text); + _baseAdresse = _adresseCtrl.text.trim(); + _baseVille = _villeCtrl.text.trim(); + _baseCodePostal = _cpCtrl.text.trim(); + _baseAgrement = _agrementCtrl.text.trim(); + _baseNir = nirToRaw(_nirCtrl.text); + _baseDateNaissanceIso = _frDateToIso(_dateNaissanceCtrl.text); + _baseLieuNaissanceVille = _lieuNaissanceVilleCtrl.text.trim(); + _baseLieuNaissancePays = _lieuNaissancePaysCtrl.text.trim(); + _baseAgreementDateIso = _frDateToIso(_dateAgrementCtrl.text); + _baseCapacite = _capaciteMax(); + } + + bool _hasUnsavedChanges() { + if (_statut != _baseStatut) return true; + if (_disponible != _baseDisponible) return true; + if (_placesAvailable != _basePlacesAvailable) return true; + final childIds = _children.map((c) => c.id).toList()..sort(); + if (!listEquals(childIds, _baseChildIds)) return true; + if (_nomCtrl.text.trim() != _baseNom) return true; + if (_prenomCtrl.text.trim() != _basePrenom) return true; + if (_emailCtrl.text.trim() != _baseEmail) return true; + if (normalizePhone(_telCtrl.text) != _baseTelephone) return true; + if (_adresseCtrl.text.trim() != _baseAdresse) return true; + if (_villeCtrl.text.trim() != _baseVille) return true; + if (_cpCtrl.text.trim() != _baseCodePostal) return true; + if (_agrementCtrl.text.trim() != _baseAgrement) return true; + if (nirToRaw(_nirCtrl.text) != _baseNir) return true; + if (_frDateToIso(_dateNaissanceCtrl.text) != _baseDateNaissanceIso) { + return true; + } + if (_lieuNaissanceVilleCtrl.text.trim() != _baseLieuNaissanceVille) { + return true; + } + if (_lieuNaissancePaysCtrl.text.trim() != _baseLieuNaissancePays) { + return true; + } + if (_frDateToIso(_dateAgrementCtrl.text) != _baseAgreementDateIso) { + return true; + } + if (_capaciteMax() != _baseCapacite) return true; + return false; + } + + void _syncDirtyState() { + final next = _hasUnsavedChanges(); + if (next != _dirty) setState(() => _dirty = next); + } + void _onTabChanged() { if (!_tabCtrl.indexIsChanging) setState(() {}); } void _onNameFieldChanged() => setState(() {}); - void _onCapacityChanged() => setState(() {}); + void _onCapacityChanged() { + setState(() {}); + _syncDirtyState(); + } @override void dispose() { @@ -182,9 +263,7 @@ class _AdminAmEditModalState extends State super.dispose(); } - void _markDirty() { - if (!_dirty) setState(() => _dirty = true); - } + void _markDirty() => _syncDirtyState(); String _headerTitle() { final fn = _prenomCtrl.text.trim(); @@ -240,10 +319,8 @@ class _AdminAmEditModalState extends State void _applyPlacesCorrection() { final expected = _computedPlacesAvailable(); if (expected == null) return; - setState(() { - _placesAvailable = expected; - _dirty = true; - }); + setState(() => _placesAvailable = expected); + _syncDirtyState(); } String? _placesInconsistencyMessage() { @@ -318,6 +395,7 @@ class _AdminAmEditModalState extends State _dirty = false; _saving = false; }); + _captureBaseline(); widget.onSaved?.call(); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Fiche assistante maternelle enregistrée')), @@ -346,6 +424,7 @@ class _AdminAmEditModalState extends State if (!mounted) return; setState(() => _children = kids); + _syncDirtyState(); } catch (_) {} } @@ -574,10 +653,10 @@ class _AdminAmEditModalState extends State style: TextStyle(fontSize: 13), ), value: _disponible, - onChanged: (v) => setState(() { - _disponible = v; - _dirty = true; - }), + onChanged: (v) { + setState(() => _disponible = v); + _syncDirtyState(); + }, ), ], ), @@ -756,10 +835,10 @@ class _AdminAmEditModalState extends State width: 160, child: AdminStatusCapsule( statut: _statut, - onChanged: (v) => setState(() { - _statut = v; - _dirty = true; - }), + onChanged: (v) { + setState(() => _statut = v); + _syncDirtyState(); + }, ), ), IconButton( diff --git a/frontend/lib/widgets/admin/common/admin_status_capsule.dart b/frontend/lib/widgets/admin/common/admin_status_capsule.dart index a42b015..1e60375 100644 --- a/frontend/lib/widgets/admin/common/admin_status_capsule.dart +++ b/frontend/lib/widgets/admin/common/admin_status_capsule.dart @@ -30,7 +30,10 @@ class AdminStatusCapsule extends StatelessWidget { @override Widget build(BuildContext context) { - final value = statuts.contains(statut) ? statut : statuts.first; + final value = statut; + final items = statuts.contains(statut) + ? statuts + : [statut, ...statuts]; return Container( height: 34, decoration: BoxDecoration( @@ -46,7 +49,7 @@ class AdminStatusCapsule extends StatelessWidget { isDense: true, style: const TextStyle(fontSize: 13, color: Colors.black87), icon: const Icon(Icons.arrow_drop_down, size: 20), - items: statuts + items: items .map( (s) => DropdownMenuItem( value: s, From c865d11dc344709d81d43a126ee728b62a9f79c4 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 7 Jul 2026 23:12:09 +0200 Subject: [PATCH 22/85] Revert "fix(#131): activer Sauvegarder sur statut, enfants et champs AM." This reverts commit d03a8e6c8b4363e9443e4a64711a7c68a21c5700. --- .../admin/common/admin_am_edit_modal.dart | 111 +++--------------- .../admin/common/admin_status_capsule.dart | 7 +- 2 files changed, 18 insertions(+), 100 deletions(-) diff --git a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart index d536a29..082356e 100644 --- a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; @@ -58,25 +57,6 @@ class _AdminAmEditModalState extends State bool _saving = false; bool _dirty = false; - late String _baseStatut; - late bool _baseDisponible; - late int? _basePlacesAvailable; - late List _baseChildIds; - late String _baseNom; - late String _basePrenom; - late String _baseEmail; - late String _baseTelephone; - late String _baseAdresse; - late String _baseVille; - late String _baseCodePostal; - late String _baseAgrement; - late String _baseNir; - late String? _baseDateNaissanceIso; - late String _baseLieuNaissanceVille; - late String _baseLieuNaissancePays; - late String? _baseAgreementDateIso; - late int? _baseCapacite; - static const double _modalWidth = 930; static const double _photoProGap = 24; static const double _proColumnMinWidth = 260; @@ -167,77 +147,16 @@ class _AdminAmEditModalState extends State _nomCtrl.addListener(_onNameFieldChanged); _prenomCtrl.addListener(_onNameFieldChanged); _tabCtrl.addListener(_onTabChanged); - _captureBaseline(); _reloadChildren(); } - void _captureBaseline() { - _baseStatut = _statut; - _baseDisponible = _disponible; - _basePlacesAvailable = _placesAvailable; - _baseChildIds = _children.map((c) => c.id).toList()..sort(); - _baseNom = _nomCtrl.text.trim(); - _basePrenom = _prenomCtrl.text.trim(); - _baseEmail = _emailCtrl.text.trim(); - _baseTelephone = normalizePhone(_telCtrl.text); - _baseAdresse = _adresseCtrl.text.trim(); - _baseVille = _villeCtrl.text.trim(); - _baseCodePostal = _cpCtrl.text.trim(); - _baseAgrement = _agrementCtrl.text.trim(); - _baseNir = nirToRaw(_nirCtrl.text); - _baseDateNaissanceIso = _frDateToIso(_dateNaissanceCtrl.text); - _baseLieuNaissanceVille = _lieuNaissanceVilleCtrl.text.trim(); - _baseLieuNaissancePays = _lieuNaissancePaysCtrl.text.trim(); - _baseAgreementDateIso = _frDateToIso(_dateAgrementCtrl.text); - _baseCapacite = _capaciteMax(); - } - - bool _hasUnsavedChanges() { - if (_statut != _baseStatut) return true; - if (_disponible != _baseDisponible) return true; - if (_placesAvailable != _basePlacesAvailable) return true; - final childIds = _children.map((c) => c.id).toList()..sort(); - if (!listEquals(childIds, _baseChildIds)) return true; - if (_nomCtrl.text.trim() != _baseNom) return true; - if (_prenomCtrl.text.trim() != _basePrenom) return true; - if (_emailCtrl.text.trim() != _baseEmail) return true; - if (normalizePhone(_telCtrl.text) != _baseTelephone) return true; - if (_adresseCtrl.text.trim() != _baseAdresse) return true; - if (_villeCtrl.text.trim() != _baseVille) return true; - if (_cpCtrl.text.trim() != _baseCodePostal) return true; - if (_agrementCtrl.text.trim() != _baseAgrement) return true; - if (nirToRaw(_nirCtrl.text) != _baseNir) return true; - if (_frDateToIso(_dateNaissanceCtrl.text) != _baseDateNaissanceIso) { - return true; - } - if (_lieuNaissanceVilleCtrl.text.trim() != _baseLieuNaissanceVille) { - return true; - } - if (_lieuNaissancePaysCtrl.text.trim() != _baseLieuNaissancePays) { - return true; - } - if (_frDateToIso(_dateAgrementCtrl.text) != _baseAgreementDateIso) { - return true; - } - if (_capaciteMax() != _baseCapacite) return true; - return false; - } - - void _syncDirtyState() { - final next = _hasUnsavedChanges(); - if (next != _dirty) setState(() => _dirty = next); - } - void _onTabChanged() { if (!_tabCtrl.indexIsChanging) setState(() {}); } void _onNameFieldChanged() => setState(() {}); - void _onCapacityChanged() { - setState(() {}); - _syncDirtyState(); - } + void _onCapacityChanged() => setState(() {}); @override void dispose() { @@ -263,7 +182,9 @@ class _AdminAmEditModalState extends State super.dispose(); } - void _markDirty() => _syncDirtyState(); + void _markDirty() { + if (!_dirty) setState(() => _dirty = true); + } String _headerTitle() { final fn = _prenomCtrl.text.trim(); @@ -319,8 +240,10 @@ class _AdminAmEditModalState extends State void _applyPlacesCorrection() { final expected = _computedPlacesAvailable(); if (expected == null) return; - setState(() => _placesAvailable = expected); - _syncDirtyState(); + setState(() { + _placesAvailable = expected; + _dirty = true; + }); } String? _placesInconsistencyMessage() { @@ -395,7 +318,6 @@ class _AdminAmEditModalState extends State _dirty = false; _saving = false; }); - _captureBaseline(); widget.onSaved?.call(); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Fiche assistante maternelle enregistrée')), @@ -424,7 +346,6 @@ class _AdminAmEditModalState extends State if (!mounted) return; setState(() => _children = kids); - _syncDirtyState(); } catch (_) {} } @@ -653,10 +574,10 @@ class _AdminAmEditModalState extends State style: TextStyle(fontSize: 13), ), value: _disponible, - onChanged: (v) { - setState(() => _disponible = v); - _syncDirtyState(); - }, + onChanged: (v) => setState(() { + _disponible = v; + _dirty = true; + }), ), ], ), @@ -835,10 +756,10 @@ class _AdminAmEditModalState extends State width: 160, child: AdminStatusCapsule( statut: _statut, - onChanged: (v) { - setState(() => _statut = v); - _syncDirtyState(); - }, + onChanged: (v) => setState(() { + _statut = v; + _dirty = true; + }), ), ), IconButton( diff --git a/frontend/lib/widgets/admin/common/admin_status_capsule.dart b/frontend/lib/widgets/admin/common/admin_status_capsule.dart index 1e60375..a42b015 100644 --- a/frontend/lib/widgets/admin/common/admin_status_capsule.dart +++ b/frontend/lib/widgets/admin/common/admin_status_capsule.dart @@ -30,10 +30,7 @@ class AdminStatusCapsule extends StatelessWidget { @override Widget build(BuildContext context) { - final value = statut; - final items = statuts.contains(statut) - ? statuts - : [statut, ...statuts]; + final value = statuts.contains(statut) ? statut : statuts.first; return Container( height: 34, decoration: BoxDecoration( @@ -49,7 +46,7 @@ class AdminStatusCapsule extends StatelessWidget { isDense: true, style: const TextStyle(fontSize: 13, color: Colors.black87), icon: const Icon(Icons.arrow_drop_down, size: 20), - items: items + items: statuts .map( (s) => DropdownMenuItem( value: s, From 43a2cd213b7b861ff239f5cfd756a47561b692f0 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 7 Jul 2026 23:12:38 +0200 Subject: [PATCH 23/85] fix(#131): aligner PATCH fiche AM sur le DTO back actuel. Retire nir, dates et lieux de naissance du body tant que UpdateAmFicheAdminDto ne les accepte pas. Co-authored-by: Cursor --- frontend/lib/widgets/admin/common/admin_am_edit_modal.dart | 7 ------- 1 file changed, 7 deletions(-) diff --git a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart index 082356e..6148a86 100644 --- a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart @@ -300,13 +300,6 @@ class _AdminAmEditModalState extends State 'code_postal': _cpCtrl.text.trim(), 'statut': _statut, 'approval_number': _agrementCtrl.text.trim(), - 'nir': nirToRaw(_nirCtrl.text), - if (_frDateToIso(_dateNaissanceCtrl.text) != null) - 'date_naissance': _frDateToIso(_dateNaissanceCtrl.text), - 'lieu_naissance_ville': _lieuNaissanceVilleCtrl.text.trim(), - 'lieu_naissance_pays': _lieuNaissancePaysCtrl.text.trim(), - if (_frDateToIso(_dateAgrementCtrl.text) != null) - 'agreement_date': _frDateToIso(_dateAgrementCtrl.text), if (_capaciteMax() != null) 'max_children': _capaciteMax(), if (_placesAvailable != widget.assistante.placesAvailable) 'places_available': _placesAvailable, From fbf22f25401189c5bb2753f2f3b1b99f3ffa85f2 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 7 Jul 2026 23:13:58 +0200 Subject: [PATCH 24/85] Revert "fix(#131): aligner PATCH fiche AM sur le DTO back actuel." This reverts commit 43a2cd213b7b861ff239f5cfd756a47561b692f0. --- frontend/lib/widgets/admin/common/admin_am_edit_modal.dart | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart index 6148a86..082356e 100644 --- a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart @@ -300,6 +300,13 @@ class _AdminAmEditModalState extends State 'code_postal': _cpCtrl.text.trim(), 'statut': _statut, 'approval_number': _agrementCtrl.text.trim(), + 'nir': nirToRaw(_nirCtrl.text), + if (_frDateToIso(_dateNaissanceCtrl.text) != null) + 'date_naissance': _frDateToIso(_dateNaissanceCtrl.text), + 'lieu_naissance_ville': _lieuNaissanceVilleCtrl.text.trim(), + 'lieu_naissance_pays': _lieuNaissancePaysCtrl.text.trim(), + if (_frDateToIso(_dateAgrementCtrl.text) != null) + 'agreement_date': _frDateToIso(_dateAgrementCtrl.text), if (_capaciteMax() != null) 'max_children': _capaciteMax(), if (_placesAvailable != widget.assistante.placesAvailable) 'places_available': _placesAvailable, From 18718670e90350df3512f1d025a48f7261a48aed Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 7 Jul 2026 23:36:06 +0200 Subject: [PATCH 25/85] =?UTF-8?q?fix(#131):=20diff=C3=A9rer=20rattachement?= =?UTF-8?q?=20enfants=20AM=20jusqu'=C3=A0=20Sauvegarder.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rattachement et détachement restent locaux, recalculent places_available et sont persistés avec le PATCH fiche. Co-authored-by: Cursor --- .../admin/common/admin_am_edit_modal.dart | 107 +++++++++++------- 1 file changed, 67 insertions(+), 40 deletions(-) diff --git a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart index 082356e..b3ca7f1 100644 --- a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart @@ -53,6 +53,7 @@ class _AdminAmEditModalState extends State late bool _disponible; late int? _placesAvailable; late List _children; + late Set _baselineChildIds; bool _saving = false; bool _dirty = false; @@ -124,6 +125,7 @@ class _AdminAmEditModalState extends State _disponible = am.available ?? true; _placesAvailable = am.placesAvailable; _children = List.of(am.children); + _baselineChildIds = _children.map((c) => c.id).toSet(); for (final c in [ _nomCtrl, @@ -147,7 +149,18 @@ class _AdminAmEditModalState extends State _nomCtrl.addListener(_onNameFieldChanged); _prenomCtrl.addListener(_onNameFieldChanged); _tabCtrl.addListener(_onTabChanged); - _reloadChildren(); + _fetchChildrenFromServer(); + } + + bool _childrenChanged() { + final current = _children.map((c) => c.id).toSet(); + return current.length != _baselineChildIds.length || + !current.containsAll(_baselineChildIds); + } + + void _syncPlacesAfterChildrenChange() { + final expected = _computedPlacesAvailable(); + if (expected != null) _placesAvailable = expected; } void _onTabChanged() { @@ -288,6 +301,22 @@ class _AdminAmEditModalState extends State setState(() => _saving = true); try { + if (_childrenChanged()) _syncPlacesAfterChildrenChange(); + + final currentIds = _children.map((c) => c.id).toSet(); + for (final id in _baselineChildIds.difference(currentIds)) { + await UserService.detachEnfantFromAm( + amUserId: widget.assistante.user.id, + enfantId: id, + ); + } + for (final id in currentIds.difference(_baselineChildIds)) { + await UserService.attachEnfantToAm( + amUserId: widget.assistante.user.id, + enfantId: id, + ); + } + await UserService.updateAmFiche( amUserId: widget.assistante.user.id, body: { @@ -308,8 +337,7 @@ class _AdminAmEditModalState extends State if (_frDateToIso(_dateAgrementCtrl.text) != null) 'agreement_date': _frDateToIso(_dateAgrementCtrl.text), if (_capaciteMax() != null) 'max_children': _capaciteMax(), - if (_placesAvailable != widget.assistante.placesAvailable) - 'places_available': _placesAvailable, + if (_placesAvailable != null) 'places_available': _placesAvailable, 'available': _disponible, }, ); @@ -317,6 +345,7 @@ class _AdminAmEditModalState extends State setState(() { _dirty = false; _saving = false; + _baselineChildIds = currentIds; }); widget.onSaved?.call(); ScaffoldMessenger.of(context).showSnackBar( @@ -331,7 +360,7 @@ class _AdminAmEditModalState extends State } } - Future _reloadChildren() async { + Future _fetchChildrenFromServer() async { try { final refreshed = await UserService.getAssistanteMaternelle(widget.assistante.user.id); @@ -345,7 +374,26 @@ class _AdminAmEditModalState extends State }).toList(); if (!mounted) return; - setState(() => _children = kids); + setState(() { + _children = kids; + _baselineChildIds = kids.map((c) => c.id).toSet(); + }); + } catch (_) {} + } + + Future _refreshChildrenDetails() async { + try { + final all = await UserService.getEnfants(); + final byId = {for (final e in all) e.id: e}; + + if (!mounted) return; + setState(() { + _children = _children.map((c) { + final full = byId[c.id]; + if (full != null) return ParentChildSummary.fromEnfant(full); + return c; + }).toList(); + }); } catch (_) {} } @@ -357,7 +405,7 @@ class _AdminAmEditModalState extends State context: context, builder: (ctx) => AdminChildDetailModal( enfant: enfant, - onSaved: _reloadChildren, + onSaved: _refreshChildrenDetails, ), ); } catch (e) { @@ -392,23 +440,11 @@ class _AdminAmEditModalState extends State ); if (confirmed != true || !mounted) return; - try { - await UserService.detachEnfantFromAm( - amUserId: widget.assistante.user.id, - enfantId: child.id, - ); - if (!mounted) return; - await _reloadChildren(); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Enfant détaché')), - ); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), - ); - } + setState(() { + _children = _children.where((c) => c.id != child.id).toList(); + _syncPlacesAfterChildrenChange(); + _dirty = true; + }); } Future _attachChild() async { @@ -450,23 +486,14 @@ class _AdminAmEditModalState extends State ); if (selected == null || !mounted) return; - try { - await UserService.attachEnfantToAm( - amUserId: widget.assistante.user.id, - enfantId: selected.id, - ); - if (!mounted) return; - await _reloadChildren(); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Enfant rattaché')), - ); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), - ); - } + setState(() { + _children = [ + ..._children, + ParentChildSummary.fromEnfant(selected), + ]; + _syncPlacesAfterChildrenChange(); + _dirty = true; + }); } From 9ad371a3424f1c2fe6746599f9bf4fed7c83c152 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 7 Jul 2026 23:37:42 +0200 Subject: [PATCH 26/85] fix(#131): aligner PATCH fiche AM back sur le payload front MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Étend UpdateAmFicheAdminDto (nir, date/lieu naissance, agreement_date) et persiste ces champs dans updateFicheAdmin avec validation NIR. Co-authored-by: Cursor --- .../assistantes_maternelles.service.ts | 37 +++++++++++++++++++ .../dto/update-am-fiche-admin.dto.ts | 29 +++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.service.ts b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.service.ts index 9949126..66a3c97 100644 --- a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.service.ts +++ b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.service.ts @@ -13,6 +13,7 @@ import { Children, StatutEnfantType } from 'src/entities/children.entity'; import { CreateAssistanteDto } from '../user/dto/create_assistante.dto'; import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto'; import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto'; +import { validateNir } from 'src/common/utils/nir.util'; const AM_CHILDREN_RELATIONS = ['user', 'amChildren', 'amChildren.child'] as const; @@ -99,6 +100,15 @@ export class AssistantesMaternellesService { if (dto.ville !== undefined) user.ville = dto.ville; if (dto.code_postal !== undefined) user.code_postal = dto.code_postal; if (dto.statut !== undefined) user.statut = dto.statut; + if (dto.date_naissance !== undefined) { + user.date_naissance = dto.date_naissance ? new Date(dto.date_naissance) : undefined; + } + if (dto.lieu_naissance_ville !== undefined) { + user.lieu_naissance_ville = dto.lieu_naissance_ville || undefined; + } + if (dto.lieu_naissance_pays !== undefined) { + user.lieu_naissance_pays = dto.lieu_naissance_pays || undefined; + } await this.usersRepository.save(user); @@ -109,6 +119,33 @@ export class AssistantesMaternellesService { if (dto.places_available !== undefined) amPatch.places_available = dto.places_available; if (dto.biography !== undefined) amPatch.biography = dto.biography; if (dto.available !== undefined) amPatch.available = dto.available; + if (dto.agreement_date !== undefined) { + amPatch.agreement_date = dto.agreement_date ? new Date(dto.agreement_date) : undefined; + } + + if (dto.nir !== undefined) { + const nirNormalized = dto.nir.replace(/\s/g, '').toUpperCase(); + if (nirNormalized) { + const nirValidation = validateNir(nirNormalized, { + dateNaissance: + dto.date_naissance ?? user.date_naissance?.toISOString().slice(0, 10), + }); + if (!nirValidation.valid) { + throw new BadRequestException(nirValidation.error || 'NIR invalide'); + } + const nirDejaUtilise = await this.assistantesMaternelleRepository.findOne({ + where: { nir: nirNormalized }, + }); + if (nirDejaUtilise && nirDejaUtilise.user_id !== amUserId) { + throw new ConflictException( + 'Un compte assistante maternelle avec ce numéro NIR existe déjà.', + ); + } + amPatch.nir = nirNormalized; + } else { + (amPatch as { nir?: string | null }).nir = null; + } + } if (Object.keys(amPatch).length > 0) { await this.assistantesMaternelleRepository.update(amUserId, amPatch); diff --git a/backend/src/routes/assistantes_maternelles/dto/update-am-fiche-admin.dto.ts b/backend/src/routes/assistantes_maternelles/dto/update-am-fiche-admin.dto.ts index 81a665d..cbac068 100644 --- a/backend/src/routes/assistantes_maternelles/dto/update-am-fiche-admin.dto.ts +++ b/backend/src/routes/assistantes_maternelles/dto/update-am-fiche-admin.dto.ts @@ -1,6 +1,7 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { IsBoolean, + IsDateString, IsEmail, IsEnum, IsInt, @@ -59,12 +60,40 @@ export class UpdateAmFicheAdminDto { @IsEnum(StatutUtilisateurType) statut?: StatutUtilisateurType; + @ApiPropertyOptional({ example: '123456789012345' }) + @IsOptional() + @IsString() + @MaxLength(15) + nir?: string; + + @ApiPropertyOptional({ example: '1985-03-12' }) + @IsOptional() + @IsDateString() + date_naissance?: string; + + @ApiPropertyOptional({ example: 'Lyon' }) + @IsOptional() + @IsString() + @MaxLength(100) + lieu_naissance_ville?: string; + + @ApiPropertyOptional({ example: 'France' }) + @IsOptional() + @IsString() + @MaxLength(100) + lieu_naissance_pays?: string; + @ApiPropertyOptional({ example: 'AGR-2024-12345' }) @IsOptional() @IsString() @MaxLength(50) approval_number?: string; + @ApiPropertyOptional({ example: '2020-01-15' }) + @IsOptional() + @IsDateString() + agreement_date?: string; + @ApiPropertyOptional({ example: 'Lyon' }) @IsOptional() @IsString() From 8ed68797aa92bca6e66d8e990df2bb1ef537cf58 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 7 Jul 2026 23:41:45 +0200 Subject: [PATCH 27/85] =?UTF-8?q?fix(#131):=20PATCH=20fiche=20AM=20?= =?UTF-8?q?=E2=80=94=20NIR=20sans=20crash=20date=20ni=20effacement=20NOT?= =?UTF-8?q?=20NULL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../assistantes_maternelles.service.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.service.ts b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.service.ts index 66a3c97..ced0e0e 100644 --- a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.service.ts +++ b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.service.ts @@ -126,9 +126,15 @@ export class AssistantesMaternellesService { if (dto.nir !== undefined) { const nirNormalized = dto.nir.replace(/\s/g, '').toUpperCase(); if (nirNormalized) { + const dateNaissanceForNir = + dto.date_naissance ?? + (user.date_naissance instanceof Date + ? user.date_naissance.toISOString().slice(0, 10) + : user.date_naissance + ? String(user.date_naissance).slice(0, 10) + : undefined); const nirValidation = validateNir(nirNormalized, { - dateNaissance: - dto.date_naissance ?? user.date_naissance?.toISOString().slice(0, 10), + dateNaissance: dateNaissanceForNir, }); if (!nirValidation.valid) { throw new BadRequestException(nirValidation.error || 'NIR invalide'); @@ -142,9 +148,8 @@ export class AssistantesMaternellesService { ); } amPatch.nir = nirNormalized; - } else { - (amPatch as { nir?: string | null }).nir = null; } + // NIR vide : ne pas effacer (colonne NOT NULL en BDD) — le front renvoie toujours la clé. } if (Object.keys(amPatch).length > 0) { From 7b69f27ca5770196953114556d4601b4f01fe056 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Sat, 11 Jul 2026 23:18:40 +0200 Subject: [PATCH 28/85] feat(#142): ouvrir la modale au clic sur les cartes du dashboard admin. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unifie le comportement des listes Parents, AM, Enfants, Gestionnaires, Admins et À valider avec le bouton d'action au survol. Co-authored-by: Cursor --- frontend/lib/widgets/admin/admin_management_widget.dart | 1 + .../admin/assistante_maternelle_management_widget.dart | 1 + .../lib/widgets/admin/common/admin_enfant_user_card.dart | 7 +++++++ frontend/lib/widgets/admin/common/admin_user_card.dart | 7 ++++++- frontend/lib/widgets/admin/enfant_management_widget.dart | 1 + .../lib/widgets/admin/gestionnaire_management_widget.dart | 1 + frontend/lib/widgets/admin/parent_managmant_widget.dart | 1 + frontend/lib/widgets/admin/pending_validation_widget.dart | 3 ++- 8 files changed, 20 insertions(+), 2 deletions(-) diff --git a/frontend/lib/widgets/admin/admin_management_widget.dart b/frontend/lib/widgets/admin/admin_management_widget.dart index 01f74d9..d0a2bed 100644 --- a/frontend/lib/widgets/admin/admin_management_widget.dart +++ b/frontend/lib/widgets/admin/admin_management_widget.dart @@ -122,6 +122,7 @@ class _AdminManagementWidgetState extends State { fallbackIcon: isSuperAdmin ? Icons.verified_user_outlined : Icons.manage_accounts_outlined, + onCardTap: () => _openAdminEditDialog(user), subtitleLines: [ user.email, 'Téléphone : ${user.telephone?.trim().isNotEmpty == true ? formatPhoneForDisplay(user.telephone!) : 'Non renseigné'}', diff --git a/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart b/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart index 5ee5ed1..dc28b11 100644 --- a/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart +++ b/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart @@ -83,6 +83,7 @@ class _AssistanteMaternelleManagementWidgetState avatarUrl: assistante.user.photoUrl, fallbackIcon: Icons.face, vigilanceTooltip: vigilance, + onCardTap: () => _openAssistanteDetails(assistante), subtitleLines: [ assistante.user.email, 'Zone : ${assistante.residenceCity ?? 'N/A'} | Capacité : ${assistante.maxChildren ?? 0}', diff --git a/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart b/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart index a6349b8..69dfa65 100644 --- a/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart +++ b/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart @@ -33,6 +33,7 @@ class AdminEnfantUserCard extends StatelessWidget { final String? photoUrl; final List subtitleLines; final List actions; + final VoidCallback? onCardTap; const AdminEnfantUserCard({ super.key, @@ -40,12 +41,14 @@ class AdminEnfantUserCard extends StatelessWidget { this.photoUrl, required this.subtitleLines, this.actions = const [], + this.onCardTap, }); factory AdminEnfantUserCard.fromEnfant( EnfantAdminModel enfant, { List extraSubtitleLines = const [], List actions = const [], + VoidCallback? onCardTap, }) { final parents = enfant.parentLinks .map((l) => l.parentName ?? 'Parent') @@ -64,6 +67,7 @@ class AdminEnfantUserCard extends StatelessWidget { ], ), actions: actions, + onCardTap: onCardTap, ); } @@ -71,6 +75,7 @@ class AdminEnfantUserCard extends StatelessWidget { ParentChildSummary child, { List extraSubtitleLines = const [], List actions = const [], + VoidCallback? onCardTap, }) { return AdminEnfantUserCard( title: child.fullName, @@ -82,6 +87,7 @@ class AdminEnfantUserCard extends StatelessWidget { extra: extraSubtitleLines, ), actions: actions, + onCardTap: onCardTap, ); } @@ -93,6 +99,7 @@ class AdminEnfantUserCard extends StatelessWidget { avatarUrl: photoUrl, subtitleLines: subtitleLines, actions: actions, + onCardTap: onCardTap, ); } } diff --git a/frontend/lib/widgets/admin/common/admin_user_card.dart b/frontend/lib/widgets/admin/common/admin_user_card.dart index 7caafe1..354ae68 100644 --- a/frontend/lib/widgets/admin/common/admin_user_card.dart +++ b/frontend/lib/widgets/admin/common/admin_user_card.dart @@ -13,6 +13,7 @@ class AdminUserCard extends StatefulWidget { final Color? titleColor; final Color? infoColor; final String? vigilanceTooltip; + final VoidCallback? onCardTap; const AdminUserCard({ super.key, @@ -26,6 +27,7 @@ class AdminUserCard extends StatefulWidget { this.titleColor, this.infoColor, this.vigilanceTooltip, + this.onCardTap, }); @override @@ -46,11 +48,14 @@ class _AdminUserCardState extends State { return MouseRegion( onEnter: (_) => setState(() => _isHovered = true), onExit: (_) => setState(() => _isHovered = false), + cursor: widget.onCardTap != null + ? SystemMouseCursors.click + : MouseCursor.defer, child: Material( color: Colors.transparent, borderRadius: BorderRadius.circular(10), child: InkWell( - onTap: () {}, + onTap: widget.onCardTap, borderRadius: BorderRadius.circular(10), hoverColor: const Color(0x149CC5C0), child: Card( diff --git a/frontend/lib/widgets/admin/enfant_management_widget.dart b/frontend/lib/widgets/admin/enfant_management_widget.dart index bc90aaf..9aaf59b 100644 --- a/frontend/lib/widgets/admin/enfant_management_widget.dart +++ b/frontend/lib/widgets/admin/enfant_management_widget.dart @@ -84,6 +84,7 @@ class _EnfantManagementWidgetState extends State { final enfant = filtered[index]; return AdminEnfantUserCard.fromEnfant( enfant, + onCardTap: () => _openEnfant(enfant), actions: [ IconButton( icon: const Icon(Icons.visibility_outlined), diff --git a/frontend/lib/widgets/admin/gestionnaire_management_widget.dart b/frontend/lib/widgets/admin/gestionnaire_management_widget.dart index 2692831..1ce89d6 100644 --- a/frontend/lib/widgets/admin/gestionnaire_management_widget.dart +++ b/frontend/lib/widgets/admin/gestionnaire_management_widget.dart @@ -88,6 +88,7 @@ class _GestionnaireManagementWidgetState title: user.fullName, fallbackIcon: Icons.assignment_ind_outlined, avatarUrl: user.photoUrl, + onCardTap: () => _openGestionnaireEditDialog(user), subtitleLines: [ user.email, 'Statut : ${user.statut ?? 'Inconnu'}', diff --git a/frontend/lib/widgets/admin/parent_managmant_widget.dart b/frontend/lib/widgets/admin/parent_managmant_widget.dart index becbd6c..dcc2a66 100644 --- a/frontend/lib/widgets/admin/parent_managmant_widget.dart +++ b/frontend/lib/widgets/admin/parent_managmant_widget.dart @@ -77,6 +77,7 @@ class _ParentManagementWidgetState extends State { title: parent.user.fullName, fallbackIcon: Icons.supervisor_account_outlined, avatarUrl: parent.user.photoUrl, + onCardTap: () => _openParentDetails(parent), subtitleLines: [ parent.user.email, 'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${parent.children.isNotEmpty ? parent.children.length : parent.childrenCount}', diff --git a/frontend/lib/widgets/admin/pending_validation_widget.dart b/frontend/lib/widgets/admin/pending_validation_widget.dart index e1e68a9..361a54c 100644 --- a/frontend/lib/widgets/admin/pending_validation_widget.dart +++ b/frontend/lib/widgets/admin/pending_validation_widget.dart @@ -321,11 +321,12 @@ class _PendingValidationRowState extends State<_PendingValidationRow> { return MouseRegion( onEnter: (_) => setState(() => _isHovered = true), onExit: (_) => setState(() => _isHovered = false), + cursor: SystemMouseCursors.click, child: Material( color: Colors.transparent, borderRadius: BorderRadius.circular(10), child: InkWell( - onTap: () {}, + onTap: widget.onOpen, borderRadius: BorderRadius.circular(10), hoverColor: const Color(0x149CC5C0), child: Card( From 6788351070f59b2419d827ba4d20763e4bc28766 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Sun, 12 Jul 2026 12:10:06 +0200 Subject: [PATCH 29/85] =?UTF-8?q?fix(#143):=20masquer=20Supprimer=20quand?= =?UTF-8?q?=20un=20gestionnaire=20=C3=A9dite=20sa=20propre=20fiche?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- ...te-gitea-issue-gestionnaire-self-delete.js | 111 ++++++++++++++++++ .../creation/gestionnaires_create.dart | 28 ++++- 2 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 backend/scripts/create-gitea-issue-gestionnaire-self-delete.js diff --git a/backend/scripts/create-gitea-issue-gestionnaire-self-delete.js b/backend/scripts/create-gitea-issue-gestionnaire-self-delete.js new file mode 100644 index 0000000..0bc75cb --- /dev/null +++ b/backend/scripts/create-gitea-issue-gestionnaire-self-delete.js @@ -0,0 +1,111 @@ +/** + * Crée l'issue Gitea — gestionnaire ne doit pas pouvoir se supprimer. + * Usage: node backend/scripts/create-gitea-issue-gestionnaire-self-delete.js + */ +const https = require('https'); +const fs = require('fs'); +const path = require('path'); + +const repoRoot = path.join(__dirname, '../..'); +const MILESTONE_0_1_0 = 10; + +let token = process.env.GITEA_TOKEN; +if (!token) { + try { + const tokenFile = path.join(repoRoot, '.gitea-token'); + if (fs.existsSync(tokenFile)) token = fs.readFileSync(tokenFile, 'utf8').trim(); + } catch (_) {} +} +if (!token) { + try { + const briefing = fs.readFileSync( + path.join(repoRoot, 'docs/27_BRIEFING-FRONTEND.md'), + 'utf8', + ); + const m = briefing.match(/Token:\s*(gitebu_[a-f0-9]+)/); + if (m) token = m[1].trim(); + } catch (_) {} +} +if (!token) { + console.error('Token non trouvé : .gitea-token ou GITEA_TOKEN'); + process.exit(1); +} + +const body = `## Contexte + +Quand un **gestionnaire** connecté ouvre sa propre fiche dans l'onglet **Gestionnaires** (Gestion des utilisateurs), la modale **Modifier un "Gestionnaire"** affiche le bouton **Supprimer**. + +Comportement actuel : le gestionnaire voit et peut tenter de supprimer son propre compte. + +## Comportement attendu + +Comme pour le **super administrateur** (bouton Supprimer masqué sur la fiche super admin) : + +- **Pas de bouton Supprimer** quand l'utilisateur édite **sa propre fiche** +- **Modification** des informations (prénom, nom, email, téléphone, relais, mot de passe) **toujours autorisée** + +## Périmètre + +- Frontend : \`AdminUserFormDialog\` (\`gestionnaires_create.dart\`) +- Comparer \`initialUser.id\` avec l'utilisateur connecté (\`AuthService.getCurrentUser\`) +- Masquer Supprimer si édition de soi-même ; conserver garde existante super admin + +## Critères d'acceptation + +- [ ] Gestionnaire connecté → ouvre sa fiche → **pas** de bouton Supprimer +- [ ] Gestionnaire connecté → peut **Modifier** ses informations +- [ ] Super admin / admin → peut toujours supprimer **un autre** gestionnaire (si droits API) +- [ ] Pas de régression sur fiche super administrateur (Supprimer toujours masqué) + +## Fichiers clés + +- \`frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart\` +- \`frontend/lib/widgets/admin/gestionnaire_management_widget.dart\` + +## Milestone + +**0.1.0** — correction UX / sécurité gestion utilisateurs.`; + +const payloadClean = JSON.stringify({ + title: '[Bug] Gestionnaire peut voir Supprimer sur sa propre fiche', + body, + milestone: MILESTONE_0_1_0, +}); + +const opts = { + hostname: 'git.ptits-pas.fr', + path: '/api/v1/repos/jmartin/petitspas/issues', + method: 'POST', + headers: { + Authorization: 'token ' + token, + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payloadClean), + }, +}; + +const req = https.request(opts, (res) => { + let d = ''; + res.on('data', (c) => (d += c)); + res.on('end', () => { + try { + const o = JSON.parse(d); + if (o.number) { + console.log('NUMBER:', o.number); + console.log('URL:', o.html_url); + console.log('MILESTONE:', o.milestone?.title ?? '(aucun)'); + } else { + console.error('Réponse:', d); + process.exit(1); + } + } catch (e) { + console.error(d); + process.exit(1); + } + }); +}); +req.on('error', (e) => { + console.error(e); + process.exit(1); +}); +req.write(payloadClean); +req.end(); diff --git a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart index a1b5f92..4f89ab7 100644 --- a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart +++ b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart @@ -5,6 +5,7 @@ import 'package:p_tits_pas/utils/email_utils.dart'; import 'package:p_tits_pas/widgets/email_text_field.dart'; import 'package:p_tits_pas/widgets/french_phone_field.dart'; import 'package:p_tits_pas/models/user.dart'; +import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/relais_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; @@ -41,9 +42,15 @@ class _AdminUserFormDialogState extends State { bool _isLoadingRelais = true; List _relais = []; String? _selectedRelaisId; + String? _currentUserId; bool get _isEditMode => widget.initialUser != null; bool get _isSuperAdminTarget => (widget.initialUser?.role ?? '').toLowerCase() == 'super_admin'; + bool get _isSelfTarget => + _isEditMode && + _currentUserId != null && + widget.initialUser!.id == _currentUserId; + bool get _canDeleteTarget => !_isSuperAdminTarget && !_isSelfTarget; bool get _isLockedAdminIdentity => _isEditMode && widget.adminMode && _isSuperAdminTarget; String get _targetRoleKey { @@ -109,6 +116,23 @@ class _AdminUserFormDialogState extends State { } else { _isLoadingRelais = false; } + _loadCurrentUserId(); + } + + Future _loadCurrentUserId() async { + final cached = await AuthService.getCurrentUser(); + if (!mounted) return; + if (cached != null) { + setState(() { + _currentUserId = cached.id; + }); + return; + } + final refreshed = await AuthService.refreshCurrentUser(); + if (!mounted || refreshed == null) return; + setState(() { + _currentUserId = refreshed.id; + }); } @override @@ -335,7 +359,7 @@ class _AdminUserFormDialogState extends State { Future _delete() async { if (widget.readOnly) return; - if (_isSuperAdminTarget) return; + if (!_canDeleteTarget) return; if (!_isEditMode || _isSubmitting) return; final confirmed = await showDialog( @@ -462,7 +486,7 @@ class _AdminUserFormDialogState extends State { child: const Text('Fermer'), ), ] else if (_isEditMode) ...[ - if (!_isSuperAdminTarget) + if (_canDeleteTarget) OutlinedButton( onPressed: _isSubmitting ? null : _delete, style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700), From 77d952a6f74a21b7fdf76116afdf6588a825e0c4 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 12:36:05 +0200 Subject: [PATCH 30/85] fix(#144): persister le consentement photo des enfants - DTO inscription enfant : champ consent_photo - Inscription + reprise parent : sauvegarde bool + date - Front payload : envoi consent_photo - PATCH /enfants : horodatage du consentement Co-authored-by: Cursor --- backend/src/routes/auth/auth.service.ts | 9 ++++++++- backend/src/routes/auth/dto/enfant-inscription.dto.ts | 9 +++++++++ backend/src/routes/enfants/enfants.service.ts | 8 +++++++- frontend/lib/utils/parent_registration_payload.dart | 1 + frontend/lib/utils/reprise_mapper.dart | 4 ++-- 5 files changed, 27 insertions(+), 4 deletions(-) diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index 308bb81..e31085e 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -552,7 +552,8 @@ export class AuthService { : undefined; enfant.photo_url = urlPhoto || undefined; enfant.status = enfantDto.date_naissance ? StatutEnfantType.SANS_GARDE : StatutEnfantType.A_NAITRE; - enfant.consent_photo = false; + enfant.consent_photo = !!enfantDto.consent_photo; + enfant.consent_photo_at = enfant.consent_photo ? new Date() : null!; enfant.is_multiple = enfantDto.grossesse_multiple || false; const enfantEnregistre = await manager.save(Children, enfant); @@ -1074,6 +1075,12 @@ export class AuthService { if (enfantDto.grossesse_multiple !== undefined) { enfant.is_multiple = enfantDto.grossesse_multiple; } + if (enfantDto.consent_photo !== undefined) { + enfant.consent_photo = !!enfantDto.consent_photo; + enfant.consent_photo_at = enfant.consent_photo + ? new Date() + : null!; + } if (enfantDto.photo_base64 && enfantDto.photo_filename) { enfant.photo_url = await this.sauvegarderPhotoDepuisBase64( diff --git a/backend/src/routes/auth/dto/enfant-inscription.dto.ts b/backend/src/routes/auth/dto/enfant-inscription.dto.ts index 9ee120c..dce0f51 100644 --- a/backend/src/routes/auth/dto/enfant-inscription.dto.ts +++ b/backend/src/routes/auth/dto/enfant-inscription.dto.ts @@ -59,5 +59,14 @@ export class EnfantInscriptionDto { @IsOptional() @IsBoolean() grossesse_multiple?: boolean; + + @ApiProperty({ + example: true, + required: false, + description: 'Consentement affichage / stockage photo (colonne enfants.consentement_photo)', + }) + @IsOptional() + @IsBoolean() + consent_photo?: boolean; } diff --git a/backend/src/routes/enfants/enfants.service.ts b/backend/src/routes/enfants/enfants.service.ts index e4b46c4..7f4552c 100644 --- a/backend/src/routes/enfants/enfants.service.ts +++ b/backend/src/routes/enfants/enfants.service.ts @@ -120,7 +120,13 @@ export class EnfantsService { const child = await this.childrenRepository.findOne({ where: { id } }); if (!child) throw new NotFoundException('Enfant introuvable'); - await this.childrenRepository.update(id, dto); + const patch: Partial = { ...dto } as Partial; + if (dto.consent_photo !== undefined) { + patch.consent_photo = dto.consent_photo; + patch.consent_photo_at = dto.consent_photo ? new Date() : null!; + } + + await this.childrenRepository.update(id, patch); return this.findOne(id, currentUser); } diff --git a/frontend/lib/utils/parent_registration_payload.dart b/frontend/lib/utils/parent_registration_payload.dart index 70a9459..467cd35 100644 --- a/frontend/lib/utils/parent_registration_payload.dart +++ b/frontend/lib/utils/parent_registration_payload.dart @@ -167,6 +167,7 @@ class ParentRegistrationPayload { final map = { 'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre', 'grossesse_multiple': c.multipleBirth, + 'consent_photo': c.photoConsent, }; final prenom = c.firstName.trim(); diff --git a/frontend/lib/utils/reprise_mapper.dart b/frontend/lib/utils/reprise_mapper.dart index 53c285b..f965e1a 100644 --- a/frontend/lib/utils/reprise_mapper.dart +++ b/frontend/lib/utils/reprise_mapper.dart @@ -44,8 +44,8 @@ class RepriseMapper { lastName: e.lastName ?? '', dob: dob, genre: e.gender ?? '', - // Inscription initiale exigeait la coche pour envoyer la photo ; le back - // ne persistait pas toujours consent_photo — on pré-coche si photo en base. + // Legacy : dossiers inscrits avant #144 avaient consent_photo=false malgré une photo. + // On pré-coche encore si photo en base pour ne pas bloquer la reprise. photoConsent: e.consentPhoto || hasPhoto, multipleBirth: e.estMultiple, isUnbornChild: isUnborn, From 7951c38d4dffc84a5fc13887e03011cbe3619a25 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Sun, 12 Jul 2026 18:46:08 +0200 Subject: [PATCH 31/85] feat(#138): refonte modale enfant admin en format paysage. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alignement sur les fiches AM/parent : photo identité, champs en grille, zone AM rattachable (y compris à naître) ou carte scolarisation, suppression réservée au super_admin et ouverture au clic sur la carte depuis la fiche parent. Refs: #138 Co-authored-by: Cursor --- frontend/lib/services/user_service.dart | 19 + .../common/admin_child_detail_modal.dart | 1006 ++++++++++++----- .../admin_children_affiliation_panel.dart | 1 + .../widgets/admin/common/admin_user_card.dart | 4 +- 4 files changed, 751 insertions(+), 279 deletions(-) diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index 31ea722..b16e89f 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -478,6 +478,25 @@ class UserService { return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map); } + static Future deleteEnfant(String enfantId) async { + final response = await http.delete( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'), + headers: await _headers(), + ); + if (response.statusCode != 200 && response.statusCode != 204) { + throw Exception(_extractErrorMessage(response.body, 'Erreur suppression enfant')); + } + } + + /// AM dont la liste d'enfants actifs contient [enfantId] (API actuelle). + static Future findAmForEnfant(String enfantId) async { + final ams = await getAssistantesMaternelles(); + for (final am in ams) { + if (am.children.any((c) => c.id == enfantId)) return am; + } + return null; + } + static ParentModel _parentModelFromBody(String body) { final decoded = jsonDecode(body); return ParentModel.fromJson( diff --git a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart index 30941ed..df2c7bc 100644 --- a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -1,17 +1,27 @@ import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; import 'package:p_tits_pas/models/enfant_admin_model.dart'; +import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/utils/date_display_utils.dart'; import 'package:p_tits_pas/utils/enfant_status_utils.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; +import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; +import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; -/// Fiche enfant consultation / édition (ticket #138). +/// Fiche enfant consultation / édition (ticket #138) — format paysage comme fiche AM. class AdminChildDetailModal extends StatefulWidget { final EnfantAdminModel enfant; final VoidCallback? onSaved; + final VoidCallback? onDeleted; const AdminChildDetailModal({ super.key, required this.enfant, this.onSaved, + this.onDeleted, }); @override @@ -29,14 +39,39 @@ class _AdminChildDetailModalState extends State { late bool _isMultiple; bool _dirty = false; bool _saving = false; + bool _deleting = false; + bool _placementBusy = false; + bool _loadingAm = true; + String? _currentUserRole; + AssistanteMaternelleModel? _linkedAm; - static const _genders = ['H', 'F', 'Autre']; + static const double _modalWidth = 930; + static const double _mainRowHeight = 380; + static const double _placementHeight = 96; + static const double _fieldHeight = 44; + static const double _photoProGap = 24; + static const double _proColumnMinWidth = 260; + static const double _photoColumnMinWidth = 160; + static const double _photoColumnMaxWidth = 280; + static const List _fieldsRowLayout = [2, 2, 1]; - static const _labelStyle = TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Colors.black87, - ); + bool get _isUnborn => _status == 'a_naitre'; + bool get _isScolarise => _status == 'scolarise'; + bool get _showsAmPlacement => !_isScolarise; + + List get _availableGenders => + _isUnborn ? const ['H', 'F', 'Autre'] : const ['H', 'F']; + + TextEditingController get _activeDateCtrl => + _isUnborn ? _dueCtrl : _birthCtrl; + + String get _dateFieldLabel => + _isUnborn ? 'Date prévisionnelle' : 'Date de naissance'; + + bool get _canDelete => + (_currentUserRole ?? '').toLowerCase() == 'super_admin'; + + bool get _busy => _saving || _deleting || _placementBusy; @override void initState() { @@ -44,18 +79,55 @@ class _AdminChildDetailModalState extends State { final e = widget.enfant; _prenomCtrl = TextEditingController(text: e.firstName ?? ''); _nomCtrl = TextEditingController(text: e.lastName ?? ''); - _birthCtrl = TextEditingController(text: e.birthDate ?? ''); - _dueCtrl = TextEditingController(text: e.dueDate ?? ''); + _birthCtrl = TextEditingController( + text: formatIsoDateFr(e.birthDate, ifEmpty: ''), + ); + _dueCtrl = TextEditingController( + text: formatIsoDateFr(e.dueDate, ifEmpty: ''), + ); _status = normalizeEnfantStatus(e.status); if (!enfantStatusValues.contains(_status)) { _status = 'sans_garde'; } - _gender = _normalizeGender(e.gender); + _gender = _normalizeGender(e.gender, allowUnknown: _isUnborn); _consentPhoto = e.consentPhoto; _isMultiple = e.isMultiple; for (final c in [_prenomCtrl, _nomCtrl, _birthCtrl, _dueCtrl]) { c.addListener(_markDirty); } + _prenomCtrl.addListener(_onNameChanged); + _nomCtrl.addListener(_onNameChanged); + _loadCurrentUserRole(); + _loadLinkedAm(); + } + + void _onNameChanged() => setState(() {}); + + Future _loadLinkedAm() async { + setState(() => _loadingAm = true); + try { + final am = await UserService.findAmForEnfant(widget.enfant.id); + if (!mounted) return; + setState(() { + _linkedAm = am; + _loadingAm = false; + }); + } catch (_) { + if (!mounted) return; + setState(() => _loadingAm = false); + } + } + + Future _loadCurrentUserRole() async { + final cached = await AuthService.getCurrentUser(); + if (!mounted) return; + if (cached != null) { + setState(() => _currentUserRole = cached.role); + return; + } + final refreshed = await AuthService.refreshCurrentUser(); + if (!mounted || refreshed == null) return; + setState(() => _currentUserRole = refreshed.role); } @override @@ -66,17 +138,48 @@ class _AdminChildDetailModalState extends State { super.dispose(); } - static String _normalizeGender(String? raw) { + static String _normalizeGender(String? raw, {required bool allowUnknown}) { final g = (raw ?? '').trim(); if (g == 'M') return 'H'; - if (_genders.contains(g)) return g; + if (g == 'F' || g == 'H') return g; + if (g == 'Autre' && allowUnknown) return 'Autre'; return 'H'; } + void _coerceGenderForStatus() { + if (!_availableGenders.contains(_gender)) { + _gender = 'H'; + } + } + void _markDirty() { if (!_dirty) setState(() => _dirty = true); } + String? _dateToIso(String text) => parseFrDateToIso(text); + + String _headerTitle() { + final fn = _prenomCtrl.text.trim(); + final ln = _nomCtrl.text.trim(); + if (fn.isEmpty && ln.isEmpty) { + final fallback = widget.enfant.fullName.trim(); + return fallback.isNotEmpty ? fallback : 'Enfant'; + } + return '$fn $ln'.trim(); + } + + String? _parentsSubtitle() { + final names = widget.enfant.parentLinks + .map((l) { + final n = (l.parentName ?? '').trim(); + return n.isNotEmpty ? n : 'Parent rattaché'; + }) + .where((s) => s.isNotEmpty) + .toList(); + if (names.isEmpty) return null; + return 'Responsables : ${names.join(', ')}'; + } + Future _save() async { if (!_dirty) return; setState(() => _saving = true); @@ -88,9 +191,11 @@ class _AdminChildDetailModalState extends State { 'last_name': _nomCtrl.text.trim(), 'status': _status, 'gender': _gender, - if (_birthCtrl.text.trim().isNotEmpty) - 'birth_date': _birthCtrl.text.trim(), - if (_dueCtrl.text.trim().isNotEmpty) 'due_date': _dueCtrl.text.trim(), + if (_isUnborn) + if (_dateToIso(_dueCtrl.text) != null) + 'due_date': _dateToIso(_dueCtrl.text) + else if (_dateToIso(_birthCtrl.text) != null) + 'birth_date': _dateToIso(_birthCtrl.text), 'consent_photo': _consentPhoto, 'is_multiple': _isMultiple, }, @@ -113,289 +218,564 @@ class _AdminChildDetailModalState extends State { } } - String _parentsLine() { - final names = widget.enfant.parentLinks - .map((l) { - final n = (l.parentName ?? '').trim(); - return n.isNotEmpty ? n : 'Parent rattaché'; - }) - .where((s) => s.isNotEmpty) - .toList(); - if (names.isEmpty) return ''; - return names.join(', '); + Future _delete() async { + if (!_canDelete || _deleting) return; + + final name = _headerTitle(); + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Supprimer l\'enfant'), + content: Text( + 'Supprimer définitivement la fiche de $name ?\n' + 'Cette action est irréversible.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red.shade700, + foregroundColor: Colors.white, + ), + child: const Text('Supprimer'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + + setState(() => _deleting = true); + try { + await UserService.deleteEnfant(widget.enfant.id); + if (!mounted) return; + widget.onDeleted?.call(); + widget.onSaved?.call(); + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Fiche enfant supprimée')), + ); + } catch (e) { + if (!mounted) return; + setState(() => _deleting = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } } - @override - Widget build(BuildContext context) { - final parents = _parentsLine(); - final showDueDate = _status == 'a_naitre'; + Future _openLinkedAm() async { + final am = _linkedAm; + if (am == null) return; + await showDialog( + context: context, + builder: (ctx) => AdminAmEditModal( + assistante: am, + onSaved: () { + _loadLinkedAm(); + widget.onSaved?.call(); + }, + ), + ); + } - return Dialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - child: SizedBox( - width: 480, - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 640), - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.enfant.fullName, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - ), - ), - if (parents.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - 'Responsables : $parents', - style: const TextStyle( - fontSize: 13, - color: Colors.black54, - ), - ), - ], - ], - ), - ), - IconButton( - onPressed: () => Navigator.of(context).pop(), - icon: const Icon(Icons.close), - tooltip: 'Fermer', - ), - ], - ), - const SizedBox(height: 12), - const Divider(height: 1), - const SizedBox(height: 12), - Flexible( - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: _labeledField( - 'Prénom', - TextField( - controller: _prenomCtrl, - decoration: _decoration(), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: _labeledField( - 'Nom', - TextField( - controller: _nomCtrl, - decoration: _decoration(), - ), - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: _labeledDropdown( - 'Statut', - _status, - enfantStatusValues - .map( - (s) => MapEntry( - s, - enfantStatusLabel(s, gender: _gender), - ), - ) - .toList(), - (v) => setState(() { - _status = v; - _dirty = true; - }), - ), - ), - const SizedBox(width: 12), - Expanded( - child: _labeledDropdown( - 'Genre', - _gender, - _genders - .map((g) => MapEntry(g, _genderLabel(g))) - .toList(), - (v) => setState(() { - _gender = v; - _dirty = true; - }), - ), - ), - ], - ), - const SizedBox(height: 12), - if (showDueDate) - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: _labeledField( - 'Date de naissance', - TextField( - controller: _birthCtrl, - decoration: - _decoration(hint: 'AAAA-MM-JJ'), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: _labeledField( - 'Date prévue', - TextField( - controller: _dueCtrl, - decoration: - _decoration(hint: 'AAAA-MM-JJ'), - ), - ), - ), - ], - ) - else - _labeledField( - 'Date de naissance', - TextField( - controller: _birthCtrl, - decoration: _decoration(hint: 'AAAA-MM-JJ'), - ), - ), - const SizedBox(height: 16), - _switchRow( - 'Consentement photo', - _consentPhoto, - (v) => setState(() { - _consentPhoto = v; - _dirty = true; - }), - ), - _switchRow( - 'Naissance multiple', - _isMultiple, - (v) => setState(() { - _isMultiple = v; - _dirty = true; - }), - ), - ], + Future _attachAm() async { + List all; + try { + all = await UserService.getAssistantesMaternelles(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + return; + } + + if (_linkedAm != null) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Détachez l\'assistante actuelle avant d\'en choisir une autre'), + ), + ); + return; + } + + final candidates = all; + if (candidates.isEmpty) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Aucune assistante maternelle disponible')), + ); + return; + } + + if (!mounted) return; + final selected = await showDialog( + context: context, + builder: (ctx) => SimpleDialog( + title: const Text('Choisir une assistante maternelle'), + children: candidates + .map( + (am) => SimpleDialogOption( + onPressed: () => Navigator.pop(ctx, am), + child: Text(am.user.fullName), + ), + ) + .toList(), + ), + ); + if (selected == null || !mounted) return; + + setState(() => _placementBusy = true); + try { + await UserService.attachEnfantToAm( + amUserId: selected.user.id, + enfantId: widget.enfant.id, + ); + if (!mounted) return; + await _loadLinkedAm(); + widget.onSaved?.call(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Assistante maternelle rattachée')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } finally { + if (mounted) setState(() => _placementBusy = false); + } + } + + Future _detachAm() async { + final am = _linkedAm; + if (am == null) return; + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Détacher l\'assistante'), + content: Text( + 'Retirer ${am.user.fullName} de la garde de cet enfant ?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + style: ValidationModalTheme.primaryElevatedStyle, + child: const Text('Détacher'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + + setState(() => _placementBusy = true); + try { + await UserService.detachEnfantFromAm( + amUserId: am.user.id, + enfantId: widget.enfant.id, + ); + if (!mounted) return; + await _loadLinkedAm(); + widget.onSaved?.call(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Assistante maternelle détachée')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } finally { + if (mounted) setState(() => _placementBusy = false); + } + } + + InputDecoration _inputDecoration({String? hint}) { + return ValidationFieldDecoration.input(hint: hint).copyWith( + isDense: false, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + ); + } + + Widget _dropdownField({ + Key? key, + required String value, + required List> items, + required ValueChanged onChanged, + }) { + final safeValue = + items.any((e) => e.key == value) ? value : items.first.key; + return SizedBox( + height: _fieldHeight, + child: DropdownButtonFormField( + key: key, + value: safeValue, + isExpanded: true, + decoration: _inputDecoration(), + items: items + .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value))) + .toList(), + onChanged: _busy ? null : (v) { + if (v != null) onChanged(v); + }, + ), + ); + } + + Widget _textField(TextEditingController controller, {String? hint, Key? key}) { + return SizedBox( + height: _fieldHeight, + child: TextField( + key: key, + controller: controller, + textAlignVertical: TextAlignVertical.center, + style: const TextStyle(color: Colors.black87, fontSize: 14), + decoration: _inputDecoration(hint: hint), + ), + ); + } + + Widget _fieldsGrid() { + return ValidationEditableSection( + rowLayout: _fieldsRowLayout, + fields: [ + ValidationLabeledField( + label: 'Prénom', + field: _textField(_prenomCtrl), + ), + ValidationLabeledField( + label: 'Nom', + field: _textField(_nomCtrl), + ), + ValidationLabeledField( + label: _dateFieldLabel, + field: _textField( + _activeDateCtrl, + hint: 'jj/mm/aaaa', + key: ValueKey(_status), + ), + ), + ValidationLabeledField( + label: 'Statut', + field: _dropdownField( + value: _status, + items: enfantStatusValues + .map( + (s) => MapEntry( + s, + enfantStatusLabel(s, gender: _gender), + ), + ) + .toList(), + onChanged: (v) => setState(() { + _status = v; + _coerceGenderForStatus(); + _dirty = true; + }), + ), + ), + ValidationLabeledField( + label: 'Genre', + field: _dropdownField( + key: ValueKey('genre-$_status'), + value: _gender, + items: _availableGenders + .map((g) => MapEntry(g, _genderLabel(g))) + .toList(), + onChanged: (v) => setState(() { + _gender = v; + _dirty = true; + }), + ), + ), + ], + ); + } + + Widget _consentPhotoSwitch() { + return SwitchListTile( + contentPadding: EdgeInsets.zero, + dense: true, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + title: const Text( + 'Consentement photo', + style: TextStyle(fontSize: 13), + ), + value: _consentPhoto, + onChanged: _busy + ? null + : (v) => setState(() { + _consentPhoto = v; + _dirty = true; + }), + ); + } + + Widget _mainRow() { + return LayoutBuilder( + builder: (context, c) { + final maxRowW = c.maxWidth; + final maxRowH = c.maxHeight; + final idealPhotoW = + maxRowH * AdminAmPhotoFrame.idPhotoAspectRatio + 16; + final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth) + .clamp(0.0, double.infinity); + var photoW = idealPhotoW.clamp(_photoColumnMinWidth, _photoColumnMaxWidth); + if (photoW > maxPhotoW) photoW = maxPhotoW; + + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: photoW, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: AdminAmPhotoFrame( + photoUrl: widget.enfant.photoUrl, ), ), + const SizedBox(height: 8), + _consentPhotoSwitch(), + ], + ), + ), + const SizedBox(width: _photoProGap), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _fieldsGrid(), + const Spacer(), + const SizedBox(height: 16), + _placementSection(), + ], + ), + ), + ], + ); + }, + ); + } + + Widget _scolariseCard() { + return Container( + height: _placementHeight, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + const Color(0xFFE8F4FD), + const Color(0xFFDCEEFB), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFF90CAF9)), + ), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.school_rounded, + size: 32, + color: Colors.blue.shade700, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Scolarisé', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.blue.shade900, + ), ), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Fermer'), - ), - const SizedBox(width: 8), - ElevatedButton.icon( - onPressed: !_dirty || _saving ? null : _save, - icon: _saving - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.save), - label: const Text('Sauvegarder'), - ), - ], + const SizedBox(height: 4), + Text( + 'Enfant scolarisé — pas de garde chez une assistante maternelle', + style: TextStyle( + fontSize: 13, + color: Colors.blue.shade800.withValues(alpha: 0.85), + ), ), ], ), ), + Icon(Icons.auto_stories_outlined, size: 28, color: Colors.blue.shade400), + ], + ), + ); + } + + Widget _emptyAmSlot() { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: _busy ? null : _attachAm, + borderRadius: BorderRadius.circular(10), + child: Container( + height: _placementHeight, + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.grey.shade300), + ), + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.face_outlined, size: 28, color: Colors.grey.shade400), + const SizedBox(width: 12), + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Aucune assistante maternelle', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.grey.shade700, + ), + ), + Text( + 'Cliquer pour choisir une assistante', + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + ], + ), + const SizedBox(width: 8), + Icon(Icons.add_circle_outline, color: Colors.grey.shade500), + ], + ), ), ), ); } - InputDecoration _decoration({String? hint}) { - return InputDecoration( - isDense: true, - border: const OutlineInputBorder(), - hintText: hint, - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - ); - } - - Widget _labeledField(String label, Widget field) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text(label, style: _labelStyle), - const SizedBox(height: 4), - field, - ], - ); - } - - Widget _labeledDropdown( - String label, - String value, - List> items, - ValueChanged onChanged, - ) { - final safeValue = - items.any((e) => e.key == value) ? value : items.first.key; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text(label, style: _labelStyle), - const SizedBox(height: 4), - DropdownButtonFormField( - value: safeValue, - isExpanded: true, - decoration: _decoration(), - items: items - .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value))) - .toList(), - onChanged: (v) { - if (v != null) onChanged(v); - }, - ), - ], - ); - } - - Widget _switchRow(String label, bool value, ValueChanged onChanged) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - children: [ - Expanded(child: Text(label, style: _labelStyle)), - Switch( - value: value, - onChanged: onChanged, + Widget _linkedAmCard() { + final am = _linkedAm!; + return SizedBox( + height: _placementHeight, + child: AdminUserCard( + margin: EdgeInsets.zero, + title: am.user.fullName, + avatarUrl: am.user.photoUrl, + fallbackIcon: Icons.face, + onCardTap: _busy ? null : _openLinkedAm, + subtitleLines: [ + if (am.residenceCity != null && am.residenceCity!.trim().isNotEmpty) + 'Zone : ${am.residenceCity!.trim()}', + if (am.approvalNumber != null && am.approvalNumber!.trim().isNotEmpty) + 'Agrément : ${am.approvalNumber!.trim()}', + ], + actions: [ + IconButton( + icon: const Icon(Icons.open_in_new), + tooltip: 'Ouvrir la fiche AM', + onPressed: _busy ? null : _openLinkedAm, + ), + IconButton( + icon: Icon(Icons.link_off, color: Colors.orange.shade800), + tooltip: 'Détacher', + onPressed: _busy ? null : _detachAm, ), ], ), ); } + Widget _placementSection() { + if (!_showsAmPlacement) return _scolariseCard(); + + if (_loadingAm || _placementBusy) { + return SizedBox( + height: _placementHeight, + child: Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.grey.shade600, + ), + ), + ), + ); + } + + if (_linkedAm != null) return _linkedAmCard(); + return _emptyAmSlot(); + } + + Widget _buildFooter() { + return Row( + children: [ + if (_canDelete) + OutlinedButton( + onPressed: _busy ? null : _delete, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red.shade700, + ), + child: _deleting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Supprimer'), + ), + const Spacer(), + TextButton( + onPressed: _busy ? null : () => Navigator.of(context).pop(), + child: const Text('Fermer'), + ), + const SizedBox(width: 12), + ElevatedButton( + style: ValidationModalTheme.primaryElevatedStyle, + onPressed: !_dirty || _busy ? null : _save, + child: _saving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text(_dirty ? 'Sauvegarder' : 'Aucune modification'), + ), + ], + ); + } + String _genderLabel(String gender) { switch (gender) { case 'H': @@ -403,9 +783,79 @@ class _AdminChildDetailModalState extends State { case 'F': return 'Fille'; case 'Autre': - return 'Autre'; + return 'Inconnu'; default: return gender; } } + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: _modalWidth), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(18, 16, 4, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _headerTitle(), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + if (_parentsSubtitle() != null) ...[ + const SizedBox(height: 4), + Text( + _parentsSubtitle()!, + style: const TextStyle( + fontSize: 13, + color: Colors.black54, + ), + ), + ], + ], + ), + ), + IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 40, + minHeight: 40, + ), + icon: const Icon(Icons.close), + onPressed: _busy ? null : () => Navigator.of(context).pop(), + tooltip: 'Fermer', + ), + ], + ), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox(height: _mainRowHeight, child: _mainRow()), + const SizedBox(height: 12), + _buildFooter(), + ], + ), + ), + ], + ), + ), + ); + } } diff --git a/frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart b/frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart index e984e66..526d7f8 100644 --- a/frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart +++ b/frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart @@ -64,6 +64,7 @@ class AdminChildrenAffiliationPanel extends StatelessWidget { final c = children[i]; return AdminEnfantUserCard.fromSummary( c, + onCardTap: () => onOpen(c), actions: [ IconButton( icon: const Icon(Icons.visibility_outlined), diff --git a/frontend/lib/widgets/admin/common/admin_user_card.dart b/frontend/lib/widgets/admin/common/admin_user_card.dart index 354ae68..2d02650 100644 --- a/frontend/lib/widgets/admin/common/admin_user_card.dart +++ b/frontend/lib/widgets/admin/common/admin_user_card.dart @@ -14,6 +14,7 @@ class AdminUserCard extends StatefulWidget { final Color? infoColor; final String? vigilanceTooltip; final VoidCallback? onCardTap; + final EdgeInsetsGeometry? margin; const AdminUserCard({ super.key, @@ -28,6 +29,7 @@ class AdminUserCard extends StatefulWidget { this.infoColor, this.vigilanceTooltip, this.onCardTap, + this.margin, }); @override @@ -59,7 +61,7 @@ class _AdminUserCardState extends State { borderRadius: BorderRadius.circular(10), hoverColor: const Color(0x149CC5C0), child: Card( - margin: const EdgeInsets.only(bottom: 12), + margin: widget.margin ?? const EdgeInsets.only(bottom: 12), elevation: 0, color: widget.backgroundColor, shape: RoundedRectangleBorder( From cf6acbae7c5dae3ef6bd894b84205b05a4b23c4c Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 12:24:20 +0200 Subject: [PATCH 32/85] =?UTF-8?q?fix(#138):=20cadre=20vide=20en=20sans=20g?= =?UTF-8?q?arde=20et=20passage=20auto=20en=20garde=20=C3=A0=20l'attach=20A?= =?UTF-8?q?M.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit En statut sans_garde, la zone placement affiche toujours le cadre vide ; sélectionner une AM passe le statut à en garde, et passer à sans_garde détache l'AM rattachée. Co-authored-by: Cursor --- .../common/admin_child_detail_modal.dart | 66 +++++++++++++++++-- 1 file changed, 59 insertions(+), 7 deletions(-) diff --git a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart index df2c7bc..df063f2 100644 --- a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -57,6 +57,7 @@ class _AdminChildDetailModalState extends State { bool get _isUnborn => _status == 'a_naitre'; bool get _isScolarise => _status == 'scolarise'; + bool get _isSansGarde => _status == 'sans_garde'; bool get _showsAmPlacement => !_isScolarise; List get _availableGenders => @@ -294,7 +295,7 @@ class _AdminChildDetailModalState extends State { return; } - if (_linkedAm != null) { + if (_linkedAm != null && !_isSansGarde) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -332,11 +333,25 @@ class _AdminChildDetailModalState extends State { setState(() => _placementBusy = true); try { + // Sans garde : éventuel lien résiduel à clôturer avant rattachement. + final previousAm = _linkedAm; + if (previousAm != null) { + await UserService.detachEnfantFromAm( + amUserId: previousAm.user.id, + enfantId: widget.enfant.id, + ); + } await UserService.attachEnfantToAm( amUserId: selected.user.id, enfantId: widget.enfant.id, ); if (!mounted) return; + setState(() { + // Le back passe déjà sans_garde → garde à l'attach ; on aligne l'UI. + if (_status == 'sans_garde') { + _status = 'garde'; + } + }); await _loadLinkedAm(); widget.onSaved?.call(); if (!mounted) return; @@ -386,7 +401,13 @@ class _AdminChildDetailModalState extends State { enfantId: widget.enfant.id, ); if (!mounted) return; - await _loadLinkedAm(); + setState(() { + // Le back repasse en sans_garde sauf a_naitre / scolarise. + if (_status != 'a_naitre' && _status != 'scolarise') { + _status = 'sans_garde'; + } + _linkedAm = null; + }); widget.onSaved?.call(); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( @@ -402,6 +423,30 @@ class _AdminChildDetailModalState extends State { } } + /// Passe en « Sans garde » : cadre vide + détachement AM si besoin. + Future _applySansGardeStatus() async { + final am = _linkedAm; + if (am == null) return; + + setState(() => _placementBusy = true); + try { + await UserService.detachEnfantFromAm( + amUserId: am.user.id, + enfantId: widget.enfant.id, + ); + if (!mounted) return; + setState(() => _linkedAm = null); + widget.onSaved?.call(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } finally { + if (mounted) setState(() => _placementBusy = false); + } + } + InputDecoration _inputDecoration({String? hint}) { return ValidationFieldDecoration.input(hint: hint).copyWith( isDense: false, @@ -479,11 +524,16 @@ class _AdminChildDetailModalState extends State { ), ) .toList(), - onChanged: (v) => setState(() { - _status = v; - _coerceGenderForStatus(); - _dirty = true; - }), + onChanged: (v) { + setState(() { + _status = v; + _coerceGenderForStatus(); + _dirty = true; + }); + if (v == 'sans_garde') { + _applySansGardeStatus(); + } + }, ), ), ValidationLabeledField( @@ -731,6 +781,8 @@ class _AdminChildDetailModalState extends State { ); } + // Sans garde : toujours le cadre vide (comme après détachement AM). + if (_isSansGarde) return _emptyAmSlot(); if (_linkedAm != null) return _linkedAmCard(); return _emptyAmSlot(); } From 59919834b98bc311eae131f97f95e03b7849f609 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 12:34:40 +0200 Subject: [PATCH 33/85] =?UTF-8?q?fix(#138):=20carte=20AM=20d=C3=A9di=C3=A9?= =?UTF-8?q?e,=20titre=20placement=20et=20consent=5Fphoto=20au=20payload.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Améliore la zone AM/scolarisation (carte dédiée + titre), envoie consent_photo à l'inscription parent, et parse le consentement enfant de façon plus robuste (heuristique photo tant que le back ne persiste pas). Co-authored-by: Cursor --- frontend/lib/models/enfant_admin_model.dart | 38 ++- .../utils/parent_registration_payload.dart | 3 + .../common/admin_child_detail_modal.dart | 224 ++++++++++++++---- 3 files changed, 207 insertions(+), 58 deletions(-) diff --git a/frontend/lib/models/enfant_admin_model.dart b/frontend/lib/models/enfant_admin_model.dart index 5c7c84d..696efa4 100644 --- a/frontend/lib/models/enfant_admin_model.dart +++ b/frontend/lib/models/enfant_admin_model.dart @@ -48,17 +48,28 @@ class EnfantAdminModel { } } + final photoUrl = json['photo_url'] as String? ?? json['photoUrl'] as String?; + final hasPhoto = (photoUrl ?? '').trim().isNotEmpty; + // L'inscription parent exigeait le consentement pour envoyer la photo, mais + // le back a longtemps forcé consent_photo=false (voir reprise_mapper). + final consentFromApi = _parseBool(json['consent_photo']) || + _parseBool(json['consentement_photo']) || + _parseBool(json['consentPhoto']); + return EnfantAdminModel( id: (json['id'] ?? '').toString(), - firstName: json['first_name'] as String?, - lastName: json['last_name'] as String?, - gender: json['gender'] as String?, - birthDate: _dateString(json['birth_date']), - dueDate: _dateString(json['due_date']), - status: normalizeEnfantStatus(json['status']?.toString()), - photoUrl: json['photo_url'] as String?, - consentPhoto: json['consent_photo'] == true, - isMultiple: json['is_multiple'] == true, + firstName: json['first_name'] as String? ?? json['prenom'] as String?, + lastName: json['last_name'] as String? ?? json['nom'] as String?, + gender: json['gender'] as String? ?? json['genre'] as String?, + birthDate: _dateString(json['birth_date'] ?? json['date_naissance']), + dueDate: _dateString(json['due_date'] ?? json['date_prevue_naissance']), + status: normalizeEnfantStatus( + (json['status'] ?? json['statut'])?.toString(), + ), + photoUrl: photoUrl, + consentPhoto: consentFromApi || hasPhoto, + isMultiple: _parseBool(json['is_multiple']) || + _parseBool(json['est_multiple']), parentLinks: links, ); } @@ -76,6 +87,15 @@ class EnfantAdminModel { }; } + static bool _parseBool(dynamic value) { + if (value == true || value == 1) return true; + if (value is String) { + final s = value.trim().toLowerCase(); + return s == 'true' || s == '1' || s == 't' || s == 'yes'; + } + return false; + } + static String? _dateString(dynamic v) { if (v == null) return null; if (v is String) return v.split('T').first; diff --git a/frontend/lib/utils/parent_registration_payload.dart b/frontend/lib/utils/parent_registration_payload.dart index 467cd35..26d515e 100644 --- a/frontend/lib/utils/parent_registration_payload.dart +++ b/frontend/lib/utils/parent_registration_payload.dart @@ -199,6 +199,9 @@ class ParentRegistrationPayload { map['photo_base64'] = photo.$1; map['photo_filename'] = photo.$2; } + // Consentement photo enfant (le back l'ignore encore à l'inscription — + // à brancher côté API ; en attendant le front admin infère via photo). + map['consent_photo'] = c.photoConsent; return map; } diff --git a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart index df063f2..70b36a3 100644 --- a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -1,15 +1,16 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; import 'package:p_tits_pas/models/enfant_admin_model.dart'; +import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/utils/date_display_utils.dart'; import 'package:p_tits_pas/utils/enfant_status_utils.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; +import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; /// Fiche enfant consultation / édition (ticket #138) — format paysage comme fiche AM. class AdminChildDetailModal extends StatefulWidget { @@ -610,9 +611,15 @@ class _AdminChildDetailModalState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _fieldsGrid(), - const Spacer(), - const SizedBox(height: 16), - _placementSection(), + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 16), + child: Align( + alignment: Alignment.bottomCenter, + child: _placementBlock(), + ), + ), + ), ], ), ), @@ -625,6 +632,7 @@ class _AdminChildDetailModalState extends State { Widget _scolariseCard() { return Container( height: _placementHeight, + width: double.infinity, decoration: BoxDecoration( gradient: LinearGradient( colors: [ @@ -692,36 +700,54 @@ class _AdminChildDetailModalState extends State { borderRadius: BorderRadius.circular(10), child: Container( height: _placementHeight, + width: double.infinity, decoration: BoxDecoration( color: Colors.grey.shade50, borderRadius: BorderRadius.circular(10), border: Border.all(color: Colors.grey.shade300), ), - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), child: Row( - mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.face_outlined, size: 28, color: Colors.grey.shade400), - const SizedBox(width: 12), - Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Aucune assistante maternelle', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Colors.grey.shade700, + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade200), + ), + child: Icon( + Icons.face_outlined, + size: 30, + color: Colors.grey.shade400, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Aucune assistante maternelle', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: Colors.grey.shade800, + ), ), - ), - Text( - 'Cliquer pour choisir une assistante', - style: TextStyle(fontSize: 12, color: Colors.grey.shade600), - ), - ], + const SizedBox(height: 4), + Text( + 'Cliquer pour choisir une assistante', + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade600, + ), + ), + ], + ), ), - const SizedBox(width: 8), Icon(Icons.add_circle_outline, color: Colors.grey.shade500), ], ), @@ -732,42 +758,142 @@ class _AdminChildDetailModalState extends State { Widget _linkedAmCard() { final am = _linkedAm!; - return SizedBox( - height: _placementHeight, - child: AdminUserCard( - margin: EdgeInsets.zero, - title: am.user.fullName, - avatarUrl: am.user.photoUrl, - fallbackIcon: Icons.face, - onCardTap: _busy ? null : _openLinkedAm, - subtitleLines: [ - if (am.residenceCity != null && am.residenceCity!.trim().isNotEmpty) - 'Zone : ${am.residenceCity!.trim()}', - if (am.approvalNumber != null && am.approvalNumber!.trim().isNotEmpty) - 'Agrément : ${am.approvalNumber!.trim()}', - ], - actions: [ - IconButton( - icon: const Icon(Icons.open_in_new), - tooltip: 'Ouvrir la fiche AM', - onPressed: _busy ? null : _openLinkedAm, + final zone = (am.residenceCity ?? '').trim(); + final agrement = (am.approvalNumber ?? '').trim(); + final subtitle = [ + if (zone.isNotEmpty) 'Zone : $zone', + if (agrement.isNotEmpty) 'Agrément : $agrement', + ].join(' · '); + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: _busy ? null : _openLinkedAm, + borderRadius: BorderRadius.circular(10), + child: Container( + height: _placementHeight, + width: double.infinity, + decoration: BoxDecoration( + color: const Color(0xFFF7F3FC), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFD4C4EF)), ), - IconButton( - icon: Icon(Icons.link_off, color: Colors.orange.shade800), - tooltip: 'Détacher', - onPressed: _busy ? null : _detachAm, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + _amAvatar(am.user.photoUrl), + const SizedBox(width: 16), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + am.user.fullName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: Color(0xFF4A2F7A), + ), + ), + if (subtitle.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade700, + ), + ), + ], + ], + ), + ), + IconButton( + icon: const Icon(Icons.open_in_new, size: 20), + tooltip: 'Ouvrir la fiche AM', + onPressed: _busy ? null : _openLinkedAm, + ), + IconButton( + icon: Icon(Icons.link_off, size: 20, color: Colors.orange.shade800), + tooltip: 'Détacher', + onPressed: _busy ? null : _detachAm, + ), + ], ), - ], + ), ), ); } + Widget _amAvatar(String? photoUrl) { + const size = 56.0; + const bg = Color(0xFFEDE5FA); + const iconColor = Color(0xFF6B3FA0); + final url = ApiConfig.absoluteMediaUrl(photoUrl); + + if (url.isEmpty) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.face, size: 30, color: iconColor), + ); + } + + return ClipRRect( + borderRadius: BorderRadius.circular(12), + child: AuthNetworkImage( + url: url, + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container( + width: size, + height: size, + color: bg, + child: const Icon(Icons.face, size: 30, color: iconColor), + ), + ), + ); + } + + String get _placementTitle => + _isScolarise ? 'Scolarisation' : 'Assistante maternelle'; + + Widget _placementBlock() { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + _placementTitle, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.grey.shade800, + ), + ), + const SizedBox(height: 8), + _placementSection(), + ], + ); + } + Widget _placementSection() { if (!_showsAmPlacement) return _scolariseCard(); if (_loadingAm || _placementBusy) { return SizedBox( height: _placementHeight, + width: double.infinity, child: Center( child: SizedBox( width: 24, From 291ea26b34543ae1f3d86f80b8ae16839ae65f17 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 12:46:54 +0200 Subject: [PATCH 34/85] fix(#138): lire le consentement photo sans heuristique photo. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin et reprise utilisentent uniquement la valeur API ; plus de pré-cochage implicite si une photo est présente. Co-authored-by: Cursor --- frontend/lib/models/enfant_admin_model.dart | 7 ++----- frontend/lib/utils/parent_registration_payload.dart | 3 --- frontend/lib/utils/reprise_mapper.dart | 7 ++----- 3 files changed, 4 insertions(+), 13 deletions(-) diff --git a/frontend/lib/models/enfant_admin_model.dart b/frontend/lib/models/enfant_admin_model.dart index 696efa4..8eaf815 100644 --- a/frontend/lib/models/enfant_admin_model.dart +++ b/frontend/lib/models/enfant_admin_model.dart @@ -49,10 +49,7 @@ class EnfantAdminModel { } final photoUrl = json['photo_url'] as String? ?? json['photoUrl'] as String?; - final hasPhoto = (photoUrl ?? '').trim().isNotEmpty; - // L'inscription parent exigeait le consentement pour envoyer la photo, mais - // le back a longtemps forcé consent_photo=false (voir reprise_mapper). - final consentFromApi = _parseBool(json['consent_photo']) || + final consentPhoto = _parseBool(json['consent_photo']) || _parseBool(json['consentement_photo']) || _parseBool(json['consentPhoto']); @@ -67,7 +64,7 @@ class EnfantAdminModel { (json['status'] ?? json['statut'])?.toString(), ), photoUrl: photoUrl, - consentPhoto: consentFromApi || hasPhoto, + consentPhoto: consentPhoto, isMultiple: _parseBool(json['is_multiple']) || _parseBool(json['est_multiple']), parentLinks: links, diff --git a/frontend/lib/utils/parent_registration_payload.dart b/frontend/lib/utils/parent_registration_payload.dart index 26d515e..467cd35 100644 --- a/frontend/lib/utils/parent_registration_payload.dart +++ b/frontend/lib/utils/parent_registration_payload.dart @@ -199,9 +199,6 @@ class ParentRegistrationPayload { map['photo_base64'] = photo.$1; map['photo_filename'] = photo.$2; } - // Consentement photo enfant (le back l'ignore encore à l'inscription — - // à brancher côté API ; en attendant le front admin infère via photo). - map['consent_photo'] = c.photoConsent; return map; } diff --git a/frontend/lib/utils/reprise_mapper.dart b/frontend/lib/utils/reprise_mapper.dart index f965e1a..f33b53d 100644 --- a/frontend/lib/utils/reprise_mapper.dart +++ b/frontend/lib/utils/reprise_mapper.dart @@ -38,15 +38,12 @@ class RepriseMapper { ? isoToDdMmYyyy(e.dueDate) : isoToDdMmYyyy(e.birthDate); final photo = e.photoUrl?.trim(); - final hasPhoto = photo != null && photo.isNotEmpty; return ChildData( firstName: e.firstName ?? '', lastName: e.lastName ?? '', dob: dob, genre: e.gender ?? '', - // Legacy : dossiers inscrits avant #144 avaient consent_photo=false malgré une photo. - // On pré-coche encore si photo en base pour ne pas bloquer la reprise. - photoConsent: e.consentPhoto || hasPhoto, + photoConsent: e.consentPhoto, multipleBirth: e.estMultiple, isUnbornChild: isUnborn, cardColor: _childCardColors[index % _childCardColors.length], @@ -200,7 +197,7 @@ class RepriseMapper { postalCode: dossier.codePostal ?? '', city: dossier.ville ?? '', existingPhotoUrl: displayPhoto, - consentementPhoto: dossier.consentementPhoto || hasPhoto, + consentementPhoto: dossier.consentementPhoto, dateOfBirth: parseIsoDate(dossier.dateNaissance), birthCity: dossier.lieuNaissanceVille ?? '', birthCountry: dossier.lieuNaissancePays ?? '', From b903dbf60b79d65bbe17603d79221e714124e297 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 12:51:07 +0200 Subject: [PATCH 35/85] =?UTF-8?q?fix(#138):=20libell=C3=A9=20garde=20accor?= =?UTF-8?q?d=C3=A9=20au=20genre=20(Gard=C3=A9=20/=20Gard=C3=A9e).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remplace « En garde » par Gardé/Gardée selon le genre, comme Scolarisé/Scolarisée. Co-authored-by: Cursor --- frontend/lib/utils/enfant_status_utils.dart | 10 +++++----- .../widgets/admin/common/admin_child_detail_modal.dart | 2 +- frontend/lib/widgets/admin/user_management_panel.dart | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/lib/utils/enfant_status_utils.dart b/frontend/lib/utils/enfant_status_utils.dart index 9ea84b5..deb5f03 100644 --- a/frontend/lib/utils/enfant_status_utils.dart +++ b/frontend/lib/utils/enfant_status_utils.dart @@ -14,10 +14,10 @@ String normalizeEnfantStatus(String? raw) { return s; } -String _scolariseAccordeAuGenre(String? gender) { +String _accordeAuGenre(String masculin, String feminin, String? gender) { final g = (gender ?? '').trim().toUpperCase(); - if (g == 'F') return 'Scolarisée'; - return 'Scolarisé'; + if (g == 'F') return feminin; + return masculin; } /// Libellé affiché pour un statut enfant. @@ -28,9 +28,9 @@ String enfantStatusLabel(String? status, {String? gender}) { case 'sans_garde': return 'Sans garde'; case 'garde': - return 'En garde'; + return _accordeAuGenre('Gardé', 'Gardée', gender); case 'scolarise': - return _scolariseAccordeAuGenre(gender); + return _accordeAuGenre('Scolarisé', 'Scolarisée', gender); default: return status?.trim().isNotEmpty == true ? status!.trim() : '–'; } diff --git a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart index 70b36a3..baf4c0e 100644 --- a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -668,7 +668,7 @@ class _AdminChildDetailModalState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - 'Scolarisé', + enfantStatusLabel('scolarise', gender: _gender), style: TextStyle( fontSize: 16, fontWeight: FontWeight.w700, diff --git a/frontend/lib/widgets/admin/user_management_panel.dart b/frontend/lib/widgets/admin/user_management_panel.dart index 9de03bc..d272864 100644 --- a/frontend/lib/widgets/admin/user_management_panel.dart +++ b/frontend/lib/widgets/admin/user_management_panel.dart @@ -215,7 +215,7 @@ class _UserManagementPanelState extends State { value: 'garde', child: Padding( padding: EdgeInsets.only(left: 10), - child: Text('En garde', style: TextStyle(fontSize: 12)), + child: Text('Gardé', style: TextStyle(fontSize: 12)), ), ), DropdownMenuItem( From 90b185740cea01994aca52f0ec016a7eb87957e9 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 12:54:32 +0200 Subject: [PATCH 36/85] feat(#138): ouvrir la fiche parent depuis les responsables de la modale enfant. Les noms du sous-titre deviennent des liens cliquables vers AdminParentEditModal, comme l'ouverture de fiche AM. Co-authored-by: Cursor --- .../common/admin_child_detail_modal.dart | 90 +++++++++++++++---- 1 file changed, 73 insertions(+), 17 deletions(-) diff --git a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart index baf4c0e..a08948e 100644 --- a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -8,6 +8,7 @@ import 'package:p_tits_pas/utils/date_display_utils.dart'; import 'package:p_tits_pas/utils/enfant_status_utils.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; @@ -170,16 +171,77 @@ class _AdminChildDetailModalState extends State { return '$fn $ln'.trim(); } - String? _parentsSubtitle() { - final names = widget.enfant.parentLinks - .map((l) { - final n = (l.parentName ?? '').trim(); - return n.isNotEmpty ? n : 'Parent rattaché'; - }) - .where((s) => s.isNotEmpty) - .toList(); - if (names.isEmpty) return null; - return 'Responsables : ${names.join(', ')}'; + List get _parentLinks => widget.enfant.parentLinks + .where((l) => l.parentId.trim().isNotEmpty) + .toList(); + + Future _openParent(EnfantParentLink link) async { + if (_busy) return; + final id = link.parentId.trim(); + if (id.isEmpty) return; + + try { + final parent = await UserService.getParent(id); + if (!mounted) return; + await showDialog( + context: context, + builder: (ctx) => AdminParentEditModal( + parent: parent, + onSaved: () => widget.onSaved?.call(), + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } + } + + Widget? _parentsSubtitle() { + final links = _parentLinks; + if (links.isEmpty) return null; + + return Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + const Text( + 'Responsables : ', + style: TextStyle(fontSize: 13, color: Colors.black54), + ), + for (var i = 0; i < links.length; i++) ...[ + if (i > 0) + const Text( + ', ', + style: TextStyle(fontSize: 13, color: Colors.black54), + ), + _parentNameLink(links[i]), + ], + ], + ); + } + + Widget _parentNameLink(EnfantParentLink link) { + final name = (link.parentName ?? '').trim(); + final label = name.isNotEmpty ? name : 'Parent rattaché'; + return InkWell( + onTap: _busy ? null : () => _openParent(link), + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 2), + child: Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: ValidationModalTheme.primaryActionBackground, + decoration: TextDecoration.underline, + decorationColor: ValidationModalTheme.primaryActionBackground + .withValues(alpha: 0.5), + ), + ), + ), + ); } Future _save() async { @@ -994,13 +1056,7 @@ class _AdminChildDetailModalState extends State { ), if (_parentsSubtitle() != null) ...[ const SizedBox(height: 4), - Text( - _parentsSubtitle()!, - style: const TextStyle( - fontSize: 13, - color: Colors.black54, - ), - ), + _parentsSubtitle()!, ], ], ), From 267fe63aec2c9432d7a46fdc386b58ced143e57e Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 13:12:46 +0200 Subject: [PATCH 37/85] =?UTF-8?q?fix(#138):=20diff=C3=A9rer=20le=20rattach?= =?UTF-8?q?ement=20AM=20de=20la=20fiche=20enfant=20au=20Sauvegarder.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attach/détach AM restent locaux comme sur la fiche AM ; la sync API n'a lieu qu'à l'enregistrement, avec rechargement du statut au retour de la fiche AM. Co-authored-by: Cursor --- .../common/admin_child_detail_modal.dart | 175 ++++++++---------- 1 file changed, 82 insertions(+), 93 deletions(-) diff --git a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart index a08948e..b1dac62 100644 --- a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -42,10 +42,11 @@ class _AdminChildDetailModalState extends State { bool _dirty = false; bool _saving = false; bool _deleting = false; - bool _placementBusy = false; bool _loadingAm = true; String? _currentUserRole; AssistanteMaternelleModel? _linkedAm; + /// AM rattachée au chargement (pour sync différé au Sauvegarder). + String? _baselineAmUserId; static const double _modalWidth = 930; static const double _mainRowHeight = 380; @@ -74,7 +75,7 @@ class _AdminChildDetailModalState extends State { bool get _canDelete => (_currentUserRole ?? '').toLowerCase() == 'super_admin'; - bool get _busy => _saving || _deleting || _placementBusy; + bool get _busy => _saving || _deleting; @override void initState() { @@ -113,11 +114,15 @@ class _AdminChildDetailModalState extends State { if (!mounted) return; setState(() { _linkedAm = am; + _baselineAmUserId = am?.user.id; _loadingAm = false; }); } catch (_) { if (!mounted) return; - setState(() => _loadingAm = false); + setState(() { + _baselineAmUserId = null; + _loadingAm = false; + }); } } @@ -248,6 +253,8 @@ class _AdminChildDetailModalState extends State { if (!_dirty) return; setState(() => _saving = true); try { + await _syncAmPlacement(); + await UserService.updateEnfant( enfantId: widget.enfant.id, body: { @@ -268,6 +275,7 @@ class _AdminChildDetailModalState extends State { setState(() { _dirty = false; _saving = false; + _baselineAmUserId = _linkedAm?.user.id; }); widget.onSaved?.call(); ScaffoldMessenger.of(context).showSnackBar( @@ -282,6 +290,25 @@ class _AdminChildDetailModalState extends State { } } + /// Applique rattachement / détachement AM (différé jusqu'au Sauvegarder). + Future _syncAmPlacement() async { + final baselineId = _baselineAmUserId; + final currentId = _linkedAm?.user.id; + + if (baselineId != null && baselineId != currentId) { + await UserService.detachEnfantFromAm( + amUserId: baselineId, + enfantId: widget.enfant.id, + ); + } + if (currentId != null && currentId != baselineId) { + await UserService.attachEnfantToAm( + amUserId: currentId, + enfantId: widget.enfant.id, + ); + } + } + Future _delete() async { if (!_canDelete || _deleting) return; @@ -338,14 +365,39 @@ class _AdminChildDetailModalState extends State { context: context, builder: (ctx) => AdminAmEditModal( assistante: am, - onSaved: () { - _loadLinkedAm(); + onSaved: () async { + await _reloadPlacementFromServer(); widget.onSaved?.call(); }, ), ); } + /// Recharge AM + statut après une modale externe (ex. fiche AM). + Future _reloadPlacementFromServer() async { + try { + final results = await Future.wait([ + UserService.findAmForEnfant(widget.enfant.id), + UserService.getEnfant(widget.enfant.id), + ]); + if (!mounted) return; + final am = results[0] as AssistanteMaternelleModel?; + final enfant = results[1] as EnfantAdminModel; + setState(() { + _linkedAm = am; + _baselineAmUserId = am?.user.id; + _status = normalizeEnfantStatus(enfant.status); + if (!enfantStatusValues.contains(_status)) { + _status = 'sans_garde'; + } + _coerceGenderForStatus(); + }); + } catch (_) { + if (!mounted) return; + await _loadLinkedAm(); + } + } + Future _attachAm() async { List all; try { @@ -368,8 +420,7 @@ class _AdminChildDetailModalState extends State { return; } - final candidates = all; - if (candidates.isEmpty) { + if (all.isEmpty) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Aucune assistante maternelle disponible')), @@ -382,7 +433,7 @@ class _AdminChildDetailModalState extends State { context: context, builder: (ctx) => SimpleDialog( title: const Text('Choisir une assistante maternelle'), - children: candidates + children: all .map( (am) => SimpleDialogOption( onPressed: () => Navigator.pop(ctx, am), @@ -394,41 +445,13 @@ class _AdminChildDetailModalState extends State { ); if (selected == null || !mounted) return; - setState(() => _placementBusy = true); - try { - // Sans garde : éventuel lien résiduel à clôturer avant rattachement. - final previousAm = _linkedAm; - if (previousAm != null) { - await UserService.detachEnfantFromAm( - amUserId: previousAm.user.id, - enfantId: widget.enfant.id, - ); + setState(() { + _linkedAm = selected; + if (_status == 'sans_garde') { + _status = 'garde'; } - await UserService.attachEnfantToAm( - amUserId: selected.user.id, - enfantId: widget.enfant.id, - ); - if (!mounted) return; - setState(() { - // Le back passe déjà sans_garde → garde à l'attach ; on aligne l'UI. - if (_status == 'sans_garde') { - _status = 'garde'; - } - }); - await _loadLinkedAm(); - widget.onSaved?.call(); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Assistante maternelle rattachée')), - ); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), - ); - } finally { - if (mounted) setState(() => _placementBusy = false); - } + _dirty = true; + }); } Future _detachAm() async { @@ -440,7 +463,8 @@ class _AdminChildDetailModalState extends State { builder: (ctx) => AlertDialog( title: const Text('Détacher l\'assistante'), content: Text( - 'Retirer ${am.user.fullName} de la garde de cet enfant ?', + 'Retirer ${am.user.fullName} de la garde de cet enfant ?\n' + '(Effectif après Sauvegarder.)', ), actions: [ TextButton( @@ -457,57 +481,22 @@ class _AdminChildDetailModalState extends State { ); if (confirmed != true || !mounted) return; - setState(() => _placementBusy = true); - try { - await UserService.detachEnfantFromAm( - amUserId: am.user.id, - enfantId: widget.enfant.id, - ); - if (!mounted) return; - setState(() { - // Le back repasse en sans_garde sauf a_naitre / scolarise. - if (_status != 'a_naitre' && _status != 'scolarise') { - _status = 'sans_garde'; - } - _linkedAm = null; - }); - widget.onSaved?.call(); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Assistante maternelle détachée')), - ); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), - ); - } finally { - if (mounted) setState(() => _placementBusy = false); - } + setState(() { + _linkedAm = null; + if (_status != 'a_naitre' && _status != 'scolarise') { + _status = 'sans_garde'; + } + _dirty = true; + }); } - /// Passe en « Sans garde » : cadre vide + détachement AM si besoin. - Future _applySansGardeStatus() async { - final am = _linkedAm; - if (am == null) return; - - setState(() => _placementBusy = true); - try { - await UserService.detachEnfantFromAm( - amUserId: am.user.id, - enfantId: widget.enfant.id, - ); - if (!mounted) return; - setState(() => _linkedAm = null); - widget.onSaved?.call(); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), - ); - } finally { - if (mounted) setState(() => _placementBusy = false); - } + /// Passe en « Sans garde » : cadre vide local (détachement au Sauvegarder). + void _applySansGardeStatus() { + if (_linkedAm == null) return; + setState(() { + _linkedAm = null; + _dirty = true; + }); } InputDecoration _inputDecoration({String? hint}) { @@ -952,7 +941,7 @@ class _AdminChildDetailModalState extends State { Widget _placementSection() { if (!_showsAmPlacement) return _scolariseCard(); - if (_loadingAm || _placementBusy) { + if (_loadingAm) { return SizedBox( height: _placementHeight, width: double.infinity, From faa50f637c9bf503e46dec898dd906b733fcf90f Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 13:19:09 +0200 Subject: [PATCH 38/85] fix(#138): enrichir les noms des responsables sur GET enfant. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /enfants/:id ne joint pas parent.user ; on complète les noms via GET parent pour l'affichage des liens (ex. ouverture depuis la fiche AM). Co-authored-by: Cursor --- frontend/lib/models/enfant_admin_model.dart | 41 +++++++++++++++++++++ frontend/lib/services/user_service.dart | 40 +++++++++++++++++++- 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/frontend/lib/models/enfant_admin_model.dart b/frontend/lib/models/enfant_admin_model.dart index 8eaf815..e07e5b0 100644 --- a/frontend/lib/models/enfant_admin_model.dart +++ b/frontend/lib/models/enfant_admin_model.dart @@ -37,6 +37,34 @@ class EnfantAdminModel { return '$fn $ln'; } + EnfantAdminModel copyWith({ + String? id, + String? firstName, + String? lastName, + String? gender, + String? birthDate, + String? dueDate, + String? status, + String? photoUrl, + bool? consentPhoto, + bool? isMultiple, + List? parentLinks, + }) { + return EnfantAdminModel( + id: id ?? this.id, + firstName: firstName ?? this.firstName, + lastName: lastName ?? this.lastName, + gender: gender ?? this.gender, + birthDate: birthDate ?? this.birthDate, + dueDate: dueDate ?? this.dueDate, + status: status ?? this.status, + photoUrl: photoUrl ?? this.photoUrl, + consentPhoto: consentPhoto ?? this.consentPhoto, + isMultiple: isMultiple ?? this.isMultiple, + parentLinks: parentLinks ?? this.parentLinks, + ); + } + factory EnfantAdminModel.fromJson(Map json) { final linksRaw = json['parentLinks'] as List?; final links = []; @@ -106,6 +134,13 @@ class EnfantParentLink { EnfantParentLink({required this.parentId, this.parentName}); + EnfantParentLink copyWith({String? parentId, String? parentName}) { + return EnfantParentLink( + parentId: parentId ?? this.parentId, + parentName: parentName ?? this.parentName, + ); + } + factory EnfantParentLink.fromJson(Map json) { final parentId = (json['parentId'] ?? json['id_parent'] ?? '').toString(); @@ -116,6 +151,12 @@ class EnfantParentLink { if (user is Map) { final u = AppUser.fromJson(user); name = u.fullName.isNotEmpty ? u.fullName : u.email; + } else { + // Parfois le parent est aplati (prenom/nom) sans nested user. + final prenom = (parent['prenom'] ?? parent['first_name'] ?? '').toString().trim(); + final nom = (parent['nom'] ?? parent['last_name'] ?? '').toString().trim(); + final flat = '$prenom $nom'.trim(); + if (flat.isNotEmpty) name = flat; } } return EnfantParentLink( diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index b16e89f..f190463 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -460,7 +460,40 @@ class UserService { if (response.statusCode != 200) { throw Exception(_extractErrorMessage(response.body, 'Erreur chargement enfant')); } - return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map); + final enfant = EnfantAdminModel.fromJson( + jsonDecode(response.body) as Map, + ); + // GET /enfants/:id ne joint pas toujours parent.user → noms manquants. + return enrichEnfantParentNames(enfant); + } + + /// Complète [parentName] via GET parent quand l'API n'a pas fourni le nested user. + static Future enrichEnfantParentNames( + EnfantAdminModel enfant, + ) async { + final links = enfant.parentLinks; + if (links.isEmpty) return enfant; + if (links.every((l) => (l.parentName ?? '').trim().isNotEmpty)) { + return enfant; + } + + final enriched = await Future.wait( + links.map((link) async { + if ((link.parentName ?? '').trim().isNotEmpty) return link; + final id = link.parentId.trim(); + if (id.isEmpty) return link; + try { + final parent = await getParent(id); + final name = parent.user.fullName.trim(); + if (name.isEmpty) return link; + return link.copyWith(parentName: name); + } catch (_) { + return link; + } + }), + ); + + return enfant.copyWith(parentLinks: enriched); } static Future updateEnfant({ @@ -475,7 +508,10 @@ class UserService { if (response.statusCode != 200) { throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour enfant')); } - return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map); + final enfant = EnfantAdminModel.fromJson( + jsonDecode(response.body) as Map, + ); + return enrichEnfantParentNames(enfant); } static Future deleteEnfant(String enfantId) async { From 09386f8aa640e3b77a18b93f120c131a25d75222 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 14:56:32 +0200 Subject: [PATCH 39/85] feat(#145): lien co-parent cliquable dans la fiche parent. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Même UX que les responsables de la fiche enfant : ouverture de AdminParentEditModal au clic sur le nom du co-parent. Co-authored-by: Cursor --- .../admin/common/admin_parent_edit_modal.dart | 77 ++++++++++++++++--- 1 file changed, 67 insertions(+), 10 deletions(-) diff --git a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart index b1fe436..85c70a6 100644 --- a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart @@ -113,10 +113,73 @@ class _AdminParentEditModalState extends State { return '$fn $ln'.trim(); } - String? _coParentSubtitle() { + String? _coParentName() { final name = _coParent?.fullName.trim() ?? ''; - if (name.isEmpty) return null; - return 'Co-parent : $name'; + return name.isEmpty ? null : name; + } + + Future _openCoParent() async { + if (_saving) return; + final co = _coParent; + final id = (co?.id ?? '').trim(); + if (co == null || id.isEmpty) return; + + try { + final parent = await UserService.getParent(id); + if (!mounted) return; + await showDialog( + context: context, + builder: (ctx) => AdminParentEditModal( + parent: parent, + onSaved: () async { + try { + final refreshed = await UserService.getParent(widget.parent.user.id); + if (!mounted) return; + setState(() => _coParent = refreshed.coParent); + } catch (_) {} + widget.onSaved?.call(); + }, + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } + } + + Widget? _coParentSubtitle() { + final name = _coParentName(); + if (name == null) return null; + + return Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + const Text( + 'Co-parent : ', + style: TextStyle(fontSize: 13, color: Colors.black54), + ), + InkWell( + onTap: _saving ? null : _openCoParent, + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 2), + child: Text( + name, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: ValidationModalTheme.primaryActionBackground, + decoration: TextDecoration.underline, + decorationColor: ValidationModalTheme.primaryActionBackground + .withValues(alpha: 0.5), + ), + ), + ), + ), + ], + ); } Future _save() async { @@ -395,13 +458,7 @@ class _AdminParentEditModalState extends State { ), if (_coParentSubtitle() != null) ...[ const SizedBox(height: 4), - Text( - _coParentSubtitle()!, - style: const TextStyle( - fontSize: 13, - color: Colors.black54, - ), - ), + _coParentSubtitle()!, ], ], ), From 2d80ad0d7ed9c41a50e85265548bd4d6e577e148 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 15:31:47 +0200 Subject: [PATCH 40/85] =?UTF-8?q?feat(#146):=20modale=20de=20s=C3=A9lectio?= =?UTF-8?q?n=20d'enfant=20pour=20rattachement=20AM/parent.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recherche dynamique, filtre Sans garde côté AM, confirmation de transfert et détachement préalable pour laisser le point de vigilance places sur l'AM d'origine. Co-authored-by: Cursor --- .../admin/common/admin_am_edit_modal.dart | 107 ++++-- .../admin/common/admin_enfant_user_card.dart | 7 + .../admin/common/admin_parent_edit_modal.dart | 40 +-- .../common/admin_select_enfant_modal.dart | 68 ++++ .../admin/common/admin_select_list_modal.dart | 334 ++++++++++++++++++ 5 files changed, 486 insertions(+), 70 deletions(-) create mode 100644 frontend/lib/widgets/admin/common/admin_select_enfant_modal.dart create mode 100644 frontend/lib/widgets/admin/common/admin_select_list_modal.dart diff --git a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart index b3ca7f1..c612436 100644 --- a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; -import 'package:p_tits_pas/models/enfant_admin_model.dart'; import 'package:p_tits_pas/models/parent_child_summary.dart'; import 'package:p_tits_pas/utils/am_vigilance.dart'; import 'package:p_tits_pas/utils/date_display_utils.dart'; @@ -11,6 +10,7 @@ import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_children_capacity_grid.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_select_enfant_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; import 'package:p_tits_pas/widgets/common/identity_block.dart'; @@ -55,6 +55,10 @@ class _AdminAmEditModalState extends State late List _children; late Set _baselineChildIds; + /// Enfants ajoutés localement qui étaient déjà chez une autre AM + /// (enfantId → amUserId d'origine). Au save : détacher puis rattacher. + final Map _transferFromAmIds = {}; + bool _saving = false; bool _dirty = false; @@ -311,6 +315,17 @@ class _AdminAmEditModalState extends State ); } for (final id in currentIds.difference(_baselineChildIds)) { + // Transfert : détacher l'ancienne AM sans recalculer ses places + // (le ! de vigilance apparaît côté liste AM). + final previousAmId = _transferFromAmIds[id] ?? + (await UserService.findAmForEnfant(id))?.user.id; + if (previousAmId != null && + previousAmId != widget.assistante.user.id) { + await UserService.detachEnfantFromAm( + amUserId: previousAmId, + enfantId: id, + ); + } await UserService.attachEnfantToAm( amUserId: widget.assistante.user.id, enfantId: id, @@ -346,6 +361,7 @@ class _AdminAmEditModalState extends State _dirty = false; _saving = false; _baselineChildIds = currentIds; + _transferFromAmIds.clear(); }); widget.onSaved?.call(); ScaffoldMessenger.of(context).showSnackBar( @@ -377,6 +393,7 @@ class _AdminAmEditModalState extends State setState(() { _children = kids; _baselineChildIds = kids.map((c) => c.id).toSet(); + _transferFromAmIds.clear(); }); } catch (_) {} } @@ -442,55 +459,75 @@ class _AdminAmEditModalState extends State setState(() { _children = _children.where((c) => c.id != child.id).toList(); + _transferFromAmIds.remove(child.id); _syncPlacesAfterChildrenChange(); _dirty = true; }); } Future _attachChild() async { - List all; - try { - all = await UserService.getEnfants(); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), - ); - return; - } - - final linkedIds = _children.map((c) => c.id).toSet(); - final candidates = all.where((e) => !linkedIds.contains(e.id)).toList(); - if (candidates.isEmpty) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Aucun enfant disponible à rattacher')), - ); - return; - } - if (!mounted) return; - final selected = await showDialog( - context: context, - builder: (ctx) => SimpleDialog( - title: const Text('Rattacher un enfant'), - children: candidates - .map( - (e) => SimpleDialogOption( - onPressed: () => Navigator.pop(ctx, e), - child: Text(e.fullName), - ), - ) - .toList(), - ), + final selected = await AdminSelectEnfantModal.show( + context, + excludeIds: _children.map((c) => c.id).toSet(), + title: 'Rattacher un enfant', + showSansGardeFilter: true, ); if (selected == null || !mounted) return; + AssistanteMaternelleModel? previousAm; + try { + previousAm = await UserService.findAmForEnfant(selected.id); + } catch (_) { + previousAm = null; + } + if (!mounted) return; + + final previousAmId = previousAm?.user.id; + final isTransfer = previousAmId != null && + previousAmId != widget.assistante.user.id; + + if (isTransfer) { + final amName = previousAm!.user.fullName.trim().isNotEmpty + ? previousAm.user.fullName.trim() + : 'une autre assistante maternelle'; + final gardeLabel = + (selected.gender ?? '').trim().toUpperCase() == 'F' + ? 'déjà gardée' + : 'déjà gardé'; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Changer d\'affectation'), + content: Text( + '${selected.fullName} est $gardeLabel par $amName.\n\n' + 'Confirmer le transfert vers cette assistante ? ' + 'L\'enfant sera détaché de $amName.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + style: ValidationModalTheme.primaryElevatedStyle, + child: const Text('Confirmer'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + } + setState(() { _children = [ ..._children, ParentChildSummary.fromEnfant(selected), ]; + if (isTransfer) { + _transferFromAmIds[selected.id] = previousAmId!; + } _syncPlacesAfterChildrenChange(); _dirty = true; }); diff --git a/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart b/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart index 69dfa65..6284e3d 100644 --- a/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart +++ b/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart @@ -34,6 +34,7 @@ class AdminEnfantUserCard extends StatelessWidget { final List subtitleLines; final List actions; final VoidCallback? onCardTap; + final EdgeInsetsGeometry? margin; const AdminEnfantUserCard({ super.key, @@ -42,6 +43,7 @@ class AdminEnfantUserCard extends StatelessWidget { required this.subtitleLines, this.actions = const [], this.onCardTap, + this.margin, }); factory AdminEnfantUserCard.fromEnfant( @@ -49,6 +51,7 @@ class AdminEnfantUserCard extends StatelessWidget { List extraSubtitleLines = const [], List actions = const [], VoidCallback? onCardTap, + EdgeInsetsGeometry? margin, }) { final parents = enfant.parentLinks .map((l) => l.parentName ?? 'Parent') @@ -68,6 +71,7 @@ class AdminEnfantUserCard extends StatelessWidget { ), actions: actions, onCardTap: onCardTap, + margin: margin, ); } @@ -76,6 +80,7 @@ class AdminEnfantUserCard extends StatelessWidget { List extraSubtitleLines = const [], List actions = const [], VoidCallback? onCardTap, + EdgeInsetsGeometry? margin, }) { return AdminEnfantUserCard( title: child.fullName, @@ -88,6 +93,7 @@ class AdminEnfantUserCard extends StatelessWidget { ), actions: actions, onCardTap: onCardTap, + margin: margin, ); } @@ -100,6 +106,7 @@ class AdminEnfantUserCard extends StatelessWidget { subtitleLines: subtitleLines, actions: actions, onCardTap: onCardTap, + margin: margin, ); } } diff --git a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart index 85c70a6..176f1ce 100644 --- a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:p_tits_pas/models/enfant_admin_model.dart'; import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/models/parent_child_summary.dart'; import 'package:p_tits_pas/models/parent_model.dart'; @@ -7,6 +6,7 @@ import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_children_affiliation_panel.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_select_enfant_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart'; import 'package:p_tits_pas/widgets/common/identity_block.dart'; import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; @@ -321,41 +321,11 @@ class _AdminParentEditModalState extends State { } Future _attachChild() async { - List all; - try { - all = await UserService.getEnfants(); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), - ); - return; - } - - final linkedIds = _children.map((c) => c.id).toSet(); - final candidates = all.where((e) => !linkedIds.contains(e.id)).toList(); - if (candidates.isEmpty) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Aucun enfant disponible à rattacher')), - ); - return; - } - if (!mounted) return; - final selected = await showDialog( - context: context, - builder: (ctx) => SimpleDialog( - title: const Text('Rattacher un enfant'), - children: candidates - .map( - (e) => SimpleDialogOption( - onPressed: () => Navigator.pop(ctx, e), - child: Text(e.fullName), - ), - ) - .toList(), - ), + final selected = await AdminSelectEnfantModal.show( + context, + excludeIds: _children.map((c) => c.id).toSet(), + title: 'Rattacher un enfant', ); if (selected == null || !mounted) return; diff --git a/frontend/lib/widgets/admin/common/admin_select_enfant_modal.dart b/frontend/lib/widgets/admin/common/admin_select_enfant_modal.dart new file mode 100644 index 0000000..1c5bc48 --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_select_enfant_modal.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/enfant_admin_model.dart'; +import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/utils/enfant_status_utils.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart'; + +/// Sélection d'un enfant à rattacher (fiche AM / fiche parent) — ticket #146. +/// S'appuie sur [AdminSelectListModal] (shell partagé avec #147). +class AdminSelectEnfantModal { + AdminSelectEnfantModal._(); + + static Future show( + BuildContext context, { + Set excludeIds = const {}, + String title = 'Rattacher un enfant', + /// Affiché uniquement depuis la fiche AM : filtre les enfants déjà en garde. + bool showSansGardeFilter = false, + }) { + return AdminSelectListModal.show( + context, + title: title, + searchHint: 'Rechercher par nom ou prénom…', + emptyMessage: 'Aucun enfant disponible à rattacher', + noResultsMessage: showSansGardeFilter + ? 'Aucun enfant sans garde pour cette recherche' + : 'Aucun résultat pour cette recherche', + toggleFilter: showSansGardeFilter + ? AdminSelectToggleFilter( + label: 'Sans garde', + initialValue: true, + whenEnabled: (e) => + normalizeEnfantStatus(e.status) == 'sans_garde', + ) + : null, + loadItems: () async { + final list = await UserService.getEnfants(); + return list + .where((e) => !excludeIds.contains(e.id)) + .toList() + ..sort( + (a, b) => + a.fullName.toLowerCase().compareTo(b.fullName.toLowerCase()), + ); + }, + matchesQuery: (e, q) { + final name = e.fullName.toLowerCase(); + final fn = (e.firstName ?? '').toLowerCase(); + final ln = (e.lastName ?? '').toLowerCase(); + return name.contains(q) || fn.contains(q) || ln.contains(q); + }, + itemBuilder: (context, e, onSelect) { + return AdminEnfantUserCard.fromEnfant( + e, + onCardTap: onSelect, + margin: const EdgeInsets.only(bottom: 6), + actions: [ + IconButton( + icon: const Icon(Icons.add_link), + tooltip: 'Rattacher', + onPressed: onSelect, + ), + ], + ); + }, + ); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_select_list_modal.dart b/frontend/lib/widgets/admin/common/admin_select_list_modal.dart new file mode 100644 index 0000000..bd5c0e5 --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_select_list_modal.dart @@ -0,0 +1,334 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; + +/// Filtre optionnel (switch) sur la même ligne que la barre de recherche. +class AdminSelectToggleFilter { + final String label; + final bool initialValue; + + /// Si le switch est activé, ne garde que les éléments pour lesquels + /// [whenEnabled] renvoie `true`. + final bool Function(T item) whenEnabled; + + const AdminSelectToggleFilter({ + required this.label, + required this.whenEnabled, + this.initialValue = true, + }); +} + +/// Shell générique « rechercher + liste + sélection » pour les modales admin. +/// Utilisé par la sélection d'enfant (#146) et prévu pour la sélection d'AM (#147). +class AdminSelectListModal extends StatefulWidget { + final String title; + final String searchHint; + final Future> Function() loadItems; + final bool Function(T item, String query) matchesQuery; + final Widget Function( + BuildContext context, + T item, + VoidCallback onSelect, + ) itemBuilder; + final String emptyMessage; + final String noResultsMessage; + final double modalWidth; + + /// Hauteur d'une carte (pour dimensionner la liste à ≥ [minVisibleCards]). + final double cardExtent; + + /// Nombre minimum de cartes visibles dans la zone scrollable. + final int minVisibleCards; + + /// Switch optionnel à droite du champ de recherche (ex. « Sans garde »). + final AdminSelectToggleFilter? toggleFilter; + + const AdminSelectListModal({ + super.key, + required this.title, + required this.loadItems, + required this.matchesQuery, + required this.itemBuilder, + this.searchHint = 'Rechercher…', + this.emptyMessage = 'Aucun élément disponible', + this.noResultsMessage = 'Aucun résultat pour cette recherche', + this.modalWidth = 930, + this.cardExtent = 70, + this.minVisibleCards = 5, + this.toggleFilter, + }); + + static Future show( + BuildContext context, { + required String title, + required Future> Function() loadItems, + required bool Function(T item, String query) matchesQuery, + required Widget Function( + BuildContext context, + T item, + VoidCallback onSelect, + ) itemBuilder, + String searchHint = 'Rechercher…', + String emptyMessage = 'Aucun élément disponible', + String noResultsMessage = 'Aucun résultat pour cette recherche', + double modalWidth = 930, + double cardExtent = 70, + int minVisibleCards = 5, + AdminSelectToggleFilter? toggleFilter, + }) { + return showDialog( + context: context, + builder: (ctx) => AdminSelectListModal( + title: title, + loadItems: loadItems, + matchesQuery: matchesQuery, + itemBuilder: itemBuilder, + searchHint: searchHint, + emptyMessage: emptyMessage, + noResultsMessage: noResultsMessage, + modalWidth: modalWidth, + cardExtent: cardExtent, + minVisibleCards: minVisibleCards, + toggleFilter: toggleFilter, + ), + ); + } + + @override + State> createState() => + _AdminSelectListModalState(); +} + +class _AdminSelectListModalState extends State> { + final _searchCtrl = TextEditingController(); + List _all = []; + bool _loading = true; + String? _error; + late bool _toggleOn; + + double get _listHeight => + widget.cardExtent * widget.minVisibleCards; + + @override + void initState() { + super.initState(); + _toggleOn = widget.toggleFilter?.initialValue ?? false; + _searchCtrl.addListener(() => setState(() {})); + _load(); + } + + @override + void dispose() { + _searchCtrl.dispose(); + super.dispose(); + } + + Future _load() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final list = await widget.loadItems(); + if (!mounted) return; + setState(() { + _all = list; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _loading = false; + _error = e.toString().replaceFirst('Exception: ', ''); + }); + } + } + + List get _filtered { + var list = _all; + final toggle = widget.toggleFilter; + if (toggle != null && _toggleOn) { + list = list.where(toggle.whenEnabled).toList(); + } + final q = _searchCtrl.text.trim().toLowerCase(); + if (q.isEmpty) return list; + return list.where((item) => widget.matchesQuery(item, q)).toList(); + } + + @override + Widget build(BuildContext context) { + final toggle = widget.toggleFilter; + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: widget.modalWidth), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(18, 16, 4, 0), + child: Row( + children: [ + Expanded( + child: Text( + widget.title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + tooltip: 'Fermer', + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: TextField( + controller: _searchCtrl, + decoration: InputDecoration( + isDense: true, + hintText: widget.searchHint, + prefixIcon: const Icon(Icons.search, size: 20), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + ), + ), + ), + if (toggle != null) ...[ + const SizedBox(width: 12), + Text( + toggle.label, + style: const TextStyle(fontSize: 13), + ), + const SizedBox(width: 4), + Switch( + value: _toggleOn, + activeColor: + ValidationModalTheme.primaryActionBackground, + onChanged: (v) => setState(() => _toggleOn = v), + ), + ], + ], + ), + const SizedBox(height: 12), + SizedBox( + height: _listHeight, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: _buildBody(), + ), + ), + ), + const SizedBox(height: 12), + Row( + children: [ + const Spacer(), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Annuler'), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildBody() { + if (_loading) { + return const Center( + child: CircularProgressIndicator( + color: ValidationModalTheme.primaryActionBackground, + ), + ); + } + + if (_error != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, size: 40, color: Colors.red.shade400), + const SizedBox(height: 12), + Text( + _error!, + textAlign: TextAlign.center, + style: TextStyle(color: Colors.red.shade700), + ), + const SizedBox(height: 12), + TextButton.icon( + onPressed: _load, + icon: const Icon(Icons.refresh), + label: const Text('Réessayer'), + ), + ], + ), + ), + ); + } + + final items = _filtered; + if (_all.isEmpty) { + return Center( + child: Text( + widget.emptyMessage, + style: const TextStyle(fontSize: 14, color: Colors.black54), + ), + ); + } + + if (items.isEmpty) { + return Center( + child: Text( + widget.noResultsMessage, + style: const TextStyle(fontSize: 14, color: Colors.black54), + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 4), + itemExtent: widget.cardExtent, + itemCount: items.length, + itemBuilder: (context, i) { + final item = items[i]; + return widget.itemBuilder( + context, + item, + () => Navigator.of(context).pop(item), + ); + }, + ); + } +} From 3277f77846d9b8a5c139553a3aa4fdc3ec103dcc Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 15:39:59 +0200 Subject: [PATCH 41/85] fix(#146): densifier la liste et corriger l'affichage des responsables. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les cartes compactes montrent plus d'enfants d'un coup ; le nom ne monopolise plus la largeur au détriment des infos. Co-authored-by: Cursor --- .../lib/widgets/admin/common/admin_enfant_user_card.dart | 7 +++++++ .../widgets/admin/common/admin_select_enfant_modal.dart | 6 +++++- .../lib/widgets/admin/common/admin_select_list_modal.dart | 8 ++++---- frontend/lib/widgets/admin/common/admin_user_card.dart | 8 +++++++- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart b/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart index 6284e3d..a9b0f7f 100644 --- a/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart +++ b/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart @@ -35,6 +35,7 @@ class AdminEnfantUserCard extends StatelessWidget { final List actions; final VoidCallback? onCardTap; final EdgeInsetsGeometry? margin; + final EdgeInsetsGeometry? contentPadding; const AdminEnfantUserCard({ super.key, @@ -44,6 +45,7 @@ class AdminEnfantUserCard extends StatelessWidget { this.actions = const [], this.onCardTap, this.margin, + this.contentPadding, }); factory AdminEnfantUserCard.fromEnfant( @@ -52,6 +54,7 @@ class AdminEnfantUserCard extends StatelessWidget { List actions = const [], VoidCallback? onCardTap, EdgeInsetsGeometry? margin, + EdgeInsetsGeometry? contentPadding, }) { final parents = enfant.parentLinks .map((l) => l.parentName ?? 'Parent') @@ -72,6 +75,7 @@ class AdminEnfantUserCard extends StatelessWidget { actions: actions, onCardTap: onCardTap, margin: margin, + contentPadding: contentPadding, ); } @@ -81,6 +85,7 @@ class AdminEnfantUserCard extends StatelessWidget { List actions = const [], VoidCallback? onCardTap, EdgeInsetsGeometry? margin, + EdgeInsetsGeometry? contentPadding, }) { return AdminEnfantUserCard( title: child.fullName, @@ -94,6 +99,7 @@ class AdminEnfantUserCard extends StatelessWidget { actions: actions, onCardTap: onCardTap, margin: margin, + contentPadding: contentPadding, ); } @@ -107,6 +113,7 @@ class AdminEnfantUserCard extends StatelessWidget { actions: actions, onCardTap: onCardTap, margin: margin, + contentPadding: contentPadding, ); } } diff --git a/frontend/lib/widgets/admin/common/admin_select_enfant_modal.dart b/frontend/lib/widgets/admin/common/admin_select_enfant_modal.dart index 1c5bc48..dafe5d4 100644 --- a/frontend/lib/widgets/admin/common/admin_select_enfant_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_select_enfant_modal.dart @@ -53,7 +53,11 @@ class AdminSelectEnfantModal { return AdminEnfantUserCard.fromEnfant( e, onCardTap: onSelect, - margin: const EdgeInsets.only(bottom: 6), + margin: const EdgeInsets.only(bottom: 4), + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), actions: [ IconButton( icon: const Icon(Icons.add_link), diff --git a/frontend/lib/widgets/admin/common/admin_select_list_modal.dart b/frontend/lib/widgets/admin/common/admin_select_list_modal.dart index bd5c0e5..61bcd39 100644 --- a/frontend/lib/widgets/admin/common/admin_select_list_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_select_list_modal.dart @@ -52,8 +52,8 @@ class AdminSelectListModal extends StatefulWidget { this.emptyMessage = 'Aucun élément disponible', this.noResultsMessage = 'Aucun résultat pour cette recherche', this.modalWidth = 930, - this.cardExtent = 70, - this.minVisibleCards = 5, + this.cardExtent = 52, + this.minVisibleCards = 8, this.toggleFilter, }); @@ -71,8 +71,8 @@ class AdminSelectListModal extends StatefulWidget { String emptyMessage = 'Aucun élément disponible', String noResultsMessage = 'Aucun résultat pour cette recherche', double modalWidth = 930, - double cardExtent = 70, - int minVisibleCards = 5, + double cardExtent = 52, + int minVisibleCards = 8, AdminSelectToggleFilter? toggleFilter, }) { return showDialog( diff --git a/frontend/lib/widgets/admin/common/admin_user_card.dart b/frontend/lib/widgets/admin/common/admin_user_card.dart index 2d02650..f7cbc1b 100644 --- a/frontend/lib/widgets/admin/common/admin_user_card.dart +++ b/frontend/lib/widgets/admin/common/admin_user_card.dart @@ -15,6 +15,7 @@ class AdminUserCard extends StatefulWidget { final String? vigilanceTooltip; final VoidCallback? onCardTap; final EdgeInsetsGeometry? margin; + final EdgeInsetsGeometry? contentPadding; const AdminUserCard({ super.key, @@ -30,6 +31,7 @@ class AdminUserCard extends StatefulWidget { this.vigilanceTooltip, this.onCardTap, this.margin, + this.contentPadding, }); @override @@ -69,7 +71,8 @@ class _AdminUserCardState extends State { side: BorderSide(color: widget.borderColor ?? Colors.grey.shade300), ), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), + padding: widget.contentPadding ?? + const EdgeInsets.symmetric(horizontal: 12, vertical: 9), child: Row( children: [ _buildAvatar(avatarUrl), @@ -88,7 +91,10 @@ class _AdminUserCardState extends State { Expanded( child: Row( children: [ + // flex: 0 → largeur du nom ; le reste va aux infos + // (évite le partage 50/50 qui tronque « Responsables »). Flexible( + flex: 0, fit: FlexFit.loose, child: Text( widget.title.isNotEmpty ? widget.title : 'Sans nom', From f194b5f9e8994cbe9c1273c723444e0449d0fac0 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 16:11:55 +0200 Subject: [PATCH 42/85] =?UTF-8?q?feat(#147):=20modale=20de=20s=C3=A9lectio?= =?UTF-8?q?n=20d'AM=20depuis=20la=20fiche=20enfant.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Réutilise AdminSelectListModal avec filtre Libre, cartes saturées en rouge, avertissement + lien fiche AM, et recalcul des places à chaque rattachement. Co-authored-by: Cursor --- frontend/lib/services/user_service.dart | 20 +- frontend/lib/utils/am_vigilance.dart | 12 + .../common/admin_child_detail_modal.dart | 43 +--- .../admin/common/admin_select_am_modal.dart | 218 ++++++++++++++++++ .../admin/common/admin_select_list_modal.dart | 39 +++- 5 files changed, 295 insertions(+), 37 deletions(-) create mode 100644 frontend/lib/widgets/admin/common/admin_select_am_modal.dart diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index f190463..b13d50b 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -8,6 +8,7 @@ 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'; +import 'package:p_tits_pas/utils/am_vigilance.dart'; class DocumentActifInfo { final String id; @@ -688,7 +689,24 @@ class UserService { if (response.statusCode != 200 && response.statusCode != 201) { throw Exception(_extractErrorMessage(response.body, 'Erreur rattachement enfant')); } - return _amModelFromBody(response.body); + final am = _amModelFromBody(response.body); + // Recalcule toujours places_available (capacité − enfants) après attachement. + return syncAmPlacesAvailable(am); + } + + /// Aligne `places_available` sur capacité − enfants rattachés. + static Future syncAmPlacesAvailable( + AssistanteMaternelleModel am, + ) async { + final expected = amExpectedPlacesAvailable( + maxChildren: am.maxChildren, + childrenCount: am.children.length, + ); + if (expected == null || am.placesAvailable == expected) return am; + return updateAmFiche( + amUserId: am.user.id, + body: {'places_available': expected}, + ); } static Future detachEnfantFromAm({ diff --git a/frontend/lib/utils/am_vigilance.dart b/frontend/lib/utils/am_vigilance.dart index 944dc93..85a9bf6 100644 --- a/frontend/lib/utils/am_vigilance.dart +++ b/frontend/lib/utils/am_vigilance.dart @@ -9,6 +9,18 @@ int? amExpectedPlacesAvailable({ return (maxChildren - childrenCount).clamp(0, maxChildren); } +/// True s'il reste au moins une place d'accueil (capacité − enfants). +bool amHasFreePlace(AssistanteMaternelleModel am) { + final expected = amExpectedPlacesAvailable( + maxChildren: am.maxChildren, + childrenCount: am.children.length, + ); + if (expected != null) return expected > 0; + final places = am.placesAvailable; + if (places != null) return places > 0; + return true; +} + /// True si la valeur déclarée par l'AM ne correspond pas au calcul métier. bool amHasPlacesInconsistency({ required int? maxChildren, diff --git a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart index b1dac62..cae3ca8 100644 --- a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -9,6 +9,7 @@ import 'package:p_tits_pas/utils/enfant_status_utils.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_select_am_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; @@ -399,49 +400,25 @@ class _AdminChildDetailModalState extends State { } Future _attachAm() async { - List all; - try { - all = await UserService.getAssistantesMaternelles(); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), - ); - return; - } - if (_linkedAm != null && !_isSansGarde) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Détachez l\'assistante actuelle avant d\'en choisir une autre'), + content: Text( + 'Détachez l\'assistante actuelle avant d\'en choisir une autre', + ), ), ); return; } - if (all.isEmpty) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Aucune assistante maternelle disponible')), - ); - return; - } - if (!mounted) return; - final selected = await showDialog( - context: context, - builder: (ctx) => SimpleDialog( - title: const Text('Choisir une assistante maternelle'), - children: all - .map( - (am) => SimpleDialogOption( - onPressed: () => Navigator.pop(ctx, am), - child: Text(am.user.fullName), - ), - ) - .toList(), - ), + final selected = await AdminSelectAmModal.show( + context, + excludeIds: { + if (_linkedAm != null) _linkedAm!.user.id, + }, + title: 'Choisir une assistante maternelle', ); if (selected == null || !mounted) return; diff --git a/frontend/lib/widgets/admin/common/admin_select_am_modal.dart b/frontend/lib/widgets/admin/common/admin_select_am_modal.dart new file mode 100644 index 0000000..ab46601 --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_select_am_modal.dart @@ -0,0 +1,218 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; +import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/utils/am_vigilance.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; +import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; + +List _amSelectSubtitleLines(AssistanteMaternelleModel am) { + final lines = []; + final zone = (am.residenceCity ?? '').trim(); + if (zone.isNotEmpty) lines.add('Zone : $zone'); + final agrement = (am.approvalNumber ?? '').trim(); + if (agrement.isNotEmpty) lines.add('Agrément : $agrement'); + final max = am.maxChildren; + final free = amExpectedPlacesAvailable( + maxChildren: max, + childrenCount: am.children.length, + ) ?? + am.placesAvailable; + if (free != null || max != null) { + lines.add('Places libres : ${free ?? '–'} / capa. ${max ?? '–'}'); + } + lines.add('${am.children.length} enfant(s)'); + return lines; +} + +/// Sélection d'une AM à rattacher (fiche enfant) — ticket #147. +/// S'appuie sur [AdminSelectListModal] (shell partagé avec #146). +class AdminSelectAmModal { + AdminSelectAmModal._(); + + static Future show( + BuildContext context, { + Set excludeIds = const {}, + String title = 'Choisir une assistante maternelle', + }) { + return AdminSelectListModal.show( + context, + title: title, + searchHint: 'Rechercher par nom, prénom ou zone…', + emptyMessage: 'Aucune assistante maternelle disponible', + noResultsMessage: 'Aucune AM avec place libre pour cette recherche', + toggleFilter: const AdminSelectToggleFilter( + label: 'Libre', + initialValue: true, + whenEnabled: amHasFreePlace, + ), + loadItems: () async { + final list = await UserService.getAssistantesMaternelles(); + return list + .where((am) => !excludeIds.contains(am.user.id)) + .toList() + ..sort( + (a, b) => a.user.fullName + .toLowerCase() + .compareTo(b.user.fullName.toLowerCase()), + ); + }, + matchesQuery: (am, q) { + final u = am.user; + final name = u.fullName.toLowerCase(); + final fn = (u.prenom ?? '').toLowerCase(); + final ln = (u.nom ?? '').toLowerCase(); + final zone = (am.residenceCity ?? '').toLowerCase(); + final agrement = (am.approvalNumber ?? '').toLowerCase(); + return name.contains(q) || + fn.contains(q) || + ln.contains(q) || + zone.contains(q) || + agrement.contains(q); + }, + resolveSelect: (ctx, am, reload) => + _resolveAmSelection(ctx, am, reload), + itemBuilder: (context, am, onSelect) { + final full = !amHasFreePlace(am); + return AdminUserCard( + title: am.user.fullName, + avatarUrl: am.user.photoUrl, + fallbackIcon: Icons.face, + subtitleLines: _amSelectSubtitleLines(am), + vigilanceTooltip: amPlacesVigilanceMessage(am), + onCardTap: onSelect, + margin: const EdgeInsets.only(bottom: 4), + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + backgroundColor: full ? const Color(0xFFFFEBEE) : null, + borderColor: full ? Colors.red.shade200 : null, + actions: [ + IconButton( + icon: const Icon(Icons.add_link), + tooltip: 'Rattacher', + onPressed: onSelect, + ), + ], + ); + }, + ); + } + + static Future _resolveAmSelection( + BuildContext context, + AssistanteMaternelleModel am, + Future Function() reloadList, + ) async { + if (amHasFreePlace(am)) return am; + + final selected = await showDialog( + context: context, + builder: (ctx) => _AmNoPlaceWarningDialog(initialAm: am), + ); + await reloadList(); + return selected; + } +} + +/// Avertissement AM saturée + lien vers la fiche pour ajuster les places. +class _AmNoPlaceWarningDialog extends StatefulWidget { + final AssistanteMaternelleModel initialAm; + + const _AmNoPlaceWarningDialog({required this.initialAm}); + + @override + State<_AmNoPlaceWarningDialog> createState() => + _AmNoPlaceWarningDialogState(); +} + +class _AmNoPlaceWarningDialogState extends State<_AmNoPlaceWarningDialog> { + late AssistanteMaternelleModel _am; + TapGestureRecognizer? _linkRecognizer; + + @override + void initState() { + super.initState(); + _am = widget.initialAm; + _linkRecognizer = TapGestureRecognizer()..onTap = _openAmFiche; + } + + @override + void dispose() { + _linkRecognizer?.dispose(); + super.dispose(); + } + + Future _openAmFiche() async { + if (!mounted) return; + await showDialog( + context: context, + builder: (ctx) => AdminAmEditModal( + assistante: _am, + onSaved: () async { + try { + final fresh = + await UserService.getAssistanteMaternelle(_am.user.id); + if (mounted) setState(() => _am = fresh); + } catch (_) {} + }, + ), + ); + if (!mounted) return; + + try { + final fresh = await UserService.getAssistanteMaternelle(_am.user.id); + if (!mounted) return; + setState(() => _am = fresh); + if (amHasFreePlace(fresh)) { + Navigator.of(context).pop(fresh); + } + } catch (_) {} + } + + @override + Widget build(BuildContext context) { + final name = _am.user.fullName.trim().isNotEmpty + ? _am.user.fullName.trim() + : 'Cette assistante maternelle'; + const linkColor = ValidationModalTheme.primaryActionBackground; + + return AlertDialog( + title: const Text('Plus de place disponible'), + content: Text.rich( + TextSpan( + style: const TextStyle(fontSize: 14, color: Colors.black87, height: 1.4), + children: [ + TextSpan( + text: '$name n\'a plus de place libre pour accueillir ' + 'un enfant supplémentaire.\n\n', + ), + const TextSpan(text: 'Vous pouvez '), + TextSpan( + text: 'ouvrir sa fiche', + style: TextStyle( + color: linkColor, + decoration: TextDecoration.underline, + fontWeight: FontWeight.w600, + ), + recognizer: _linkRecognizer, + ), + const TextSpan( + text: ' pour modifier la capacité ou les places, ' + 'puis la sélectionner si une place se libère.', + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Fermer'), + ), + ], + ); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_select_list_modal.dart b/frontend/lib/widgets/admin/common/admin_select_list_modal.dart index 61bcd39..4fdc70c 100644 --- a/frontend/lib/widgets/admin/common/admin_select_list_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_select_list_modal.dart @@ -18,7 +18,7 @@ class AdminSelectToggleFilter { } /// Shell générique « rechercher + liste + sélection » pour les modales admin. -/// Utilisé par la sélection d'enfant (#146) et prévu pour la sélection d'AM (#147). +/// Utilisé par la sélection d'enfant (#146) et la sélection d'AM (#147). class AdminSelectListModal extends StatefulWidget { final String title; final String searchHint; @@ -39,9 +39,17 @@ class AdminSelectListModal extends StatefulWidget { /// Nombre minimum de cartes visibles dans la zone scrollable. final int minVisibleCards; - /// Switch optionnel à droite du champ de recherche (ex. « Sans garde »). + /// Switch optionnel à droite du champ de recherche (ex. « Sans garde », « Libre »). final AdminSelectToggleFilter? toggleFilter; + /// Si fourni, appelé avant de valider la sélection. + /// Retourne l'élément à pop (éventuellement rafraîchi), ou `null` pour annuler. + final Future Function( + BuildContext context, + T item, + Future Function() reload, + )? resolveSelect; + const AdminSelectListModal({ super.key, required this.title, @@ -55,6 +63,7 @@ class AdminSelectListModal extends StatefulWidget { this.cardExtent = 52, this.minVisibleCards = 8, this.toggleFilter, + this.resolveSelect, }); static Future show( @@ -74,6 +83,11 @@ class AdminSelectListModal extends StatefulWidget { double cardExtent = 52, int minVisibleCards = 8, AdminSelectToggleFilter? toggleFilter, + Future Function( + BuildContext context, + T item, + Future Function() reload, + )? resolveSelect, }) { return showDialog( context: context, @@ -89,6 +103,7 @@ class AdminSelectListModal extends StatefulWidget { cardExtent: cardExtent, minVisibleCards: minVisibleCards, toggleFilter: toggleFilter, + resolveSelect: resolveSelect, ), ); } @@ -104,6 +119,7 @@ class _AdminSelectListModalState extends State> { bool _loading = true; String? _error; late bool _toggleOn; + bool _resolving = false; double get _listHeight => widget.cardExtent * widget.minVisibleCards; @@ -143,6 +159,23 @@ class _AdminSelectListModalState extends State> { } } + Future _handleSelect(T item) async { + if (_resolving) return; + final resolve = widget.resolveSelect; + if (resolve == null) { + Navigator.of(context).pop(item); + return; + } + setState(() => _resolving = true); + try { + final chosen = await resolve(context, item, _load); + if (!mounted || chosen == null) return; + Navigator.of(context).pop(chosen); + } finally { + if (mounted) setState(() => _resolving = false); + } + } + List get _filtered { var list = _all; final toggle = widget.toggleFilter; @@ -326,7 +359,7 @@ class _AdminSelectListModalState extends State> { return widget.itemBuilder( context, item, - () => Navigator.of(context).pop(item), + () => _handleSelect(item), ); }, ); From 18c1d1eba710f674f641244773e4e1d2acb9f315 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 16:12:53 +0200 Subject: [PATCH 43/85] =?UTF-8?q?fix(#148):=20d=C3=A9sactiver=20=C2=AB=20R?= =?UTF-8?q?attacher=20un=20enfant=20=C2=BB=20si=20capacit=C3=A9=20AM=20ple?= =?UTF-8?q?ine.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le lien du footer est grisé avec tooltip quand enfants ≥ capacité max ; il se réactive après détachement. Co-authored-by: Cursor --- .../admin/common/admin_am_edit_modal.dart | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart index c612436..706250e 100644 --- a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart @@ -238,6 +238,13 @@ class _AdminAmEditModalState extends State int? _capaciteMax() => _parseIntField(_capaciteCtrl); + /// True si plus aucune place d'accueil (enfants ≥ capacité max). + bool get _capacityFull { + final max = _capaciteMax(); + if (max == null) return false; + return _children.length >= max; + } + int? _computedPlacesAvailable() => amExpectedPlacesAvailable( maxChildren: _capaciteMax(), childrenCount: _children.length, @@ -466,7 +473,7 @@ class _AdminAmEditModalState extends State } Future _attachChild() async { - if (!mounted) return; + if (_capacityFull || !mounted) return; final selected = await AdminSelectEnfantModal.show( context, excludeIds: _children.map((c) => c.id).toSet(), @@ -745,6 +752,7 @@ class _AdminAmEditModalState extends State Widget _buildFooter() { final isChildrenTab = _tabCtrl.index == 2; + final canAttachChild = !_saving && !_capacityFull; return Row( children: [ TextButton( @@ -753,10 +761,15 @@ class _AdminAmEditModalState extends State ), const Spacer(), if (isChildrenTab) - TextButton.icon( - onPressed: _attachChild, - icon: const Icon(Icons.link, size: 18), - label: const Text('Rattacher un enfant'), + Tooltip( + message: _capacityFull + ? 'Capacité maximale atteinte' + : 'Rattacher un enfant', + child: TextButton.icon( + onPressed: canAttachChild ? _attachChild : null, + icon: const Icon(Icons.link, size: 18), + label: const Text('Rattacher un enfant'), + ), ), if (isChildrenTab) const SizedBox(width: 12), ElevatedButton( From 134b9781c8f6520e0585143fef537c90a55945c1 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 16:19:12 +0200 Subject: [PATCH 44/85] feat(#149): clic sur case libre pour rattacher un enfant (fiche AM). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les emplacements vides de la grille ouvrent le même flux que le lien footer, avec hover et respect de la capacité max. Co-authored-by: Cursor --- .../admin_am_children_capacity_grid.dart | 57 +++++++++++++++---- .../admin/common/admin_am_edit_modal.dart | 1 + 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/frontend/lib/widgets/admin/common/admin_am_children_capacity_grid.dart b/frontend/lib/widgets/admin/common/admin_am_children_capacity_grid.dart index b77871c..5b28e3c 100644 --- a/frontend/lib/widgets/admin/common/admin_am_children_capacity_grid.dart +++ b/frontend/lib/widgets/admin/common/admin_am_children_capacity_grid.dart @@ -20,6 +20,8 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget { final int capacity; final void Function(ParentChildSummary child) onOpen; final void Function(ParentChildSummary child) onDetach; + /// Clic sur une case libre → même flux que « Rattacher un enfant » (#149). + final VoidCallback? onAttachEmpty; const AdminAmChildrenCapacityGrid({ super.key, @@ -27,6 +29,7 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget { required this.capacity, required this.onOpen, required this.onDetach, + this.onAttachEmpty, }); @override @@ -78,7 +81,7 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget { ); } if (index < maxSlots) { - return const _EmptySlot(); + return _EmptySlot(onTap: onAttachEmpty); } return const _UnavailableSlot(); } @@ -129,18 +132,52 @@ class _UnavailableSlot extends StatelessWidget { } } -class _EmptySlot extends StatelessWidget { - const _EmptySlot(); +class _EmptySlot extends StatefulWidget { + final VoidCallback? onTap; + + const _EmptySlot({this.onTap}); + + @override + State<_EmptySlot> createState() => _EmptySlotState(); +} + +class _EmptySlotState extends State<_EmptySlot> { + bool _hovered = false; @override Widget build(BuildContext context) { - return _SlotShell( - backgroundColor: Colors.grey.shade50, - borderColor: Colors.grey.shade300, - child: Center( - child: Text( - 'Place libre', - style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + final clickable = widget.onTap != null; + return MouseRegion( + onEnter: clickable ? (_) => setState(() => _hovered = true) : null, + onExit: clickable ? (_) => setState(() => _hovered = false) : null, + cursor: clickable ? SystemMouseCursors.click : MouseCursor.defer, + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: widget.onTap, + borderRadius: BorderRadius.circular(8), + hoverColor: const Color(0x149CC5C0), + child: _SlotShell( + backgroundColor: _hovered + ? const Color(0xFFF3F0FA) + : Colors.grey.shade50, + borderColor: _hovered + ? const Color(0xFFB8A4D4) + : Colors.grey.shade300, + child: Center( + child: Text( + 'Place libre', + style: TextStyle( + fontSize: 12, + color: _hovered + ? const Color(0xFF6B3FA0) + : Colors.grey.shade500, + fontWeight: _hovered ? FontWeight.w600 : FontWeight.w400, + ), + ), + ), + ), ), ), ); diff --git a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart index 706250e..6663be2 100644 --- a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart @@ -745,6 +745,7 @@ class _AdminAmEditModalState extends State capacity: capacity, onOpen: _openChild, onDetach: _detachChild, + onAttachEmpty: _capacityFull ? null : _attachChild, ), ], ); From 6fe2b89a615affb75b95a6d232757d7587496e39 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 16:52:46 +0200 Subject: [PATCH 45/85] fix(#151): autoriser GET /relais pour gestionnaire + robustesse combo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Back: RoleType.GESTIONNAIRE sur GET list/get (CRUD write admin-only) - Front: ne plus effacer la sélection si le chargement échoue - Fallback affichage depuis relaisNom de l'utilisateur Co-authored-by: Cursor --- .../src/routes/relais/relais.controller.ts | 10 ++++++--- .../creation/gestionnaires_create.dart | 22 ++++++++++++++++--- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/backend/src/routes/relais/relais.controller.ts b/backend/src/routes/relais/relais.controller.ts index 5d30871..8125c16 100644 --- a/backend/src/routes/relais/relais.controller.ts +++ b/backend/src/routes/relais/relais.controller.ts @@ -24,15 +24,19 @@ export class RelaisController { } @Get() - @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) - @ApiOperation({ summary: 'Lister tous les relais' }) + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) + @ApiOperation({ + summary: 'Lister tous les relais', + description: + 'Lecture ouverte aux gestionnaires (combobox fiches). CRUD write reste admin-only. Ticket #151.', + }) @ApiResponse({ status: 200, description: 'Liste des relais.' }) findAll() { return this.relaisService.findAll(); } @Get(':id') - @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) @ApiOperation({ summary: 'Récupérer un relais par ID' }) @ApiResponse({ status: 200, description: 'Le relais trouvé.' }) findOne(@Param('id') id: string) { diff --git a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart index 4f89ab7..9a24de6 100644 --- a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart +++ b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart @@ -146,6 +146,21 @@ class _AdminUserFormDialogState extends State { super.dispose(); } + /// Fallback si GET /relais échoue : conserve le relais déjà connu sur l'utilisateur. + List _fallbackRelaisFromUser() { + final id = _selectedRelaisId?.trim(); + if (id == null || id.isEmpty) return const []; + final nom = (widget.initialUser?.relaisNom ?? '').trim(); + return [ + RelaisModel( + id: id, + nom: nom.isNotEmpty ? nom : 'Relais actuel', + adresse: '', + actif: true, + ), + ]; + } + Future _loadRelais() async { try { final list = await RelaisService.getRelais(); @@ -162,7 +177,8 @@ class _AdminUserFormDialogState extends State { if (selected != null) { filtered.add(selected); } else { - _selectedRelaisId = null; + // Garder l'id sélectionné et afficher un item de secours (nom carte). + filtered.addAll(_fallbackRelaisFromUser()); } } @@ -172,9 +188,9 @@ class _AdminUserFormDialogState extends State { }); } catch (_) { if (!mounted) return; + // Ne pas nullifier _selectedRelaisId (#151) — la carte a déjà le bon libellé. setState(() { - _selectedRelaisId = null; - _relais = []; + _relais = _fallbackRelaisFromUser(); _isLoadingRelais = false; }); } From c3acf1970d5941318f8ef63b0efa0789d4a2de4d Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 18:00:41 +0200 Subject: [PATCH 46/85] =?UTF-8?q?feat(#132):=20cr=C3=A9ation=20enfant=20ad?= =?UTF-8?q?min=20=E2=80=94=20modale=20+=20s=C3=A9lection=20famille.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Onglet Enfants « Ajouter » ouvre la fiche en mode création ; bloc Famille/dossier ; client POST /enfants avec parent_user_id (contrat back à livrer). Co-authored-by: Cursor --- frontend/lib/services/user_service.dart | 26 ++ .../common/admin_child_detail_modal.dart | 338 ++++++++++++++++-- .../common/admin_select_famille_modal.dart | 127 +++++++ .../widgets/admin/user_management_panel.dart | 20 ++ 4 files changed, 482 insertions(+), 29 deletions(-) create mode 100644 frontend/lib/widgets/admin/common/admin_select_famille_modal.dart diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index b13d50b..3ab0f16 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -515,6 +515,32 @@ class UserService { return enrichEnfantParentNames(enfant); } + /// Création enfant côté staff (#132) — nécessite `POST /enfants` étendu (voir mini-spec). + /// [parentUserId] : parent pivot du foyer (`parent_user_id`). + static Future createEnfant({ + required String parentUserId, + required Map body, + }) async { + final payload = { + ...body, + 'parent_user_id': parentUserId, + }; + final response = await http.post( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'), + headers: await _headers(), + body: jsonEncode(payload), + ); + if (response.statusCode != 200 && response.statusCode != 201) { + throw Exception( + _extractErrorMessage(response.body, 'Erreur création enfant'), + ); + } + final enfant = EnfantAdminModel.fromJson( + jsonDecode(response.body) as Map, + ); + return enrichEnfantParentNames(enfant); + } + static Future deleteEnfant(String enfantId) async { final response = await http.delete( Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'), diff --git a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart index cae3ca8..561e3c6 100644 --- a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -10,22 +10,32 @@ import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_select_am_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_select_famille_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; -/// Fiche enfant consultation / édition (ticket #138) — format paysage comme fiche AM. +/// Fiche enfant consultation / édition (#138) ou création (#132). class AdminChildDetailModal extends StatefulWidget { - final EnfantAdminModel enfant; + final EnfantAdminModel? enfant; final VoidCallback? onSaved; final VoidCallback? onDeleted; + final bool isCreating; const AdminChildDetailModal({ super.key, - required this.enfant, + required EnfantAdminModel this.enfant, this.onSaved, this.onDeleted, - }); + }) : isCreating = false; + + /// Création depuis l'onglet Enfants (#132). + const AdminChildDetailModal.create({ + super.key, + this.onSaved, + }) : enfant = null, + onDeleted = null, + isCreating = true; @override State createState() => _AdminChildDetailModalState(); @@ -49,6 +59,9 @@ class _AdminChildDetailModalState extends State { /// AM rattachée au chargement (pour sync différé au Sauvegarder). String? _baselineAmUserId; + /// Famille choisie en mode création (#132). + AdminFamilleFoyer? _selectedFamily; + static const double _modalWidth = 930; static const double _mainRowHeight = 380; static const double _placementHeight = 96; @@ -74,6 +87,7 @@ class _AdminChildDetailModalState extends State { _isUnborn ? 'Date prévisionnelle' : 'Date de naissance'; bool get _canDelete => + !widget.isCreating && (_currentUserRole ?? '').toLowerCase() == 'super_admin'; bool get _busy => _saving || _deleting; @@ -82,36 +96,50 @@ class _AdminChildDetailModalState extends State { void initState() { super.initState(); final e = widget.enfant; - _prenomCtrl = TextEditingController(text: e.firstName ?? ''); - _nomCtrl = TextEditingController(text: e.lastName ?? ''); + _prenomCtrl = TextEditingController(text: e?.firstName ?? ''); + _nomCtrl = TextEditingController(text: e?.lastName ?? ''); _birthCtrl = TextEditingController( - text: formatIsoDateFr(e.birthDate, ifEmpty: ''), + text: formatIsoDateFr(e?.birthDate, ifEmpty: ''), ); _dueCtrl = TextEditingController( - text: formatIsoDateFr(e.dueDate, ifEmpty: ''), + text: formatIsoDateFr(e?.dueDate, ifEmpty: ''), ); - _status = normalizeEnfantStatus(e.status); + _status = normalizeEnfantStatus(e?.status); if (!enfantStatusValues.contains(_status)) { _status = 'sans_garde'; } - _gender = _normalizeGender(e.gender, allowUnknown: _isUnborn); - _consentPhoto = e.consentPhoto; - _isMultiple = e.isMultiple; + _gender = _normalizeGender(e?.gender, allowUnknown: _isUnborn); + _consentPhoto = e?.consentPhoto ?? false; + _isMultiple = e?.isMultiple ?? false; for (final c in [_prenomCtrl, _nomCtrl, _birthCtrl, _dueCtrl]) { c.addListener(_markDirty); } _prenomCtrl.addListener(_onNameChanged); _nomCtrl.addListener(_onNameChanged); _loadCurrentUserRole(); - _loadLinkedAm(); + if (widget.isCreating) { + _loadingAm = false; + _dirty = true; + } else { + _loadLinkedAm(); + } } void _onNameChanged() => setState(() {}); Future _loadLinkedAm() async { + if (widget.isCreating) { + setState(() => _loadingAm = false); + return; + } + final enfantId = widget.enfant?.id; + if (enfantId == null || enfantId.isEmpty) { + setState(() => _loadingAm = false); + return; + } setState(() => _loadingAm = true); try { - final am = await UserService.findAmForEnfant(widget.enfant.id); + final am = await UserService.findAmForEnfant(enfantId); if (!mounted) return; setState(() { _linkedAm = am; @@ -168,18 +196,25 @@ class _AdminChildDetailModalState extends State { String? _dateToIso(String text) => parseFrDateToIso(text); String _headerTitle() { + if (widget.isCreating) { + final fn = _prenomCtrl.text.trim(); + final ln = _nomCtrl.text.trim(); + if (fn.isEmpty && ln.isEmpty) return 'Nouvel enfant'; + return '$fn $ln'.trim(); + } final fn = _prenomCtrl.text.trim(); final ln = _nomCtrl.text.trim(); if (fn.isEmpty && ln.isEmpty) { - final fallback = widget.enfant.fullName.trim(); + final fallback = widget.enfant?.fullName.trim() ?? ''; return fallback.isNotEmpty ? fallback : 'Enfant'; } return '$fn $ln'.trim(); } - List get _parentLinks => widget.enfant.parentLinks - .where((l) => l.parentId.trim().isNotEmpty) - .toList(); + List get _parentLinks => + (widget.enfant?.parentLinks ?? const []) + .where((l) => l.parentId.trim().isNotEmpty) + .toList(); Future _openParent(EnfantParentLink link) async { if (_busy) return; @@ -205,6 +240,17 @@ class _AdminChildDetailModalState extends State { } Widget? _parentsSubtitle() { + if (widget.isCreating) { + final family = _selectedFamily; + if (family == null) return null; + return Text( + family.parentNames.isNotEmpty + ? 'Famille : ${family.parentNames.join(', ')}' + : family.displayTitle, + style: const TextStyle(fontSize: 13, color: Colors.black54), + ); + } + final links = _parentLinks; if (links.isEmpty) return null; @@ -252,12 +298,21 @@ class _AdminChildDetailModalState extends State { Future _save() async { if (!_dirty) return; + + if (widget.isCreating) { + await _saveCreate(); + return; + } + + final enfantId = widget.enfant?.id; + if (enfantId == null || enfantId.isEmpty) return; + setState(() => _saving = true); try { await _syncAmPlacement(); await UserService.updateEnfant( - enfantId: widget.enfant.id, + enfantId: enfantId, body: { 'first_name': _prenomCtrl.text.trim(), 'last_name': _nomCtrl.text.trim(), @@ -291,21 +346,86 @@ class _AdminChildDetailModalState extends State { } } + Future _saveCreate() async { + final family = _selectedFamily; + if (family == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Choisissez une famille / un dossier avant d\'enregistrer'), + ), + ); + return; + } + final prenom = _prenomCtrl.text.trim(); + final nom = _nomCtrl.text.trim(); + if (prenom.isEmpty || nom.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Prénom et nom sont obligatoires')), + ); + return; + } + if (!_isUnborn && _dateToIso(_birthCtrl.text) == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Date de naissance invalide ou manquante')), + ); + return; + } + + setState(() => _saving = true); + try { + await UserService.createEnfant( + parentUserId: family.pivotParentUserId, + body: { + 'first_name': prenom, + 'last_name': nom, + 'status': _status, + 'gender': _gender, + if (_isUnborn) + if (_dateToIso(_dueCtrl.text) != null) + 'due_date': _dateToIso(_dueCtrl.text) + else if (_dateToIso(_birthCtrl.text) != null) + 'birth_date': _dateToIso(_birthCtrl.text), + 'consent_photo': _consentPhoto, + 'is_multiple': _isMultiple, + }, + ); + if (!mounted) return; + setState(() { + _dirty = false; + _saving = false; + }); + widget.onSaved?.call(); + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Enfant créé')), + ); + } catch (e) { + if (!mounted) return; + setState(() => _saving = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } + } + /// Applique rattachement / détachement AM (différé jusqu'au Sauvegarder). Future _syncAmPlacement() async { + final enfantId = widget.enfant?.id; + if (enfantId == null || enfantId.isEmpty) return; + final baselineId = _baselineAmUserId; final currentId = _linkedAm?.user.id; if (baselineId != null && baselineId != currentId) { await UserService.detachEnfantFromAm( amUserId: baselineId, - enfantId: widget.enfant.id, + enfantId: enfantId, ); } if (currentId != null && currentId != baselineId) { await UserService.attachEnfantToAm( amUserId: currentId, - enfantId: widget.enfant.id, + enfantId: enfantId, ); } } @@ -342,7 +462,9 @@ class _AdminChildDetailModalState extends State { setState(() => _deleting = true); try { - await UserService.deleteEnfant(widget.enfant.id); + final id = widget.enfant?.id; + if (id == null || id.isEmpty) return; + await UserService.deleteEnfant(id); if (!mounted) return; widget.onDeleted?.call(); widget.onSaved?.call(); @@ -376,10 +498,12 @@ class _AdminChildDetailModalState extends State { /// Recharge AM + statut après une modale externe (ex. fiche AM). Future _reloadPlacementFromServer() async { + final id = widget.enfant?.id; + if (id == null || id.isEmpty) return; try { final results = await Future.wait([ - UserService.findAmForEnfant(widget.enfant.id), - UserService.getEnfant(widget.enfant.id), + UserService.findAmForEnfant(id), + UserService.getEnfant(id), ]); if (!mounted) return; final am = results[0] as AssistanteMaternelleModel?; @@ -625,7 +749,7 @@ class _AdminChildDetailModalState extends State { children: [ Expanded( child: AdminAmPhotoFrame( - photoUrl: widget.enfant.photoUrl, + photoUrl: widget.enfant?.photoUrl, ), ), const SizedBox(height: 8), @@ -893,8 +1017,10 @@ class _AdminChildDetailModalState extends State { ); } - String get _placementTitle => - _isScolarise ? 'Scolarisation' : 'Assistante maternelle'; + String get _placementTitle { + if (widget.isCreating) return 'Famille / dossier'; + return _isScolarise ? 'Scolarisation' : 'Assistante maternelle'; + } Widget _placementBlock() { return Column( @@ -910,11 +1036,161 @@ class _AdminChildDetailModalState extends State { ), ), const SizedBox(height: 8), - _placementSection(), + if (widget.isCreating) _familyPlacementSection() else _placementSection(), ], ); } + Future _pickFamily() async { + if (_busy) return; + final selected = await AdminSelectFamilleModal.show( + context, + title: 'Choisir une famille', + ); + if (selected == null || !mounted) return; + setState(() { + _selectedFamily = selected; + _dirty = true; + }); + } + + Widget _familyPlacementSection() { + final family = _selectedFamily; + if (family == null) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: _busy ? null : _pickFamily, + borderRadius: BorderRadius.circular(10), + child: Container( + height: _placementHeight, + width: double.infinity, + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.grey.shade300), + ), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade200), + ), + child: Icon( + Icons.family_restroom, + size: 30, + color: Colors.grey.shade400, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Aucune famille sélectionnée', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: Colors.grey.shade800, + ), + ), + const SizedBox(height: 4), + Text( + 'Cliquer pour choisir un dossier / foyer', + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade600, + ), + ), + ], + ), + ), + Icon(Icons.add_circle_outline, color: Colors.grey.shade500), + ], + ), + ), + ), + ); + } + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: _busy ? null : _pickFamily, + borderRadius: BorderRadius.circular(10), + child: Container( + height: _placementHeight, + width: double.infinity, + decoration: BoxDecoration( + color: const Color(0xFFF8F5FC), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFD8CCE8)), + ), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: const Color(0xFFEDE5FA), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.family_restroom, + size: 30, + color: Color(0xFF6B3FA0), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + family.displayTitle, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (family.parentNames.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + family.parentNames.join(', '), + style: const TextStyle( + fontSize: 13, + color: Colors.black54, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + IconButton( + icon: Icon(Icons.swap_horiz, color: Colors.grey.shade700), + tooltip: 'Changer de famille', + onPressed: _busy ? null : _pickFamily, + ), + ], + ), + ), + ), + ); + } + Widget _placementSection() { if (!_showsAmPlacement) return _scolariseCard(); @@ -976,7 +1252,11 @@ class _AdminChildDetailModalState extends State { color: Colors.white, ), ) - : Text(_dirty ? 'Sauvegarder' : 'Aucune modification'), + : Text( + widget.isCreating + ? 'Créer' + : (_dirty ? 'Sauvegarder' : 'Aucune modification'), + ), ), ], ); diff --git a/frontend/lib/widgets/admin/common/admin_select_famille_modal.dart b/frontend/lib/widgets/admin/common/admin_select_famille_modal.dart new file mode 100644 index 0000000..ebfc947 --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_select_famille_modal.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/parent_model.dart'; +import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; + +/// Foyer / famille sélectionnable pour rattacher un nouvel enfant (#132). +class AdminFamilleFoyer { + /// Parent pivot pour `POST /enfants` (`parent_user_id`). + final String pivotParentUserId; + final String? numeroDossier; + final String displayTitle; + final List parentNames; + + const AdminFamilleFoyer({ + required this.pivotParentUserId, + required this.displayTitle, + required this.parentNames, + this.numeroDossier, + }); + + String get subtitle { + final parts = []; + final dossier = (numeroDossier ?? '').trim(); + if (dossier.isNotEmpty) parts.add('Dossier $dossier'); + if (parentNames.isNotEmpty) { + parts.add('Responsables : ${parentNames.join(', ')}'); + } + return parts.join(' '); + } +} + +/// Construit la liste des foyers uniques à partir de `GET /parents`. +List buildFamilleFoyers(List parents) { + final seenDossiers = {}; + final seenUserIds = {}; + final foyers = []; + + for (final p in parents) { + final dossier = (p.user.numeroDossier ?? '').trim(); + if (dossier.isNotEmpty) { + if (seenDossiers.contains(dossier)) continue; + seenDossiers.add(dossier); + } else if (seenUserIds.contains(p.user.id)) { + continue; + } + + seenUserIds.add(p.user.id); + final co = p.coParent; + if (co != null) seenUserIds.add(co.id); + + final names = [ + if (p.user.fullName.trim().isNotEmpty) p.user.fullName.trim(), + if (co != null && co.fullName.trim().isNotEmpty) co.fullName.trim(), + ]; + + final title = dossier.isNotEmpty + ? 'Dossier $dossier' + : (names.isNotEmpty ? names.first : 'Famille'); + + foyers.add( + AdminFamilleFoyer( + pivotParentUserId: p.user.id, + numeroDossier: dossier.isNotEmpty ? dossier : null, + displayTitle: title, + parentNames: names, + ), + ); + } + + foyers.sort( + (a, b) => a.displayTitle.toLowerCase().compareTo(b.displayTitle.toLowerCase()), + ); + return foyers; +} + +/// Sélection d'une famille / dossier pour créer un enfant — ticket #132. +class AdminSelectFamilleModal { + AdminSelectFamilleModal._(); + + static Future show( + BuildContext context, { + String title = 'Choisir une famille', + }) { + return AdminSelectListModal.show( + context, + title: title, + searchHint: 'Rechercher par dossier, nom…', + emptyMessage: 'Aucune famille disponible', + noResultsMessage: 'Aucun résultat pour cette recherche', + loadItems: () async { + final parents = await UserService.getParents(); + return buildFamilleFoyers(parents); + }, + matchesQuery: (f, q) { + final dossier = (f.numeroDossier ?? '').toLowerCase(); + final title = f.displayTitle.toLowerCase(); + final names = f.parentNames.join(' ').toLowerCase(); + return dossier.contains(q) || title.contains(q) || names.contains(q); + }, + itemBuilder: (context, f, onSelect) { + return AdminUserCard( + title: f.displayTitle, + fallbackIcon: Icons.family_restroom, + subtitleLines: [ + if (f.parentNames.isNotEmpty) f.parentNames.join(', '), + if ((f.numeroDossier ?? '').isNotEmpty) + 'Dossier ${f.numeroDossier}', + ], + onCardTap: onSelect, + margin: const EdgeInsets.only(bottom: 4), + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + actions: [ + IconButton( + icon: const Icon(Icons.check_circle_outline), + tooltip: 'Choisir', + onPressed: onSelect, + ), + ], + ); + }, + ); + } +} diff --git a/frontend/lib/widgets/admin/user_management_panel.dart b/frontend/lib/widgets/admin/user_management_panel.dart index d272864..3abcb4c 100644 --- a/frontend/lib/widgets/admin/user_management_panel.dart +++ b/frontend/lib/widgets/admin/user_management_panel.dart @@ -3,6 +3,7 @@ import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart'; import 'package:p_tits_pas/widgets/admin/enfant_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart'; @@ -26,6 +27,7 @@ class _UserManagementPanelState extends State { int _subIndex = 0; int _gestionnaireRefreshTick = 0; int _adminRefreshTick = 0; + int _enfantRefreshTick = 0; final TextEditingController _searchController = TextEditingController(); final TextEditingController _amCapacityController = TextEditingController(); String? _parentStatus; @@ -264,6 +266,7 @@ class _UserManagementPanelState extends State { ); case 1: return EnfantManagementWidget( + key: ValueKey('enfants-$_enfantRefreshTick'), searchQuery: _searchController.text, statusFilter: _enfantStatus, ); @@ -311,6 +314,23 @@ class _UserManagementPanelState extends State { Future _handleAddPressed() async { final contentIndex = _subIndex - _contentIndexOffset; + + if (contentIndex == 1) { + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + return AdminChildDetailModal.create( + onSaved: () { + if (!mounted) return; + setState(() => _enfantRefreshTick++); + }, + ); + }, + ); + return; + } + if (contentIndex == 3) { final created = await showDialog( context: context, From 0ec3410457dd7b9eab7b1e5e39142a26fc4bda5d Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 18:03:41 +0200 Subject: [PATCH 47/85] =?UTF-8?q?feat(#132):=20POST=20/enfants=20staff=20?= =?UTF-8?q?=E2=80=94=20parent=5Fuser=5Fid=20+=20liens=20foyer.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Autorise GESTIONNAIRE/ADMIN/SUPER_ADMIN à créer un enfant rattaché à un foyer existant (JSON + parent_user_id). Parent multipart inchangé. Co-authored-by: Cursor --- .../routes/enfants/dto/create_enfants.dto.ts | 16 ++- .../src/routes/enfants/enfants.controller.ts | 96 +++++++++++---- backend/src/routes/enfants/enfants.service.ts | 114 ++++++++++++++---- 3 files changed, 177 insertions(+), 49 deletions(-) diff --git a/backend/src/routes/enfants/dto/create_enfants.dto.ts b/backend/src/routes/enfants/dto/create_enfants.dto.ts index 7d69067..edfc5cb 100644 --- a/backend/src/routes/enfants/dto/create_enfants.dto.ts +++ b/backend/src/routes/enfants/dto/create_enfants.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsBoolean, IsDateString, @@ -6,6 +6,7 @@ import { IsNotEmpty, IsOptional, IsString, + IsUUID, MaxLength, ValidateIf, } from 'class-validator'; @@ -63,4 +64,17 @@ export class CreateEnfantsDto { @ApiProperty({ default: false }) @IsBoolean() is_multiple: boolean; + + /** + * Parent pivot du foyer — obligatoire pour staff (gestionnaire/admin). + * Ignoré / interdit en externe pour un PARENT (ticket #132). + */ + @ApiPropertyOptional({ + description: + 'UUID du parent pivot (staff only). Obligatoire pour GESTIONNAIRE / ADMIN / SUPER_ADMIN.', + format: 'uuid', + }) + @IsOptional() + @IsUUID('4') + parent_user_id?: string; } diff --git a/backend/src/routes/enfants/enfants.controller.ts b/backend/src/routes/enfants/enfants.controller.ts index 2579a45..5505704 100644 --- a/backend/src/routes/enfants/enfants.controller.ts +++ b/backend/src/routes/enfants/enfants.controller.ts @@ -1,20 +1,33 @@ import { Body, + CallHandler, Controller, Delete, + ExecutionContext, Get, + HttpCode, + HttpStatus, + Injectable, + NestInterceptor, Param, ParseUUIDPipe, Patch, Post, + UploadedFile, UseGuards, UseInterceptors, - UploadedFile, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; -import { ApiBearerAuth, ApiTags, ApiConsumes } from '@nestjs/swagger'; +import { + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; import { diskStorage } from 'multer'; import { extname } from 'path'; +import { Observable } from 'rxjs'; import { EnfantsService } from './enfants.service'; import { CreateEnfantsDto } from './dto/create_enfants.dto'; import { UpdateEnfantsDto } from './dto/update_enfants.dto'; @@ -24,6 +37,47 @@ import { AuthGuard } from 'src/common/guards/auth.guard'; import { Roles } from 'src/common/decorators/roles.decorator'; import { RolesGuard } from 'src/common/guards/roles.guard'; +const photoMulterOptions = { + storage: diskStorage({ + destination: './uploads/photos', + filename: (req, file, cb) => { + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); + const ext = extname(file.originalname); + cb(null, `enfant-${uniqueSuffix}${ext}`); + }, + }), + fileFilter: (req, file, cb) => { + if (!file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) { + return cb(new Error('Seules les images sont autorisées'), false); + } + cb(null, true); + }, + limits: { + fileSize: 5 * 1024 * 1024, + }, +}; + +/** + * Multer uniquement si Content-Type multipart (parcours parent). + * Les appels staff JSON (#132) passent sans interceptor fichier. + */ +@Injectable() +class OptionalEnfantPhotoInterceptor implements NestInterceptor { + private readonly multipart = new (FileInterceptor( + 'photo', + photoMulterOptions, + ))(); + + intercept(context: ExecutionContext, next: CallHandler): Observable | Promise> { + const req = context.switchToHttp().getRequest(); + const ct = String(req.headers['content-type'] ?? ''); + if (!ct.includes('multipart/form-data')) { + return next.handle(); + } + return this.multipart.intercept(context, next); + } +} + @ApiBearerAuth('access-token') @ApiTags('Enfants') @UseGuards(AuthGuard, RolesGuard) @@ -31,30 +85,22 @@ import { RolesGuard } from 'src/common/guards/roles.guard'; export class EnfantsController { constructor(private readonly enfantsService: EnfantsService) { } - @Roles(RoleType.PARENT) - @Post() - @ApiConsumes('multipart/form-data') - @UseInterceptors( - FileInterceptor('photo', { - storage: diskStorage({ - destination: './uploads/photos', - filename: (req, file, cb) => { - const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); - const ext = extname(file.originalname); - cb(null, `enfant-${uniqueSuffix}${ext}`); - }, - }), - fileFilter: (req, file, cb) => { - if (!file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) { - return cb(new Error('Seules les images sont autorisées'), false); - } - cb(null, true); - }, - limits: { - fileSize: 5 * 1024 * 1024, - }, - }), + @Roles( + RoleType.PARENT, + RoleType.GESTIONNAIRE, + RoleType.ADMINISTRATEUR, + RoleType.SUPER_ADMIN, ) + @Post() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: 'Créer un enfant', + description: + 'PARENT : multipart éventuel, rattache au compte connecté. Staff : JSON + parent_user_id (foyer). Ticket #132.', + }) + @ApiConsumes('application/json', 'multipart/form-data') + @ApiBody({ type: CreateEnfantsDto }) + @UseInterceptors(OptionalEnfantPhotoInterceptor) create( @Body() dto: CreateEnfantsDto, @UploadedFile() photo: Express.Multer.File, diff --git a/backend/src/routes/enfants/enfants.service.ts b/backend/src/routes/enfants/enfants.service.ts index 7f4552c..fc3a301 100644 --- a/backend/src/routes/enfants/enfants.service.ts +++ b/backend/src/routes/enfants/enfants.service.ts @@ -13,6 +13,12 @@ import { ParentsChildren } from 'src/entities/parents_children.entity'; import { RoleType, Users } from 'src/entities/users.entity'; import { CreateEnfantsDto } from './dto/create_enfants.dto'; +const STAFF_ROLES: RoleType[] = [ + RoleType.GESTIONNAIRE, + RoleType.ADMINISTRATEUR, + RoleType.SUPER_ADMIN, +]; + @Injectable() export class EnfantsService { constructor( @@ -24,20 +30,34 @@ export class EnfantsService { private readonly parentsChildrenRepository: Repository, ) { } - // Création d'un enfant - async create(dto: CreateEnfantsDto, currentUser: Users, photoFile?: Express.Multer.File): Promise { + private isStaff(user: Users): boolean { + return STAFF_ROLES.includes(user.role); + } + + /** + * Création d'un enfant. + * - PARENT : rattache au parent connecté (multipart photo optionnel). + * - Staff : JSON + `parent_user_id` obligatoire (foyer existant). Ticket #132. + */ + async create( + dto: CreateEnfantsDto, + currentUser: Users, + photoFile?: Express.Multer.File, + ): Promise { + const pivotUserId = this.resolvePivotParentUserId(dto, currentUser); + const parent = await this.parentsRepository.findOne({ - where: { user_id: currentUser.id }, + where: { user_id: pivotUserId }, relations: ['co_parent'], }); if (!parent) throw new NotFoundException('Parent introuvable'); - // Vérif métier simple + // Vérif métier simple (aligné comportement historique parent) if (dto.status !== StatutEnfantType.A_NAITRE && !dto.birth_date) { throw new BadRequestException('Un enfant né doit avoir une date de naissance'); } - // Vérif doublon éventuel (ex: même prénom + date de naissance pour ce parent) + // Vérif doublon éventuel (ex: même prénom + date de naissance) const exist = await this.childrenRepository.findOne({ where: { first_name: dto.first_name, @@ -47,37 +67,84 @@ export class EnfantsService { }); if (exist) throw new ConflictException('Cet enfant existe déjà'); - // Gestion de la photo uploadée + // Gestion de la photo uploadée (parcours parent multipart) + let photoUrl = dto.photo_url; + let consentAt: Date | undefined; if (photoFile) { - dto.photo_url = `/uploads/photos/${photoFile.filename}`; + photoUrl = `/uploads/photos/${photoFile.filename}`; if (dto.consent_photo) { - dto.consent_photo_at = new Date().toISOString(); + consentAt = new Date(); } + } else if (dto.consent_photo) { + consentAt = dto.consent_photo_at + ? new Date(dto.consent_photo_at) + : new Date(); } - // Création - const child = this.childrenRepository.create(dto); + const child = this.childrenRepository.create({ + status: dto.status, + first_name: dto.first_name, + last_name: dto.last_name, + gender: dto.gender, + birth_date: dto.birth_date ? new Date(dto.birth_date) : undefined, + due_date: dto.due_date ? new Date(dto.due_date) : undefined, + photo_url: photoUrl, + consent_photo: !!dto.consent_photo, + consent_photo_at: consentAt, + is_multiple: !!dto.is_multiple, + }); await this.childrenRepository.save(child); - // Lien parent-enfant (Parent 1) - const parentLink = this.parentsChildrenRepository.create({ - parentId: parent.user_id, - enfantId: child.id, - }); - await this.parentsChildrenRepository.save(parentLink); + // Lien parent-enfant (pivot) + await this.parentsChildrenRepository.save( + this.parentsChildrenRepository.create({ + parentId: parent.user_id, + enfantId: child.id, + }), + ); // Rattachement automatique au co-parent s'il existe if (parent.co_parent) { - const coParentLink = this.parentsChildrenRepository.create({ - parentId: parent.co_parent.id, - enfantId: child.id, - }); - await this.parentsChildrenRepository.save(coParentLink); + await this.parentsChildrenRepository.save( + this.parentsChildrenRepository.create({ + parentId: parent.co_parent.id, + enfantId: child.id, + }), + ); } return this.findOne(child.id, currentUser); } + private resolvePivotParentUserId( + dto: CreateEnfantsDto, + currentUser: Users, + ): string { + if (this.isStaff(currentUser)) { + const id = dto.parent_user_id?.trim(); + if (!id) { + throw new BadRequestException( + 'parent_user_id est obligatoire pour créer un enfant (staff)', + ); + } + return id; + } + + if (currentUser.role === RoleType.PARENT) { + if ( + dto.parent_user_id && + dto.parent_user_id.trim() !== currentUser.id + ) { + throw new ForbiddenException( + 'Un parent ne peut pas créer un enfant pour un autre compte', + ); + } + return currentUser.id; + } + + throw new ForbiddenException('Accès interdit'); + } + // Liste des enfants (admin/gestionnaire) async findAll(): Promise { return this.childrenRepository.find({ @@ -90,7 +157,7 @@ export class EnfantsService { async findOne(id: string, currentUser: Users): Promise { const child = await this.childrenRepository.findOne({ where: { id }, - relations: ['parentLinks'], + relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'], }); if (!child) throw new NotFoundException('Enfant introuvable'); @@ -120,7 +187,8 @@ export class EnfantsService { const child = await this.childrenRepository.findOne({ where: { id } }); if (!child) throw new NotFoundException('Enfant introuvable'); - const patch: Partial = { ...dto } as Partial; + const { parent_user_id: _ignored, ...rest } = dto; + const patch: Partial = { ...rest } as Partial; if (dto.consent_photo !== undefined) { patch.consent_photo = dto.consent_photo; patch.consent_photo_at = dto.consent_photo ? new Date() : null!; From 26e9738ec7be83d9694aa865a7c9bd9d7e785340 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 18:07:47 +0200 Subject: [PATCH 48/85] =?UTF-8?q?feat(#132):=20upload=20photo=20=C3=A0=20l?= =?UTF-8?q?a=20cr=C3=A9ation=20d'enfant=20(admin).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- frontend/lib/services/user_service.dart | 82 ++++++++++++++-- .../admin/common/admin_am_photo_frame.dart | 95 +++++++++++++++++-- .../common/admin_child_detail_modal.dart | 76 ++++++++++++++- frontend/pubspec.lock | 2 +- frontend/pubspec.yaml | 1 + 5 files changed, 235 insertions(+), 21 deletions(-) diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index 3ab0f16..86f5cad 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'package:http/http.dart' as http; +import 'package:http_parser/http_parser.dart'; import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/enfant_admin_model.dart'; import 'package:p_tits_pas/models/parent_model.dart'; @@ -57,6 +58,33 @@ class UserService { return v.toString(); } + /// MIME pour upload photo enfant (filtre Nest : jpg/jpeg/png/gif). + static MediaType _imageMediaType(String filename, List bytes) { + if (bytes.length >= 3 && + bytes[0] == 0xFF && + bytes[1] == 0xD8 && + bytes[2] == 0xFF) { + return MediaType('image', 'jpeg'); + } + if (bytes.length >= 8 && + bytes[0] == 0x89 && + bytes[1] == 0x50 && + bytes[2] == 0x4E && + bytes[3] == 0x47) { + return MediaType('image', 'png'); + } + if (bytes.length >= 6 && + bytes[0] == 0x47 && + bytes[1] == 0x49 && + bytes[2] == 0x46) { + return MediaType('image', 'gif'); + } + final lower = filename.toLowerCase(); + if (lower.endsWith('.png')) return MediaType('image', 'png'); + if (lower.endsWith('.gif')) return MediaType('image', 'gif'); + return MediaType('image', 'jpeg'); + } + static String _errMessage(dynamic err) { if (err == null) return 'Erreur inconnue'; if (err is String) return err; @@ -517,19 +545,55 @@ class UserService { /// Création enfant côté staff (#132) — nécessite `POST /enfants` étendu (voir mini-spec). /// [parentUserId] : parent pivot du foyer (`parent_user_id`). + /// Avec [photoBytes] : `multipart/form-data` (champ fichier `photo`), sinon JSON. static Future createEnfant({ required String parentUserId, required Map body, + List? photoBytes, + String? photoFilename, }) async { - final payload = { - ...body, - 'parent_user_id': parentUserId, - }; - final response = await http.post( - Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'), - headers: await _headers(), - body: jsonEncode(payload), - ); + final hasPhoto = photoBytes != null && photoBytes.isNotEmpty; + final http.Response response; + if (hasPhoto) { + final token = await TokenService.getToken(); + final req = http.MultipartRequest( + 'POST', + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'), + ); + req.headers['Accept'] = 'application/json'; + if (token != null) { + req.headers['Authorization'] = 'Bearer $token'; + } + body.forEach((key, value) { + if (value == null) return; + req.fields[key] = value is bool + ? (value ? 'true' : 'false') + : value.toString(); + }); + req.fields['parent_user_id'] = parentUserId; + final name = (photoFilename ?? '').trim(); + final filename = name.isNotEmpty ? name : 'photo.jpg'; + req.files.add( + http.MultipartFile.fromBytes( + 'photo', + photoBytes, + filename: filename, + contentType: _imageMediaType(filename, photoBytes), + ), + ); + final streamed = await req.send(); + response = await http.Response.fromStream(streamed); + } else { + final payload = { + ...body, + 'parent_user_id': parentUserId, + }; + response = await http.post( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'), + headers: await _headers(), + body: jsonEncode(payload), + ); + } if (response.statusCode != 200 && response.statusCode != 201) { throw Exception( _extractErrorMessage(response.body, 'Erreur création enfant'), diff --git a/frontend/lib/widgets/admin/common/admin_am_photo_frame.dart b/frontend/lib/widgets/admin/common/admin_am_photo_frame.dart index 17ac62e..3da8051 100644 --- a/frontend/lib/widgets/admin/common/admin_am_photo_frame.dart +++ b/frontend/lib/widgets/admin/common/admin_am_photo_frame.dart @@ -1,14 +1,27 @@ +import 'dart:typed_data'; + import 'package:flutter/material.dart'; import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; -/// Cadre photo identité AM (35×45 mm) — même logique que [ValidationAmWizard]. +/// Cadre photo identité AM / enfant (35×45 mm) — même logique que [ValidationAmWizard]. class AdminAmPhotoFrame extends StatelessWidget { final String? photoUrl; + final Uint8List? imageBytes; + final VoidCallback? onTap; + final VoidCallback? onClear; + final String emptyLabel; static const double idPhotoAspectRatio = 35 / 45; - const AdminAmPhotoFrame({super.key, this.photoUrl}); + const AdminAmPhotoFrame({ + super.key, + this.photoUrl, + this.imageBytes, + this.onTap, + this.onClear, + this.emptyLabel = 'Aucune photo', + }); /// Largeur colonne photo pour remplir [height] (cadre inclus). static double columnWidthForHeight(double height) { @@ -36,9 +49,12 @@ class AdminAmPhotoFrame extends StatelessWidget { ph = pw / ar; } + final hasLocal = imageBytes != null && imageBytes!.isNotEmpty; + final showClear = onClear != null && hasLocal; + // Cadre gris = taille photo + padding uniforme ; centré dans la colonne // (évite le vide blanc en bas quand le conteneur parent est plus haut). - return Align( + Widget frame = Align( alignment: Alignment.topCenter, child: Container( decoration: BoxDecoration( @@ -60,22 +76,81 @@ class AdminAmPhotoFrame extends StatelessWidget { ), ), ); + + if (onTap != null) { + frame = MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: frame, + ), + ); + } + + if (!showClear) return frame; + + return Stack( + clipBehavior: Clip.none, + children: [ + frame, + Positioned( + top: 0, + right: 0, + child: Material( + color: Colors.transparent, + child: IconButton( + tooltip: 'Retirer la photo', + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + icon: Icon( + Icons.cancel, + size: 22, + color: Colors.grey.shade700, + ), + onPressed: onClear, + ), + ), + ), + ], + ); }, ); } Widget _photoContent(String fullUrl, double pw, double ph) { + if (imageBytes != null && imageBytes!.isNotEmpty) { + return Image.memory( + imageBytes!, + width: pw, + height: ph, + fit: BoxFit.cover, + alignment: Alignment.topCenter, + ); + } if (fullUrl.isEmpty) { return ColoredBox( color: Colors.grey.shade200, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.person_off_outlined, size: 36, color: Colors.grey.shade400), + Icon( + onTap != null ? Icons.add_a_photo_outlined : Icons.person_off_outlined, + size: 36, + color: Colors.grey.shade400, + ), const SizedBox(height: 6), - Text( - 'Aucune photo', - style: TextStyle(color: Colors.grey.shade600, fontSize: 11), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Text( + emptyLabel, + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey.shade600, fontSize: 11), + ), ), ], ), @@ -108,7 +183,11 @@ class AdminAmPhotoFrame extends StatelessWidget { }, errorBuilder: (_, __, ___) => ColoredBox( color: Colors.grey.shade200, - child: Icon(Icons.broken_image_outlined, size: 36, color: Colors.grey.shade400), + child: Icon( + Icons.broken_image_outlined, + size: 36, + color: Colors.grey.shade400, + ), ), ); } diff --git a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart index 561e3c6..2678ace 100644 --- a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -1,4 +1,7 @@ +import 'dart:typed_data'; + import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; import 'package:p_tits_pas/models/enfant_admin_model.dart'; import 'package:p_tits_pas/services/api/api_config.dart'; @@ -62,6 +65,10 @@ class _AdminChildDetailModalState extends State { /// Famille choisie en mode création (#132). AdminFamilleFoyer? _selectedFamily; + /// Photo locale (création) — upload multipart `photo`. + Uint8List? _photoBytes; + String? _photoFilename; + static const double _modalWidth = 930; static const double _mainRowHeight = 380; static const double _placementHeight = 96; @@ -370,11 +377,24 @@ class _AdminChildDetailModalState extends State { ); return; } + final hasPhoto = _photoBytes != null && _photoBytes!.isNotEmpty; + if (hasPhoto && !_consentPhoto) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Activez le consentement photo pour enregistrer une photo', + ), + ), + ); + return; + } setState(() => _saving = true); try { await UserService.createEnfant( parentUserId: family.pivotParentUserId, + photoBytes: hasPhoto ? _photoBytes : null, + photoFilename: _photoFilename, body: { 'first_name': prenom, 'last_name': nom, @@ -383,8 +403,8 @@ class _AdminChildDetailModalState extends State { if (_isUnborn) if (_dateToIso(_dueCtrl.text) != null) 'due_date': _dateToIso(_dueCtrl.text) - else if (_dateToIso(_birthCtrl.text) != null) - 'birth_date': _dateToIso(_birthCtrl.text), + else if (_dateToIso(_birthCtrl.text) != null) + 'birth_date': _dateToIso(_birthCtrl.text), 'consent_photo': _consentPhoto, 'is_multiple': _isMultiple, }, @@ -727,6 +747,43 @@ class _AdminChildDetailModalState extends State { ); } + Future _pickPhoto() async { + if (_busy || !widget.isCreating) return; + try { + final picked = await ImagePicker().pickImage( + source: ImageSource.gallery, + imageQuality: 75, + maxWidth: 1200, + maxHeight: 1200, + ); + if (picked == null) return; + final bytes = await picked.readAsBytes(); + if (bytes.isEmpty) return; + final name = picked.name.trim(); + if (!mounted) return; + setState(() { + _photoBytes = bytes; + _photoFilename = name.isNotEmpty ? name : 'photo.jpg'; + _consentPhoto = true; + _dirty = true; + }); + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Impossible de charger la photo')), + ); + } + } + + void _clearPhoto() { + if (_busy || !widget.isCreating) return; + setState(() { + _photoBytes = null; + _photoFilename = null; + _dirty = true; + }); + } + Widget _mainRow() { return LayoutBuilder( builder: (context, c) { @@ -749,7 +806,20 @@ class _AdminChildDetailModalState extends State { children: [ Expanded( child: AdminAmPhotoFrame( - photoUrl: widget.enfant?.photoUrl, + photoUrl: widget.isCreating + ? null + : widget.enfant?.photoUrl, + imageBytes: widget.isCreating ? _photoBytes : null, + onTap: widget.isCreating && !_busy ? _pickPhoto : null, + onClear: widget.isCreating && + !_busy && + _photoBytes != null && + _photoBytes!.isNotEmpty + ? _clearPhoto + : null, + emptyLabel: widget.isCreating + ? 'Cliquer pour ajouter' + : 'Aucune photo', ), ), const SizedBox(height: 8), diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index 09ded02..3672396 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -206,7 +206,7 @@ packages: source: hosted version: "1.2.2" http_parser: - dependency: transitive + dependency: "direct main" description: name: http_parser sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index 0f2e0aa..b3f4ca8 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: js: ^0.6.7 url_launcher: ^6.2.4 http: ^1.2.2 + http_parser: ^4.0.2 # flutter_secure_storage: ^9.0.0 pdfx: ^2.5.0 universal_platform: ^1.1.0 From f7ac628445c9063442bbb50b629d38cf280c401a Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 18:08:52 +0200 Subject: [PATCH 49/85] =?UTF-8?q?feat(#132):=20photo=20=C3=A0=20la=20cr?= =?UTF-8?q?=C3=A9ation=20enfant=20staff=20(multipart=20+=20bools).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transform "true"/"false" pour class-validator ; multipart staff avec FileInterceptor('photo') inchangé côté stockage. Co-authored-by: Cursor --- .../src/routes/enfants/dto/create_enfants.dto.ts | 14 ++++++++++++++ backend/src/routes/enfants/enfants.controller.ts | 13 +++++++++---- backend/src/routes/enfants/enfants.service.ts | 5 +++-- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/backend/src/routes/enfants/dto/create_enfants.dto.ts b/backend/src/routes/enfants/dto/create_enfants.dto.ts index edfc5cb..0d66d46 100644 --- a/backend/src/routes/enfants/dto/create_enfants.dto.ts +++ b/backend/src/routes/enfants/dto/create_enfants.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; import { IsBoolean, IsDateString, @@ -12,6 +13,17 @@ import { } from 'class-validator'; import { GenreType, StatutEnfantType } from 'src/entities/children.entity'; +/** Multipart envoie des strings ("true"/"false") — JSON envoie déjà des booleans. */ +function toBoolean({ value }: { value: unknown }): boolean | unknown { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') { + const v = value.trim().toLowerCase(); + if (v === 'true' || v === '1') return true; + if (v === 'false' || v === '0' || v === '') return false; + } + return value; +} + export class CreateEnfantsDto { @ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.SANS_GARDE }) @IsEnum(StatutEnfantType) @@ -53,6 +65,7 @@ export class CreateEnfantsDto { photo_url?: string; @ApiProperty({ default: false }) + @Transform(toBoolean) @IsBoolean() consent_photo: boolean; @@ -62,6 +75,7 @@ export class CreateEnfantsDto { consent_photo_at?: string; @ApiProperty({ default: false }) + @Transform(toBoolean) @IsBoolean() is_multiple: boolean; diff --git a/backend/src/routes/enfants/enfants.controller.ts b/backend/src/routes/enfants/enfants.controller.ts index 5505704..492c885 100644 --- a/backend/src/routes/enfants/enfants.controller.ts +++ b/backend/src/routes/enfants/enfants.controller.ts @@ -58,8 +58,8 @@ const photoMulterOptions = { }; /** - * Multer uniquement si Content-Type multipart (parcours parent). - * Les appels staff JSON (#132) passent sans interceptor fichier. + * Multer uniquement si Content-Type multipart (parent ou staff + photo). + * JSON sans photo (#132) passe sans interceptor fichier. */ @Injectable() class OptionalEnfantPhotoInterceptor implements NestInterceptor { @@ -96,10 +96,15 @@ export class EnfantsController { @ApiOperation({ summary: 'Créer un enfant', description: - 'PARENT : multipart éventuel, rattache au compte connecté. Staff : JSON + parent_user_id (foyer). Ticket #132.', + 'PARENT : multipart éventuel, rattache au compte connecté. ' + + 'Staff : parent_user_id obligatoire ; JSON sans photo OK ; avec photo → multipart (champ fichier `photo`, max 5 Mo). Ticket #132.', }) @ApiConsumes('application/json', 'multipart/form-data') - @ApiBody({ type: CreateEnfantsDto }) + @ApiBody({ + description: + 'Champs métier (+ parent_user_id côté staff). Fichier optionnel `photo` en multipart.', + type: CreateEnfantsDto, + }) @UseInterceptors(OptionalEnfantPhotoInterceptor) create( @Body() dto: CreateEnfantsDto, diff --git a/backend/src/routes/enfants/enfants.service.ts b/backend/src/routes/enfants/enfants.service.ts index fc3a301..bd77d68 100644 --- a/backend/src/routes/enfants/enfants.service.ts +++ b/backend/src/routes/enfants/enfants.service.ts @@ -37,7 +37,8 @@ export class EnfantsService { /** * Création d'un enfant. * - PARENT : rattache au parent connecté (multipart photo optionnel). - * - Staff : JSON + `parent_user_id` obligatoire (foyer existant). Ticket #132. + * - Staff : `parent_user_id` obligatoire ; JSON sans photo OK ; + * avec photo → multipart (même stockage `/uploads/photos/...`). Ticket #132. */ async create( dto: CreateEnfantsDto, @@ -67,7 +68,7 @@ export class EnfantsService { }); if (exist) throw new ConflictException('Cet enfant existe déjà'); - // Gestion de la photo uploadée (parcours parent multipart) + // Gestion de la photo uploadée (multipart parent ou staff) let photoUrl = dto.photo_url; let consentAt: Date | undefined; if (photoFile) { From 6b89d7b405d9cae72d7ccee28e7fb4a1e3cb3a6e Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 18:46:48 +0200 Subject: [PATCH 50/85] =?UTF-8?q?fix(#132):=20envoyer=20birth=5Fdate=20?= =?UTF-8?q?=C3=A0=20la=20cr=C3=A9ation=20(enfant=20n=C3=A9).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le if imbriqué n’incluait birth_date que pour le cas à naître. Co-authored-by: Cursor --- .../widgets/admin/common/admin_child_detail_modal.dart | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart index 2678ace..bf1619e 100644 --- a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -400,11 +400,13 @@ class _AdminChildDetailModalState extends State { 'last_name': nom, 'status': _status, 'gender': _gender, - if (_isUnborn) + if (_isUnborn) ...{ if (_dateToIso(_dueCtrl.text) != null) - 'due_date': _dateToIso(_dueCtrl.text) - else if (_dateToIso(_birthCtrl.text) != null) + 'due_date': _dateToIso(_dueCtrl.text), + } else ...{ + if (_dateToIso(_birthCtrl.text) != null) 'birth_date': _dateToIso(_birthCtrl.text), + }, 'consent_photo': _consentPhoto, 'is_multiple': _isMultiple, }, From 7a3d997ff924ef2442fdcd401cf1b79cac591ebc Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 18:51:00 +0200 Subject: [PATCH 51/85] =?UTF-8?q?fix(#132):=20UX=20cr=C3=A9ation=20enfant?= =?UTF-8?q?=20=E2=80=94=20champs,=20genre=20et=20validation.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- frontend/lib/utils/date_display_utils.dart | 83 +++++++- frontend/lib/utils/name_format_utils.dart | 41 ++++ .../common/admin_child_detail_modal.dart | 196 ++++++++++++++---- 3 files changed, 281 insertions(+), 39 deletions(-) diff --git a/frontend/lib/utils/date_display_utils.dart b/frontend/lib/utils/date_display_utils.dart index 61ecb5e..3b7c5bd 100644 --- a/frontend/lib/utils/date_display_utils.dart +++ b/frontend/lib/utils/date_display_utils.dart @@ -1,3 +1,4 @@ +import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; import 'package:p_tits_pas/utils/enfant_status_utils.dart'; @@ -11,10 +12,50 @@ String formatIsoDateFr(String? s, {String ifEmpty = '–'}) { } } -/// Convertit `dd/MM/yyyy` (ou ISO) en `yyyy-MM-dd` pour l'API. +/// Affiche une date ISO en saisie guidée `jj / mm / aaaa`. +String formatIsoDateFrInput(String? s, {String ifEmpty = ''}) { + if (s == null || s.trim().isEmpty) return ifEmpty; + try { + final dt = DateTime.parse(s.trim()); + return formatFrenchDateDigits( + DateFormat('ddMMyyyy').format(dt), + ); + } catch (_) { + return formatFrenchDateDigits(s.replaceAll(RegExp(r'\D'), '')); + } +} + +/// Formate jusqu’à 8 chiffres en `jj / mm / aaaa`. +String formatFrenchDateDigits(String digits) { + final d = digits.replaceAll(RegExp(r'\D'), ''); + final limited = d.length > 8 ? d.substring(0, 8) : d; + final buf = StringBuffer(); + for (var i = 0; i < limited.length; i++) { + if (i == 2 || i == 4) buf.write(' / '); + buf.write(limited[i]); + } + return buf.toString(); +} + +/// Convertit `dd/MM/yyyy`, `dd / MM / yyyy`, 8 chiffres ou ISO en `yyyy-MM-dd`. String? parseFrDateToIso(String text) { final t = text.trim(); if (t.isEmpty) return null; + + final digits = t.replaceAll(RegExp(r'\D'), ''); + if (digits.length == 8) { + final dd = digits.substring(0, 2); + final mm = digits.substring(2, 4); + final yyyy = digits.substring(4, 8); + try { + return DateFormat('dd/MM/yyyy') + .parseStrict('$dd/$mm/$yyyy') + .toIso8601String() + .split('T') + .first; + } catch (_) {} + } + try { return DateFormat('dd/MM/yyyy') .parseStrict(t) @@ -96,3 +137,43 @@ String formatChildAgeLabel({ return 'Né le ${formatIsoDateFr(birthDate)}'; } } + +/// Saisie date FR : 8 chiffres → `jj / mm / aaaa`. +class FrenchDateInputFormatter extends TextInputFormatter { + const FrenchDateInputFormatter(); + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + final digits = newValue.text.replaceAll(RegExp(r'\D'), ''); + final limited = digits.length > 8 ? digits.substring(0, 8) : digits; + final formatted = formatFrenchDateDigits(limited); + + final digitsBeforeCursor = newValue.text + .substring(0, newValue.selection.start.clamp(0, newValue.text.length)) + .replaceAll(RegExp(r'\D'), '') + .length + .clamp(0, limited.length); + + return TextEditingValue( + text: formatted, + selection: TextSelection.collapsed( + offset: _cursorOffset(formatted, digitsBeforeCursor), + ), + ); + } + + static int _cursorOffset(String formatted, int digitsBeforeCursor) { + if (digitsBeforeCursor <= 0) return 0; + var seen = 0; + for (var i = 0; i < formatted.length; i++) { + if (RegExp(r'\d').hasMatch(formatted[i])) { + seen++; + if (seen >= digitsBeforeCursor) return i + 1; + } + } + return formatted.length; + } +} diff --git a/frontend/lib/utils/name_format_utils.dart b/frontend/lib/utils/name_format_utils.dart index feddec5..f09eea9 100644 --- a/frontend/lib/utils/name_format_utils.dart +++ b/frontend/lib/utils/name_format_utils.dart @@ -1,3 +1,5 @@ +import 'package:flutter/services.dart'; + /// Formatage affichage prénom / nom (capitalisation par mot et segments après `-` ou `'`). String formatPersonNameCase(String raw) { @@ -9,6 +11,22 @@ String formatPersonNameCase(String raw) { return words.map(_capitalizeComposedWord).join(' '); } +/// Variante saisie live : pas de majuscule tant que le mot n’a qu’une lettre ; +/// dès la 2ᵉ lettre, capitalisation comme [formatPersonNameCase]. +String formatPersonNameCaseTyping(String raw) { + if (raw.isEmpty) return raw; + final trailingMatch = RegExp(r'(\s*)$').firstMatch(raw); + final trailing = trailingMatch?.group(1) ?? ''; + final core = raw.substring(0, raw.length - trailing.length); + if (core.isEmpty) return raw; + final words = core.split(RegExp(r'\s+')); + final formatted = words.map((w) { + if (w.length < 2) return w; + return _capitalizeComposedWord(w); + }).join(' '); + return formatted + trailing; +} + String _capitalizeComposedWord(String word) { if (word.isEmpty) { return word; @@ -30,3 +48,26 @@ String _capitalizeComposedWord(String word) { } return buffer.toString(); } + +/// Formateur de saisie prénom / nom (capitalisation progressive). +class PersonNameInputFormatter extends TextInputFormatter { + const PersonNameInputFormatter(); + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + final formatted = formatPersonNameCaseTyping(newValue.text); + if (formatted == newValue.text) return newValue; + + final sel = newValue.selection; + final offset = sel.isValid + ? sel.baseOffset.clamp(0, formatted.length) + : formatted.length; + return TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: offset), + ); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart index bf1619e..1a8a6c7 100644 --- a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -1,6 +1,5 @@ -import 'dart:typed_data'; - import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:image_picker/image_picker.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; import 'package:p_tits_pas/models/enfant_admin_model.dart'; @@ -9,6 +8,7 @@ import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/utils/date_display_utils.dart'; import 'package:p_tits_pas/utils/enfant_status_utils.dart'; +import 'package:p_tits_pas/utils/name_format_utils.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart'; @@ -50,7 +50,7 @@ class _AdminChildDetailModalState extends State { late final TextEditingController _birthCtrl; late final TextEditingController _dueCtrl; late String _status; - late String _gender; + late String? _gender; late bool _consentPhoto; late bool _isMultiple; bool _dirty = false; @@ -99,6 +99,22 @@ class _AdminChildDetailModalState extends State { bool get _busy => _saving || _deleting; + /// Création : tous les champs sauf photo / consentement ; édition : au moins une modif. + bool get _canSubmit { + if (_busy) return false; + if (widget.isCreating) return _isCreateFormComplete; + return _dirty; + } + + bool get _isCreateFormComplete { + if (_selectedFamily == null) return false; + if (formatPersonNameCase(_prenomCtrl.text).isEmpty) return false; + if (formatPersonNameCase(_nomCtrl.text).isEmpty) return false; + if (_dateToIso(_activeDateCtrl.text) == null) return false; + if (_gender == null || !_availableGenders.contains(_gender)) return false; + return true; + } + @override void initState() { super.initState(); @@ -106,34 +122,61 @@ class _AdminChildDetailModalState extends State { _prenomCtrl = TextEditingController(text: e?.firstName ?? ''); _nomCtrl = TextEditingController(text: e?.lastName ?? ''); _birthCtrl = TextEditingController( - text: formatIsoDateFr(e?.birthDate, ifEmpty: ''), + text: formatIsoDateFrInput(e?.birthDate, ifEmpty: ''), ); _dueCtrl = TextEditingController( - text: formatIsoDateFr(e?.dueDate, ifEmpty: ''), + text: formatIsoDateFrInput(e?.dueDate, ifEmpty: ''), ); _status = normalizeEnfantStatus(e?.status); if (!enfantStatusValues.contains(_status)) { _status = 'sans_garde'; } - _gender = _normalizeGender(e?.gender, allowUnknown: _isUnborn); + if (widget.isCreating) { + _gender = null; + } else { + _gender = _normalizeGender(e?.gender, allowUnknown: _isUnborn); + } _consentPhoto = e?.consentPhoto ?? false; _isMultiple = e?.isMultiple ?? false; - for (final c in [_prenomCtrl, _nomCtrl, _birthCtrl, _dueCtrl]) { - c.addListener(_markDirty); + for (final c in [_prenomCtrl, _nomCtrl]) { + c.addListener(_onNameChanged); + } + for (final c in [_birthCtrl, _dueCtrl]) { + c.addListener(_onDateChanged); } - _prenomCtrl.addListener(_onNameChanged); - _nomCtrl.addListener(_onNameChanged); _loadCurrentUserRole(); if (widget.isCreating) { _loadingAm = false; _dirty = true; } else { + _prenomCtrl.addListener(_markDirty); + _nomCtrl.addListener(_markDirty); _loadLinkedAm(); } } void _onNameChanged() => setState(() {}); + void _onDateChanged() { + setState(() => _dirty = true); + } + + /// Date prévisionnelle strictement avant aujourd’hui (statut à naître). + bool get _dueDateInPast { + if (!_isUnborn) return false; + final iso = _dateToIso(_dueCtrl.text); + if (iso == null) return false; + try { + final due = DateTime.parse(iso); + final now = DateTime.now(); + final dueDay = DateTime(due.year, due.month, due.day); + final today = DateTime(now.year, now.month, now.day); + return dueDay.isBefore(today); + } catch (_) { + return false; + } + } + Future _loadLinkedAm() async { if (widget.isCreating) { setState(() => _loadingAm = false); @@ -191,8 +234,8 @@ class _AdminChildDetailModalState extends State { } void _coerceGenderForStatus() { - if (!_availableGenders.contains(_gender)) { - _gender = 'H'; + if (_gender != null && !_availableGenders.contains(_gender)) { + _gender = null; } } @@ -321,8 +364,8 @@ class _AdminChildDetailModalState extends State { await UserService.updateEnfant( enfantId: enfantId, body: { - 'first_name': _prenomCtrl.text.trim(), - 'last_name': _nomCtrl.text.trim(), + 'first_name': formatPersonNameCase(_prenomCtrl.text), + 'last_name': formatPersonNameCase(_nomCtrl.text), 'status': _status, 'gender': _gender, if (_isUnborn) @@ -363,8 +406,8 @@ class _AdminChildDetailModalState extends State { ); return; } - final prenom = _prenomCtrl.text.trim(); - final nom = _nomCtrl.text.trim(); + final prenom = formatPersonNameCase(_prenomCtrl.text); + final nom = formatPersonNameCase(_nomCtrl.text); if (prenom.isEmpty || nom.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Prénom et nom sont obligatoires')), @@ -622,70 +665,145 @@ class _AdminChildDetailModalState extends State { }); } - InputDecoration _inputDecoration({String? hint}) { - return ValidationFieldDecoration.input(hint: hint).copyWith( + /// Conserve la date saisie en basculant naissance ↔ prévisionnelle. + void _preserveDateOnStatusChange(String from, String to) { + final becomingUnborn = to == 'a_naitre' && from != 'a_naitre'; + final leavingUnborn = from == 'a_naitre' && to != 'a_naitre'; + if (becomingUnborn) { + if (_dueCtrl.text.trim().isEmpty && + _birthCtrl.text.trim().isNotEmpty) { + _dueCtrl.text = _birthCtrl.text; + } + } else if (leavingUnborn) { + if (_birthCtrl.text.trim().isEmpty && + _dueCtrl.text.trim().isNotEmpty) { + _birthCtrl.text = _dueCtrl.text; + } + } + } + + InputDecoration _inputDecoration({String? hint, bool error = false}) { + final base = ValidationFieldDecoration.input(hint: hint).copyWith( isDense: false, contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), ); + if (!error) return base; + final border = OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: BorderSide(color: Colors.red.shade400), + ); + return base.copyWith( + fillColor: Colors.red.shade50, + enabledBorder: border, + focusedBorder: border, + border: border, + ); } Widget _dropdownField({ Key? key, - required String value, + required String? value, required List> items, required ValueChanged onChanged, + String? hint, }) { final safeValue = - items.any((e) => e.key == value) ? value : items.first.key; + value != null && items.any((e) => e.key == value) ? value : null; return SizedBox( height: _fieldHeight, child: DropdownButtonFormField( key: key, value: safeValue, + hint: hint != null + ? Text(hint, style: TextStyle(color: Colors.grey.shade600)) + : null, isExpanded: true, decoration: _inputDecoration(), - items: items - .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value))) - .toList(), - onChanged: _busy ? null : (v) { - if (v != null) onChanged(v); - }, + items: items + .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value))) + .toList(), + onChanged: _busy + ? null + : (v) { + if (v != null) onChanged(v); + }, ), ); } - Widget _textField(TextEditingController controller, {String? hint, Key? key}) { + Widget _textField( + TextEditingController controller, { + String? hint, + Key? key, + TextInputType? keyboardType, + List? inputFormatters, + bool error = false, + Widget? suffixIcon, + }) { return SizedBox( height: _fieldHeight, child: TextField( key: key, controller: controller, + keyboardType: keyboardType, + inputFormatters: inputFormatters, textAlignVertical: TextAlignVertical.center, - style: const TextStyle(color: Colors.black87, fontSize: 14), - decoration: _inputDecoration(hint: hint), + style: TextStyle( + color: error ? Colors.red.shade800 : Colors.black87, + fontSize: 14, + ), + decoration: _inputDecoration( + hint: hint, + error: error, + ).copyWith(suffixIcon: suffixIcon), ), ); } + Widget _dateField() { + final past = _dueDateInPast; + return _textField( + _activeDateCtrl, + hint: 'jj / mm / aaaa', + key: ValueKey(_status), + keyboardType: TextInputType.number, + inputFormatters: const [FrenchDateInputFormatter()], + error: past, + suffixIcon: past + ? Tooltip( + message: + 'Date prévisionnelle antérieure à aujourd’hui', + child: Icon( + Icons.info_outline, + size: 20, + color: Colors.red.shade700, + ), + ) + : null, + ); + } + Widget _fieldsGrid() { return ValidationEditableSection( rowLayout: _fieldsRowLayout, fields: [ ValidationLabeledField( label: 'Prénom', - field: _textField(_prenomCtrl), + field: _textField( + _prenomCtrl, + inputFormatters: const [PersonNameInputFormatter()], + ), ), ValidationLabeledField( label: 'Nom', - field: _textField(_nomCtrl), + field: _textField( + _nomCtrl, + inputFormatters: const [PersonNameInputFormatter()], + ), ), ValidationLabeledField( label: _dateFieldLabel, - field: _textField( - _activeDateCtrl, - hint: 'jj/mm/aaaa', - key: ValueKey(_status), - ), + field: _dateField(), ), ValidationLabeledField( label: 'Statut', @@ -701,6 +819,7 @@ class _AdminChildDetailModalState extends State { .toList(), onChanged: (v) { setState(() { + _preserveDateOnStatusChange(_status, v); _status = v; _coerceGenderForStatus(); _dirty = true; @@ -716,6 +835,7 @@ class _AdminChildDetailModalState extends State { field: _dropdownField( key: ValueKey('genre-$_status'), value: _gender, + hint: 'Veuillez sélectionner le genre', items: _availableGenders .map((g) => MapEntry(g, _genderLabel(g))) .toList(), @@ -1090,7 +1210,7 @@ class _AdminChildDetailModalState extends State { } String get _placementTitle { - if (widget.isCreating) return 'Famille / dossier'; + if (widget.isCreating) return 'Sélection du dossier de la famille'; return _isScolarise ? 'Scolarisation' : 'Assistante maternelle'; } @@ -1314,7 +1434,7 @@ class _AdminChildDetailModalState extends State { const SizedBox(width: 12), ElevatedButton( style: ValidationModalTheme.primaryElevatedStyle, - onPressed: !_dirty || _busy ? null : _save, + onPressed: !_canSubmit || _busy ? null : _save, child: _saving ? const SizedBox( width: 18, From 2ececa711b9784b3a8d5f15e9444757d041d2143 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 19:02:21 +0200 Subject: [PATCH 52/85] feat(#157): alerte enfants sans responsable + rattachement foyer. Co-authored-by: Cursor --- frontend/lib/models/enfant_admin_model.dart | 4 + frontend/lib/utils/enfant_vigilance.dart | 12 +++ .../common/admin_child_detail_modal.dart | 75 +++++++++++++++++-- .../admin/common/admin_enfant_user_card.dart | 15 ++++ .../admin/common/admin_parent_edit_modal.dart | 3 +- .../common/admin_select_famille_modal.dart | 14 +++- .../admin/enfant_management_widget.dart | 9 ++- 7 files changed, 123 insertions(+), 9 deletions(-) create mode 100644 frontend/lib/utils/enfant_vigilance.dart diff --git a/frontend/lib/models/enfant_admin_model.dart b/frontend/lib/models/enfant_admin_model.dart index e07e5b0..9d591eb 100644 --- a/frontend/lib/models/enfant_admin_model.dart +++ b/frontend/lib/models/enfant_admin_model.dart @@ -37,6 +37,10 @@ class EnfantAdminModel { return '$fn $ln'; } + /// Aucun lien parent valide (`enfants_parents` vide) — ticket #157. + bool get hasNoResponsable => + !parentLinks.any((l) => l.parentId.trim().isNotEmpty); + EnfantAdminModel copyWith({ String? id, String? firstName, diff --git a/frontend/lib/utils/enfant_vigilance.dart b/frontend/lib/utils/enfant_vigilance.dart new file mode 100644 index 0000000..354efae --- /dev/null +++ b/frontend/lib/utils/enfant_vigilance.dart @@ -0,0 +1,12 @@ +import 'package:p_tits_pas/models/enfant_admin_model.dart'; + +/// Enfant sans lien parent (orphelins d’affiliation) — ticket #157. +bool enfantHasNoResponsable(EnfantAdminModel enfant) { + return !enfant.parentLinks.any((l) => l.parentId.trim().isNotEmpty); +} + +/// Message vigilance liste Enfants (même usage que [amPlacesVigilanceMessage]). +String? enfantSansResponsableVigilanceMessage(EnfantAdminModel enfant) { + if (!enfantHasNoResponsable(enfant)) return null; + return 'Aucun responsable rattaché — à rattacher à un foyer'; +} diff --git a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart index 1a8a6c7..e160b25 100644 --- a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -65,6 +65,9 @@ class _AdminChildDetailModalState extends State { /// Famille choisie en mode création (#132). AdminFamilleFoyer? _selectedFamily; + /// Liens parents locaux (édition) — mis à jour après rattachement foyer (#157). + List? _localParentLinks; + /// Photo locale (création) — upload multipart `photo`. Uint8List? _photoBytes; String? _photoFilename; @@ -262,10 +265,14 @@ class _AdminChildDetailModalState extends State { } List get _parentLinks => - (widget.enfant?.parentLinks ?? const []) + (_localParentLinks ?? + widget.enfant?.parentLinks ?? + const []) .where((l) => l.parentId.trim().isNotEmpty) .toList(); + bool get _isOrphan => !widget.isCreating && _parentLinks.isEmpty; + Future _openParent(EnfantParentLink link) async { if (_busy) return; final id = link.parentId.trim(); @@ -580,6 +587,7 @@ class _AdminChildDetailModalState extends State { if (!enfantStatusValues.contains(_status)) { _status = 'sans_garde'; } + _localParentLinks = enfant.parentLinks; _coerceGenderForStatus(); }); } catch (_) { @@ -960,7 +968,9 @@ class _AdminChildDetailModalState extends State { padding: const EdgeInsets.only(top: 16), child: Align( alignment: Alignment.bottomCenter, - child: _placementBlock(), + child: SingleChildScrollView( + child: _placementBlock(), + ), ), ), ), @@ -1219,6 +1229,24 @@ class _AdminChildDetailModalState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + if (_isOrphan) ...[ + Text( + 'Sélection du dossier de la famille', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.red.shade800, + ), + ), + const SizedBox(height: 4), + Text( + 'Aucun responsable rattaché — choisissez un foyer', + style: TextStyle(fontSize: 12, color: Colors.red.shade700), + ), + const SizedBox(height: 8), + _familyPlacementSection(), + const SizedBox(height: 16), + ], Text( _placementTitle, style: TextStyle( @@ -1240,10 +1268,45 @@ class _AdminChildDetailModalState extends State { title: 'Choisir une famille', ); if (selected == null || !mounted) return; - setState(() { - _selectedFamily = selected; - _dirty = true; - }); + + if (widget.isCreating) { + setState(() { + _selectedFamily = selected; + _dirty = true; + }); + return; + } + + // Édition orphelin (#157) : rattache immédiatement au foyer. + final enfantId = widget.enfant?.id; + if (enfantId == null || enfantId.isEmpty) return; + + setState(() => _saving = true); + try { + for (final parentId in selected.parentUserIds) { + await UserService.attachEnfantToParent( + parentUserId: parentId, + enfantId: enfantId, + ); + } + final refreshed = await UserService.getEnfant(enfantId); + if (!mounted) return; + setState(() { + _localParentLinks = refreshed.parentLinks; + _selectedFamily = selected; + _saving = false; + }); + widget.onSaved?.call(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Enfant rattaché au foyer')), + ); + } catch (e) { + if (!mounted) return; + setState(() => _saving = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } } Widget _familyPlacementSection() { diff --git a/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart b/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart index a9b0f7f..04b22e5 100644 --- a/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart +++ b/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart @@ -3,6 +3,7 @@ import 'package:p_tits_pas/models/enfant_admin_model.dart'; import 'package:p_tits_pas/models/parent_child_summary.dart'; import 'package:p_tits_pas/utils/date_display_utils.dart'; import 'package:p_tits_pas/utils/enfant_status_utils.dart'; +import 'package:p_tits_pas/utils/enfant_vigilance.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; List enfantAdminSubtitleLines({ @@ -36,6 +37,8 @@ class AdminEnfantUserCard extends StatelessWidget { final VoidCallback? onCardTap; final EdgeInsetsGeometry? margin; final EdgeInsetsGeometry? contentPadding; + final Color? borderColor; + final String? vigilanceTooltip; const AdminEnfantUserCard({ super.key, @@ -46,6 +49,8 @@ class AdminEnfantUserCard extends StatelessWidget { this.onCardTap, this.margin, this.contentPadding, + this.borderColor, + this.vigilanceTooltip, }); factory AdminEnfantUserCard.fromEnfant( @@ -55,10 +60,13 @@ class AdminEnfantUserCard extends StatelessWidget { VoidCallback? onCardTap, EdgeInsetsGeometry? margin, EdgeInsetsGeometry? contentPadding, + Color? borderColor, + String? vigilanceTooltip, }) { final parents = enfant.parentLinks .map((l) => l.parentName ?? 'Parent') .join(', '); + final orphan = enfantHasNoResponsable(enfant); return AdminEnfantUserCard( title: enfant.fullName, photoUrl: enfant.photoUrl, @@ -69,6 +77,7 @@ class AdminEnfantUserCard extends StatelessWidget { gender: enfant.gender, extra: [ if (parents.isNotEmpty) 'Responsables : $parents', + if (orphan) 'Aucun responsable rattaché', ...extraSubtitleLines, ], ), @@ -76,6 +85,10 @@ class AdminEnfantUserCard extends StatelessWidget { onCardTap: onCardTap, margin: margin, contentPadding: contentPadding, + borderColor: borderColor ?? + (orphan ? Colors.red.shade300 : null), + vigilanceTooltip: vigilanceTooltip ?? + enfantSansResponsableVigilanceMessage(enfant), ); } @@ -114,6 +127,8 @@ class AdminEnfantUserCard extends StatelessWidget { onCardTap: onCardTap, margin: margin, contentPadding: contentPadding, + borderColor: borderColor, + vigilanceTooltip: vigilanceTooltip, ); } } diff --git a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart index 176f1ce..e9bdab7 100644 --- a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart @@ -284,7 +284,8 @@ class _AdminParentEditModalState extends State { title: const Text('Détacher l\'enfant'), content: Text( 'Retirer ${child.fullName} de la fiche de ce parent ?\n' - '(L\'enfant ne sera pas supprimé.)', + '(L\'enfant ne sera pas supprimé. S\'il n\'a plus d\'autre ' + 'responsable, il restera visible dans l\'onglet Enfants avec une alerte.)', ), actions: [ TextButton( diff --git a/frontend/lib/widgets/admin/common/admin_select_famille_modal.dart b/frontend/lib/widgets/admin/common/admin_select_famille_modal.dart index ebfc947..4fbd3a3 100644 --- a/frontend/lib/widgets/admin/common/admin_select_famille_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_select_famille_modal.dart @@ -4,10 +4,12 @@ import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; -/// Foyer / famille sélectionnable pour rattacher un nouvel enfant (#132). +/// Foyer / famille sélectionnable pour rattacher un nouvel enfant (#132 / #157). class AdminFamilleFoyer { /// Parent pivot pour `POST /enfants` (`parent_user_id`). final String pivotParentUserId; + /// Co-parent éventuel (rattachement foyer #157). + final String? coParentUserId; final String? numeroDossier; final String displayTitle; final List parentNames; @@ -16,9 +18,18 @@ class AdminFamilleFoyer { required this.pivotParentUserId, required this.displayTitle, required this.parentNames, + this.coParentUserId, this.numeroDossier, }); + /// Parents du foyer à lier à l’enfant (pivot puis co-parent). + List get parentUserIds { + final ids = [pivotParentUserId]; + final co = (coParentUserId ?? '').trim(); + if (co.isNotEmpty && co != pivotParentUserId) ids.add(co); + return ids; + } + String get subtitle { final parts = []; final dossier = (numeroDossier ?? '').trim(); @@ -61,6 +72,7 @@ List buildFamilleFoyers(List parents) { foyers.add( AdminFamilleFoyer( pivotParentUserId: p.user.id, + coParentUserId: co?.id, numeroDossier: dossier.isNotEmpty ? dossier : null, displayTitle: title, parentNames: names, diff --git a/frontend/lib/widgets/admin/enfant_management_widget.dart b/frontend/lib/widgets/admin/enfant_management_widget.dart index 9aaf59b..5ef03f1 100644 --- a/frontend/lib/widgets/admin/enfant_management_widget.dart +++ b/frontend/lib/widgets/admin/enfant_management_widget.dart @@ -72,7 +72,14 @@ class _EnfantManagementWidgetState extends State { normalizeEnfantStatus(e.status) == normalizeEnfantStatus(widget.statusFilter); return matchesName && matchesStatus; - }).toList(); + }).toList() + ..sort((a, b) { + // Orphelins (#157) en tête, puis ordre alphabétique. + final ao = a.hasNoResponsable ? 0 : 1; + final bo = b.hasNoResponsable ? 0 : 1; + if (ao != bo) return ao.compareTo(bo); + return a.fullName.toLowerCase().compareTo(b.fullName.toLowerCase()); + }); return UserList( isLoading: _isLoading, From ef7512dc1e6ca18acfb5a83c32ef27abb3696051 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 19:31:18 +0200 Subject: [PATCH 53/85] =?UTF-8?q?feat(#157):=20autoriser=20d=C3=A9tachemen?= =?UTF-8?q?t=20dernier=20parent=20+=20flag=20sans=5Fresponsable.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supprime la garde totalLinks<=1 ; GET/findOne enfants exposent sans_responsable pour les orphelins toujours listés. Co-authored-by: Cursor --- backend/src/routes/enfants/enfants.service.ts | 27 +++++++++++++------ backend/src/routes/parents/parents.service.ts | 9 +++---- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/backend/src/routes/enfants/enfants.service.ts b/backend/src/routes/enfants/enfants.service.ts index bd77d68..4e0d393 100644 --- a/backend/src/routes/enfants/enfants.service.ts +++ b/backend/src/routes/enfants/enfants.service.ts @@ -146,16 +146,27 @@ export class EnfantsService { throw new ForbiddenException('Accès interdit'); } - // Liste des enfants (admin/gestionnaire) - async findAll(): Promise { - return this.childrenRepository.find({ - relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'], - order: { last_name: 'ASC', first_name: 'ASC' }, + /** Flag API #157 — true si aucun lien enfants_parents. */ + private withSansResponsable(child: Children): Children & { sans_responsable: boolean } { + return Object.assign(child, { + sans_responsable: !child.parentLinks || child.parentLinks.length === 0, }); } + // Liste des enfants (admin/gestionnaire) — inclut les orphelins (parentLinks: []) + async findAll(): Promise> { + const children = await this.childrenRepository.find({ + relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'], + order: { last_name: 'ASC', first_name: 'ASC' }, + }); + return children.map((c) => this.withSansResponsable(c)); + } + // Récupérer un enfant par id - async findOne(id: string, currentUser: Users): Promise { + async findOne( + id: string, + currentUser: Users, + ): Promise { const child = await this.childrenRepository.findOne({ where: { id }, relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'], @@ -172,14 +183,14 @@ export class EnfantsService { case RoleType.ADMINISTRATEUR: case RoleType.SUPER_ADMIN: case RoleType.GESTIONNAIRE: - // accès complet + // accès complet (y compris orphelins) break; default: throw new ForbiddenException('Accès interdit'); } - return child; + return this.withSansResponsable(child); } diff --git a/backend/src/routes/parents/parents.service.ts b/backend/src/routes/parents/parents.service.ts index 2696593..ac00f0a 100644 --- a/backend/src/routes/parents/parents.service.ts +++ b/backend/src/routes/parents/parents.service.ts @@ -138,7 +138,9 @@ export class ParentsService { } /** - * Détacher un enfant d'un parent sans supprimer l'enfant. Ticket #115 / doc 28 §6.2. + * Détacher un enfant d'un parent sans supprimer l'enfant. + * Autorise le détachement du dernier responsable (#157) — l'enfant reste listé + * via GET /enfants avec parentLinks vides (alerte front). */ async detachEnfant(parentUserId: string, enfantId: string): Promise { await this.findOne(parentUserId); @@ -150,11 +152,6 @@ export class ParentsService { throw new NotFoundException('Lien parent-enfant introuvable'); } - const totalLinks = await this.parentsChildrenRepository.count({ where: { enfantId } }); - if (totalLinks <= 1) { - throw new BadRequestException('Un enfant doit rester rattaché à au moins un responsable'); - } - await this.parentsChildrenRepository.delete({ parentId: parentUserId, enfantId }); return this.findOne(parentUserId); } From b0dddd66959914f0c4e1317926243e425f36f3ac Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 19:33:21 +0200 Subject: [PATCH 54/85] feat(#157): consommer le flag API sans_responsable. Co-authored-by: Cursor --- frontend/lib/models/enfant_admin_model.dart | 21 ++++++++++++++++++--- frontend/lib/utils/enfant_vigilance.dart | 4 +--- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/frontend/lib/models/enfant_admin_model.dart b/frontend/lib/models/enfant_admin_model.dart index 9d591eb..e835d26 100644 --- a/frontend/lib/models/enfant_admin_model.dart +++ b/frontend/lib/models/enfant_admin_model.dart @@ -14,6 +14,8 @@ class EnfantAdminModel { final bool consentPhoto; final bool isMultiple; final List parentLinks; + /// Flag API #157 (sinon déduit de [parentLinks]). + final bool? sansResponsable; EnfantAdminModel({ required this.id, @@ -27,6 +29,7 @@ class EnfantAdminModel { this.consentPhoto = false, this.isMultiple = false, this.parentLinks = const [], + this.sansResponsable, }); String get fullName { @@ -37,9 +40,11 @@ class EnfantAdminModel { return '$fn $ln'; } - /// Aucun lien parent valide (`enfants_parents` vide) — ticket #157. - bool get hasNoResponsable => - !parentLinks.any((l) => l.parentId.trim().isNotEmpty); + /// Aucun lien parent valide — ticket #157. + bool get hasNoResponsable { + if (sansResponsable != null) return sansResponsable!; + return !parentLinks.any((l) => l.parentId.trim().isNotEmpty); + } EnfantAdminModel copyWith({ String? id, @@ -53,6 +58,7 @@ class EnfantAdminModel { bool? consentPhoto, bool? isMultiple, List? parentLinks, + bool? sansResponsable, }) { return EnfantAdminModel( id: id ?? this.id, @@ -66,6 +72,7 @@ class EnfantAdminModel { consentPhoto: consentPhoto ?? this.consentPhoto, isMultiple: isMultiple ?? this.isMultiple, parentLinks: parentLinks ?? this.parentLinks, + sansResponsable: sansResponsable ?? this.sansResponsable, ); } @@ -85,6 +92,13 @@ class EnfantAdminModel { _parseBool(json['consentement_photo']) || _parseBool(json['consentPhoto']); + bool? sansResponsable; + if (json.containsKey('sans_responsable') || + json.containsKey('sansResponsable')) { + sansResponsable = _parseBool(json['sans_responsable']) || + _parseBool(json['sansResponsable']); + } + return EnfantAdminModel( id: (json['id'] ?? '').toString(), firstName: json['first_name'] as String? ?? json['prenom'] as String?, @@ -100,6 +114,7 @@ class EnfantAdminModel { isMultiple: _parseBool(json['is_multiple']) || _parseBool(json['est_multiple']), parentLinks: links, + sansResponsable: sansResponsable, ); } diff --git a/frontend/lib/utils/enfant_vigilance.dart b/frontend/lib/utils/enfant_vigilance.dart index 354efae..5488416 100644 --- a/frontend/lib/utils/enfant_vigilance.dart +++ b/frontend/lib/utils/enfant_vigilance.dart @@ -1,9 +1,7 @@ import 'package:p_tits_pas/models/enfant_admin_model.dart'; /// Enfant sans lien parent (orphelins d’affiliation) — ticket #157. -bool enfantHasNoResponsable(EnfantAdminModel enfant) { - return !enfant.parentLinks.any((l) => l.parentId.trim().isNotEmpty); -} +bool enfantHasNoResponsable(EnfantAdminModel enfant) => enfant.hasNoResponsable; /// Message vigilance liste Enfants (même usage que [amPlacesVigilanceMessage]). String? enfantSansResponsableVigilanceMessage(EnfantAdminModel enfant) { From 925b6d5cd412e376d5509559ebbca6f2610eb19a Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 19:42:36 +0200 Subject: [PATCH 55/85] =?UTF-8?q?fix(#157):=20texte=20d=C3=A9tach=20+=20co?= =?UTF-8?q?mpteur=20enfants=20foyer=20(interim).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- frontend/lib/models/parent_model.dart | 52 +++++++++++++++++++ .../admin/common/admin_am_edit_modal.dart | 3 +- .../admin/common/admin_parent_edit_modal.dart | 4 +- .../admin/parent_managmant_widget.dart | 2 +- 4 files changed, 55 insertions(+), 6 deletions(-) diff --git a/frontend/lib/models/parent_model.dart b/frontend/lib/models/parent_model.dart index 3a0cd5e..040aeda 100644 --- a/frontend/lib/models/parent_model.dart +++ b/frontend/lib/models/parent_model.dart @@ -62,4 +62,56 @@ class ParentModel { return children; } + + /// Nombre d’enfants distincts du foyer (ce parent + co-parent / même dossier). + /// Évite Claire=7 / Thomas=6 quand un lien n’est que sur un des deux (#157). + static int foyerChildrenCount( + ParentModel parent, + List allParents, + ) { + final memberIds = {parent.user.id}; + final coId = parent.coParent?.id.trim(); + if (coId != null && coId.isNotEmpty) memberIds.add(coId); + + final dossier = (parent.user.numeroDossier ?? '').trim(); + + for (final other in allParents) { + if (memberIds.contains(other.user.id)) continue; + final otherCo = other.coParent?.id.trim(); + if (otherCo != null && memberIds.contains(otherCo)) { + memberIds.add(other.user.id); + continue; + } + if (dossier.isNotEmpty && + (other.user.numeroDossier ?? '').trim() == dossier) { + memberIds.add(other.user.id); + final oc = other.coParent?.id.trim(); + if (oc != null && oc.isNotEmpty) memberIds.add(oc); + } + } + + final childIds = {}; + for (final p in allParents) { + if (!memberIds.contains(p.user.id)) continue; + for (final c in p.children) { + final id = c.id.trim(); + if (id.isNotEmpty) childIds.add(id); + } + // Repli si la liste enfants n’est pas hydratée. + if (p.children.isEmpty && p.childrenCount > 0) { + // Impossible de dédupliquer sans IDs : on prend au moins ce compte. + // (évite d’afficher 0 si l’API n’envoie que childrenCount) + } + } + + if (childIds.isNotEmpty) return childIds.length; + + var maxCount = 0; + for (final p in allParents) { + if (!memberIds.contains(p.user.id)) continue; + final n = p.children.isNotEmpty ? p.children.length : p.childrenCount; + if (n > maxCount) maxCount = n; + } + return maxCount; + } } diff --git a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart index 6663be2..7059c2f 100644 --- a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart @@ -446,8 +446,7 @@ class _AdminAmEditModalState extends State builder: (ctx) => AlertDialog( title: const Text('Détacher l\'enfant'), content: Text( - 'Retirer ${child.fullName} de la fiche de cette assistante ?\n' - '(L\'enfant ne sera pas supprimé.)', + 'Retirer ${child.fullName} de la fiche de cette assistante ?', ), actions: [ TextButton( diff --git a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart index e9bdab7..6365159 100644 --- a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart @@ -283,9 +283,7 @@ class _AdminParentEditModalState extends State { builder: (ctx) => AlertDialog( title: const Text('Détacher l\'enfant'), content: Text( - 'Retirer ${child.fullName} de la fiche de ce parent ?\n' - '(L\'enfant ne sera pas supprimé. S\'il n\'a plus d\'autre ' - 'responsable, il restera visible dans l\'onglet Enfants avec une alerte.)', + 'Retirer ${child.fullName} de la fiche de ce parent ?', ), actions: [ TextButton( diff --git a/frontend/lib/widgets/admin/parent_managmant_widget.dart b/frontend/lib/widgets/admin/parent_managmant_widget.dart index dcc2a66..24e0ef5 100644 --- a/frontend/lib/widgets/admin/parent_managmant_widget.dart +++ b/frontend/lib/widgets/admin/parent_managmant_widget.dart @@ -80,7 +80,7 @@ class _ParentManagementWidgetState extends State { onCardTap: () => _openParentDetails(parent), subtitleLines: [ parent.user.email, - 'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${parent.children.isNotEmpty ? parent.children.length : parent.childrenCount}', + 'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${ParentModel.foyerChildrenCount(parent, _parents)}', ], actions: [ IconButton( From 93912d137477e237764923fef1f00c28fbfdbce0 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 19:47:13 +0200 Subject: [PATCH 56/85] feat(#158): attach/detach enfant au niveau foyer (pivot + co-parent). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST/DELETE /parents/:id/enfants/:enfantId propagent les liens à tous les responsables du foyer (co-parent bidirectionnel + même dossier). Co-authored-by: Cursor --- backend/src/routes/parents/parents.service.ts | 89 +++++++++++++++---- 1 file changed, 72 insertions(+), 17 deletions(-) diff --git a/backend/src/routes/parents/parents.service.ts b/backend/src/routes/parents/parents.service.ts index ac00f0a..15c0581 100644 --- a/backend/src/routes/parents/parents.service.ts +++ b/backend/src/routes/parents/parents.service.ts @@ -114,36 +114,86 @@ export class ParentsService { } /** - * Rattacher un enfant existant à un parent (enfants_parents). Ticket #115 / doc 28 §6.2. + * Membres du foyer (user ids) pour affiliation enfant. + * Pivot + co-parent (A→B et B→A) + même numero_dossier. Ticket #158. */ - async attachEnfant(parentUserId: string, enfantId: string): Promise { - await this.findOne(parentUserId); + private async resolveFoyerParentUserIds(parent: Parents): Promise { + const ids = new Set([parent.user_id]); - const existing = await this.parentsChildrenRepository.findOne({ - where: { parentId: parentUserId, enfantId }, - }); - if (existing) { - throw new ConflictException('Cet enfant est déjà rattaché à ce parent'); + if (parent.co_parent?.id) { + ids.add(parent.co_parent.id); } - const child = await this.parentsRepository.manager.findOne(Children, { where: { id: enfantId } }); + // Sens inverse : parents qui déclarent ce user comme co-parent + const reverseLinks = await this.parentsRepository.find({ + where: { co_parent: { id: parent.user_id } }, + relations: ['co_parent'], + }); + for (const p of reverseLinks) { + ids.add(p.user_id); + if (p.co_parent?.id) ids.add(p.co_parent.id); + } + + const dossier = parent.numero_dossier?.trim(); + if (dossier) { + const sameDossier = await this.parentsRepository.find({ + where: { numero_dossier: dossier }, + relations: ['co_parent'], + }); + for (const p of sameDossier) { + ids.add(p.user_id); + if (p.co_parent?.id) ids.add(p.co_parent.id); + } + } + + return [...ids]; + } + + /** + * Rattacher un enfant au foyer du parent (tous les responsables). Ticket #158. + * Un seul POST suffit : liens créés pour pivot + co-parent / même dossier. + */ + async attachEnfant(parentUserId: string, enfantId: string): Promise { + const parent = await this.findOne(parentUserId); + + const child = await this.parentsRepository.manager.findOne(Children, { + where: { id: enfantId }, + }); if (!child) { throw new NotFoundException('Enfant introuvable'); } - await this.parentsChildrenRepository.save( - this.parentsChildrenRepository.create({ parentId: parentUserId, enfantId }), - ); + const foyerIds = await this.resolveFoyerParentUserIds(parent); + let created = 0; + + for (const memberId of foyerIds) { + const existing = await this.parentsChildrenRepository.findOne({ + where: { parentId: memberId, enfantId }, + }); + if (existing) continue; + + await this.parentsChildrenRepository.save( + this.parentsChildrenRepository.create({ + parentId: memberId, + enfantId, + }), + ); + created += 1; + } + + if (created === 0) { + throw new ConflictException('Cet enfant est déjà rattaché à ce foyer'); + } + return this.findOne(parentUserId); } /** - * Détacher un enfant d'un parent sans supprimer l'enfant. - * Autorise le détachement du dernier responsable (#157) — l'enfant reste listé - * via GET /enfants avec parentLinks vides (alerte front). + * Détacher un enfant du foyer du parent (tous les responsables). Ticket #158. + * Si plus aucun lien ensuite → enfant orphelin (#157). */ async detachEnfant(parentUserId: string, enfantId: string): Promise { - await this.findOne(parentUserId); + const parent = await this.findOne(parentUserId); const link = await this.parentsChildrenRepository.findOne({ where: { parentId: parentUserId, enfantId }, @@ -152,7 +202,12 @@ export class ParentsService { throw new NotFoundException('Lien parent-enfant introuvable'); } - await this.parentsChildrenRepository.delete({ parentId: parentUserId, enfantId }); + const foyerIds = await this.resolveFoyerParentUserIds(parent); + await this.parentsChildrenRepository.delete({ + parentId: In(foyerIds), + enfantId, + }); + return this.findOne(parentUserId); } From d247867fa013b9c91e1ce6faf60a7eed4d77094d Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 19:53:31 +0200 Subject: [PATCH 57/85] =?UTF-8?q?feat(#158):=20front=20=E2=80=94=20attach/?= =?UTF-8?q?detach=20foyer=20(un=20seul=20appel=20API).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../admin/common/admin_child_detail_modal.dart | 12 +++++------- .../admin/common/admin_parent_edit_modal.dart | 8 +++++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart index e160b25..eae7d4d 100644 --- a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -1277,18 +1277,16 @@ class _AdminChildDetailModalState extends State { return; } - // Édition orphelin (#157) : rattache immédiatement au foyer. + // Édition orphelin (#157/#158) : un seul attach — le back propage au foyer. final enfantId = widget.enfant?.id; if (enfantId == null || enfantId.isEmpty) return; setState(() => _saving = true); try { - for (final parentId in selected.parentUserIds) { - await UserService.attachEnfantToParent( - parentUserId: parentId, - enfantId: enfantId, - ); - } + await UserService.attachEnfantToParent( + parentUserId: selected.pivotParentUserId, + enfantId: enfantId, + ); final refreshed = await UserService.getEnfant(enfantId); if (!mounted) return; setState(() { diff --git a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart index 6365159..101b6a5 100644 --- a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart @@ -283,7 +283,7 @@ class _AdminParentEditModalState extends State { builder: (ctx) => AlertDialog( title: const Text('Détacher l\'enfant'), content: Text( - 'Retirer ${child.fullName} de la fiche de ce parent ?', + 'Retirer ${child.fullName} du foyer (tous les responsables) ?', ), actions: [ TextButton( @@ -308,8 +308,9 @@ class _AdminParentEditModalState extends State { if (!mounted) return; await _reloadChildren(); if (!mounted) return; + widget.onSaved?.call(); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Enfant détaché')), + const SnackBar(content: Text('Enfant détaché du foyer')), ); } catch (e) { if (!mounted) return; @@ -336,8 +337,9 @@ class _AdminParentEditModalState extends State { if (!mounted) return; await _reloadChildren(); if (!mounted) return; + widget.onSaved?.call(); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Enfant rattaché')), + const SnackBar(content: Text('Enfant rattaché au foyer')), ); } catch (e) { if (!mounted) return; From d2172eafdbc8ac235e44d328a42fc8152a6f78c4 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Wed, 22 Jul 2026 18:27:49 +0200 Subject: [PATCH 58/85] docs: snapshot empreinte code / disque (2026-07-22). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mémoire après alerte disque plein — lignes de code et contexte VPS. Co-authored-by: Cursor --- .../métriques/2026-07-22_analyse-empreinte.md | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 docs/métriques/2026-07-22_analyse-empreinte.md diff --git a/docs/métriques/2026-07-22_analyse-empreinte.md b/docs/métriques/2026-07-22_analyse-empreinte.md new file mode 100644 index 0000000..025e208 --- /dev/null +++ b/docs/métriques/2026-07-22_analyse-empreinte.md @@ -0,0 +1,104 @@ +# Analyse d’empreinte — P'titsPas + +**Date :** 2026-07-22 +**Contexte :** snapshot pour mémoire (après alerte disque plein + purge cache Gitea) +**Périmètre :** application `jmartin/petitspas` déployée sur le VPS (`/home/deploy/dev/ptitspas-app`) + +--- + +## 1. Lignes de code + +Comptage brut (`wc -l`), hors `node_modules` / builds / `.dart_tool`. + +| Zone | Lignes | Fichiers | +|------|--------|----------| +| **Frontend** (Dart `frontend/lib/`) | ~29 800 | 141 | +| **Backend** (TypeScript `backend/src/`) | ~10 000 | 141 | +| **BDD** (SQL sous `database/`) | ~1 250 | ~15 | +| **Total** | **~41 000** | | + +### Frontend (détail) + +| Dossier | Lignes | +|---------|--------| +| `widgets/` | ~18 400 | +| `screens/` | ~5 000 | +| `services/` | ~2 300 | +| `models/` | ~2 200 | +| `utils/` | ~1 400 | +| reste | ~500 | + +### Backend (détail) + +| Dossier | Lignes | +|---------|--------| +| `routes/` | ~6 500 | +| `modules/` | ~1 700 | +| `entities/` | ~1 000 | +| `common/` + `config/` | ~400 | + +### BDD (détail) + +| Élément | Lignes | +|---------|--------| +| `BDD.sql` (schéma) | ~470 | +| seeds | ~300 | +| migrations / patches | ~280 | +| tests SQL | ~205 | + +--- + +## 2. Empreinte disque — runtime (Docker) + +| Composant | Taille | Notes | +|-----------|--------|--------| +| Image `ptitspas-app-backend` | ~300 Mo | NestJS | +| Image `ptitspas-app-frontend` | ~122 Mo | Flutter web + Nginx | +| Image `postgres:17` | ~454 Mo | | +| Image `dpage/pgadmin4` | ~534 Mo | optionnel | +| Volume `postgres_data` | ~49 Mo | données BDD live | +| Volume `backend_uploads` | ~20 Mo | photos | +| Volume `backend_documents_legaux` | ~0 | | +| **Stack complète (avec pgAdmin)** | **~1,5 Go** | | +| **Stack prod sans pgAdmin** | **~945 Mo** | | + +--- + +## 3. Empreinte disque — code source + +| Élément | Taille | +|---------|--------| +| Clone `/home/deploy/dev/ptitspas-app` | ~701 Mo | +| dont `backend/node_modules` | ~354 Mo | +| dont `frontend` (+ `.dart_tool`) | ~114 Mo | +| **Code utile** (hors deps / `.git` / builds) | **~51 Mo** | +| Repo Gitea `petitspas.git` | ~109 Mo | +| Dump `database/BDD.sql` | ~19 Ko | + +--- + +## 4. Pour (re)déployer + +Minimum requis : + +1. Images custom backend + frontend (~422 Mo), ou code + build Docker +2. Image `postgres:17` (~454 Mo) +3. Volumes persistants (~70 Mo au snapshot) +4. Optionnel : pgAdmin (~534 Mo) + +**Ordre de grandeur :** ~1 Go pour faire tourner l’app en prod (sans pgAdmin). + +--- + +## 5. Notes infra du jour (lié) + +- Disque VPS passé de **100 %** à ~**44 %** après : + - purge cache Docker / journals + - purge officielle Gitea `delete_repo_archives` (~24 Go de zip/bundle `petitspas`) +- Crons Gitea activés : + - `archive_cleanup` @midnight (`OLDER_THAN = 24h`) + - `delete_repo_archives` @weekly + +--- + +*Fichier généré pour historique projet — ne pas considérer comme métrique CI automatisée.* From 59afeb0a8ddb5af3c6a505827c5605a3e7f5e746 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Wed, 22 Jul 2026 19:03:37 +0200 Subject: [PATCH 59/85] feat(#156): POST /assistantes-maternelles/dossier staff (actif + mail MDP). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Factorise createAmDossier depuis register/am ; staff crée un dossier déjà actif et envoie l’e-mail de création de mot de passe. Co-authored-by: Cursor --- backend/package.json | 3 + ...assistantes_maternelles.controller.spec.ts | 53 ++++- .../assistantes_maternelles.controller.ts | 36 +++- .../staff-create-am-dossier-response.dto.ts | 23 ++ .../dto/staff-create-am-dossier.dto.ts | 29 +++ backend/src/routes/auth/auth.service.ts | 201 ++++++++++++------ docs/tmp/156-contrat-api-staff-am.md | 75 +++++++ 7 files changed, 356 insertions(+), 64 deletions(-) create mode 100644 backend/src/routes/assistantes_maternelles/dto/staff-create-am-dossier-response.dto.ts create mode 100644 backend/src/routes/assistantes_maternelles/dto/staff-create-am-dossier.dto.ts create mode 100644 docs/tmp/156-contrat-api-staff-am.md diff --git a/backend/package.json b/backend/package.json index d5a1710..6cdacdb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -89,6 +89,9 @@ "transform": { "^.+\\.(t|j)s$": "ts-jest" }, + "moduleNameMapper": { + "^src/(.*)$": "/$1" + }, "collectCoverageFrom": [ "**/*.(t|j)s" ], diff --git a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.spec.ts b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.spec.ts index b0932ef..5a7322d 100644 --- a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.spec.ts +++ b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.spec.ts @@ -1,20 +1,69 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AssistantesMaternellesController } from './assistantes_maternelles.controller'; import { AssistantesMaternellesService } from './assistantes_maternelles.service'; +import { AuthService } from '../auth/auth.service'; +import { AuthGuard } from 'src/common/guards/auth.guard'; +import { RolesGuard } from 'src/common/guards/roles.guard'; describe('AssistantesMaternellesController', () => { let controller: AssistantesMaternellesController; + const authServiceMock = { + createAmDossierStaff: jest.fn(), + }; + const amServiceMock = {}; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [AssistantesMaternellesController], - providers: [AssistantesMaternellesService], - }).compile(); + providers: [ + { provide: AssistantesMaternellesService, useValue: amServiceMock }, + { provide: AuthService, useValue: authServiceMock }, + ], + }) + .overrideGuard(AuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(RolesGuard) + .useValue({ canActivate: () => true }) + .compile(); controller = module.get(AssistantesMaternellesController); + jest.clearAllMocks(); }); it('should be defined', () => { expect(controller).toBeDefined(); }); + + it('createDossier delegates to authService.createAmDossierStaff with CGU accepted', async () => { + authServiceMock.createAmDossierStaff.mockResolvedValue({ + message: 'ok', + user_id: 'u1', + statut: 'actif', + numero_dossier: '2026-000001', + }); + + const body = { + email: 'am.staff@test.fr', + prenom: 'Marie', + nom: 'TEST', + telephone: '0689567890', + consentement_photo: false, + lieu_naissance_ville: 'Paris', + lieu_naissance_pays: 'France', + nir: '285017512345678', + numero_agrement: 'AGR-TEST-001', + capacite_accueil: 3, + places_disponibles: 2, + }; + + const res = await controller.createDossier(body as any); + expect(authServiceMock.createAmDossierStaff).toHaveBeenCalledWith( + expect.objectContaining({ + email: body.email, + acceptation_cgu: true, + acceptation_privacy: true, + }), + ); + expect(res.numero_dossier).toBe('2026-000001'); + }); }); diff --git a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts index 540c73e..7d5609e 100644 --- a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts +++ b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts @@ -7,6 +7,8 @@ import { Param, Delete, UseGuards, + HttpCode, + HttpStatus, } from '@nestjs/common'; import { AssistantesMaternellesService } from './assistantes_maternelles.service'; import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; @@ -16,17 +18,49 @@ import { RoleType, Users } from 'src/entities/users.entity'; import { CreateAssistanteDto } from '../user/dto/create_assistante.dto'; import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto'; import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto'; +import { StaffCreateAmDossierDto } from './dto/staff-create-am-dossier.dto'; +import { StaffCreateAmDossierResponseDto } from './dto/staff-create-am-dossier-response.dto'; import { RolesGuard } from 'src/common/guards/roles.guard'; import { AuthGuard } from 'src/common/guards/auth.guard'; import { User } from 'src/common/decorators/user.decorator'; import { mapAmForApi, mapAmsForApi } from './assistantes_maternelles.mapper'; +import { AuthService } from '../auth/auth.service'; +import { RegisterAMCompletDto } from '../auth/dto/register-am-complet.dto'; @ApiTags("Assistantes Maternelles") @ApiBearerAuth('access-token') @UseGuards(AuthGuard, RolesGuard) @Controller('assistantes-maternelles') export class AssistantesMaternellesController { - constructor(private readonly assistantesMaternellesService: AssistantesMaternellesService) { } + constructor( + private readonly assistantesMaternellesService: AssistantesMaternellesService, + private readonly authService: AuthService, + ) { } + + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) + @Post('dossier') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: 'Créer un dossier AM complet (staff) — ticket #156', + description: + 'Crée user + fiche AM avec statut actif, n° dossier, et envoie l’e-mail de création de mot de passe. ' + + 'Ne pas utiliser POST /auth/register/am depuis le dashboard.', + }) + @ApiBody({ type: StaffCreateAmDossierDto }) + @ApiResponse({ status: 201, type: StaffCreateAmDossierResponseDto }) + @ApiResponse({ status: 400, description: 'Validation métier / NIR' }) + @ApiResponse({ status: 403, description: 'Rôle non autorisé' }) + @ApiResponse({ status: 409, description: 'Email / NIR / agrément déjà pris' }) + async createDossier( + @Body() dto: StaffCreateAmDossierDto, + ): Promise { + const registerDto = { + ...dto, + acceptation_cgu: true, + acceptation_privacy: true, + } as RegisterAMCompletDto; + return this.authService.createAmDossierStaff(registerDto); + } @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE) @ApiOperation({ summary: 'Créer nounou' }) diff --git a/backend/src/routes/assistantes_maternelles/dto/staff-create-am-dossier-response.dto.ts b/backend/src/routes/assistantes_maternelles/dto/staff-create-am-dossier-response.dto.ts new file mode 100644 index 0000000..305d382 --- /dev/null +++ b/backend/src/routes/assistantes_maternelles/dto/staff-create-am-dossier-response.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { StatutUtilisateurType } from 'src/entities/users.entity'; + +/** Réponse 201 POST /assistantes-maternelles/dossier (#156). */ +export class StaffCreateAmDossierResponseDto { + @ApiProperty() + message: string; + + @ApiProperty({ format: 'uuid' }) + user_id: string; + + @ApiProperty({ + enum: StatutUtilisateurType, + example: StatutUtilisateurType.ACTIF, + }) + statut: StatutUtilisateurType; + + @ApiProperty({ + example: '2026-000042', + description: 'Numéro de dossier attribué', + }) + numero_dossier: string; +} diff --git a/backend/src/routes/assistantes_maternelles/dto/staff-create-am-dossier.dto.ts b/backend/src/routes/assistantes_maternelles/dto/staff-create-am-dossier.dto.ts new file mode 100644 index 0000000..eed1381 --- /dev/null +++ b/backend/src/routes/assistantes_maternelles/dto/staff-create-am-dossier.dto.ts @@ -0,0 +1,29 @@ +import { ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { IsBoolean, IsOptional } from 'class-validator'; +import { RegisterAMCompletDto } from 'src/routes/auth/dto/register-am-complet.dto'; + +/** + * Création dossier AM par staff (#156). + * Mêmes champs que l'inscription publique, sans CGU/privacy obligatoires + * (acceptées côté serveur pour le compte du gestionnaire). + */ +export class StaffCreateAmDossierDto extends OmitType(RegisterAMCompletDto, [ + 'acceptation_cgu', + 'acceptation_privacy', +] as const) { + @ApiPropertyOptional({ + description: 'Ignoré côté staff (CGU acceptées serveur). Conservé pour compat éventuelle.', + default: true, + }) + @IsOptional() + @IsBoolean() + acceptation_cgu?: boolean; + + @ApiPropertyOptional({ + description: 'Ignoré côté staff (privacy acceptée serveur).', + default: true, + }) + @IsOptional() + @IsBoolean() + acceptation_privacy?: boolean; +} diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index e31085e..14d0cc4 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -645,11 +645,28 @@ export class AuthService { } /** - * Inscription Assistante Maternelle COMPLÈTE - Un seul endpoint (identité + pro + photo + CGU) - * Crée User (role AM) + entrée assistantes_maternelles, token création MDP + * Cœur partagé création dossier AM (#156). + * - public : statut en_attente + mail pending + * - staff : statut actif + mail création MDP */ - async inscrireAMComplet(dto: RegisterAMCompletDto) { - if (!dto.acceptation_cgu || !dto.acceptation_privacy) { + async createAmDossier( + dto: RegisterAMCompletDto, + options: { + statut: StatutUtilisateurType; + sendPendingEmail: boolean; + sendPasswordSetupEmail: boolean; + requireCgu: boolean; + logContext?: string; + }, + ): Promise<{ + message: string; + user_id: string; + statut: StatutUtilisateurType; + numero_dossier: string; + }> { + const logCtx = options.logContext ?? 'createAmDossier'; + + if (options.requireCgu && (!dto.acceptation_cgu || !dto.acceptation_privacy)) { throw new BadRequestException( "L'acceptation des CGU et de la politique de confidentialité est obligatoire", ); @@ -669,8 +686,7 @@ export class AuthService { throw new BadRequestException(nirValidation.error || 'NIR invalide'); } if (nirValidation.warning) { - // Warning uniquement : on ne bloque pas (AM souvent étrangères, DOM-TOM, Corse) - console.warn('[inscrireAMComplet] NIR warning:', nirValidation.warning, 'email=', dto.email); + console.warn(`[${logCtx}] NIR warning:`, nirValidation.warning, 'email=', dto.email); } const existe = await this.usersService.findByEmailOrNull(dto.email); @@ -721,79 +737,142 @@ export class AuthService { let resultat: { user: Users }; try { resultat = await this.usersRepo.manager.transaction(async (manager) => { - const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager); + const { numero: numeroDossier } = + await this.numeroDossierService.getNextNumeroDossier(manager); - const user = manager.create(Users, { - email: dto.email, - prenom: dto.prenom, - nom: dto.nom, - role: RoleType.ASSISTANTE_MATERNELLE, - statut: StatutUtilisateurType.EN_ATTENTE, - telephone: dto.telephone, - adresse: dto.adresse, - code_postal: dto.code_postal, - ville: dto.ville, - token_creation_mdp: tokenCreationMdp, - token_creation_mdp_expire_le: dateExpiration, - photo_url: urlPhoto ?? undefined, - consentement_photo: dto.consentement_photo, - date_consentement_photo: dateConsentementPhoto, - date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined, - lieu_naissance_ville: dto.lieu_naissance_ville, - lieu_naissance_pays: dto.lieu_naissance_pays, - numero_dossier: numeroDossier, + const user = manager.create(Users, { + email: dto.email, + prenom: dto.prenom, + nom: dto.nom, + role: RoleType.ASSISTANTE_MATERNELLE, + statut: options.statut, + telephone: dto.telephone, + adresse: dto.adresse, + code_postal: dto.code_postal, + ville: dto.ville, + token_creation_mdp: tokenCreationMdp, + token_creation_mdp_expire_le: dateExpiration, + photo_url: urlPhoto ?? undefined, + consentement_photo: dto.consentement_photo, + date_consentement_photo: dateConsentementPhoto, + date_naissance: dto.date_naissance + ? new Date(dto.date_naissance) + : undefined, + lieu_naissance_ville: dto.lieu_naissance_ville, + lieu_naissance_pays: dto.lieu_naissance_pays, + numero_dossier: numeroDossier, + }); + const userEnregistre = await manager.save(Users, user); + + const amRepo = manager.getRepository(AssistanteMaternelle); + const am = amRepo.create({ + user_id: userEnregistre.id, + approval_number: dto.numero_agrement, + nir: nirNormalized, + max_children: dto.capacite_accueil, + places_available: dto.places_disponibles, + biography: dto.biographie, + 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); + + return { user: userEnregistre }; }); - const userEnregistre = await manager.save(Users, user); - - const amRepo = manager.getRepository(AssistanteMaternelle); - const am = amRepo.create({ - user_id: userEnregistre.id, - approval_number: dto.numero_agrement, - nir: nirNormalized, - max_children: dto.capacite_accueil, - places_available: dto.places_disponibles, - biography: dto.biographie, - 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); - - 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 new ConflictException( + 'Un compte avec cet email existe déjà (contrainte unique en base).', + ); } throw err; } const numeroDossier = resultat.user.numero_dossier ?? ''; - try { - await this.mailService.sendRegistrationPendingEmail( - resultat.user.email, - resultat.user.prenom ?? '', - resultat.user.nom ?? '', - numeroDossier, - ); - } catch (err) { - this.logger.error( - "[inscrireAMComplet] Échec envoi email d'accusé de réception (inscription conservée)", - err instanceof Error ? err.stack : String(err), - ); + if (options.sendPendingEmail) { + try { + await this.mailService.sendRegistrationPendingEmail( + resultat.user.email, + resultat.user.prenom ?? '', + resultat.user.nom ?? '', + numeroDossier, + ); + } catch (err) { + this.logger.error( + `[${logCtx}] Échec envoi email d'accusé de réception (inscription conservée)`, + err instanceof Error ? err.stack : String(err), + ); + } } + if (options.sendPasswordSetupEmail) { + try { + await this.mailService.sendValidatedAccountPasswordSetupEmail( + { + email: resultat.user.email, + prenom: resultat.user.prenom ?? '', + nom: resultat.user.nom ?? '', + token: tokenCreationMdp, + numeroDossier, + }, + 'am', + ); + } catch (err) { + this.logger.error( + `[${logCtx}] Échec envoi email création MDP (dossier conservé)`, + err instanceof Error ? err.stack : String(err), + ); + } + } + + const message = + options.statut === StatutUtilisateurType.ACTIF + ? 'Dossier AM créé et validé. Un e-mail de création de mot de passe a été envoyé.' + : 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.'; + return { - message: - 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.', + message, user_id: resultat.user.id, - statut: StatutUtilisateurType.EN_ATTENTE, + statut: options.statut, numero_dossier: numeroDossier, }; } + /** + * Inscription Assistante Maternelle COMPLÈTE - Un seul endpoint (identité + pro + photo + CGU) + * Crée User (role AM) + entrée assistantes_maternelles, token création MDP + */ + async inscrireAMComplet(dto: RegisterAMCompletDto) { + return this.createAmDossier(dto, { + statut: StatutUtilisateurType.EN_ATTENTE, + sendPendingEmail: true, + sendPasswordSetupEmail: false, + requireCgu: true, + logContext: 'inscrireAMComplet', + }); + } + + /** + * Création dossier AM par staff (#156) — statut actif + e-mail création MDP. + */ + async createAmDossierStaff(dto: RegisterAMCompletDto) { + return this.createAmDossier(dto, { + statut: StatutUtilisateurType.ACTIF, + sendPendingEmail: false, + sendPasswordSetupEmail: true, + requireCgu: false, + logContext: 'createAmDossierStaff', + }); + } + + /** + * Sauvegarde une photo depuis base64 vers le système de fichiers + */ /** * Sauvegarde une photo depuis base64 vers le système de fichiers */ diff --git a/docs/tmp/156-contrat-api-staff-am.md b/docs/tmp/156-contrat-api-staff-am.md new file mode 100644 index 0000000..312e148 --- /dev/null +++ b/docs/tmp/156-contrat-api-staff-am.md @@ -0,0 +1,75 @@ +# Mini-spec API — POST /assistantes-maternelles/dossier (#156) + +Contrat pour le **plan front** (wizard création AM staff). + +## Endpoint + +| | | +|--|--| +| **Méthode** | `POST` | +| **URL** | `{base}/assistantes-maternelles/dossier` | +| **Auth** | Bearer JWT | +| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` | +| **Content-Type** | `application/json` | + +Ne **pas** appeler `POST /auth/register/am` depuis le dashboard. + +## Body (JSON) + +Aligné inscription AM publique, **sans** CGU/privacy obligatoires (acceptées serveur). + +| Champ | Type | Obligatoire | Notes | +|-------|------|-------------|--------| +| `email` | string | oui | unique | +| `prenom` | string | oui | | +| `nom` | string | oui | | +| `telephone` | string | oui | `0X…` ou `+33…` | +| `adresse` | string | non | | +| `code_postal` | string | non | | +| `ville` | string | non | | +| `photo_base64` | string | non | data-URL `data:image/…;base64,…` | +| `photo_filename` | string | non | hint nom fichier | +| `consentement_photo` | bool | oui | | +| `date_naissance` | date ISO | non | `YYYY-MM-DD` | +| `lieu_naissance_ville` | string | oui | | +| `lieu_naissance_pays` | string | oui | | +| `nir` | string | oui | 15 car. (Corse 2A/2B OK) | +| `numero_agrement` | string | oui | unique | +| `date_agrement` | date ISO | non | | +| `capacite_accueil` | int | oui | 1–10 | +| `places_disponibles` | int | oui | 0–10, ≤ capacité | +| `biographie` | string | non | max 2000 | + +## Réponses + +### 201 Created + +```json +{ + "message": "Dossier AM créé et validé. Un e-mail de création de mot de passe a été envoyé.", + "user_id": "uuid", + "statut": "actif", + "numero_dossier": "2026-000042" +} +``` + +Effets serveur : user AM **actif**, fiche `assistantes_maternelles`, n° dossier, **e-mail création MDP** (pas d’accusé « en attente »). + +### Erreurs + +| Code | Cas | +|------|-----| +| 400 | Validation / NIR / places > capacité | +| 403 | Rôle non staff | +| 409 | Email, NIR ou agrément déjà pris | +| 401 | Token manquant / invalide | + +## Front + +- `UserService.createAmDossier(body)` → cet endpoint +- Après 201 : refresh liste AM ; snackbar OK +- Wizard create : ne pas envoyer `acceptation_cgu` / `acceptation_privacy` (optionnels) + +## Branche + +`feature/156-creation-dossier-am` From 471a62ddb7450a1b978a12ccd3304fe4ad0392b5 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Wed, 22 Jul 2026 19:07:08 +0200 Subject: [PATCH 60/85] =?UTF-8?q?feat(#156):=20wizard=20admin=20cr=C3=A9at?= =?UTF-8?q?ion=20dossier=20AM=20(create/review).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Généralise ValidationAmWizard en AmDossierWizard ; le + Asmat ouvre le mode création vers POST /assistantes-maternelles/dossier. Co-authored-by: Cursor --- frontend/lib/services/api/api_config.dart | 3 + frontend/lib/services/user_service.dart | 50 + .../admin/am_dossier_create_modal.dart | 91 ++ .../lib/widgets/admin/am_dossier_wizard.dart | 981 ++++++++++++++++++ .../widgets/admin/user_management_panel.dart | 23 +- .../widgets/admin/validation_am_wizard.dart | 494 +-------- 6 files changed, 1155 insertions(+), 487 deletions(-) create mode 100644 frontend/lib/widgets/admin/am_dossier_create_modal.dart create mode 100644 frontend/lib/widgets/admin/am_dossier_wizard.dart diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index 30508a4..75a9610 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -61,6 +61,9 @@ class ApiConfig { static const String gestionnaires = '/gestionnaires'; static const String parents = '/parents'; static const String assistantesMaternelles = '/assistantes-maternelles'; + /// Création dossier AM actif par le staff (#156) — body type register AM. + static const String assistantesMaternellesDossier = + '/assistantes-maternelles/dossier'; static const String enfants = '/enfants'; static const String relais = '/relais'; static const String dossiers = '/dossiers'; diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index 86f5cad..ce8f662 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -645,6 +645,56 @@ class UserService { return fallback; } + /// Création dossier AM actif côté staff (#156). + /// `POST /assistantes-maternelles/dossier` — body aligné sur register AM. + /// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur). + /// Ne pas utiliser `POST /auth/register/am` (public / en_attente). + static Future> createAmDossier( + Map body, + ) async { + final http.Response response; + try { + response = await http.post( + Uri.parse( + '${ApiConfig.baseUrl}${ApiConfig.assistantesMaternellesDossier}', + ), + headers: await _headers(), + body: jsonEncode(body), + ); + } on http.ClientException { + throw Exception( + 'Connexion au serveur impossible. Vérifiez votre réseau puis réessayez.', + ); + } + + if (response.statusCode == 200 || response.statusCode == 201) { + if (response.body.trim().isEmpty) return {}; + try { + final decoded = jsonDecode(response.body); + if (decoded is Map) { + return Map.from(decoded); + } + } catch (_) {} + return {}; + } + + final message = _extractErrorMessage( + response.body, + 'Erreur création dossier AM', + ); + if (response.statusCode == 409) { + throw Exception(message.isNotEmpty + ? message + : 'Conflit : e-mail, NIR ou agrément déjà utilisé.'); + } + if (response.statusCode == 400) { + throw Exception( + message.isNotEmpty ? message : 'Données invalides (400).', + ); + } + throw Exception(message); + } + // Récupérer la liste des assistantes maternelles static Future> getAssistantesMaternelles() async { diff --git a/frontend/lib/widgets/admin/am_dossier_create_modal.dart b/frontend/lib/widgets/admin/am_dossier_create_modal.dart new file mode 100644 index 0000000..412ec82 --- /dev/null +++ b/frontend/lib/widgets/admin/am_dossier_create_modal.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/widgets/admin/am_dossier_wizard.dart'; + +/// Modale de création dossier AM (#156) — même shell que [ValidationDossierModal]. +class AmDossierCreateModal extends StatefulWidget { + final VoidCallback onClose; + final VoidCallback? onSuccess; + + const AmDossierCreateModal({ + super.key, + required this.onClose, + this.onSuccess, + }); + + @override + State createState() => _AmDossierCreateModalState(); +} + +class _AmDossierCreateModalState extends State { + int? _stepIndex; + int? _stepTotal; + + void _onStepChanged(int step, int total) { + if (!mounted) return; + setState(() { + _stepIndex = step; + _stepTotal = total; + }); + } + + void _onSuccess() { + widget.onSuccess?.call(); + } + + static const double _modalWidth = 930; + 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: [ + const Padding( + padding: EdgeInsets.fromLTRB(18, 18, 0, 12), + child: Text( + 'Nouvelle assistante maternelle', + style: 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: AmDossierWizard.create( + onClose: widget.onClose, + onSuccess: _onSuccess, + onStepChanged: _onStepChanged, + ), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/am_dossier_wizard.dart b/frontend/lib/widgets/admin/am_dossier_wizard.dart new file mode 100644 index 0000000..01fcc4c --- /dev/null +++ b/frontend/lib/widgets/admin/am_dossier_wizard.dart @@ -0,0 +1,981 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:p_tits_pas/models/am_registration_data.dart'; +import 'package:p_tits_pas/models/dossier_unifie.dart'; +import 'package:p_tits_pas/models/user.dart'; +import 'package:p_tits_pas/services/api/api_config.dart'; +import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/utils/date_display_utils.dart'; +import 'package:p_tits_pas/utils/email_utils.dart'; +import 'package:p_tits_pas/utils/nir_utils.dart'; +import 'package:p_tits_pas/utils/phone_utils.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.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/admin/validation_modal_theme.dart'; +import 'package:p_tits_pas/widgets/admin/validation_refus_form.dart'; +import 'package:p_tits_pas/widgets/admin/validation_valider_confirm_dialog.dart'; +import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; +import 'package:p_tits_pas/widgets/common/identity_block.dart'; + +enum AmDossierWizardMode { review, create } + +/// Wizard dossier AM — modes [review] (validation pending) et [create] (staff #156). +class AmDossierWizard extends StatefulWidget { + final AmDossierWizardMode mode; + final DossierAM? dossier; + final VoidCallback onClose; + final VoidCallback onSuccess; + final void Function(int step, int total)? onStepChanged; + + const AmDossierWizard._({ + super.key, + required this.mode, + this.dossier, + required this.onClose, + required this.onSuccess, + this.onStepChanged, + }); + + factory AmDossierWizard.review({ + Key? key, + required DossierAM dossier, + required VoidCallback onClose, + required VoidCallback onSuccess, + void Function(int step, int total)? onStepChanged, + }) { + return AmDossierWizard._( + key: key, + mode: AmDossierWizardMode.review, + dossier: dossier, + onClose: onClose, + onSuccess: onSuccess, + onStepChanged: onStepChanged, + ); + } + + factory AmDossierWizard.create({ + Key? key, + required VoidCallback onClose, + required VoidCallback onSuccess, + void Function(int step, int total)? onStepChanged, + }) { + return AmDossierWizard._( + key: key, + mode: AmDossierWizardMode.create, + onClose: onClose, + onSuccess: onSuccess, + onStepChanged: onStepChanged, + ); + } + + bool get isCreate => mode == AmDossierWizardMode.create; + + @override + State createState() => _AmDossierWizardState(); +} + +class _AmDossierWizardState extends State { + int _step = 0; + bool _showRefusForm = false; + bool _submitting = false; + + static const int _stepCount = 3; + static const List _photoProRowLayout = [2, 2, 2, 2]; + static const double _idPhotoAspectRatio = 35 / 45; + static const double _photoProGap = 24; + static const double _proColumnMinWidth = 260; + static const double _photoColumnMinWidth = 160; + + // --- Create mode controllers / state --- + late final TextEditingController _nomCtrl; + late final TextEditingController _prenomCtrl; + late final TextEditingController _telCtrl; + late final TextEditingController _emailCtrl; + late final TextEditingController _adresseCtrl; + late final TextEditingController _cpCtrl; + late final TextEditingController _villeCtrl; + late final TextEditingController _nirCtrl; + late final TextEditingController _dateNaissanceCtrl; + late final TextEditingController _lieuNaissanceVilleCtrl; + late final TextEditingController _lieuNaissancePaysCtrl; + late final TextEditingController _agrementCtrl; + late final TextEditingController _dateAgrementCtrl; + late final TextEditingController _capaciteCtrl; + late final TextEditingController _placesCtrl; + late final TextEditingController _presentationCtrl; + + Uint8List? _photoBytes; + String? _photoFilename; + + bool get _isCreate => widget.isCreate; + DossierAM get _dossier => widget.dossier!; + bool get _isEnAttente => + !_isCreate && _dossier.user.statut == 'en_attente'; + + static String _v(String? s) => + (s != null && s.trim().isNotEmpty) ? s.trim() : '–'; + + 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(); + _nomCtrl = TextEditingController(); + _prenomCtrl = TextEditingController(); + _telCtrl = TextEditingController(); + _emailCtrl = TextEditingController(); + _adresseCtrl = TextEditingController(); + _cpCtrl = TextEditingController(); + _villeCtrl = TextEditingController(); + _nirCtrl = TextEditingController(); + _dateNaissanceCtrl = TextEditingController(); + _lieuNaissanceVilleCtrl = TextEditingController(); + _lieuNaissancePaysCtrl = TextEditingController(text: 'France'); + _agrementCtrl = TextEditingController(); + _dateAgrementCtrl = TextEditingController(); + _capaciteCtrl = TextEditingController(text: '1'); + _placesCtrl = TextEditingController(text: '1'); + _presentationCtrl = TextEditingController(); + WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep()); + } + + @override + void dispose() { + _nomCtrl.dispose(); + _prenomCtrl.dispose(); + _telCtrl.dispose(); + _emailCtrl.dispose(); + _adresseCtrl.dispose(); + _cpCtrl.dispose(); + _villeCtrl.dispose(); + _nirCtrl.dispose(); + _dateNaissanceCtrl.dispose(); + _lieuNaissanceVilleCtrl.dispose(); + _lieuNaissancePaysCtrl.dispose(); + _agrementCtrl.dispose(); + _dateAgrementCtrl.dispose(); + _capaciteCtrl.dispose(); + _placesCtrl.dispose(); + _presentationCtrl.dispose(); + super.dispose(); + } + + void _emitStep() => widget.onStepChanged?.call(_step, _stepCount); + + List _photoProFields(DossierAM d) { + final u = d.user; + return [ + AdminDetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)), + AdminDetailField( + label: 'Date de naissance', + value: formatIsoDateFr(u.dateNaissance), + ), + AdminDetailField( + label: 'Ville de naissance', + value: _v(u.lieuNaissanceVille), + ), + AdminDetailField( + label: 'Pays de naissance', + value: _v(u.lieuNaissancePays), + ), + AdminDetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)), + AdminDetailField( + label: 'Date d’agrément', + value: formatIsoDateFr(d.dateAgrement), + ), + AdminDetailField( + label: 'Capacité max (enfants)', + value: d.nbMaxEnfants != null ? d.nbMaxEnfants.toString() : '–', + ), + AdminDetailField( + label: 'Places disponibles', + value: d.placesDisponibles != null + ? d.placesDisponibles.toString() + : '–', + ), + ]; + } + + static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url); + + Widget _buildPhotoSectionReview(AppUser u) { + final photoUrl = _fullPhotoUrl(u.photoUrl); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: LayoutBuilder( + builder: (context, c) { + 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), + ), + ], + ), + ) + : AuthNetworkImage( + url: 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), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + }, + ), + ), + ), + ], + ); + } + + Future _pickPhoto() async { + try { + final picked = await ImagePicker().pickImage( + source: ImageSource.gallery, + maxWidth: 1200, + imageQuality: 85, + ); + if (picked == null) return; + final bytes = await picked.readAsBytes(); + if (!mounted) return; + setState(() { + _photoBytes = bytes; + _photoFilename = picked.name; + }); + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Impossible de charger la photo'), + backgroundColor: Colors.red.shade700, + ), + ); + } + } + + void _clearPhoto() => setState(() { + _photoBytes = null; + _photoFilename = null; + }); + + String? _validateStep0() { + final nom = _nomCtrl.text.trim(); + final prenom = _prenomCtrl.text.trim(); + if (nom.length < 2) return 'Le nom doit contenir au moins 2 caractères.'; + if (prenom.length < 2) { + return 'Le prénom doit contenir au moins 2 caractères.'; + } + final phoneErr = validateFrenchNationalPhone(_telCtrl.text); + if (phoneErr != null) return phoneErr; + final emailErr = validateEmail(_emailCtrl.text); + if (emailErr != null) return emailErr; + if (_adresseCtrl.text.trim().isEmpty) { + return 'L’adresse est requise.'; + } + if (_cpCtrl.text.trim().isEmpty) return 'Le code postal est requis.'; + if (_villeCtrl.text.trim().isEmpty) return 'La ville est requise.'; + return null; + } + + String? _validateStep1() { + if (_photoBytes == null || _photoBytes!.isEmpty) { + return 'Une photo de profil est requise.'; + } + final birthIso = parseFrDateToIso(_dateNaissanceCtrl.text); + if (birthIso == null) { + return 'La date de naissance est invalide (jj/mm/aaaa).'; + } + final ville = _lieuNaissanceVilleCtrl.text.trim(); + final pays = _lieuNaissancePaysCtrl.text.trim(); + if (ville.length < 2 || pays.length < 2) { + return 'La ville et le pays de naissance sont requis (au moins 2 caractères).'; + } + final nirErr = validateNir(_nirCtrl.text); + if (nirErr != null) return nirErr; + if (_agrementCtrl.text.trim().isEmpty) { + return 'Le numéro d’agrément est requis.'; + } + final agrementIso = parseFrDateToIso(_dateAgrementCtrl.text); + if (agrementIso == null) { + return 'La date d’agrément est invalide (jj/mm/aaaa).'; + } + final capa = int.tryParse(_capaciteCtrl.text.trim()); + if (capa == null || capa < 1 || capa > kAmCapaciteAccueilMax) { + return 'La capacité doit être entre 1 et $kAmCapaciteAccueilMax.'; + } + final places = int.tryParse(_placesCtrl.text.trim()); + if (places == null || places < 0 || places > capa) { + return 'Les places disponibles doivent être entre 0 et la capacité.'; + } + return null; + } + + String? _validateCurrentStep() { + if (!_isCreate) return null; + switch (_step) { + case 0: + return _validateStep0(); + case 1: + return _validateStep1(); + default: + return null; + } + } + + void _goNext() { + final err = _validateCurrentStep(); + if (err != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(err), backgroundColor: Colors.red.shade700), + ); + return; + } + setState(() => _step++); + _emitStep(); + } + + static String _imageMimeForBytes(Uint8List bytes) { + if (bytes.length >= 8 && + bytes[0] == 0x89 && + bytes[1] == 0x50 && + bytes[2] == 0x4E && + bytes[3] == 0x47) { + return 'image/png'; + } + if (bytes.length >= 3 && + bytes[0] == 0xFF && + bytes[1] == 0xD8 && + bytes[2] == 0xFF) { + return 'image/jpeg'; + } + if (bytes.length >= 6 && + bytes[0] == 0x47 && + bytes[1] == 0x49 && + bytes[2] == 0x46) { + return 'image/gif'; + } + if (bytes.length >= 12 && + bytes[0] == 0x52 && + bytes[1] == 0x49 && + bytes[2] == 0x46 && + bytes[3] == 0x46) { + return 'image/webp'; + } + return 'image/jpeg'; + } + + Map _buildCreateBody() { + final bytes = _photoBytes!; + final mime = _imageMimeForBytes(bytes); + final photoBase64 = 'data:$mime;base64,${base64Encode(bytes)}'; + final fn = (_photoFilename ?? '').trim(); + final birthIso = parseFrDateToIso(_dateNaissanceCtrl.text)!; + final agrementIso = parseFrDateToIso(_dateAgrementCtrl.text)!; + final capa = int.parse(_capaciteCtrl.text.trim()); + final places = int.parse(_placesCtrl.text.trim()); + final presentation = _presentationCtrl.text.trim(); + + return { + 'email': normalizeEmailText(_emailCtrl.text), + 'prenom': _prenomCtrl.text.trim(), + 'nom': _nomCtrl.text.trim(), + 'telephone': normalizePhone(_telCtrl.text), + 'adresse': + _adresseCtrl.text.trim().isNotEmpty ? _adresseCtrl.text.trim() : null, + 'code_postal': + _cpCtrl.text.trim().isNotEmpty ? _cpCtrl.text.trim() : null, + 'ville': + _villeCtrl.text.trim().isNotEmpty ? _villeCtrl.text.trim() : null, + 'photo_base64': photoBase64, + 'photo_filename': fn.isNotEmpty ? fn : 'photo_am.jpg', + 'consentement_photo': true, + 'date_naissance': birthIso, + 'lieu_naissance_ville': _lieuNaissanceVilleCtrl.text.trim(), + 'lieu_naissance_pays': _lieuNaissancePaysCtrl.text.trim(), + 'nir': normalizeNir(_nirCtrl.text), + 'numero_agrement': _agrementCtrl.text.trim(), + 'date_agrement': agrementIso, + 'capacite_accueil': capa, + 'places_disponibles': places, + 'biographie': presentation.isNotEmpty ? presentation : null, + 'acceptation_cgu': true, + 'acceptation_privacy': true, + }; + } + + Future _createAndValidate() async { + if (_submitting) return; + final err0 = _validateStep0(); + if (err0 != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(err0), backgroundColor: Colors.red.shade700), + ); + return; + } + final err1 = _validateStep1(); + if (err1 != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(err1), backgroundColor: Colors.red.shade700), + ); + return; + } + + final ok = await showValidationValiderConfirmDialog( + context, + body: + 'Créer et activer le dossier de cette assistante maternelle ? Un e-mail de création de mot de passe sera envoyé.', + ); + if (!mounted || !ok) return; + + setState(() => _submitting = true); + try { + await UserService.createAmDossier(_buildCreateBody()); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Dossier créé et validé. L’assistante maternelle est active.', + ), + duration: Duration(seconds: 4), + ), + ); + 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); + } + } + + @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() { + switch (_step) { + case 0: + return _buildStep0(); + case 1: + return _buildStep1(); + case 2: + return _buildStep2(); + default: + return const SizedBox(); + } + } + + Widget _buildStep0() { + if (_isCreate) { + return LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: constraints.maxWidth), + child: IdentityBlock.editable( + title: 'Identité et coordonnées', + nomController: _nomCtrl, + prenomController: _prenomCtrl, + telephoneController: _telCtrl, + emailController: _emailCtrl, + adresseController: _adresseCtrl, + codePostalController: _cpCtrl, + villeController: _villeCtrl, + ), + ), + ); + }, + ); + } + final u = _dossier.user; + return LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: constraints.maxWidth), + child: IdentityBlock.readOnlyFromUser( + u, + title: 'Identité et coordonnées', + emptyLabel: '–', + ), + ), + ); + }, + ); + } + + Widget _buildStep1() { + return LayoutBuilder( + builder: (context, c) { + final maxRowW = c.maxWidth; + final maxRowH = c.maxHeight; + const photoHeaderH = 0.0; + final bodyH = (maxRowH - photoHeaderH).clamp(0.0, double.infinity); + final idealPhotoW = bodyH * _idPhotoAspectRatio + 16; + 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: _isCreate + ? AdminAmPhotoFrame( + imageBytes: _photoBytes, + onTap: _pickPhoto, + onClear: _photoBytes != null ? _clearPhoto : null, + emptyLabel: 'Ajouter une photo', + ) + : _buildPhotoSectionReview(_dossier.user), + ), + const SizedBox(width: _photoProGap), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: + BoxConstraints(minWidth: constraints.maxWidth), + child: _isCreate + ? _buildCreateProFields() + : ValidationDetailSection( + title: 'Dossier professionnel', + fields: _photoProFields(_dossier), + rowLayout: _photoProRowLayout, + ), + ), + ); + }, + ), + ), + ], + ); + }, + ); + } + + Widget _buildCreateProFields() { + return ValidationFormGrid( + title: 'Dossier professionnel', + rowLayout: _photoProRowLayout, + fields: [ + ValidationLabeledField( + label: 'NIR', + field: ValidationEditableField( + controller: _nirCtrl, + hintText: '15 caractères', + inputFormatters: [NirInputFormatter()], + ), + ), + ValidationLabeledField( + label: 'Date de naissance', + field: ValidationEditableField( + controller: _dateNaissanceCtrl, + hintText: 'jj / mm / aaaa', + keyboardType: TextInputType.number, + inputFormatters: const [FrenchDateInputFormatter()], + ), + ), + ValidationLabeledField( + label: 'Ville de naissance', + field: ValidationEditableField( + controller: _lieuNaissanceVilleCtrl, + ), + ), + ValidationLabeledField( + label: 'Pays de naissance', + field: ValidationEditableField( + controller: _lieuNaissancePaysCtrl, + ), + ), + ValidationLabeledField( + label: 'N° Agrément', + field: ValidationEditableField(controller: _agrementCtrl), + ), + ValidationLabeledField( + label: 'Date d’agrément', + field: ValidationEditableField( + controller: _dateAgrementCtrl, + hintText: 'jj / mm / aaaa', + keyboardType: TextInputType.number, + inputFormatters: const [FrenchDateInputFormatter()], + ), + ), + ValidationLabeledField( + label: 'Capacité max (enfants)', + field: ValidationEditableField( + controller: _capaciteCtrl, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + ), + ValidationLabeledField( + label: 'Places disponibles', + field: ValidationEditableField( + controller: _placesCtrl, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + ), + ], + ); + } + + Widget _buildStep2() { + if (_isCreate) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Présentation', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + const SizedBox(height: 12), + Expanded( + child: TextField( + controller: _presentationCtrl, + maxLines: null, + expands: true, + maxLength: 2000, + textAlignVertical: TextAlignVertical.top, + decoration: InputDecoration( + hintText: 'Présentation (optionnel, max 2000 caractères)', + filled: true, + fillColor: Colors.grey.shade50, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + ), + ), + ), + ], + ); + } + + final presentation = + (_dossier.presentation != null && _dossier.presentation!.trim().isNotEmpty) + ? _dossier.presentation! + : '–'; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Présentation', + 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( + presentation, + style: const TextStyle(color: Colors.black87, fontSize: 14), + ), + ), + ), + ); + }, + ), + ), + ], + ); + } + + 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 (_isCreate) ...[ + ElevatedButton( + style: ValidationModalTheme.primaryElevatedStyle, + onPressed: _submitting ? null : _createAndValidate, + child: Text(_submitting ? 'Envoi...' : 'Créer et valider'), + ), + ] else 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: _goNext, + 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(_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( + isSubmitting: _submitting, + onCancel: widget.onClose, + onPrevious: () => setState(() => _showRefusForm = false), + onSubmit: (comment) { + if (comment == null || comment.trim().isEmpty) return; + _refuserEnvoyer(comment.trim()); + }, + ), + ), + ], + ), + ); + } + + Future _refuserEnvoyer(String comment) async { + if (_submitting) return; + setState(() => _submitting = true); + try { + await UserService.refuseUser(_dossier.user.id, comment: comment); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Refus enregistré. Un e-mail avec le lien de reprise a été envoyé.', + ), + duration: Duration(seconds: 4), + ), + ); + 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); + } + } +} diff --git a/frontend/lib/widgets/admin/user_management_panel.dart b/frontend/lib/widgets/admin/user_management_panel.dart index 3abcb4c..d13eca7 100644 --- a/frontend/lib/widgets/admin/user_management_panel.dart +++ b/frontend/lib/widgets/admin/user_management_panel.dart @@ -2,6 +2,7 @@ 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/am_dossier_create_modal.dart'; import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart'; @@ -28,6 +29,7 @@ class _UserManagementPanelState extends State { int _gestionnaireRefreshTick = 0; int _adminRefreshTick = 0; int _enfantRefreshTick = 0; + int _amRefreshTick = 0; final TextEditingController _searchController = TextEditingController(); final TextEditingController _amCapacityController = TextEditingController(); String? _parentStatus; @@ -272,6 +274,7 @@ class _UserManagementPanelState extends State { ); case 2: return AssistanteMaternelleManagementWidget( + key: ValueKey('ams-$_amRefreshTick'), searchQuery: _searchController.text, capacityMin: int.tryParse(_amCapacityController.text), ); @@ -331,6 +334,24 @@ class _UserManagementPanelState extends State { return; } + if (contentIndex == 2) { + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + return AmDossierCreateModal( + onClose: () => Navigator.of(dialogContext).pop(), + onSuccess: () { + Navigator.of(dialogContext).pop(); + if (!mounted) return; + setState(() => _amRefreshTick++); + }, + ); + }, + ); + return; + } + if (contentIndex == 3) { final created = await showDialog( context: context, @@ -374,7 +395,7 @@ class _UserManagementPanelState extends State { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( - 'La création parent / enfant / AM sera disponible avec le ticket #129.', + 'La création parent sera disponible avec le ticket #129.', ), ), ); diff --git a/frontend/lib/widgets/admin/validation_am_wizard.dart b/frontend/lib/widgets/admin/validation_am_wizard.dart index 5ea035a..0edc9ac 100644 --- a/frontend/lib/widgets/admin/validation_am_wizard.dart +++ b/frontend/lib/widgets/admin/validation_am_wizard.dart @@ -1,20 +1,9 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart'; -import 'package:p_tits_pas/utils/date_display_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 'package:p_tits_pas/widgets/common/identity_block.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'; +import 'package:p_tits_pas/widgets/admin/am_dossier_wizard.dart'; -/// Wizard de validation dossier AM : étapes sobres (label/valeur), récap, Valider/Refuser/Annuler, page refus. Ticket #107. -class ValidationAmWizard extends StatefulWidget { +/// Wrapper historique (#107) — délègue à [AmDossierWizard.review]. +class ValidationAmWizard extends StatelessWidget { final DossierAM dossier; final VoidCallback onClose; final VoidCallback onSuccess; @@ -28,480 +17,13 @@ class ValidationAmWizard extends StatefulWidget { 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); - - /// Panneau photo + grille droite : NIR|naissance, ville|pays, agrément|date, capa|places. - List _photoProFields(DossierAM d) { - final u = d.user; - return [ - AdminDetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)), - AdminDetailField( - label: 'Date de naissance', - value: formatIsoDateFr(u.dateNaissance), - ), - AdminDetailField( - label: 'Ville de naissance', - value: _v(u.lieuNaissanceVille), - ), - AdminDetailField( - label: 'Pays de naissance', - value: _v(u.lieuNaissancePays), - ), - AdminDetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)), - AdminDetailField( - label: 'Date d’agrément', - value: formatIsoDateFr(d.dateAgrement), - ), - AdminDetailField( - label: 'Capacité max (enfants)', - value: d.nbMaxEnfants != null ? d.nbMaxEnfants.toString() : '–', - ), - AdminDetailField( - label: 'Places disponibles', - value: d.placesDisponibles != null - ? d.placesDisponibles.toString() - : '–', - ), - ]; - } - - static const List _photoProRowLayout = [2, 2, 2, 2]; - - /// 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, 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); - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - 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), - ), - ], - ), - ) - : AuthNetworkImage( - url: 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(), - ], - ), + return AmDossierWizard.review( + dossier: dossier, + onClose: onClose, + onSuccess: onSuccess, + onStepChanged: onStepChanged, ); } - - 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: IdentityBlock.readOnlyFromUser( - u, - title: 'Identité et coordonnées', - emptyLabel: '–', - ), - ), - ); - }, - ); - 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; - const photoHeaderH = 0.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: 'Dossier professionnel', - fields: _photoProFields(d), - rowLayout: _photoProRowLayout, - ), - ), - ); - }, - ), - ), - ], - ); - }, - ); - case 2: - final presentation = - (d.presentation != null && d.presentation!.trim().isNotEmpty) - ? d.presentation! - : '–'; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Text( - 'Présentation', - 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( - 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( - isSubmitting: _submitting, - onCancel: widget.onClose, - onPrevious: () => setState(() => _showRefusForm = false), - onSubmit: (comment) { - if (comment == null || comment.trim().isEmpty) return; - _refuserEnvoyer(comment.trim()); - }, - ), - ), - ], - ), - ); - } - - Future _refuserEnvoyer(String comment) async { - if (_submitting) return; - setState(() => _submitting = true); - try { - await UserService.refuseUser(widget.dossier.user.id, comment: comment); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text( - 'Refus enregistré. Un e-mail avec le lien de reprise a été envoyé.', - ), - duration: const Duration(seconds: 4), - ), - ); - 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); - } - } } From 4ae334b2471c66804c5ec350a3b78898ce4c57b1 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Wed, 22 Jul 2026 19:27:20 +0200 Subject: [PATCH 61/85] =?UTF-8?q?fix(#156):=20superviser=20nom,=20e-mail?= =?UTF-8?q?=20et=20CP=20comme=20=C3=A0=20l=E2=80=99inscription.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IdentityBlock editable : capitalisation live, e-mail/CP normalisés et validés à la perte de focus (aligné création de compte). Co-authored-by: Cursor --- .../lib/widgets/admin/am_dossier_wizard.dart | 22 ++- .../common/validation_detail_section.dart | 154 ++++++++++++++++++ .../lib/widgets/common/identity_block.dart | 26 +-- 3 files changed, 184 insertions(+), 18 deletions(-) diff --git a/frontend/lib/widgets/admin/am_dossier_wizard.dart b/frontend/lib/widgets/admin/am_dossier_wizard.dart index 01fcc4c..d1c34e1 100644 --- a/frontend/lib/widgets/admin/am_dossier_wizard.dart +++ b/frontend/lib/widgets/admin/am_dossier_wizard.dart @@ -10,8 +10,10 @@ import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/utils/date_display_utils.dart'; import 'package:p_tits_pas/utils/email_utils.dart'; +import 'package:p_tits_pas/utils/name_format_utils.dart'; import 'package:p_tits_pas/utils/nir_utils.dart'; import 'package:p_tits_pas/utils/phone_utils.dart'; +import 'package:p_tits_pas/utils/postal_utils.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.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'; @@ -361,7 +363,8 @@ class _AmDossierWizardState extends State { if (_adresseCtrl.text.trim().isEmpty) { return 'L’adresse est requise.'; } - if (_cpCtrl.text.trim().isEmpty) return 'Le code postal est requis.'; + final cpErr = validateFrenchPostalCode(_cpCtrl.text); + if (cpErr != null) return cpErr; if (_villeCtrl.text.trim().isEmpty) return 'La ville est requise.'; return null; } @@ -466,21 +469,24 @@ class _AmDossierWizardState extends State { return { 'email': normalizeEmailText(_emailCtrl.text), - 'prenom': _prenomCtrl.text.trim(), - 'nom': _nomCtrl.text.trim(), + 'prenom': formatPersonNameCase(_prenomCtrl.text), + 'nom': formatPersonNameCase(_nomCtrl.text), 'telephone': normalizePhone(_telCtrl.text), 'adresse': _adresseCtrl.text.trim().isNotEmpty ? _adresseCtrl.text.trim() : null, 'code_postal': _cpCtrl.text.trim().isNotEmpty ? _cpCtrl.text.trim() : null, - 'ville': - _villeCtrl.text.trim().isNotEmpty ? _villeCtrl.text.trim() : null, + 'ville': _villeCtrl.text.trim().isNotEmpty + ? formatPersonNameCase(_villeCtrl.text) + : null, 'photo_base64': photoBase64, 'photo_filename': fn.isNotEmpty ? fn : 'photo_am.jpg', 'consentement_photo': true, 'date_naissance': birthIso, - 'lieu_naissance_ville': _lieuNaissanceVilleCtrl.text.trim(), - 'lieu_naissance_pays': _lieuNaissancePaysCtrl.text.trim(), + 'lieu_naissance_ville': + formatPersonNameCase(_lieuNaissanceVilleCtrl.text), + 'lieu_naissance_pays': + formatPersonNameCase(_lieuNaissancePaysCtrl.text), 'nir': normalizeNir(_nirCtrl.text), 'numero_agrement': _agrementCtrl.text.trim(), 'date_agrement': agrementIso, @@ -695,12 +701,14 @@ class _AmDossierWizardState extends State { label: 'Ville de naissance', field: ValidationEditableField( controller: _lieuNaissanceVilleCtrl, + inputFormatters: const [PersonNameInputFormatter()], ), ), ValidationLabeledField( label: 'Pays de naissance', field: ValidationEditableField( controller: _lieuNaissancePaysCtrl, + inputFormatters: const [PersonNameInputFormatter()], ), ), ValidationLabeledField( diff --git a/frontend/lib/widgets/admin/common/validation_detail_section.dart b/frontend/lib/widgets/admin/common/validation_detail_section.dart index 610dd98..a5d2df8 100644 --- a/frontend/lib/widgets/admin/common/validation_detail_section.dart +++ b/frontend/lib/widgets/admin/common/validation_detail_section.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:p_tits_pas/utils/email_utils.dart'; +import 'package:p_tits_pas/utils/postal_utils.dart'; import 'admin_detail_modal.dart'; /// Bloc type formulaire (titre de section + champs read-only) pour les modales de validation. @@ -295,6 +297,158 @@ class ValidationEditableField extends StatelessWidget { } } +/// E-mail style validation — même supervision que login / création de compte : +/// [EmailMaxLengthFormatter] + à la perte de focus : trim/minuscules + validation. +class ValidationEmailField extends StatefulWidget { + final TextEditingController controller; + final String? hintText; + final bool allowEmpty; + + const ValidationEmailField({ + super.key, + required this.controller, + this.hintText, + this.allowEmpty = false, + }); + + @override + State createState() => _ValidationEmailFieldState(); +} + +class _ValidationEmailFieldState extends State { + final GlobalKey> _fieldKey = + GlobalKey>(); + late final FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(); + _focusNode.addListener(_onFocusChange); + } + + void _onFocusChange() { + if (_focusNode.hasFocus) 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); + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TextFormField( + key: _fieldKey, + controller: widget.controller, + focusNode: _focusNode, + keyboardType: TextInputType.emailAddress, + autocorrect: false, + enableSuggestions: false, + autofillHints: const [AutofillHints.email], + textInputAction: TextInputAction.next, + inputFormatters: const [EmailMaxLengthFormatter()], + style: const TextStyle(color: Colors.black87, fontSize: 14), + decoration: ValidationFieldDecoration.input(hint: widget.hintText).copyWith( + errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11), + errorMaxLines: 2, + ), + validator: (value) => + validateEmail(value, allowEmpty: widget.allowEmpty), + ); + } +} + +/// Code postal FR — même supervision que création de compte : +/// chiffres uniquement (max 5) + validation à la perte de focus. +class ValidationPostalCodeField extends StatefulWidget { + final TextEditingController controller; + final String? hintText; + final bool allowEmpty; + + const ValidationPostalCodeField({ + super.key, + required this.controller, + this.hintText, + this.allowEmpty = false, + }); + + @override + State createState() => + _ValidationPostalCodeFieldState(); +} + +class _ValidationPostalCodeFieldState extends State { + final GlobalKey> _fieldKey = + GlobalKey>(); + late final FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(); + _focusNode.addListener(_onFocusChange); + } + + void _onFocusChange() { + if (_focusNode.hasFocus) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _focusNode.hasFocus) return; + final c = widget.controller; + final trimmed = c.text.trim(); + if (trimmed != c.text) { + c.value = TextEditingValue( + text: trimmed, + selection: TextSelection.collapsed(offset: trimmed.length), + ); + } + _fieldKey.currentState?.validate(); + }); + } + + @override + void dispose() { + _focusNode.removeListener(_onFocusChange); + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TextFormField( + key: _fieldKey, + controller: widget.controller, + focusNode: _focusNode, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.next, + inputFormatters: kFrenchPostalCodeInputFormatters, + style: const TextStyle(color: Colors.black87, fontSize: 14), + decoration: ValidationFieldDecoration.input( + hint: widget.hintText ?? '5 chiffres', + ).copyWith( + errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11), + errorMaxLines: 2, + ), + validator: (value) => + validateFrenchPostalCode(value, allowEmpty: widget.allowEmpty), + ); + } +} + /// Grille label/champ éditable (délègue à [ValidationFormGrid]). class ValidationEditableSection extends StatelessWidget { final List fields; diff --git a/frontend/lib/widgets/common/identity_block.dart b/frontend/lib/widgets/common/identity_block.dart index 91a1d30..e283e2f 100644 --- a/frontend/lib/widgets/common/identity_block.dart +++ b/frontend/lib/widgets/common/identity_block.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart'; import 'package:p_tits_pas/models/user.dart'; +import 'package:p_tits_pas/utils/name_format_utils.dart'; import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; @@ -199,11 +200,17 @@ class IdentityBlock extends StatelessWidget { final String? title; fields: [ ValidationLabeledField( label: 'Nom', - field: ValidationEditableField(controller: _nomCtrl!), + field: ValidationEditableField( + controller: _nomCtrl!, + inputFormatters: const [PersonNameInputFormatter()], + ), ), ValidationLabeledField( label: 'Prénom', - field: ValidationEditableField(controller: _prenomCtrl!), + field: ValidationEditableField( + controller: _prenomCtrl!, + inputFormatters: const [PersonNameInputFormatter()], + ), ), ValidationLabeledField( label: 'Téléphone', @@ -215,10 +222,7 @@ class IdentityBlock extends StatelessWidget { final String? title; ), ValidationLabeledField( label: 'Email', - field: ValidationEditableField( - controller: _emailCtrl!, - keyboardType: TextInputType.emailAddress, - ), + field: ValidationEmailField(controller: _emailCtrl!), ), ValidationLabeledField( label: 'Adresse (N° et Rue)', @@ -226,14 +230,14 @@ class IdentityBlock extends StatelessWidget { final String? title; ), ValidationLabeledField( label: 'Code postal', - field: ValidationEditableField( - controller: _cpCtrl!, - keyboardType: TextInputType.number, - ), + field: ValidationPostalCodeField(controller: _cpCtrl!), ), ValidationLabeledField( label: 'Ville', - field: ValidationEditableField(controller: _villeCtrl!), + field: ValidationEditableField( + controller: _villeCtrl!, + inputFormatters: const [PersonNameInputFormatter()], + ), ), ], ); From e3552667bc19b5ba821c319cdcfb7b514c568598 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 23 Jul 2026 16:28:35 +0200 Subject: [PATCH 62/85] =?UTF-8?q?fix(#156):=20valider=20t=C3=A9l=C3=A9phon?= =?UTF-8?q?e=20au=20blur=20et=20NIR=20en=20live.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Formatage NIR progressif + contrôles au fil de la saisie ; téléphone validé à la perte de focus comme e-mail / CP. Co-authored-by: Cursor --- frontend/lib/utils/nir_utils.dart | 46 ++++- .../lib/widgets/admin/am_dossier_wizard.dart | 6 +- .../common/validation_detail_section.dart | 163 ++++++++++++++++++ .../lib/widgets/common/identity_block.dart | 6 +- frontend/lib/widgets/nir_text_field.dart | 2 +- 5 files changed, 203 insertions(+), 20 deletions(-) diff --git a/frontend/lib/utils/nir_utils.dart b/frontend/lib/utils/nir_utils.dart index ac64e05..01beb8e 100644 --- a/frontend/lib/utils/nir_utils.dart +++ b/frontend/lib/utils/nir_utils.dart @@ -49,12 +49,21 @@ 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 (complet ou en cours de saisie) : +/// `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)}'; + final r = nirToRaw(raw).toUpperCase(); + if (r.isEmpty) return ''; + final buf = StringBuffer(); + for (var i = 0; i < r.length && i < 15; i++) { + if (i == 1 || i == 3 || i == 5 || i == 7 || i == 10) { + buf.write(' '); + } else if (i == 13) { + buf.write(' - '); + } + buf.write(r[i]); + } + return buf.toString(); } /// Aligné sur le backend (NIR côté API / nir.util.ts) : sexe 1–3, département 2A ou 2B pour la Corse. @@ -92,20 +101,39 @@ 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. +/// Validation pendant la saisie : pas d’erreur si incomplet encore plausible ; +/// dès 15 caractères, même contrôles que [validateNir]. +String? validateNirTyping(String? value) { + if (value == null || value.trim().isEmpty) return null; + final raw = nirToRaw(value).toUpperCase(); + if (raw.isEmpty) return null; + if (raw[0] != '1' && raw[0] != '2' && raw[0] != '3') { + return 'Format NIR invalide (ex. 1 12 34 56 789 012 - 34 ou 2A pour la Corse)'; + } + if (raw.length < 15) return null; + return validateNir(value); +} + +/// Formateur de saisie : affiche le NIR formaté au fil de la frappe et limite à 15 caractères utiles. class NirInputFormatter extends TextInputFormatter { + const NirInputFormatter(); + @override TextEditingValue formatEditUpdate( TextEditingValue oldValue, TextEditingValue newValue, ) { final raw = normalizeNir(newValue.text); - if (raw.isEmpty) return newValue; + if (raw.isEmpty) { + return const TextEditingValue( + text: '', + selection: TextSelection.collapsed(offset: 0), + ); + } final formatted = formatNir(raw); - final offset = formatted.length; return TextEditingValue( text: formatted, - selection: TextSelection.collapsed(offset: offset), + selection: TextSelection.collapsed(offset: formatted.length), ); } } diff --git a/frontend/lib/widgets/admin/am_dossier_wizard.dart b/frontend/lib/widgets/admin/am_dossier_wizard.dart index d1c34e1..dca588f 100644 --- a/frontend/lib/widgets/admin/am_dossier_wizard.dart +++ b/frontend/lib/widgets/admin/am_dossier_wizard.dart @@ -682,11 +682,7 @@ class _AmDossierWizardState extends State { fields: [ ValidationLabeledField( label: 'NIR', - field: ValidationEditableField( - controller: _nirCtrl, - hintText: '15 caractères', - inputFormatters: [NirInputFormatter()], - ), + field: ValidationNirField(controller: _nirCtrl), ), ValidationLabeledField( label: 'Date de naissance', diff --git a/frontend/lib/widgets/admin/common/validation_detail_section.dart b/frontend/lib/widgets/admin/common/validation_detail_section.dart index a5d2df8..b133748 100644 --- a/frontend/lib/widgets/admin/common/validation_detail_section.dart +++ b/frontend/lib/widgets/admin/common/validation_detail_section.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:p_tits_pas/utils/email_utils.dart'; +import 'package:p_tits_pas/utils/nir_utils.dart'; +import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/utils/postal_utils.dart'; import 'admin_detail_modal.dart'; @@ -449,6 +451,167 @@ class _ValidationPostalCodeFieldState extends State { } } +/// Téléphone FR — formatters live + [validateFrenchNationalPhone] à la perte de focus. +class ValidationPhoneField extends StatefulWidget { + final TextEditingController controller; + final String? hintText; + final bool allowEmpty; + + const ValidationPhoneField({ + super.key, + required this.controller, + this.hintText, + this.allowEmpty = false, + }); + + @override + State createState() => _ValidationPhoneFieldState(); +} + +class _ValidationPhoneFieldState extends State { + final GlobalKey> _fieldKey = + GlobalKey>(); + late final FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(); + _focusNode.addListener(_onFocusChange); + } + + void _onFocusChange() { + if (_focusNode.hasFocus) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _focusNode.hasFocus) return; + final c = widget.controller; + final digits = normalizePhone(c.text); + final formatted = digits.isEmpty ? '' : formatPhoneForDisplay(digits); + if (formatted != c.text) { + c.value = TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: formatted.length), + ); + } + _fieldKey.currentState?.validate(); + }); + } + + @override + void dispose() { + _focusNode.removeListener(_onFocusChange); + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TextFormField( + key: _fieldKey, + controller: widget.controller, + focusNode: _focusNode, + keyboardType: TextInputType.phone, + textInputAction: TextInputAction.next, + inputFormatters: frenchPhoneInputFormatters, + style: const TextStyle(color: Colors.black87, fontSize: 14), + decoration: ValidationFieldDecoration.input(hint: widget.hintText).copyWith( + errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11), + errorMaxLines: 2, + ), + validator: (value) => + validateFrenchNationalPhone(value, allowEmpty: widget.allowEmpty), + ); + } +} + +/// NIR — formatage live ([NirInputFormatter]) + validation au fil de la saisie / blur. +class ValidationNirField extends StatefulWidget { + final TextEditingController controller; + final String? hintText; + final bool allowEmpty; + + const ValidationNirField({ + super.key, + required this.controller, + this.hintText, + this.allowEmpty = false, + }); + + @override + State createState() => _ValidationNirFieldState(); +} + +class _ValidationNirFieldState extends State { + final GlobalKey> _fieldKey = + GlobalKey>(); + late final FocusNode _focusNode; + bool _blurred = false; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(); + _focusNode.addListener(_onFocusChange); + } + + void _onFocusChange() { + if (_focusNode.hasFocus) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _focusNode.hasFocus) return; + setState(() => _blurred = true); + final c = widget.controller; + final raw = nirToRaw(c.text).toUpperCase(); + final formatted = raw.isEmpty ? '' : formatNir(raw); + if (formatted != c.text) { + c.value = TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: formatted.length), + ); + } + _fieldKey.currentState?.validate(); + }); + } + + @override + void dispose() { + _focusNode.removeListener(_onFocusChange); + _focusNode.dispose(); + super.dispose(); + } + + String? _validator(String? value) { + if (_blurred) { + if (widget.allowEmpty && (value == null || value.trim().isEmpty)) { + return null; + } + return validateNir(value); + } + return validateNirTyping(value); + } + + @override + Widget build(BuildContext context) { + return TextFormField( + key: _fieldKey, + controller: widget.controller, + focusNode: _focusNode, + keyboardType: TextInputType.text, + textInputAction: TextInputAction.next, + autovalidateMode: AutovalidateMode.onUserInteraction, + inputFormatters: const [NirInputFormatter()], + style: const TextStyle(color: Colors.black87, fontSize: 14), + decoration: ValidationFieldDecoration.input( + hint: widget.hintText ?? '1 12 34 56 789 012 - 34', + ).copyWith( + errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11), + errorMaxLines: 2, + ), + onChanged: (_) => _fieldKey.currentState?.validate(), + validator: _validator, + ); + } +} + /// Grille label/champ éditable (délègue à [ValidationFormGrid]). class ValidationEditableSection extends StatelessWidget { final List fields; diff --git a/frontend/lib/widgets/common/identity_block.dart b/frontend/lib/widgets/common/identity_block.dart index e283e2f..d42de66 100644 --- a/frontend/lib/widgets/common/identity_block.dart +++ b/frontend/lib/widgets/common/identity_block.dart @@ -214,11 +214,7 @@ class IdentityBlock extends StatelessWidget { final String? title; ), ValidationLabeledField( label: 'Téléphone', - field: ValidationEditableField( - controller: _telCtrl!, - keyboardType: TextInputType.phone, - inputFormatters: frenchPhoneInputFormatters, - ), + field: ValidationPhoneField(controller: _telCtrl!), ), ValidationLabeledField( label: 'Email', diff --git a/frontend/lib/widgets/nir_text_field.dart b/frontend/lib/widgets/nir_text_field.dart index 7ea3d94..16fcff9 100644 --- a/frontend/lib/widgets/nir_text_field.dart +++ b/frontend/lib/widgets/nir_text_field.dart @@ -54,7 +54,7 @@ class NirTextField extends StatelessWidget { inputFontSize: inputFontSize, keyboardType: TextInputType.text, validator: validator ?? validateNir, - inputFormatters: [NirInputFormatter()], + inputFormatters: const [NirInputFormatter()], enabled: enabled, readOnly: readOnly, style: style, From 8ee2ca8ea6bd6e4a3e53076c5b23288bb70154e8 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 23 Jul 2026 16:49:59 +0200 Subject: [PATCH 63/85] fix(#156): caler la modale AM sur les TF et unifier create/review. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ValidationFormMetrics (titres, TF, écarts) pilote la hauteur ; photo étirée sur le corps ; mêmes widgets create et validation. Co-authored-by: Cursor --- .../admin/am_dossier_create_modal.dart | 3 +- .../lib/widgets/admin/am_dossier_wizard.dart | 86 ++--- .../common/validation_detail_section.dart | 323 ++++++++++++------ .../admin/validation_dossier_modal.dart | 10 +- .../lib/widgets/common/identity_block.dart | 12 +- 5 files changed, 272 insertions(+), 162 deletions(-) diff --git a/frontend/lib/widgets/admin/am_dossier_create_modal.dart b/frontend/lib/widgets/admin/am_dossier_create_modal.dart index 412ec82..f221d93 100644 --- a/frontend/lib/widgets/admin/am_dossier_create_modal.dart +++ b/frontend/lib/widgets/admin/am_dossier_create_modal.dart @@ -33,7 +33,8 @@ class _AmDossierCreateModalState extends State { } static const double _modalWidth = 930; - static const double _bodyHeight = 435; + /// Hauteur calculée depuis 4 lignes de TF (voir [AmDossierWizard.shellBodyHeight]). + static double get _bodyHeight => AmDossierWizard.shellBodyHeight; @override Widget build(BuildContext context) { diff --git a/frontend/lib/widgets/admin/am_dossier_wizard.dart b/frontend/lib/widgets/admin/am_dossier_wizard.dart index dca588f..affb5ea 100644 --- a/frontend/lib/widgets/admin/am_dossier_wizard.dart +++ b/frontend/lib/widgets/admin/am_dossier_wizard.dart @@ -76,6 +76,10 @@ class AmDossierWizard extends StatefulWidget { bool get isCreate => mode == AmDossierWizardMode.create; + /// Hauteur corps modale AM — dérivée de [ValidationFormMetrics] (4 lignes). + static double get shellBodyHeight => + ValidationFormMetrics.shellBodyHeightForRows(4); + @override State createState() => _AmDossierWizardState(); } @@ -584,56 +588,45 @@ class _AmDossierWizardState extends State { Widget _buildStep0() { if (_isCreate) { - return LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints(minWidth: constraints.maxWidth), - child: IdentityBlock.editable( - title: 'Identité et coordonnées', - nomController: _nomCtrl, - prenomController: _prenomCtrl, - telephoneController: _telCtrl, - emailController: _emailCtrl, - adresseController: _adresseCtrl, - codePostalController: _cpCtrl, - villeController: _villeCtrl, - ), - ), - ); - }, + return IdentityBlock.editable( + title: 'Identité et coordonnées', + nomController: _nomCtrl, + prenomController: _prenomCtrl, + telephoneController: _telCtrl, + emailController: _emailCtrl, + adresseController: _adresseCtrl, + codePostalController: _cpCtrl, + villeController: _villeCtrl, ); } final u = _dossier.user; - return LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints(minWidth: constraints.maxWidth), - child: IdentityBlock.readOnlyFromUser( - u, - title: 'Identité et coordonnées', - emptyLabel: '–', - ), - ), - ); - }, + return IdentityBlock.readOnlyFromUser( + u, + title: 'Identité et coordonnées', + emptyLabel: '–', ); } Widget _buildStep1() { + // Modale calée sur les TF ; photo étirée sur toute la hauteur utile + // (largeur = [AdminAmPhotoFrame.columnWidthForHeight], cadre inclus). return LayoutBuilder( builder: (context, c) { final maxRowW = c.maxWidth; - final maxRowH = c.maxHeight; - const photoHeaderH = 0.0; - final bodyH = (maxRowH - photoHeaderH).clamp(0.0, double.infinity); - final idealPhotoW = bodyH * _idPhotoAspectRatio + 16; + final maxRowH = c.maxHeight.clamp(0.0, double.infinity); final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth) .clamp(0.0, double.infinity); - var photoW = idealPhotoW.clamp(_photoColumnMinWidth, 360.0); + var photoW = AdminAmPhotoFrame.columnWidthForHeight(maxRowH) + .clamp(_photoColumnMinWidth, 360.0); if (photoW > maxPhotoW) photoW = maxPhotoW; - photoW = photoW.clamp(0.0, maxRowW - _photoProGap); + + final form = _isCreate + ? _buildCreateProFields() + : ValidationDetailSection( + title: 'Dossier professionnel', + fields: _photoProFields(_dossier), + rowLayout: _photoProRowLayout, + ); return Row( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -651,22 +644,9 @@ class _AmDossierWizardState extends State { ), const SizedBox(width: _photoProGap), Expanded( - child: LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - child: ConstrainedBox( - constraints: - BoxConstraints(minWidth: constraints.maxWidth), - child: _isCreate - ? _buildCreateProFields() - : ValidationDetailSection( - title: 'Dossier professionnel', - fields: _photoProFields(_dossier), - rowLayout: _photoProRowLayout, - ), - ), - ); - }, + child: Align( + alignment: Alignment.topCenter, + child: form, ), ), ], diff --git a/frontend/lib/widgets/admin/common/validation_detail_section.dart b/frontend/lib/widgets/admin/common/validation_detail_section.dart index b133748..faf9f6e 100644 --- a/frontend/lib/widgets/admin/common/validation_detail_section.dart +++ b/frontend/lib/widgets/admin/common/validation_detail_section.dart @@ -6,6 +6,50 @@ import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/utils/postal_utils.dart'; import 'admin_detail_modal.dart'; +/// Réglages des formulaires validation / wizard AM — **jouer sur ces 3 leviers**. +class ValidationFormMetrics { + ValidationFormMetrics._(); + + // --- 1. Titres de section --- + static const double sectionTitleFontSize = 16; + static const double sectionTitleGapBelow = 12; + + // --- 2. TF : texte intérieur + padding vertical (= hauteur) --- + static const double fieldTextFontSize = 14; + static const double fieldContentPaddingV = 12; + static const double fieldContentPaddingH = 12; + /// Hauteur estimée du TF (texte + padding haut/bas + bordure). + static const double fieldHeight = + fieldTextFontSize + fieldContentPaddingV * 2 + 4; + + // --- 3. Espace entre les lignes de TF --- + static const double rowGapBelow = 12; + + // Libellé au-dessus du TF (titre du champ) + static const double fieldLabelFontSize = 13; + static const double fieldLabelGapBelow = 4; + + static const TextStyle fieldTextStyle = TextStyle( + color: Colors.black87, + fontSize: fieldTextFontSize, + ); + + static double get sectionTitleBlockHeight => + sectionTitleFontSize + sectionTitleGapBelow; + + static double get labeledRowHeight => + fieldLabelFontSize + fieldLabelGapBelow + fieldHeight + rowGapBelow; + + /// Corps modale AM : padding wizard + titre + [rows] lignes + nav. + static double shellBodyHeightForRows(int rows) => + 20 * 2 + // padding wizard + 4 + // espace haut + sectionTitleBlockHeight + + rows * labeledRowHeight + + 24 + // avant nav + 44; // boutons +} + /// 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). @@ -20,12 +64,16 @@ class ValidationDetailSection extends StatelessWidget { /// Flex par ligne (index de ligne -> [flex1, flex2, ...]). Ex. {3: [2, 5]} pour Code postal | Ville. final Map>? rowFlex; + /// Remplit la hauteur disponible (wizard AM étapes 1–2). + final bool expandVertically; + const ValidationDetailSection({ super.key, this.title, required this.fields, this.rowLayout, this.rowFlex, + this.expandVertically = false, }); @override @@ -34,6 +82,7 @@ class ValidationDetailSection extends StatelessWidget { title: title, rowLayout: rowLayout, rowFlex: rowFlex, + expandVertically: expandVertically, fields: fields .map( (f) => ValidationLabeledField( @@ -53,6 +102,8 @@ class ValidationFormGrid extends StatelessWidget { final List? rowLayout; final Map>? rowFlex; final bool compact; + /// Répartit la hauteur dispo entre les lignes (remplit le blanc sans scroll). + final bool expandVertically; const ValidationFormGrid({ super.key, @@ -61,6 +112,7 @@ class ValidationFormGrid extends StatelessWidget { this.rowLayout, this.rowFlex, this.compact = false, + this.expandVertically = false, }); @override @@ -68,7 +120,7 @@ class ValidationFormGrid extends StatelessWidget { final layout = rowLayout ?? List.filled(fields.length, 1); int index = 0; int rowIndex = 0; - final rows = []; + final rowWidgets = []; for (final count in layout) { if (index >= fields.length) break; final rowFields = fields.skip(index).take(count).toList(); @@ -76,48 +128,73 @@ class ValidationFormGrid extends StatelessWidget { if (rowFields.isEmpty) continue; final flexForRow = rowFlex?[rowIndex]; rowIndex++; + final labeled = rowFields + .map( + (f) => ValidationLabeledField( + label: f.label, + field: f.field, + expand: expandVertically, + ), + ) + .toList(); + + Widget row; if (count == 1) { - rows.add(Padding( - padding: EdgeInsets.only(bottom: compact ? 8 : 12), - child: rowFields.first, - )); + row = labeled.first; } else { - rows.add(Padding( - padding: EdgeInsets.only(bottom: compact ? 8 : 12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (int i = 0; i < rowFields.length; i++) ...[ - if (i > 0) SizedBox(width: compact ? 12 : 16), - Expanded( - flex: (flexForRow != null && i < flexForRow.length) - ? flexForRow[i] - : 1, - child: rowFields[i], - ), - ], + row = Row( + crossAxisAlignment: expandVertically + ? CrossAxisAlignment.stretch + : CrossAxisAlignment.start, + children: [ + for (int i = 0; i < labeled.length; i++) ...[ + if (i > 0) SizedBox(width: compact ? 12 : 16), + Expanded( + flex: (flexForRow != null && i < flexForRow.length) + ? flexForRow[i] + : 1, + child: labeled[i], + ), ], + ], + ); + } + + if (expandVertically) { + rowWidgets.add(Expanded(child: row)); + } else { + rowWidgets.add(Padding( + padding: EdgeInsets.only( + bottom: compact ? 8 : ValidationFormMetrics.rowGapBelow, ), + child: row, )); } } + final showTitle = title != null && title!.trim().isNotEmpty; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, + mainAxisSize: expandVertically ? MainAxisSize.max : MainAxisSize.min, children: [ if (showTitle) ...[ Text( title!.trim(), style: TextStyle( - fontSize: compact ? 15 : 16, + fontSize: compact + ? ValidationFormMetrics.sectionTitleFontSize - 1 + : ValidationFormMetrics.sectionTitleFontSize, fontWeight: FontWeight.w600, color: Colors.black87, ), ), - SizedBox(height: compact ? 8 : 12), + SizedBox( + height: compact + ? 8 + : ValidationFormMetrics.sectionTitleGapBelow, + ), ], - ...rows, + ...rowWidgets, ], ); } @@ -134,8 +211,8 @@ class ValidationFieldDecoration { fillColor: Colors.grey.shade50, hintText: hint, contentPadding: EdgeInsets.symmetric( - horizontal: compact ? 10 : 12, - vertical: compact ? 7 : 10, + horizontal: compact ? 10 : ValidationFormMetrics.fieldContentPaddingH, + vertical: compact ? 7 : ValidationFormMetrics.fieldContentPaddingV, ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(6), @@ -184,29 +261,31 @@ class ValidationFieldDecoration { class ValidationLabeledField extends StatelessWidget { final String label; final Widget field; + final bool expand; const ValidationLabeledField({ super.key, required this.label, required this.field, + this.expand = false, }); @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min, children: [ Text( label, style: TextStyle( - fontSize: 12, + fontSize: ValidationFormMetrics.fieldLabelFontSize, fontWeight: FontWeight.w500, color: Colors.grey.shade700, ), ), - const SizedBox(height: 4), - field, + SizedBox(height: ValidationFormMetrics.fieldLabelGapBelow), + if (expand) Expanded(child: field) else field, ], ); } @@ -268,13 +347,16 @@ class ValidationEditableField extends StatelessWidget { ); } if (!compact) { - return TextField( - controller: controller, - keyboardType: keyboardType, - inputFormatters: inputFormatters, - maxLines: 1, - style: const TextStyle(color: Colors.black87, fontSize: 14), - decoration: ValidationFieldDecoration.input(hint: hintText), + return _validationFieldFillHeight( + TextField( + controller: controller, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + maxLines: 1, + textAlignVertical: TextAlignVertical.center, + style: ValidationFormMetrics.fieldTextStyle, + decoration: ValidationFieldDecoration.input(hint: hintText), + ), ); } return SizedBox( @@ -299,6 +381,20 @@ class ValidationEditableField extends StatelessWidget { } } +/// Étire le champ si le parent impose une hauteur (grille [expandVertically]). +Widget _validationFieldFillHeight(Widget field) { + return LayoutBuilder( + builder: (context, c) { + if (!c.hasBoundedHeight || !c.maxHeight.isFinite) return field; + return SizedBox( + height: c.maxHeight, + width: double.infinity, + child: field, + ); + }, + ); +} + /// E-mail style validation — même supervision que login / création de compte : /// [EmailMaxLengthFormatter] + à la perte de focus : trim/minuscules + validation. class ValidationEmailField extends StatefulWidget { @@ -354,23 +450,27 @@ class _ValidationEmailFieldState extends State { @override Widget build(BuildContext context) { - return TextFormField( - key: _fieldKey, - controller: widget.controller, - focusNode: _focusNode, - keyboardType: TextInputType.emailAddress, - autocorrect: false, - enableSuggestions: false, - autofillHints: const [AutofillHints.email], - textInputAction: TextInputAction.next, - inputFormatters: const [EmailMaxLengthFormatter()], - style: const TextStyle(color: Colors.black87, fontSize: 14), - decoration: ValidationFieldDecoration.input(hint: widget.hintText).copyWith( - errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11), - errorMaxLines: 2, + return _validationFieldFillHeight( + TextFormField( + key: _fieldKey, + controller: widget.controller, + focusNode: _focusNode, + keyboardType: TextInputType.emailAddress, + autocorrect: false, + enableSuggestions: false, + autofillHints: const [AutofillHints.email], + textInputAction: TextInputAction.next, + textAlignVertical: TextAlignVertical.center, + inputFormatters: const [EmailMaxLengthFormatter()], + style: ValidationFormMetrics.fieldTextStyle, + decoration: + ValidationFieldDecoration.input(hint: widget.hintText).copyWith( + errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11), + errorMaxLines: 2, + ), + validator: (value) => + validateEmail(value, allowEmpty: widget.allowEmpty), ), - validator: (value) => - validateEmail(value, allowEmpty: widget.allowEmpty), ); } } @@ -431,22 +531,25 @@ class _ValidationPostalCodeFieldState extends State { @override Widget build(BuildContext context) { - return TextFormField( - key: _fieldKey, - controller: widget.controller, - focusNode: _focusNode, - keyboardType: TextInputType.number, - textInputAction: TextInputAction.next, - inputFormatters: kFrenchPostalCodeInputFormatters, - style: const TextStyle(color: Colors.black87, fontSize: 14), - decoration: ValidationFieldDecoration.input( - hint: widget.hintText ?? '5 chiffres', - ).copyWith( - errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11), - errorMaxLines: 2, + return _validationFieldFillHeight( + TextFormField( + key: _fieldKey, + controller: widget.controller, + focusNode: _focusNode, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.next, + textAlignVertical: TextAlignVertical.center, + inputFormatters: kFrenchPostalCodeInputFormatters, + style: ValidationFormMetrics.fieldTextStyle, + decoration: ValidationFieldDecoration.input( + hint: widget.hintText ?? '5 chiffres', + ).copyWith( + errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11), + errorMaxLines: 2, + ), + validator: (value) => + validateFrenchPostalCode(value, allowEmpty: widget.allowEmpty), ), - validator: (value) => - validateFrenchPostalCode(value, allowEmpty: widget.allowEmpty), ); } } @@ -506,20 +609,24 @@ class _ValidationPhoneFieldState extends State { @override Widget build(BuildContext context) { - return TextFormField( - key: _fieldKey, - controller: widget.controller, - focusNode: _focusNode, - keyboardType: TextInputType.phone, - textInputAction: TextInputAction.next, - inputFormatters: frenchPhoneInputFormatters, - style: const TextStyle(color: Colors.black87, fontSize: 14), - decoration: ValidationFieldDecoration.input(hint: widget.hintText).copyWith( - errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11), - errorMaxLines: 2, + return _validationFieldFillHeight( + TextFormField( + key: _fieldKey, + controller: widget.controller, + focusNode: _focusNode, + keyboardType: TextInputType.phone, + textInputAction: TextInputAction.next, + textAlignVertical: TextAlignVertical.center, + inputFormatters: frenchPhoneInputFormatters, + style: ValidationFormMetrics.fieldTextStyle, + decoration: + ValidationFieldDecoration.input(hint: widget.hintText).copyWith( + errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11), + errorMaxLines: 2, + ), + validator: (value) => + validateFrenchNationalPhone(value, allowEmpty: widget.allowEmpty), ), - validator: (value) => - validateFrenchNationalPhone(value, allowEmpty: widget.allowEmpty), ); } } @@ -591,23 +698,26 @@ class _ValidationNirFieldState extends State { @override Widget build(BuildContext context) { - return TextFormField( - key: _fieldKey, - controller: widget.controller, - focusNode: _focusNode, - keyboardType: TextInputType.text, - textInputAction: TextInputAction.next, - autovalidateMode: AutovalidateMode.onUserInteraction, - inputFormatters: const [NirInputFormatter()], - style: const TextStyle(color: Colors.black87, fontSize: 14), - decoration: ValidationFieldDecoration.input( - hint: widget.hintText ?? '1 12 34 56 789 012 - 34', - ).copyWith( - errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11), - errorMaxLines: 2, + return _validationFieldFillHeight( + TextFormField( + key: _fieldKey, + controller: widget.controller, + focusNode: _focusNode, + keyboardType: TextInputType.text, + textInputAction: TextInputAction.next, + textAlignVertical: TextAlignVertical.center, + autovalidateMode: AutovalidateMode.onUserInteraction, + inputFormatters: const [NirInputFormatter()], + style: ValidationFormMetrics.fieldTextStyle, + decoration: ValidationFieldDecoration.input( + hint: widget.hintText ?? '1 12 34 56 789 012 - 34', + ).copyWith( + errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11), + errorMaxLines: 2, + ), + onChanged: (_) => _fieldKey.currentState?.validate(), + validator: _validator, ), - onChanged: (_) => _fieldKey.currentState?.validate(), - validator: _validator, ); } } @@ -685,16 +795,19 @@ class _ValidationReadOnlyFieldState extends State { @override Widget build(BuildContext context) { if (!widget.compact && widget.maxLines == 1) { - return TextField( - controller: _controller, - readOnly: true, - enableInteractiveSelection: false, - style: TextStyle( - color: widget.error ? Colors.red.shade800 : Colors.black87, - fontSize: 14, - fontWeight: widget.error ? FontWeight.w600 : null, + return _validationFieldFillHeight( + TextField( + controller: _controller, + readOnly: true, + enableInteractiveSelection: false, + textAlignVertical: TextAlignVertical.center, + style: TextStyle( + color: widget.error ? Colors.red.shade800 : Colors.black87, + fontSize: ValidationFormMetrics.fieldTextFontSize, + fontWeight: widget.error ? FontWeight.w600 : null, + ), + decoration: ValidationFieldDecoration.readOnly(error: widget.error), ), - decoration: ValidationFieldDecoration.readOnly(error: widget.error), ); } diff --git a/frontend/lib/widgets/admin/validation_dossier_modal.dart b/frontend/lib/widgets/admin/validation_dossier_modal.dart index 88e2937..7bc3faf 100644 --- a/frontend/lib/widgets/admin/validation_dossier_modal.dart +++ b/frontend/lib/widgets/admin/validation_dossier_modal.dart @@ -1,6 +1,7 @@ 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/am_dossier_wizard.dart'; import 'package:p_tits_pas/widgets/admin/validation_am_wizard.dart'; import 'package:p_tits_pas/widgets/admin/validation_family_wizard.dart'; @@ -76,8 +77,13 @@ class _ValidationDossierModalState extends State { /// 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; + static const double _familyBodyHeight = 435; + + double get _bodyHeight { + final d = _dossier; + if (d != null && d.isAm) return AmDossierWizard.shellBodyHeight; + return _familyBodyHeight; + } @override Widget build(BuildContext context) { diff --git a/frontend/lib/widgets/common/identity_block.dart b/frontend/lib/widgets/common/identity_block.dart index d42de66..2aee3e8 100644 --- a/frontend/lib/widgets/common/identity_block.dart +++ b/frontend/lib/widgets/common/identity_block.dart @@ -68,7 +68,9 @@ class IdentityValues { /// Bloc identité : Nom/Prénom, Tél/Email, Adresse, CP/Ville. /// Grille partagée (création de compte, validation AM/famille, fiches admin, etc.). -class IdentityBlock extends StatelessWidget { final String? title; +class IdentityBlock extends StatelessWidget { + final String? title; + final bool expandVertically; final String? _nom; final String? _prenom; @@ -92,6 +94,7 @@ class IdentityBlock extends StatelessWidget { final String? title; const IdentityBlock.readOnly({ super.key, this.title, + this.expandVertically = false, required String nom, required String prenom, required String telephone, @@ -117,6 +120,7 @@ class IdentityBlock extends StatelessWidget { final String? title; const IdentityBlock.editable({ super.key, this.title, + this.expandVertically = false, required TextEditingController nomController, required TextEditingController prenomController, required TextEditingController telephoneController, @@ -143,11 +147,13 @@ class IdentityBlock extends StatelessWidget { final String? title; factory IdentityBlock.readOnlyValues({ Key? key, String? title, + bool expandVertically = false, required IdentityValues values, }) { return IdentityBlock.readOnly( key: key, title: title, + expandVertically: expandVertically, nom: values.nom, prenom: values.prenom, telephone: values.telephone, @@ -164,10 +170,12 @@ class IdentityBlock extends StatelessWidget { final String? title; Key? key, String? title, String emptyLabel = 'Non défini', + bool expandVertically = false, }) { return IdentityBlock.readOnlyValues( key: key, title: title, + expandVertically: expandVertically, values: IdentityValues.fromUser(user, emptyLabel: emptyLabel), ); } @@ -197,6 +205,7 @@ class IdentityBlock extends StatelessWidget { final String? title; title: title, rowLayout: rowLayout, rowFlex: rowFlex, + expandVertically: expandVertically, fields: [ ValidationLabeledField( label: 'Nom', @@ -243,6 +252,7 @@ class IdentityBlock extends StatelessWidget { final String? title; title: title, rowLayout: rowLayout, rowFlex: rowFlex, + expandVertically: expandVertically, fields: [ ValidationLabeledField( label: 'Nom', From 30ca99fb6579878f97c22da58101d4fb19b9fb06 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 23 Jul 2026 16:59:09 +0200 Subject: [PATCH 64/85] =?UTF-8?q?feat(#129):=20POST=20/parents/dossier=20?= =?UTF-8?q?=E2=80=94=20cr=C3=A9ation=20dossier=20famille=20staff.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Factorise createParentDossier (actif + mail MDP) depuis l’inscription publique qui reste en_attente + mail pending. Miroir #156 AM. Co-authored-by: Cursor --- backend/src/routes/auth/auth.module.ts | 2 +- backend/src/routes/auth/auth.service.ts | 124 ++++++++++++++---- ...taff-create-parent-dossier-response.dto.ts | 37 ++++++ .../dto/staff-create-parent-dossier.dto.ts | 29 ++++ .../routes/parents/parents.controller.spec.ts | 64 ++++++++- .../src/routes/parents/parents.controller.ts | 51 ++++++- backend/src/routes/parents/parents.module.ts | 2 + docs/tmp/129-contrat-api-staff-parent.md | 89 +++++++++++++ 8 files changed, 370 insertions(+), 28 deletions(-) create mode 100644 backend/src/routes/parents/dto/staff-create-parent-dossier-response.dto.ts create mode 100644 backend/src/routes/parents/dto/staff-create-parent-dossier.dto.ts create mode 100644 docs/tmp/129-contrat-api-staff-parent.md diff --git a/backend/src/routes/auth/auth.module.ts b/backend/src/routes/auth/auth.module.ts index 3ebf0df..99bb6f4 100644 --- a/backend/src/routes/auth/auth.module.ts +++ b/backend/src/routes/auth/auth.module.ts @@ -29,7 +29,7 @@ import { ParentsChildren } from 'src/entities/parents_children.entity'; ParentsChildren, ]), forwardRef(() => UserModule), - ParentsModule, + forwardRef(() => ParentsModule), DossiersModule, AppConfigModule, MailModule, diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index 14d0cc4..423b9b4 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -416,11 +416,20 @@ export class AuthService { } /** - * Inscription Parent COMPLÈTE - Workflow CDC 6 étapes en 1 transaction - * Gère : Parent 1 + Parent 2 (opt) + Enfants + Présentation + CGU + * Cœur partagé création dossier parent (#129). + * - public : statut en_attente + mails pending + * - staff : statut actif + mails création MDP (pas de mail « dossier en attente ») */ - async inscrireParentComplet(dto: RegisterParentCompletDto) { - if (!dto.acceptation_cgu || !dto.acceptation_privacy) { + async createParentDossier( + dto: RegisterParentCompletDto, + options: { + statut: StatutUtilisateurType; + sendPendingEmail: boolean; + sendPasswordSetupEmail: boolean; + requireCgu: boolean; + }, + ) { + if (options.requireCgu && (!dto.acceptation_cgu || !dto.acceptation_privacy)) { throw new BadRequestException('L\'acceptation des CGU et de la politique de confidentialité est obligatoire'); } @@ -471,7 +480,7 @@ export class AuthService { prenom: dto.prenom, nom: dto.nom, role: RoleType.PARENT, - statut: StatutUtilisateurType.EN_ATTENTE, + statut: options.statut, telephone: dto.telephone, adresse: dto.adresse, code_postal: dto.code_postal, @@ -496,7 +505,7 @@ export class AuthService { prenom: dto.co_parent_prenom, nom: dto.co_parent_nom, role: RoleType.PARENT, - statut: StatutUtilisateurType.EN_ATTENTE, + statut: options.statut, telephone: dto.co_parent_telephone, adresse: dto.co_parent_meme_adresse ? dto.adresse : dto.co_parent_adresse, code_postal: dto.co_parent_meme_adresse ? dto.code_postal : dto.co_parent_code_postal, @@ -612,38 +621,103 @@ export class AuthService { const numeroDossier = resultat.parent1.numero_dossier ?? ''; - try { - await this.mailService.sendRegistrationPendingEmail( - resultat.parent1.email, - resultat.parent1.prenom ?? '', - resultat.parent1.nom ?? '', - numeroDossier, - ); - if (resultat.parent2) { + if (options.sendPendingEmail) { + try { await this.mailService.sendRegistrationPendingEmail( - resultat.parent2.email, - resultat.parent2.prenom ?? '', - resultat.parent2.nom ?? '', + resultat.parent1.email, + resultat.parent1.prenom ?? '', + resultat.parent1.nom ?? '', numeroDossier, ); + if (resultat.parent2) { + await this.mailService.sendRegistrationPendingEmail( + resultat.parent2.email, + resultat.parent2.prenom ?? '', + resultat.parent2.nom ?? '', + numeroDossier, + ); + } + } catch (err) { + this.logger.error( + "[createParentDossier] Échec envoi email d'accusé de réception (inscription conservée)", + err instanceof Error ? err.stack : String(err), + ); } - } catch (err) { - this.logger.error( - "[inscrireParentComplet] Échec envoi email d'accusé de réception (inscription conservée)", - err instanceof Error ? err.stack : String(err), - ); } + if (options.sendPasswordSetupEmail) { + try { + await this.mailService.sendValidatedAccountPasswordSetupEmail( + { + email: resultat.parent1.email, + prenom: resultat.parent1.prenom ?? '', + nom: resultat.parent1.nom ?? '', + token: resultat.tokenCreationMdp, + numeroDossier, + }, + 'parent', + ); + if (resultat.parent2 && resultat.tokenCoParent) { + await this.mailService.sendValidatedAccountPasswordSetupEmail( + { + email: resultat.parent2.email, + prenom: resultat.parent2.prenom ?? '', + nom: resultat.parent2.nom ?? '', + token: resultat.tokenCoParent, + numeroDossier, + }, + 'parent', + ); + } + } catch (err) { + this.logger.error( + '[createParentDossier] Échec envoi email création MDP (dossier conservé)', + err instanceof Error ? err.stack : String(err), + ); + } + } + + const message = options.sendPasswordSetupEmail + ? 'Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.' + : 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.'; + return { - message: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.', + message, parent_id: resultat.parent1.id, - co_parent_id: resultat.parent2?.id, + parent_user_id: resultat.parent1.id, + co_parent_id: resultat.parent2?.id ?? null, + co_parent_user_id: resultat.parent2?.id ?? null, enfants_ids: resultat.enfants.map(e => e.id), - statut: StatutUtilisateurType.EN_ATTENTE, + enfant_ids: resultat.enfants.map(e => e.id), + statut: options.statut, numero_dossier: numeroDossier, }; } + /** + * Inscription Parent publique — CDC (statut en_attente + mail pending). + */ + async inscrireParentComplet(dto: RegisterParentCompletDto) { + return this.createParentDossier(dto, { + statut: StatutUtilisateurType.EN_ATTENTE, + sendPendingEmail: true, + sendPasswordSetupEmail: false, + requireCgu: true, + }); + } + + /** + * Création dossier parent par le staff (#129) — actif + mail création MDP. + */ + async createParentDossierStaff(dto: RegisterParentCompletDto) { + return this.createParentDossier(dto, { + statut: StatutUtilisateurType.ACTIF, + sendPendingEmail: false, + sendPasswordSetupEmail: true, + requireCgu: false, + }); + } + /** * Cœur partagé création dossier AM (#156). * - public : statut en_attente + mail pending diff --git a/backend/src/routes/parents/dto/staff-create-parent-dossier-response.dto.ts b/backend/src/routes/parents/dto/staff-create-parent-dossier-response.dto.ts new file mode 100644 index 0000000..f378245 --- /dev/null +++ b/backend/src/routes/parents/dto/staff-create-parent-dossier-response.dto.ts @@ -0,0 +1,37 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { StatutUtilisateurType } from 'src/entities/users.entity'; + +/** Réponse 201 POST /parents/dossier (#129). */ +export class StaffCreateParentDossierResponseDto { + @ApiProperty() + message: string; + + @ApiProperty({ + example: '2026-000043', + description: 'Numéro de dossier famille attribué', + }) + numero_dossier: string; + + @ApiProperty({ format: 'uuid', description: 'UUID user du parent pivot' }) + parent_user_id: string; + + @ApiPropertyOptional({ + format: 'uuid', + nullable: true, + description: 'UUID user du co-parent, ou null', + }) + co_parent_user_id: string | null; + + @ApiProperty({ + enum: StatutUtilisateurType, + example: StatutUtilisateurType.ACTIF, + }) + statut: StatutUtilisateurType; + + @ApiProperty({ + type: [String], + format: 'uuid', + description: 'IDs des enfants créés', + }) + enfant_ids: string[]; +} diff --git a/backend/src/routes/parents/dto/staff-create-parent-dossier.dto.ts b/backend/src/routes/parents/dto/staff-create-parent-dossier.dto.ts new file mode 100644 index 0000000..440bb60 --- /dev/null +++ b/backend/src/routes/parents/dto/staff-create-parent-dossier.dto.ts @@ -0,0 +1,29 @@ +import { ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { IsBoolean, IsOptional } from 'class-validator'; +import { RegisterParentCompletDto } from 'src/routes/auth/dto/register-parent-complet.dto'; + +/** + * Création dossier parent/famille par staff (#129). + * Mêmes champs que l'inscription publique, sans CGU/privacy obligatoires + * (acceptées côté serveur pour le compte du gestionnaire). + */ +export class StaffCreateParentDossierDto extends OmitType(RegisterParentCompletDto, [ + 'acceptation_cgu', + 'acceptation_privacy', +] as const) { + @ApiPropertyOptional({ + description: 'Ignoré côté staff (CGU acceptées serveur). Conservé pour compat éventuelle.', + default: true, + }) + @IsOptional() + @IsBoolean() + acceptation_cgu?: boolean; + + @ApiPropertyOptional({ + description: 'Ignoré côté staff (privacy acceptée serveur).', + default: true, + }) + @IsOptional() + @IsBoolean() + acceptation_privacy?: boolean; +} diff --git a/backend/src/routes/parents/parents.controller.spec.ts b/backend/src/routes/parents/parents.controller.spec.ts index 6735376..0015373 100644 --- a/backend/src/routes/parents/parents.controller.spec.ts +++ b/backend/src/routes/parents/parents.controller.spec.ts @@ -1,18 +1,80 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ParentsController } from './parents.controller'; +import { ParentsService } from './parents.service'; +import { UserService } from '../user/user.service'; +import { AuthService } from '../auth/auth.service'; +import { AuthGuard } from 'src/common/guards/auth.guard'; +import { RolesGuard } from 'src/common/guards/roles.guard'; +import { StatutUtilisateurType } from 'src/entities/users.entity'; describe('ParentsController', () => { let controller: ParentsController; + const authServiceMock = { + createParentDossierStaff: jest.fn(), + }; + const parentsServiceMock = {}; + const userServiceMock = {}; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [ParentsController], - }).compile(); + providers: [ + { provide: ParentsService, useValue: parentsServiceMock }, + { provide: UserService, useValue: userServiceMock }, + { provide: AuthService, useValue: authServiceMock }, + ], + }) + .overrideGuard(AuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(RolesGuard) + .useValue({ canActivate: () => true }) + .compile(); controller = module.get(ParentsController); + jest.clearAllMocks(); }); it('should be defined', () => { expect(controller).toBeDefined(); }); + + it('createDossier delegates to authService.createParentDossierStaff with CGU accepted', async () => { + authServiceMock.createParentDossierStaff.mockResolvedValue({ + message: 'Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.', + parent_user_id: 'p1', + co_parent_user_id: 'p2', + enfant_ids: ['e1'], + statut: StatutUtilisateurType.ACTIF, + numero_dossier: '2026-000043', + }); + + const body = { + email: 'parent.staff@test.fr', + prenom: 'Claire', + nom: 'MARTIN', + telephone: '0689567890', + enfants: [ + { + prenom: 'Emma', + nom: 'MARTIN', + date_naissance: '2023-02-15', + genre: 'F', + }, + ], + }; + + const res = await controller.createDossier(body as any); + expect(authServiceMock.createParentDossierStaff).toHaveBeenCalledWith( + expect.objectContaining({ + email: body.email, + acceptation_cgu: true, + acceptation_privacy: true, + }), + ); + expect(res.numero_dossier).toBe('2026-000043'); + expect(res.parent_user_id).toBe('p1'); + expect(res.co_parent_user_id).toBe('p2'); + expect(res.enfant_ids).toEqual(['e1']); + expect(res.statut).toBe(StatutUtilisateurType.ACTIF); + }); }); diff --git a/backend/src/routes/parents/parents.controller.ts b/backend/src/routes/parents/parents.controller.ts index 396ac2f..6d821ff 100644 --- a/backend/src/routes/parents/parents.controller.ts +++ b/backend/src/routes/parents/parents.controller.ts @@ -3,6 +3,8 @@ import { Controller, Delete, Get, + HttpCode, + HttpStatus, Param, Patch, Post, @@ -10,14 +12,25 @@ import { } from '@nestjs/common'; import { ParentsService } from './parents.service'; import { UserService } from '../user/user.service'; +import { AuthService } from '../auth/auth.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, StatutUtilisateurType } from 'src/entities/users.entity'; -import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { + ApiBearerAuth, + 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 { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto'; +import { StaffCreateParentDossierDto } from './dto/staff-create-parent-dossier.dto'; +import { StaffCreateParentDossierResponseDto } from './dto/staff-create-parent-dossier-response.dto'; +import { RegisterParentCompletDto } from '../auth/dto/register-parent-complet.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'; @@ -26,14 +39,50 @@ import { DossierFamilleCompletDto } from './dto/dossier-famille-complet.dto'; import { mapParentForApi, mapParentsForApi } from './parents.mapper'; @ApiTags('Parents') +@ApiBearerAuth('access-token') @Controller('parents') @UseGuards(AuthGuard, RolesGuard) export class ParentsController { constructor( private readonly parentsService: ParentsService, private readonly userService: UserService, + private readonly authService: AuthService, ) {} + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) + @Post('dossier') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: 'Créer un dossier famille/parent complet (staff) — ticket #129', + description: + 'Crée parent (+ co-parent optionnel) + enfants + n° dossier avec statut actif, ' + + 'et envoie l’e-mail de création de mot de passe. ' + + 'Ne pas utiliser POST /auth/register/parent depuis le dashboard.', + }) + @ApiBody({ type: StaffCreateParentDossierDto }) + @ApiResponse({ status: 201, type: StaffCreateParentDossierResponseDto }) + @ApiResponse({ status: 400, description: 'Validation DTO / métier' }) + @ApiResponse({ status: 403, description: 'Rôle non autorisé' }) + @ApiResponse({ status: 409, description: 'Email pivot et/ou co-parent déjà pris' }) + async createDossier( + @Body() dto: StaffCreateParentDossierDto, + ): Promise { + const registerDto = { + ...dto, + acceptation_cgu: true, + acceptation_privacy: true, + } as RegisterParentCompletDto; + const result = await this.authService.createParentDossierStaff(registerDto); + return { + message: result.message, + numero_dossier: result.numero_dossier, + parent_user_id: result.parent_user_id, + co_parent_user_id: result.co_parent_user_id ?? null, + statut: result.statut, + enfant_ids: result.enfant_ids, + }; + } + @Get('pending-families') @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) @ApiOperation({ summary: 'Liste des familles en attente (une entrée par famille)' }) diff --git a/backend/src/routes/parents/parents.module.ts b/backend/src/routes/parents/parents.module.ts index b746e87..35aeb2e 100644 --- a/backend/src/routes/parents/parents.module.ts +++ b/backend/src/routes/parents/parents.module.ts @@ -9,11 +9,13 @@ import { ParentsController } from './parents.controller'; import { ParentsService } from './parents.service'; import { Users } from 'src/entities/users.entity'; import { UserModule } from '../user/user.module'; +import { AuthModule } from '../auth/auth.module'; @Module({ imports: [ TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant, ParentsChildren]), forwardRef(() => UserModule), + forwardRef(() => AuthModule), JwtModule.registerAsync({ imports: [ConfigModule], useFactory: (config: ConfigService) => ({ diff --git a/docs/tmp/129-contrat-api-staff-parent.md b/docs/tmp/129-contrat-api-staff-parent.md new file mode 100644 index 0000000..b07e681 --- /dev/null +++ b/docs/tmp/129-contrat-api-staff-parent.md @@ -0,0 +1,89 @@ +# Mini-spec API — POST /parents/dossier (#129) + +Contrat pour le **plan front** (wizard création dossier famille staff). + +Miroir de **#156** (`POST /assistantes-maternelles/dossier`). + +## Endpoint + +| | | +|--|--| +| **Méthode** | `POST` | +| **URL** | `{base}/parents/dossier` | +| **Auth** | Bearer JWT | +| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` | +| **Content-Type** | `application/json` | + +Ne **pas** appeler `POST /auth/register/parent` depuis le dashboard. + +## Body (JSON) + +Aligné `RegisterParentCompletDto`, **sans** CGU/privacy obligatoires (acceptées serveur). + +### Parent 1 (obligatoire) + +| Champ | Type | Obligatoire | Notes | +|-------|------|-------------|--------| +| `email` | string | oui | unique | +| `prenom` | string | oui | | +| `nom` | string | oui | | +| `telephone` | string | oui | `0X…` ou `+33…` | +| `adresse` | string | non | | +| `code_postal` | string | non | | +| `ville` | string | non | | + +### Co-parent (optionnel) + +`co_parent_email`, `co_parent_prenom`, `co_parent_nom`, `co_parent_telephone`, +`co_parent_meme_adresse`, `co_parent_adresse`, `co_parent_code_postal`, `co_parent_ville`. + +Si co-parent fourni : e-mail distinct ; mêmes règles téléphone / adresse que register. + +### Enfants (≥ 1) + +| Champ | Type | Notes | +|-------|------|--------| +| `enfants` | `EnfantInscriptionDto[]` | `prenom`, `nom`, `date_naissance` / `date_previsionnelle_naissance`, `genre`, `photo_base64`, `photo_filename`, etc. | + +### Présentation + +| Champ | Type | Obligatoire | +|-------|------|-------------| +| `presentation_dossier` | string | non (max 2000) | + +## Réponses + +### 201 Created + +```json +{ + "message": "Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.", + "numero_dossier": "2026-000043", + "parent_user_id": "uuid-pivot", + "co_parent_user_id": "uuid-ou-null", + "statut": "actif", + "enfant_ids": ["uuid", "..."] +} +``` + +Effets serveur : user(s) parent **actif**, fiches `parents`, enfants + foyer, n° dossier, +**e-mail création MDP** pour chaque compte sans MDP (pas d’accusé « en attente »). + +### Erreurs + +| Code | Cas | +|------|-----| +| 400 | Validation DTO / métier (enfants vides, dates, etc.) | +| 401 | Token manquant / invalide | +| 403 | Rôle non staff | +| 409 | Conflit e-mail (pivot et/ou co-parent) | + +## Front + +- `UserService.createParentDossier(body)` → cet endpoint +- Wizard create basé sur `ValidationFamilyWizard` +- Ne pas envoyer `acceptation_cgu` / `acceptation_privacy` (optionnels) + +## Branche + +`feature/129-creation-dossier-parent` From cb5c1a551815d48f6aa6e8bcdb2a7c25201333f2 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 24 Jul 2026 12:33:31 +0200 Subject: [PATCH 65/85] =?UTF-8?q?feat(#129):=20wizard=20admin=20cr=C3=A9at?= =?UTF-8?q?ion=20dossier=20famille=20(create/review).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- frontend/lib/services/api/api_config.dart | 2 + frontend/lib/services/user_service.dart | 48 + .../admin/parent_dossier_create_modal.dart | 93 ++ .../widgets/admin/parent_dossier_wizard.dart | 1471 +++++++++++++++++ .../widgets/admin/user_management_panel.dart | 21 + .../admin/validation_family_wizard.dart | 711 +------- 6 files changed, 1643 insertions(+), 703 deletions(-) create mode 100644 frontend/lib/widgets/admin/parent_dossier_create_modal.dart create mode 100644 frontend/lib/widgets/admin/parent_dossier_wizard.dart diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index 75a9610..72e7667 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -60,6 +60,8 @@ class ApiConfig { static const String userChildren = '/users/children'; static const String gestionnaires = '/gestionnaires'; static const String parents = '/parents'; + /// Création dossier famille actif par le staff (#129) — body type register parent. + static const String parentsDossier = '/parents/dossier'; static const String assistantesMaternelles = '/assistantes-maternelles'; /// Création dossier AM actif par le staff (#156) — body type register AM. static const String assistantesMaternellesDossier = diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index ce8f662..be87d9e 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -645,6 +645,54 @@ class UserService { return fallback; } + /// Création dossier famille actif côté staff (#129). + /// `POST /parents/dossier` — body aligné sur register parent complet. + /// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur, par parent créé). + /// Ne pas utiliser `POST /auth/register/parent` (public / en_attente). + static Future> createParentDossier( + Map body, + ) async { + final http.Response response; + try { + response = await http.post( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parentsDossier}'), + headers: await _headers(), + body: jsonEncode(body), + ); + } on http.ClientException { + throw Exception( + 'Connexion au serveur impossible. Vérifiez votre réseau puis réessayez.', + ); + } + + if (response.statusCode == 200 || response.statusCode == 201) { + if (response.body.trim().isEmpty) return {}; + try { + final decoded = jsonDecode(response.body); + if (decoded is Map) { + return Map.from(decoded); + } + } catch (_) {} + return {}; + } + + final message = _extractErrorMessage( + response.body, + 'Erreur création dossier famille', + ); + if (response.statusCode == 409) { + throw Exception( + message.isNotEmpty ? message : 'Conflit : e-mail déjà utilisé.', + ); + } + if (response.statusCode == 400) { + throw Exception( + message.isNotEmpty ? message : 'Données invalides (400).', + ); + } + throw Exception(message); + } + /// Création dossier AM actif côté staff (#156). /// `POST /assistantes-maternelles/dossier` — body aligné sur register AM. /// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur). diff --git a/frontend/lib/widgets/admin/parent_dossier_create_modal.dart b/frontend/lib/widgets/admin/parent_dossier_create_modal.dart new file mode 100644 index 0000000..10f1126 --- /dev/null +++ b/frontend/lib/widgets/admin/parent_dossier_create_modal.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/widgets/admin/parent_dossier_wizard.dart'; + +/// Modale de création dossier famille (#129) — même shell que [AmDossierCreateModal]. +class ParentDossierCreateModal extends StatefulWidget { + final VoidCallback onClose; + final VoidCallback? onSuccess; + + const ParentDossierCreateModal({ + super.key, + required this.onClose, + this.onSuccess, + }); + + @override + State createState() => + _ParentDossierCreateModalState(); +} + +class _ParentDossierCreateModalState extends State { + int? _stepIndex; + int? _stepTotal; + + void _onStepChanged(int step, int total) { + if (!mounted) return; + setState(() { + _stepIndex = step; + _stepTotal = total; + }); + } + + void _onSuccess() { + widget.onSuccess?.call(); + } + + static const double _modalWidth = 930; + /// Hauteur calculée depuis 4 lignes de TF (voir [ParentDossierWizard.shellBodyHeight]). + static double get _bodyHeight => ParentDossierWizard.shellBodyHeight; + + @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: [ + const Padding( + padding: EdgeInsets.fromLTRB(18, 18, 0, 12), + child: Text( + 'Nouveau dossier famille', + style: 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: ParentDossierWizard.create( + onClose: widget.onClose, + onSuccess: _onSuccess, + onStepChanged: _onStepChanged, + ), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/parent_dossier_wizard.dart b/frontend/lib/widgets/admin/parent_dossier_wizard.dart new file mode 100644 index 0000000..051b183 --- /dev/null +++ b/frontend/lib/widgets/admin/parent_dossier_wizard.dart @@ -0,0 +1,1471 @@ +import 'dart:convert'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:image_picker/image_picker.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/user_service.dart'; +import 'package:p_tits_pas/utils/date_display_utils.dart'; +import 'package:p_tits_pas/utils/email_utils.dart'; +import 'package:p_tits_pas/utils/enfant_status_utils.dart'; +import 'package:p_tits_pas/utils/name_format_utils.dart'; +import 'package:p_tits_pas/utils/phone_utils.dart'; +import 'package:p_tits_pas/utils/postal_utils.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart'; +import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; +import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; +import 'package:p_tits_pas/widgets/admin/validation_refus_form.dart'; +import 'package:p_tits_pas/widgets/admin/validation_valider_confirm_dialog.dart'; +import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; +import 'package:p_tits_pas/widgets/common/identity_block.dart'; + +enum ParentDossierWizardMode { review, create } + +/// Enfant en cours de saisie (mode création). Contrôleurs propres, à disposer. +class _CreateChild { + final TextEditingController prenomCtrl = TextEditingController(); + final TextEditingController nomCtrl = TextEditingController(); + final TextEditingController dateCtrl = TextEditingController(); + String? genre; + Uint8List? photoBytes; + String? photoFilename; + + void dispose() { + prenomCtrl.dispose(); + nomCtrl.dispose(); + dateCtrl.dispose(); + } +} + +/// Wizard dossier famille — modes [review] (validation dossier en attente, ticket #107) +/// et [create] (création dossier famille actif par le staff, ticket #129). +class ParentDossierWizard extends StatefulWidget { + final ParentDossierWizardMode mode; + final DossierFamille? dossier; + final VoidCallback onClose; + final VoidCallback onSuccess; + final void Function(int step, int total)? onStepChanged; + + const ParentDossierWizard._({ + super.key, + required this.mode, + this.dossier, + required this.onClose, + required this.onSuccess, + this.onStepChanged, + }); + + factory ParentDossierWizard.review({ + Key? key, + required DossierFamille dossier, + required VoidCallback onClose, + required VoidCallback onSuccess, + void Function(int step, int total)? onStepChanged, + }) { + return ParentDossierWizard._( + key: key, + mode: ParentDossierWizardMode.review, + dossier: dossier, + onClose: onClose, + onSuccess: onSuccess, + onStepChanged: onStepChanged, + ); + } + + factory ParentDossierWizard.create({ + Key? key, + required VoidCallback onClose, + required VoidCallback onSuccess, + void Function(int step, int total)? onStepChanged, + }) { + return ParentDossierWizard._( + key: key, + mode: ParentDossierWizardMode.create, + onClose: onClose, + onSuccess: onSuccess, + onStepChanged: onStepChanged, + ); + } + + bool get isCreate => mode == ParentDossierWizardMode.create; + + /// Hauteur corps modale famille — dérivée de [ValidationFormMetrics] (4 lignes). + static double get shellBodyHeight => + ValidationFormMetrics.shellBodyHeightForRows(4); + + @override + State createState() => _ParentDossierWizardState(); +} + +class _ParentDossierWizardState 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; + + static const int _stepCount = 4; + + // --- Mode création : parent principal --- + late final TextEditingController _p1NomCtrl; + late final TextEditingController _p1PrenomCtrl; + late final TextEditingController _p1TelCtrl; + late final TextEditingController _p1EmailCtrl; + late final TextEditingController _p1AdresseCtrl; + late final TextEditingController _p1CpCtrl; + late final TextEditingController _p1VilleCtrl; + + // --- Mode création : co-parent (optionnel) --- + bool _hasCoParent = false; + bool _sameAddress = false; + late final TextEditingController _p2NomCtrl; + late final TextEditingController _p2PrenomCtrl; + late final TextEditingController _p2TelCtrl; + late final TextEditingController _p2EmailCtrl; + late final TextEditingController _p2AdresseCtrl; + late final TextEditingController _p2CpCtrl; + late final TextEditingController _p2VilleCtrl; + + // --- Mode création : enfants + présentation --- + final List<_CreateChild> _children = []; + late final TextEditingController _presentationCtrl; + + bool get _isCreate => widget.isCreate; + DossierFamille get _dossier => widget.dossier!; + + bool get _isEnAttente => !_isCreate && _dossier.isEnAttente; + + String? get _firstParentId { + if (_isCreate) return null; + return _dossier.parents.isNotEmpty ? _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) => + formatIsoDateFr(s, ifEmpty: 'Non défini'); + + static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url); + + @override + void initState() { + super.initState(); + _enfantsScrollController.addListener(_syncEnfantsScrollFades); + + _p1NomCtrl = TextEditingController(); + _p1PrenomCtrl = TextEditingController(); + _p1TelCtrl = TextEditingController(); + _p1EmailCtrl = TextEditingController(); + _p1AdresseCtrl = TextEditingController(); + _p1CpCtrl = TextEditingController(); + _p1VilleCtrl = TextEditingController(); + + _p2NomCtrl = TextEditingController(); + _p2PrenomCtrl = TextEditingController(); + _p2TelCtrl = TextEditingController(); + _p2EmailCtrl = TextEditingController(); + _p2AdresseCtrl = TextEditingController(); + _p2CpCtrl = TextEditingController(); + _p2VilleCtrl = TextEditingController(); + + _presentationCtrl = TextEditingController(); + if (_isCreate) { + _children.add(_CreateChild()); + } + + WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep()); + } + + @override + void dispose() { + _enfantsScrollController.removeListener(_syncEnfantsScrollFades); + _enfantsScrollController.dispose(); + + _p1NomCtrl.dispose(); + _p1PrenomCtrl.dispose(); + _p1TelCtrl.dispose(); + _p1EmailCtrl.dispose(); + _p1AdresseCtrl.dispose(); + _p1CpCtrl.dispose(); + _p1VilleCtrl.dispose(); + + _p2NomCtrl.dispose(); + _p2PrenomCtrl.dispose(); + _p2TelCtrl.dispose(); + _p2EmailCtrl.dispose(); + _p2AdresseCtrl.dispose(); + _p2CpCtrl.dispose(); + _p2VilleCtrl.dispose(); + + for (final c in _children) { + c.dispose(); + } + _presentationCtrl.dispose(); + super.dispose(); + } + + void _emitStep() => widget.onStepChanged?.call(_step, _stepCount); + + void _showError(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), backgroundColor: Colors.red.shade700), + ); + } + + 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; + }); + } + } + + @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() { + switch (_step) { + case 0: + return _buildStep0(); + case 1: + return _buildStep1(); + case 2: + return _buildEnfantsStep(); + case 3: + return _buildPresentationStep(); + default: + return const SizedBox(); + } + } + + Widget _buildStep0() { + if (_isCreate) { + return IdentityBlock.editable( + title: 'Parent principal', + nomController: _p1NomCtrl, + prenomController: _p1PrenomCtrl, + telephoneController: _p1TelCtrl, + emailController: _p1EmailCtrl, + adresseController: _p1AdresseCtrl, + codePostalController: _p1CpCtrl, + villeController: _p1VilleCtrl, + ); + } + return IdentityBlock.readOnlyFromParentDossier( + _dossier.parents.first, + title: 'Parent principal', + ); + } + + Widget _buildStep1() { + if (_isCreate) { + return _buildCoParentStepCreate(); + } + return _buildParent2Step(); + } + + Widget _buildCoParentStepCreate() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Ajouter un co-parent'), + value: _hasCoParent, + onChanged: (v) => setState(() => _hasCoParent = v), + ), + if (!_hasCoParent) + const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Text( + 'Un seul parent pour ce dossier.', + style: TextStyle(color: Colors.black87), + ), + ) + else ...[ + const SizedBox(height: 8), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + IdentityBlock.editable( + title: 'Deuxième parent', + nomController: _p2NomCtrl, + prenomController: _p2PrenomCtrl, + telephoneController: _p2TelCtrl, + emailController: _p2EmailCtrl, + adresseController: _p2AdresseCtrl, + codePostalController: _p2CpCtrl, + villeController: _p2VilleCtrl, + ), + const SizedBox(height: 12), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Même adresse que le parent principal'), + value: _sameAddress, + onChanged: (v) => setState(() { + _sameAddress = v; + if (v) { + _p2AdresseCtrl.text = _p1AdresseCtrl.text; + _p2CpCtrl.text = _p1CpCtrl.text; + _p2VilleCtrl.text = _p1VilleCtrl.text; + } + }), + ), + ], + ), + ), + ), + ], + ], + ); + } + + Widget _buildParent2Step() { + if (_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 IdentityBlock.readOnlyFromParentDossier( + _dossier.parents[1], + title: 'Deuxième parent', + ); + } + + static const double _idPhotoAspectRatio = 35 / 45; + + /// Liste horizontale scrollable (fondu bords) partagée par les cartes enfant + /// en lecture seule (review) et éditables (create). + Widget _buildEnfantsHorizontalList({ + required int itemCount, + required Widget Function(int index) itemBuilder, + }) { + return 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: itemCount, + itemBuilder: (_, i) => Padding( + padding: EdgeInsets.only(right: i < itemCount - 1 ? 16 : 0), + child: SizedBox( + width: cardWidth, + height: cardHeight, + child: itemBuilder(i), + ), + ), + ), + ), + ), + ); + }, + ); + } + + Widget _buildEnfantsStep() { + if (_isCreate) { + return _buildEnfantsStepCreate(); + } + final enfants = _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: _buildEnfantsHorizontalList( + itemCount: enfants.length, + itemBuilder: (i) => _buildEnfantCard(enfants[i]), + ), + ), + ], + ); + } + + Widget _buildEnfantsStepCreate() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Text( + 'Enfants', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + const Spacer(), + TextButton.icon( + onPressed: _addChild, + icon: const Icon(Icons.add), + label: const Text('Ajouter un enfant'), + ), + ], + ), + const SizedBox(height: 8), + Expanded( + child: _buildEnfantsHorizontalList( + itemCount: _children.length, + itemBuilder: (i) => _buildCreateEnfantCard(i), + ), + ), + ], + ); + } + + void _addChild() { + setState(() => _children.add(_CreateChild())); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_enfantsScrollController.hasClients) return; + _enfantsScrollController.animateTo( + _enfantsScrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + }); + } + + void _removeChild(int index) { + if (_children.length <= 1) return; + setState(() { + final removed = _children.removeAt(index); + removed.dispose(); + }); + } + + Future _pickChildPhoto(int index) async { + try { + final picked = await ImagePicker().pickImage( + source: ImageSource.gallery, + maxWidth: 1200, + imageQuality: 85, + ); + if (picked == null) return; + final bytes = await picked.readAsBytes(); + if (!mounted || index >= _children.length) return; + setState(() { + _children[index].photoBytes = bytes; + _children[index].photoFilename = picked.name; + }); + } catch (_) { + if (!mounted) return; + _showError('Impossible de charger la photo.'); + } + } + + void _clearChildPhoto(int index) { + if (index >= _children.length) return; + setState(() { + _children[index].photoBytes = null; + _children[index].photoFilename = null; + }); + } + + /// 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.withOpacity(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 éditable (création) : prénom pleine largeur, puis photo 1/3 + champs 2/3. + Widget _buildCreateEnfantCard(int index) { + final child = _children[index]; + final canRemove = _children.length > 1; + return ClipRRect( + borderRadius: BorderRadius.circular(_enfantCardRadius), + child: Container( + decoration: _enfantCardDecoration(child.genre), + child: Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 40, 8), + child: ValidationLabeledField( + label: 'Prénom', + field: ValidationEditableField( + controller: child.prenomCtrl, + inputFormatters: const [PersonNameInputFormatter()], + ), + ), + ), + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + flex: 1, + child: LayoutBuilder( + builder: (context, c) { + 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: SizedBox( + width: pw, + height: ph, + child: AdminAmPhotoFrame( + imageBytes: child.photoBytes, + onTap: () => _pickChildPhoto(index), + onClear: child.photoBytes != null + ? () => _clearChildPhoto(index) + : null, + emptyLabel: 'Ajouter une photo', + ), + ), + ), + ); + }, + ), + ), + Expanded( + flex: 2, + child: Padding( + padding: const EdgeInsets.fromLTRB(4, 4, 14, 12), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ValidationLabeledField( + label: 'Nom', + field: ValidationEditableField( + controller: child.nomCtrl, + inputFormatters: const [ + PersonNameInputFormatter(), + ], + ), + ), + const SizedBox(height: 12), + ValidationLabeledField( + label: 'Date de naissance', + field: ValidationEditableField( + controller: child.dateCtrl, + hintText: 'jj / mm / aaaa', + keyboardType: TextInputType.number, + inputFormatters: const [ + FrenchDateInputFormatter(), + ], + ), + ), + const SizedBox(height: 12), + ValidationLabeledField( + label: 'Genre', + field: _genreDropdown(index), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ], + ), + if (canRemove) + Positioned( + top: 4, + right: 4, + child: Material( + color: Colors.transparent, + child: IconButton( + tooltip: 'Retirer cet enfant', + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: + const BoxConstraints(minWidth: 32, minHeight: 32), + icon: Icon(Icons.close, + size: 18, color: Colors.grey.shade700), + onPressed: () => _removeChild(index), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _genreDropdown(int index) { + final child = _children[index]; + return SizedBox( + height: ValidationFormMetrics.fieldHeight, + child: DropdownButtonFormField( + value: child.genre, + isExpanded: true, + hint: Text( + 'Sélectionner', + style: TextStyle(color: Colors.grey.shade600, fontSize: 14), + ), + decoration: ValidationFieldDecoration.input(), + items: const [ + DropdownMenuItem(value: 'H', child: Text('Garçon')), + DropdownMenuItem(value: 'F', child: Text('Fille')), + ], + onChanged: (v) => setState(() => child.genre = v), + ), + ); + } + + /// 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, + ), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + /// Statut dans la colonne 2/3 (scolarisé·e, à naître, sans garde, en garde). + String? _enfantColumnStatusLabel(EnfantDossier e) { + return enfantColumnStatusLabel(status: e.status, gender: e.gender); + } + + /// 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) { + const photoRadius = 8.0; + return Container( + width: width, + height: height, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(photoRadius), + border: Border.all(color: Colors.black.withOpacity(0.08)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(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), + ), + ) + : AuthNetworkImage( + url: photoUrl, + fit: BoxFit.cover, + 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() { + if (_isCreate) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Présentation', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + const SizedBox(height: 12), + Expanded( + child: TextField( + controller: _presentationCtrl, + maxLines: null, + expands: true, + maxLength: 2000, + textAlignVertical: TextAlignVertical.top, + decoration: InputDecoration( + hintText: 'Présentation (optionnel, max 2000 caractères)', + filled: true, + fillColor: Colors.grey.shade50, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + ), + ), + ), + ], + ); + } + + final p = _dossier.presentation ?? ''; + final text = p.trim().isEmpty ? 'Non défini' : p; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Présentation', + 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), + ), + ), + ), + ); + }, + ), + ), + ], + ); + } + + // --- Validation (mode création) --- + + String? _validateP1() { + final nom = _p1NomCtrl.text.trim(); + final prenom = _p1PrenomCtrl.text.trim(); + if (nom.length < 2) { + return 'Le nom du parent principal doit contenir au moins 2 caractères.'; + } + if (prenom.length < 2) { + return 'Le prénom du parent principal doit contenir au moins 2 caractères.'; + } + final phoneErr = validateFrenchNationalPhone(_p1TelCtrl.text); + if (phoneErr != null) return phoneErr; + final emailErr = validateEmail(_p1EmailCtrl.text); + if (emailErr != null) return emailErr; + if (_p1AdresseCtrl.text.trim().isEmpty) { + return 'L’adresse du parent principal est requise.'; + } + final cpErr = validateFrenchPostalCode(_p1CpCtrl.text); + if (cpErr != null) return cpErr; + if (_p1VilleCtrl.text.trim().isEmpty) { + return 'La ville du parent principal est requise.'; + } + return null; + } + + String? _validateP2() { + if (!_hasCoParent) return null; + final nom = _p2NomCtrl.text.trim(); + final prenom = _p2PrenomCtrl.text.trim(); + if (nom.length < 2) { + return 'Le nom du co-parent doit contenir au moins 2 caractères.'; + } + if (prenom.length < 2) { + return 'Le prénom du co-parent doit contenir au moins 2 caractères.'; + } + final phoneErr = validateFrenchNationalPhone(_p2TelCtrl.text); + if (phoneErr != null) return 'Co-parent : $phoneErr'; + final emailErr = validateEmail(_p2EmailCtrl.text); + if (emailErr != null) return 'Co-parent : $emailErr'; + if (normalizeEmailText(_p2EmailCtrl.text) == + normalizeEmailText(_p1EmailCtrl.text)) { + return 'L’email du co-parent doit être différent de celui du parent principal.'; + } + if (_p2AdresseCtrl.text.trim().isEmpty) { + return 'L’adresse du co-parent est requise.'; + } + final cpErr = validateFrenchPostalCode(_p2CpCtrl.text); + if (cpErr != null) return 'Co-parent : $cpErr'; + if (_p2VilleCtrl.text.trim().isEmpty) { + return 'La ville du co-parent est requise.'; + } + return null; + } + + String? _validateEnfants() { + if (_children.isEmpty) return 'Au moins un enfant est requis.'; + for (var i = 0; i < _children.length; i++) { + final c = _children[i]; + final label = 'Enfant ${i + 1}'; + if (c.prenomCtrl.text.trim().length < 2) { + return '$label : le prénom doit contenir au moins 2 caractères.'; + } + if (parseFrDateToIso(c.dateCtrl.text) == null) { + return '$label : indiquez une date de naissance valide (jj/mm/aaaa).'; + } + if (c.genre != 'H' && c.genre != 'F') { + return '$label : indiquez le genre (Garçon ou Fille).'; + } + } + return null; + } + + String? _validateCurrentStep() { + if (!_isCreate) return null; + switch (_step) { + case 0: + return _validateP1(); + case 1: + return _validateP2(); + case 2: + return _validateEnfants(); + default: + return null; + } + } + + void _goNext() { + final err = _validateCurrentStep(); + if (err != null) { + _showError(err); + return; + } + setState(() => _step++); + _emitStep(); + } + + static String _imageMimeForBytes(Uint8List bytes) { + if (bytes.length >= 8 && + bytes[0] == 0x89 && + bytes[1] == 0x50 && + bytes[2] == 0x4E && + bytes[3] == 0x47) { + return 'image/png'; + } + if (bytes.length >= 3 && + bytes[0] == 0xFF && + bytes[1] == 0xD8 && + bytes[2] == 0xFF) { + return 'image/jpeg'; + } + if (bytes.length >= 6 && + bytes[0] == 0x47 && + bytes[1] == 0x49 && + bytes[2] == 0x46) { + return 'image/gif'; + } + if (bytes.length >= 12 && + bytes[0] == 0x52 && + bytes[1] == 0x49 && + bytes[2] == 0x46 && + bytes[3] == 0x46) { + return 'image/webp'; + } + return 'image/jpeg'; + } + + Map _childCreateToJson(_CreateChild c) { + final prenom = formatPersonNameCase(c.prenomCtrl.text); + final nomRaw = c.nomCtrl.text.trim(); + final nom = nomRaw.isNotEmpty + ? formatPersonNameCase(nomRaw) + : formatPersonNameCase(_p1NomCtrl.text); + final birthIso = parseFrDateToIso(c.dateCtrl.text); + + final map = { + 'prenom': prenom, + 'nom': nom, + 'date_naissance': birthIso, + 'genre': c.genre, + 'consent_photo': true, + 'grossesse_multiple': false, + }; + + final bytes = c.photoBytes; + if (bytes != null && bytes.isNotEmpty) { + final mime = _imageMimeForBytes(bytes); + final fn = (c.photoFilename ?? '').trim(); + map['photo_base64'] = 'data:$mime;base64,${base64Encode(bytes)}'; + map['photo_filename'] = fn.isNotEmpty ? fn : 'enfant.jpg'; + } + + return map; + } + + Map _buildCreateBody() { + final body = { + 'email': normalizeEmailText(_p1EmailCtrl.text), + 'prenom': formatPersonNameCase(_p1PrenomCtrl.text), + 'nom': formatPersonNameCase(_p1NomCtrl.text), + 'telephone': normalizePhone(_p1TelCtrl.text), + 'adresse': _p1AdresseCtrl.text.trim(), + 'code_postal': _p1CpCtrl.text.trim(), + 'ville': formatPersonNameCase(_p1VilleCtrl.text), + 'acceptation_cgu': true, + 'acceptation_privacy': true, + }; + + if (_hasCoParent) { + final sameAddr = + _p2AdresseCtrl.text.trim() == _p1AdresseCtrl.text.trim() && + _p2CpCtrl.text.trim() == _p1CpCtrl.text.trim() && + _p2VilleCtrl.text.trim() == _p1VilleCtrl.text.trim(); + body['co_parent_email'] = normalizeEmailText(_p2EmailCtrl.text); + body['co_parent_prenom'] = formatPersonNameCase(_p2PrenomCtrl.text); + body['co_parent_nom'] = formatPersonNameCase(_p2NomCtrl.text); + body['co_parent_telephone'] = normalizePhone(_p2TelCtrl.text); + body['co_parent_meme_adresse'] = sameAddr; + if (!sameAddr) { + body['co_parent_adresse'] = _p2AdresseCtrl.text.trim(); + body['co_parent_code_postal'] = _p2CpCtrl.text.trim(); + body['co_parent_ville'] = formatPersonNameCase(_p2VilleCtrl.text); + } + } + + final presentation = _presentationCtrl.text.trim(); + if (presentation.isNotEmpty) { + body['presentation_dossier'] = presentation; + } + + body['enfants'] = _children.map(_childCreateToJson).toList(); + + return body; + } + + Future _createAndValidate() async { + if (_submitting) return; + final err0 = _validateP1(); + if (err0 != null) { + _showError(err0); + return; + } + final err1 = _validateP2(); + if (err1 != null) { + _showError(err1); + return; + } + final err2 = _validateEnfants(); + if (err2 != null) { + _showError(err2); + return; + } + + final ok = await showValidationValiderConfirmDialog( + context, + body: + 'Créer et activer ce dossier famille ? Un e-mail de création de mot de passe sera envoyé à chaque parent créé.', + ); + if (!mounted || !ok) return; + + setState(() => _submitting = true); + try { + await UserService.createParentDossier(_buildCreateBody()); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Dossier créé et validé. La famille est active.'), + duration: Duration(seconds: 4), + ), + ); + widget.onSuccess(); + } catch (e) { + if (!mounted) return; + _showError( + e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur', + ); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + 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 (_isCreate) ...[ + ElevatedButton( + style: ValidationModalTheme.primaryElevatedStyle, + onPressed: _submitting ? null : _createAndValidate, + child: Text(_submitting ? 'Envoi...' : 'Créer et valider'), + ), + ] else 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: _goNext, + 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; + _showError( + e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur', + ); + } 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( + isSubmitting: _submitting, + onCancel: widget.onClose, + onPrevious: () => setState(() => _showRefusForm = false), + onSubmit: (comment) { + if (comment == null || comment.trim().isEmpty) return; + _refuserEnvoyer(comment.trim()); + }, + ), + ), + ], + ), + ); + } + + /// Un seul appel : le back refuse tout le dossier famille (co-parents + mails). Ticket #110. + Future _refuserEnvoyer(String comment) async { + if (_submitting) return; + final parentId = _firstParentId?.trim(); + if (parentId == null || parentId.isEmpty || !_isEnAttente) { + if (!mounted) return; + _showError('Aucun compte en attente à refuser pour ce dossier.'); + return; + } + setState(() => _submitting = true); + try { + await UserService.refuseUser(parentId, comment: comment); + if (!mounted) return; + final nbEnAttente = + _dossier.parents.where((p) => p.statut == 'en_attente').length; + final msg = nbEnAttente > 1 + ? 'Refus enregistré. Un e-mail de reprise a été envoyé à chaque parent du dossier.' + : 'Refus enregistré. Un e-mail avec le lien de reprise a été envoyé.'; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(msg), + duration: const Duration(seconds: 4), + ), + ); + widget.onSuccess(); + } catch (e) { + if (!mounted) return; + _showError( + e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur', + ); + } finally { + if (mounted) setState(() => _submitting = false); + } + } +} diff --git a/frontend/lib/widgets/admin/user_management_panel.dart b/frontend/lib/widgets/admin/user_management_panel.dart index d13eca7..7be38c7 100644 --- a/frontend/lib/widgets/admin/user_management_panel.dart +++ b/frontend/lib/widgets/admin/user_management_panel.dart @@ -8,6 +8,7 @@ import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart'; import 'package:p_tits_pas/widgets/admin/enfant_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart'; +import 'package:p_tits_pas/widgets/admin/parent_dossier_create_modal.dart'; import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart'; import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart'; @@ -27,6 +28,7 @@ class UserManagementPanel extends StatefulWidget { class _UserManagementPanelState extends State { int _subIndex = 0; int _gestionnaireRefreshTick = 0; + int _parentRefreshTick = 0; int _adminRefreshTick = 0; int _enfantRefreshTick = 0; int _amRefreshTick = 0; @@ -263,6 +265,7 @@ class _UserManagementPanelState extends State { switch (contentIndex) { case 0: return ParentManagementWidget( + key: ValueKey('parents-$_parentRefreshTick'), searchQuery: _searchController.text, statusFilter: _parentStatus, ); @@ -318,6 +321,24 @@ class _UserManagementPanelState extends State { Future _handleAddPressed() async { final contentIndex = _subIndex - _contentIndexOffset; + if (contentIndex == 0) { + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + return ParentDossierCreateModal( + onClose: () => Navigator.of(dialogContext).pop(), + onSuccess: () { + Navigator.of(dialogContext).pop(); + if (!mounted) return; + setState(() => _parentRefreshTick++); + }, + ); + }, + ); + return; + } + if (contentIndex == 1) { await showDialog( context: context, diff --git a/frontend/lib/widgets/admin/validation_family_wizard.dart b/frontend/lib/widgets/admin/validation_family_wizard.dart index 76ac741..f72fc4b 100644 --- a/frontend/lib/widgets/admin/validation_family_wizard.dart +++ b/frontend/lib/widgets/admin/validation_family_wizard.dart @@ -1,20 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:flutter/gestures.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart'; -import 'package:p_tits_pas/utils/date_display_utils.dart'; -import 'package:p_tits_pas/utils/enfant_status_utils.dart'; -import 'package:p_tits_pas/services/user_service.dart'; -import 'package:p_tits_pas/services/api/api_config.dart'; -import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; -import 'package:p_tits_pas/widgets/common/identity_block.dart'; -import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; -import 'validation_modal_theme.dart'; -import 'validation_refus_form.dart'; -import 'validation_valider_confirm_dialog.dart'; +import 'package:p_tits_pas/widgets/admin/parent_dossier_wizard.dart'; -/// Wizard de validation dossier famille : étapes sobres (label/valeur), récap, Valider/Refuser/Annuler, page refus. Ticket #107. -class ValidationFamilyWizard extends StatefulWidget { +/// Wrapper historique (#107) — délègue à [ParentDossierWizard.review]. +class ValidationFamilyWizard extends StatelessWidget { final DossierFamille dossier; final VoidCallback onClose; final VoidCallback onSuccess; @@ -28,697 +17,13 @@ class ValidationFamilyWizard extends StatefulWidget { 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) => - formatIsoDateFr(s, ifEmpty: 'Non défini'); - - static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url); - @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(), - ], - ), + return ParentDossierWizard.review( + dossier: dossier, + onClose: onClose, + onSuccess: onSuccess, + onStepChanged: onStepChanged, ); } - - Widget _buildStepContent() { - final d = widget.dossier; - switch (_step) { - case 0: - return IdentityBlock.readOnlyFromParentDossier( - d.parents.first, - title: 'Parent principal', - ); - 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 IdentityBlock.readOnlyFromParentDossier( - widget.dossier.parents[1], - title: 'Deuxième parent', - ); - } - - 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.withOpacity(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, - ), - ), - ), - ), - ], - ), - ), - ), - ], - ), - ), - ], - ), - ), - ); - } - - /// Statut dans la colonne 2/3 (scolarisé·e, à naître, sans garde, en garde). - String? _enfantColumnStatusLabel(EnfantDossier e) { - return enfantColumnStatusLabel(status: e.status, gender: e.gender); - } - - /// 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) { - const photoRadius = 8.0; - return Container( - width: width, - height: height, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(photoRadius), - border: Border.all(color: Colors.black.withOpacity(0.08)), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(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), - ), - ) - : AuthNetworkImage( - url: photoUrl, - fit: BoxFit.cover, - 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', - 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( - isSubmitting: _submitting, - onCancel: widget.onClose, - onPrevious: () => setState(() => _showRefusForm = false), - onSubmit: (comment) { - if (comment == null || comment.trim().isEmpty) return; - _refuserEnvoyer(comment.trim()); - }, - ), - ), - ], - ), - ); - } - - /// Un seul appel : le back refuse tout le dossier famille (co-parents + mails). Ticket #110. - Future _refuserEnvoyer(String comment) async { - if (_submitting) return; - final parentId = _firstParentId?.trim(); - if (parentId == null || parentId.isEmpty || !_isEnAttente) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text('Aucun compte en attente à refuser pour ce dossier.'), - backgroundColor: Colors.red.shade700, - ), - ); - return; - } - setState(() => _submitting = true); - try { - await UserService.refuseUser(parentId, comment: comment); - if (!mounted) return; - final nbEnAttente = widget.dossier.parents - .where((p) => p.statut == 'en_attente') - .length; - final msg = nbEnAttente > 1 - ? 'Refus enregistré. Un e-mail de reprise a été envoyé à chaque parent du dossier.' - : 'Refus enregistré. Un e-mail avec le lien de reprise a été envoyé.'; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(msg), - duration: const Duration(seconds: 4), - ), - ); - 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); - } - } } From b4abb7d6de1ff950b54a089e03dc48d6fe83a4a7 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 24 Jul 2026 13:01:54 +0200 Subject: [PATCH 66/85] =?UTF-8?q?fix(#129):=20peaufiner=20le=20wizard=20fa?= =?UTF-8?q?mille=20(co-parent,=20hauteurs,=20=C3=A0=20na=C3=AEtre).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../common/validation_detail_section.dart | 52 +++- .../widgets/admin/parent_dossier_wizard.dart | 275 +++++++++++++----- 2 files changed, 239 insertions(+), 88 deletions(-) diff --git a/frontend/lib/widgets/admin/common/validation_detail_section.dart b/frontend/lib/widgets/admin/common/validation_detail_section.dart index faf9f6e..80f5a42 100644 --- a/frontend/lib/widgets/admin/common/validation_detail_section.dart +++ b/frontend/lib/widgets/admin/common/validation_detail_section.dart @@ -35,19 +35,23 @@ class ValidationFormMetrics { ); static double get sectionTitleBlockHeight => - sectionTitleFontSize + sectionTitleGapBelow; + sectionTitleFontSize * 1.25 + sectionTitleGapBelow; + /// Libellé : marge au-dessus de [fieldLabelFontSize] (métriques police). static double get labeledRowHeight => - fieldLabelFontSize + fieldLabelGapBelow + fieldHeight + rowGapBelow; + fieldLabelFontSize * 1.25 + + fieldLabelGapBelow + + fieldHeight + + rowGapBelow; - /// Corps modale AM : padding wizard + titre + [rows] lignes + nav. + /// Corps modale AM / famille : padding wizard + titre + [rows] lignes + nav. static double shellBodyHeightForRows(int rows) => 20 * 2 + // padding wizard 4 + // espace haut sectionTitleBlockHeight + rows * labeledRowHeight + 24 + // avant nav - 44; // boutons + 48; // boutons (+ marge anti-overflow) } /// Bloc type formulaire (titre de section + champs read-only) pour les modales de validation. @@ -134,6 +138,7 @@ class ValidationFormGrid extends StatelessWidget { label: f.label, field: f.field, expand: expandVertically, + labelTrailing: f.labelTrailing, ), ) .toList(); @@ -262,28 +267,37 @@ class ValidationLabeledField extends StatelessWidget { final String label; final Widget field; final bool expand; + /// Widget aligné à droite sur la ligne du libellé (ex. switch « Même adresse »). + final Widget? labelTrailing; const ValidationLabeledField({ super.key, required this.label, required this.field, this.expand = false, + this.labelTrailing, }); @override Widget build(BuildContext context) { + final labelStyle = TextStyle( + fontSize: ValidationFormMetrics.fieldLabelFontSize, + fontWeight: FontWeight.w500, + color: Colors.grey.shade700, + ); return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min, children: [ - Text( - label, - style: TextStyle( - fontSize: ValidationFormMetrics.fieldLabelFontSize, - fontWeight: FontWeight.w500, - color: Colors.grey.shade700, + if (labelTrailing == null) + Text(label, style: labelStyle) + else + Row( + children: [ + Expanded(child: Text(label, style: labelStyle)), + labelTrailing!, + ], ), - ), SizedBox(height: ValidationFormMetrics.fieldLabelGapBelow), if (expand) Expanded(child: field) else field, ], @@ -299,6 +313,7 @@ class ValidationEditableField extends StatelessWidget { final String? hintText; final int maxLines; final bool compact; + final bool enabled; const ValidationEditableField({ super.key, @@ -308,6 +323,7 @@ class ValidationEditableField extends StatelessWidget { this.hintText, this.maxLines = 1, this.compact = false, + this.enabled = true, }); static const double _compactFieldHeight = 34; @@ -339,6 +355,7 @@ class ValidationEditableField extends StatelessWidget { if (maxLines > 1) { return TextField( controller: controller, + enabled: enabled, keyboardType: keyboardType, inputFormatters: inputFormatters, maxLines: maxLines, @@ -350,6 +367,7 @@ class ValidationEditableField extends StatelessWidget { return _validationFieldFillHeight( TextField( controller: controller, + enabled: enabled, keyboardType: keyboardType, inputFormatters: inputFormatters, maxLines: 1, @@ -365,6 +383,7 @@ class ValidationEditableField extends StatelessWidget { decoration: _compactDecoration(), child: TextField( controller: controller, + enabled: enabled, keyboardType: keyboardType, inputFormatters: inputFormatters, maxLines: 1, @@ -381,13 +400,15 @@ class ValidationEditableField extends StatelessWidget { } } -/// Étire le champ si le parent impose une hauteur (grille [expandVertically]). +/// Hauteur TF fixe ; en grille [expandVertically], étire jusqu’à la hauteur dispo. Widget _validationFieldFillHeight(Widget field) { return LayoutBuilder( builder: (context, c) { - if (!c.hasBoundedHeight || !c.maxHeight.isFinite) return field; + final h = (c.hasBoundedHeight && c.maxHeight.isFinite) + ? c.maxHeight + : ValidationFormMetrics.fieldHeight; return SizedBox( - height: c.maxHeight, + height: h, width: double.infinity, child: field, ); @@ -481,12 +502,14 @@ class ValidationPostalCodeField extends StatefulWidget { final TextEditingController controller; final String? hintText; final bool allowEmpty; + final bool enabled; const ValidationPostalCodeField({ super.key, required this.controller, this.hintText, this.allowEmpty = false, + this.enabled = true, }); @override @@ -536,6 +559,7 @@ class _ValidationPostalCodeFieldState extends State { key: _fieldKey, controller: widget.controller, focusNode: _focusNode, + enabled: widget.enabled, keyboardType: TextInputType.number, textInputAction: TextInputAction.next, textAlignVertical: TextAlignVertical.center, diff --git a/frontend/lib/widgets/admin/parent_dossier_wizard.dart b/frontend/lib/widgets/admin/parent_dossier_wizard.dart index 051b183..a70fc7f 100644 --- a/frontend/lib/widgets/admin/parent_dossier_wizard.dart +++ b/frontend/lib/widgets/admin/parent_dossier_wizard.dart @@ -30,6 +30,7 @@ class _CreateChild { final TextEditingController nomCtrl = TextEditingController(); final TextEditingController dateCtrl = TextEditingController(); String? genre; + bool isUnborn = false; Uint8List? photoBytes; String? photoFilename; @@ -92,9 +93,9 @@ class ParentDossierWizard extends StatefulWidget { bool get isCreate => mode == ParentDossierWizardMode.create; - /// Hauteur corps modale famille — dérivée de [ValidationFormMetrics] (4 lignes). + /// Hauteur corps modale famille — 4 lignes TF + marge pour le bandeau switch. static double get shellBodyHeight => - ValidationFormMetrics.shellBodyHeightForRows(4); + ValidationFormMetrics.shellBodyHeightForRows(4) + 20; @override State createState() => _ParentDossierWizardState(); @@ -319,60 +320,122 @@ class _ParentDossierWizardState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Ajouter un co-parent'), - value: _hasCoParent, - onChanged: (v) => setState(() => _hasCoParent = v), - ), - if (!_hasCoParent) - const Padding( - padding: EdgeInsets.symmetric(vertical: 12), - child: Text( - 'Un seul parent pour ce dossier.', - style: TextStyle(color: Colors.black87), - ), - ) - else ...[ - const SizedBox(height: 8), - Expanded( - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - IdentityBlock.editable( - title: 'Deuxième parent', - nomController: _p2NomCtrl, - prenomController: _p2PrenomCtrl, - telephoneController: _p2TelCtrl, - emailController: _p2EmailCtrl, - adresseController: _p2AdresseCtrl, - codePostalController: _p2CpCtrl, - villeController: _p2VilleCtrl, - ), - const SizedBox(height: 12), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Même adresse que le parent principal'), - value: _sameAddress, - onChanged: (v) => setState(() { - _sameAddress = v; - if (v) { - _p2AdresseCtrl.text = _p1AdresseCtrl.text; - _p2CpCtrl.text = _p1CpCtrl.text; - _p2VilleCtrl.text = _p1VilleCtrl.text; - } - }), - ), - ], + Row( + children: [ + const Text( + 'Deuxième parent', + style: TextStyle( + fontSize: ValidationFormMetrics.sectionTitleFontSize, + fontWeight: FontWeight.w600, + color: Colors.black87, ), ), + const Spacer(), + const Text( + 'Ajouter un co-parent', + style: TextStyle(fontSize: 14, color: Colors.black87), + ), + Transform.scale( + scale: 0.75, + child: Switch( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + value: _hasCoParent, + onChanged: (v) => setState(() => _hasCoParent = v), + ), + ), + ], + ), + const SizedBox(height: 8), + if (!_hasCoParent) + const Text( + 'Un seul parent pour ce dossier.', + style: TextStyle(color: Colors.black87), + ) + else + ValidationFormGrid( + compact: true, + rowLayout: IdentityBlock.rowLayout, + rowFlex: IdentityBlock.rowFlex, + fields: [ + ValidationLabeledField( + label: 'Nom', + field: ValidationEditableField( + controller: _p2NomCtrl, + inputFormatters: const [PersonNameInputFormatter()], + ), + ), + ValidationLabeledField( + label: 'Prénom', + field: ValidationEditableField( + controller: _p2PrenomCtrl, + inputFormatters: const [PersonNameInputFormatter()], + ), + ), + ValidationLabeledField( + label: 'Téléphone', + field: ValidationPhoneField(controller: _p2TelCtrl), + ), + ValidationLabeledField( + label: 'Email', + field: ValidationEmailField(controller: _p2EmailCtrl), + ), + ValidationLabeledField( + label: 'Adresse (N° et Rue)', + labelTrailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Même adresse', + style: TextStyle( + fontSize: ValidationFormMetrics.fieldLabelFontSize, + color: Colors.black87, + ), + ), + Transform.scale( + scale: 0.75, + child: Switch( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + value: _sameAddress, + onChanged: (v) => setState(() { + _sameAddress = v; + if (v) _copyP1AddressToP2(); + }), + ), + ), + ], + ), + field: ValidationEditableField( + controller: _p2AdresseCtrl, + enabled: !_sameAddress, + ), + ), + ValidationLabeledField( + label: 'Code postal', + field: ValidationPostalCodeField( + controller: _p2CpCtrl, + enabled: !_sameAddress, + ), + ), + ValidationLabeledField( + label: 'Ville', + field: ValidationEditableField( + controller: _p2VilleCtrl, + enabled: !_sameAddress, + inputFormatters: const [PersonNameInputFormatter()], + ), + ), + ], ), - ], ], ); } + void _copyP1AddressToP2() { + _p2AdresseCtrl.text = _p1AdresseCtrl.text; + _p2CpCtrl.text = _p1CpCtrl.text; + _p2VilleCtrl.text = _p1VilleCtrl.text; + } + Widget _buildParent2Step() { if (_dossier.parents.length < 2) { return const Padding( @@ -643,11 +706,12 @@ class _ParentDossierWizardState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( - padding: const EdgeInsets.fromLTRB(12, 12, 40, 8), + padding: const EdgeInsets.fromLTRB(12, 12, 14, 8), child: ValidationLabeledField( label: 'Prénom', field: ValidationEditableField( controller: child.prenomCtrl, + hintText: child.isUnborn ? 'Facultatif' : null, inputFormatters: const [PersonNameInputFormatter()], ), ), @@ -715,7 +779,36 @@ class _ParentDossierWizardState extends State { ), const SizedBox(height: 12), ValidationLabeledField( - label: 'Date de naissance', + label: child.isUnborn + ? 'Date prévisionnelle' + : 'Date de naissance', + labelTrailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'À naître', + style: TextStyle( + fontSize: ValidationFormMetrics + .fieldLabelFontSize, + color: Colors.black87, + ), + ), + Transform.scale( + scale: 0.75, + child: Switch( + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + value: child.isUnborn, + onChanged: (v) => setState(() { + child.isUnborn = v; + if (!v && child.genre == 'Autre') { + child.genre = null; + } + }), + ), + ), + ], + ), field: ValidationEditableField( controller: child.dateCtrl, hintText: 'jj / mm / aaaa', @@ -766,20 +859,28 @@ class _ParentDossierWizardState extends State { Widget _genreDropdown(int index) { final child = _children[index]; + final items = >[ + const DropdownMenuItem(value: 'H', child: Text('Garçon')), + const DropdownMenuItem(value: 'F', child: Text('Fille')), + if (child.isUnborn) + const DropdownMenuItem(value: 'Autre', child: Text('Inconnu')), + ]; + final value = child.genre != null && + items.any((e) => e.value == child.genre) + ? child.genre + : null; return SizedBox( height: ValidationFormMetrics.fieldHeight, child: DropdownButtonFormField( - value: child.genre, + key: ValueKey('genre-$index-${child.isUnborn}'), + value: value, isExpanded: true, hint: Text( 'Sélectionner', style: TextStyle(color: Colors.grey.shade600, fontSize: 14), ), decoration: ValidationFieldDecoration.input(), - items: const [ - DropdownMenuItem(value: 'H', child: Text('Garçon')), - DropdownMenuItem(value: 'F', child: Text('Fille')), - ], + items: items, onChanged: (v) => setState(() => child.genre = v), ), ); @@ -1118,6 +1219,10 @@ class _ParentDossierWizardState extends State { normalizeEmailText(_p1EmailCtrl.text)) { return 'L’email du co-parent doit être différent de celui du parent principal.'; } + if (_sameAddress) { + _copyP1AddressToP2(); + return null; + } if (_p2AdresseCtrl.text.trim().isEmpty) { return 'L’adresse du co-parent est requise.'; } @@ -1134,14 +1239,27 @@ class _ParentDossierWizardState extends State { for (var i = 0; i < _children.length; i++) { final c = _children[i]; final label = 'Enfant ${i + 1}'; - if (c.prenomCtrl.text.trim().length < 2) { - return '$label : le prénom doit contenir au moins 2 caractères.'; - } - if (parseFrDateToIso(c.dateCtrl.text) == null) { - return '$label : indiquez une date de naissance valide (jj/mm/aaaa).'; - } - if (c.genre != 'H' && c.genre != 'F') { - return '$label : indiquez le genre (Garçon ou Fille).'; + final prenom = c.prenomCtrl.text.trim(); + if (c.isUnborn) { + if (prenom.isNotEmpty && prenom.length < 2) { + return '$label : le prénom doit contenir au moins 2 caractères.'; + } + if (parseFrDateToIso(c.dateCtrl.text) == null) { + return '$label : indiquez une date prévisionnelle valide (jj/mm/aaaa).'; + } + if (c.genre != 'H' && c.genre != 'F' && c.genre != 'Autre') { + return '$label : indiquez le genre (Garçon, Fille ou Inconnu).'; + } + } else { + if (prenom.length < 2) { + return '$label : le prénom doit contenir au moins 2 caractères.'; + } + if (parseFrDateToIso(c.dateCtrl.text) == null) { + return '$label : indiquez une date de naissance valide (jj/mm/aaaa).'; + } + if (c.genre != 'H' && c.genre != 'F') { + return '$label : indiquez le genre (Garçon ou Fille).'; + } } } return null; @@ -1207,17 +1325,29 @@ class _ParentDossierWizardState extends State { final nom = nomRaw.isNotEmpty ? formatPersonNameCase(nomRaw) : formatPersonNameCase(_p1NomCtrl.text); - final birthIso = parseFrDateToIso(c.dateCtrl.text); + final dateIso = parseFrDateToIso(c.dateCtrl.text); final map = { - 'prenom': prenom, - 'nom': nom, - 'date_naissance': birthIso, 'genre': c.genre, 'consent_photo': true, 'grossesse_multiple': false, }; + if (prenom.length >= 2) { + map['prenom'] = prenom; + } + if (nom.length >= 2) { + map['nom'] = nom; + } + + if (c.isUnborn) { + if (dateIso != null) { + map['date_previsionnelle_naissance'] = dateIso; + } + } else if (dateIso != null) { + map['date_naissance'] = dateIso; + } + final bytes = c.photoBytes; if (bytes != null && bytes.isNotEmpty) { final mime = _imageMimeForBytes(bytes); @@ -1243,16 +1373,13 @@ class _ParentDossierWizardState extends State { }; if (_hasCoParent) { - final sameAddr = - _p2AdresseCtrl.text.trim() == _p1AdresseCtrl.text.trim() && - _p2CpCtrl.text.trim() == _p1CpCtrl.text.trim() && - _p2VilleCtrl.text.trim() == _p1VilleCtrl.text.trim(); + if (_sameAddress) _copyP1AddressToP2(); body['co_parent_email'] = normalizeEmailText(_p2EmailCtrl.text); body['co_parent_prenom'] = formatPersonNameCase(_p2PrenomCtrl.text); body['co_parent_nom'] = formatPersonNameCase(_p2NomCtrl.text); body['co_parent_telephone'] = normalizePhone(_p2TelCtrl.text); - body['co_parent_meme_adresse'] = sameAddr; - if (!sameAddr) { + body['co_parent_meme_adresse'] = _sameAddress; + if (!_sameAddress) { body['co_parent_adresse'] = _p2AdresseCtrl.text.trim(); body['co_parent_code_postal'] = _p2CpCtrl.text.trim(); body['co_parent_ville'] = formatPersonNameCase(_p2VilleCtrl.text); From 86701731e3c94f3299da72508f783eb28ab17154 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 24 Jul 2026 13:13:21 +0200 Subject: [PATCH 67/85] =?UTF-8?q?fix(#129):=20resserrer=20les=20champs=20e?= =?UTF-8?q?nfant=20pour=20=C3=A9viter=20le=20scroll.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../widgets/admin/parent_dossier_wizard.dart | 112 +++++++++--------- 1 file changed, 55 insertions(+), 57 deletions(-) diff --git a/frontend/lib/widgets/admin/parent_dossier_wizard.dart b/frontend/lib/widgets/admin/parent_dossier_wizard.dart index a70fc7f..262a8a1 100644 --- a/frontend/lib/widgets/admin/parent_dossier_wizard.dart +++ b/frontend/lib/widgets/admin/parent_dossier_wizard.dart @@ -763,68 +763,66 @@ class _ParentDossierWizardState extends State { Expanded( flex: 2, child: Padding( - padding: const EdgeInsets.fromLTRB(4, 4, 14, 12), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ValidationLabeledField( - label: 'Nom', - field: ValidationEditableField( - controller: child.nomCtrl, - inputFormatters: const [ - PersonNameInputFormatter(), - ], - ), + padding: const EdgeInsets.fromLTRB(4, 0, 14, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ValidationLabeledField( + label: 'Nom', + field: ValidationEditableField( + controller: child.nomCtrl, + inputFormatters: const [ + PersonNameInputFormatter(), + ], ), - const SizedBox(height: 12), - ValidationLabeledField( - label: child.isUnborn - ? 'Date prévisionnelle' - : 'Date de naissance', - labelTrailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - 'À naître', - style: TextStyle( - fontSize: ValidationFormMetrics - .fieldLabelFontSize, - color: Colors.black87, - ), + ), + const SizedBox(height: 6), + ValidationLabeledField( + label: child.isUnborn + ? 'Date prévisionnelle' + : 'Date de naissance', + labelTrailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'À naître', + style: TextStyle( + fontSize: ValidationFormMetrics + .fieldLabelFontSize, + color: Colors.black87, ), - Transform.scale( - scale: 0.75, - child: Switch( - materialTapTargetSize: - MaterialTapTargetSize.shrinkWrap, - value: child.isUnborn, - onChanged: (v) => setState(() { - child.isUnborn = v; - if (!v && child.genre == 'Autre') { - child.genre = null; - } - }), - ), + ), + Transform.scale( + scale: 0.75, + child: Switch( + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + value: child.isUnborn, + onChanged: (v) => setState(() { + child.isUnborn = v; + if (!v && child.genre == 'Autre') { + child.genre = null; + } + }), ), - ], - ), - field: ValidationEditableField( - controller: child.dateCtrl, - hintText: 'jj / mm / aaaa', - keyboardType: TextInputType.number, - inputFormatters: const [ - FrenchDateInputFormatter(), - ], - ), + ), + ], ), - const SizedBox(height: 12), - ValidationLabeledField( - label: 'Genre', - field: _genreDropdown(index), + field: ValidationEditableField( + controller: child.dateCtrl, + hintText: 'jj / mm / aaaa', + keyboardType: TextInputType.number, + inputFormatters: const [ + FrenchDateInputFormatter(), + ], ), - ], - ), + ), + const SizedBox(height: 6), + ValidationLabeledField( + label: 'Genre', + field: _genreDropdown(index), + ), + ], ), ), ), From f596f062a6348cbcdbad8e6c19e54d7ec76b50cf Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 24 Jul 2026 18:10:20 +0200 Subject: [PATCH 68/85] docs(#153): mini-spec front onglet Dossiers permanent. Co-authored-by: Cursor --- docs/tmp/153-mini-spec-front-dossiers.md | 167 +++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 docs/tmp/153-mini-spec-front-dossiers.md diff --git a/docs/tmp/153-mini-spec-front-dossiers.md b/docs/tmp/153-mini-spec-front-dossiers.md new file mode 100644 index 0000000..9e4f8f6 --- /dev/null +++ b/docs/tmp/153-mini-spec-front-dossiers.md @@ -0,0 +1,167 @@ +# Mini-spec front — Onglet permanent « Dossiers » (#153) + +Branche Git (front + back) : `feature/153-onglet-dossiers` +Ticket Gitea : **#153** (ticket normal, plus epic) + +> Suite prévue : **#135** = au clic, mode **édition** wizard + ajout 2ᵉ parent. +> **#153** = onglet + listes + navigation / validation pending. **Pas** de création, **pas** d’édition complète. + +--- + +## Contexte / objectif + +Remplacer l’onglet conditionnel **« À valider »** (apparaît/disparaît selon pending) par un onglet **permanent « Dossiers »** dans le dashboard admin/gestionnaire. + +Quand on ouvre **Dossiers** : + +1. **En haut** — section **Dossiers à valider** (AM + familles pending) +2. **En dessous** — liste de **tous les dossiers** (familles **et** AM), 1 ligne = 1 `numero_dossier` +3. Différenciation visuelle famille vs AM : **couleur + icône** +4. **Barre de recherche** (n° dossier, nom, email…) + +**Pas** de bouton « Créer un dossier » ici (création via **+ Parents** #129 / **+ Asmat** #156). + +--- + +## UX cible + +### Onglets dashboard (`UserManagementPanel`) + +| Avant (#107) | Après (#153) | +|--------------|--------------| +| « À valider » **conditionnel** si pending | **« Dossiers » toujours visible** (admin + gestionnaire) | +| Contenu = seulement pending | Pending **en haut** + liste complète **en bas** | + +Ordre suggéré des onglets : + +`Dossiers` | `Parents` | `Enfants` | `Assistantes maternelles` | `Gestionnaires` | (`Administrateurs`) + +### Section haute — À valider + +- Réutiliser / adapter `PendingValidationWidget` (ou extraire la liste dans un sous-widget). +- Sources déjà branchées : + - `UserService.getPendingUsers(role: 'assistante_maternelle')` + - `UserService.getPendingFamilies()` +- Clic ligne pending → **`ValidationDossierModal`** / wizards `.review` (inchangé). +- Si section vide : ne pas afficher de gros vide ; masquer la section ou message court « Aucun dossier en attente ». + +### Section basse — Tous les dossiers + +1 ligne = **1 dossier** (`numero_dossier`), type : + +| Type | Libellé UI | Couleur (suggestion) | +|------|------------|----------------------| +| `famille` | Famille / Parents | teinte existante parents (ex. violet / rose dashboard) | +| `assistante_maternelle` | AM | teinte existante AM (ex. teal / bleu) | + +Colonnes / infos utiles (cartes style `AdminUserCard` ou lignes type pending) : + +- n° dossier +- type (pastille couleur + icône) +- libellé (noms parents ou AM) +- email(s) principal(aux) +- statut user / dossier si dispo (`actif`, `en_attente`, …) +- date utile si dispo + +**Déduplication** : un foyer (pivot + co-parent) = **une** ligne famille (même `numero_dossier`). Idem AM. + +### Recherche + +- La search bar du panel (aujourd’hui désactivée / hint « pas de recherche » sur À valider) doit **filtrer la liste unifiée** (et idéalement aussi le pending affiché). +- Critères **minimum** : `numero_dossier`, nom, prénom, email. +- Harmoniser le hint : `Rechercher un dossier (n°, nom, email)…` + +### État vide liste complète + +Aide optionnelle : *« Pour créer un dossier → onglet Parents (+ Parents) ou Assistantes maternelles (+ Asmat) »*. + +### Clic sur un dossier de la liste complète (#153) + +| Cas | Comportement #153 | +|-----|-------------------| +| Pending | Ouvrir validation (review) — déjà en place | +| Dossier **actif** / non pending | Ouvrir consultation via `GET /dossiers/:numeroDossier` (`UserService.getDossierByNumero`) en **lecture / review** si possible **sans** save édition | + +**Ne pas** implémenter le mode `edit` ni le switch 2ᵉ parent → **#135**. + +Si l’ouverture « review » d’un dossier actif est trop lourde pour ce ticket : clic peut temporairement no-op / snackbar *« Édition dossier : prochainement (#135) »* — **à éviter** si `getDossierByNumero` + wizard review marche déjà pour les deux types. + +--- + +## Données / APIs (front) + +### Déjà disponibles (préférer composer côté front pour #153) + +| Besoin | API / service | +|--------|----------------| +| Pending AM | `getPendingUsers(role: assistante_maternelle)` | +| Pending familles | `getPendingFamilies()` | +| Parents (avec `numero_dossier`) | `getParents()` | +| AM (avec `numero_dossier`) | `getAssistantesMaternelles()` | +| Détail unifié | `getDossierByNumero(numero)` → `GET /dossiers/:numeroDossier` | + +**Pas d’endpoint `GET /dossiers` liste** aujourd’hui. Pour #153 : + +- Construire la liste unifiée **côté client** à partir de `getParents()` + `getAssistantesMaternelles()` (group by `numero_dossier`). +- Exclure ou marquer les pending déjà dans la section haute (éviter doublons visuels, ou les laisser dans les deux avec badge « à valider » — **préférence** : pending **uniquement** en haut ; liste basse = tous **hors** pending **ou** tous avec badge ; choisir une règle claire et documenter dans le PR). + +**Règle recommandée** : +- Haut = pending only +- Bas = **tous** les dossiers ayant un `numero_dossier` (y compris pending) **OU** bas = non-pending only +→ **Recommandation produit** : bas = **tous** (vision complète), pending aussi en haut pour action rapide. Si doublon gênant : bas = non-pending only. + +### Si le back ajoute plus tard `GET /dossiers` + +Brancher `UserService.getDossiers()` — hors scope bloquant #153 front si composition client OK. + +--- + +## Fichiers front probables + +| Fichier | Rôle | +|---------|------| +| `frontend/lib/widgets/admin/user_management_panel.dart` | Onglet permanent **Dossiers** ; retirer logique conditionnelle À valider ; search sur cet onglet | +| `frontend/lib/widgets/admin/pending_validation_widget.dart` | Réemploi section haute (ou refactor léger) | +| **Nouveau** `…/dossiers_management_widget.dart` (nom libre) | Shell onglet : pending + liste unifiée + refresh | +| **Nouveau** modèle léger `DossierListItem` (type, numero, libelle, emails, statut…) | Mapping parents/AM → ligne | +| `user_service.dart` / `api_config.dart` | Seulement si helper `getDossiersUnified()` côté client (pas forcément nouvel endpoint) | +| `validation_dossier_modal.dart` | Réemploi ouverture pending / détail | + +Réutiliser look & feel cartes / hover « Ouvrir » de `_PendingValidationRow` / `AdminUserCard`. + +--- + +## Hors scope (#153) + +- Bouton créer dossier +- Mode `edit` wizard + ajout 2ᵉ parent → **#135** +- Suppressions → **#154** +- Famille N responsables → **#139** +- Changer les onglets Parents / AM / Enfants (restent) + +--- + +## Critères d’acceptation front + +- [ ] Onglet **Dossiers** toujours visible (même 0 pending) +- [ ] Plus d’onglet conditionnel **« À valider »** +- [ ] Section haute pending si non vide ; validation au clic OK +- [ ] Liste unifiée familles + AM en dessous ; 1 ligne / `numero_dossier` +- [ ] Couleur + icône différencient famille / AM +- [ ] Recherche filtre (n° + nom + email minimum) +- [ ] **Aucun** bouton créer dans cet onglet +- [ ] Pas de régression validation pending (valider / refuser) + +--- + +## Back (info — Cursor back séparé si besoin) + +- Liste unifiée : **pas bloquante** si composition front +- Optionnel : `GET /api/v1/dossiers` (liste) pour perf / pagination plus tard +- `GET /dossiers/:numero` déjà là (#119) + +--- + +## Branche + +`feature/153-onglet-dossiers` (depuis `develop`) From 6708f73b060e58de055f3fd006a3db1d3321dcfb Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 24 Jul 2026 18:13:32 +0200 Subject: [PATCH 69/85] =?UTF-8?q?feat(#153):=20GET=20/dossiers=20=E2=80=94?= =?UTF-8?q?=20liste=20unifi=C3=A9e=20familles=20+=20AM.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Endpoint staff pour l’onglet Dossiers : type, n°, libellé, emails, statut, a_valider, filtre q. Complète GET /dossiers/:numero (#119). Co-authored-by: Cursor --- .../dossiers/dossiers.controller.spec.ts | 63 +++++++ .../routes/dossiers/dossiers.controller.ts | 32 +++- .../routes/dossiers/dossiers.service.spec.ts | 142 ++++++++++++++++ .../src/routes/dossiers/dossiers.service.ts | 157 +++++++++++++++++- .../dossiers/dto/dossier-list-item.dto.ts | 52 ++++++ docs/tmp/153-contrat-api-liste-dossiers.md | 77 +++++++++ 6 files changed, 520 insertions(+), 3 deletions(-) create mode 100644 backend/src/routes/dossiers/dossiers.controller.spec.ts create mode 100644 backend/src/routes/dossiers/dossiers.service.spec.ts create mode 100644 backend/src/routes/dossiers/dto/dossier-list-item.dto.ts create mode 100644 docs/tmp/153-contrat-api-liste-dossiers.md diff --git a/backend/src/routes/dossiers/dossiers.controller.spec.ts b/backend/src/routes/dossiers/dossiers.controller.spec.ts new file mode 100644 index 0000000..165e1e3 --- /dev/null +++ b/backend/src/routes/dossiers/dossiers.controller.spec.ts @@ -0,0 +1,63 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DossiersController } from './dossiers.controller'; +import { DossiersService } from './dossiers.service'; +import { AuthGuard } from 'src/common/guards/auth.guard'; +import { RolesGuard } from 'src/common/guards/roles.guard'; +import { StatutUtilisateurType } from 'src/entities/users.entity'; + +describe('DossiersController', () => { + let controller: DossiersController; + const dossiersServiceMock = { + listDossiers: jest.fn(), + getDossierByNumero: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [DossiersController], + providers: [{ provide: DossiersService, useValue: dossiersServiceMock }], + }) + .overrideGuard(AuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(RolesGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(DossiersController); + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('list delegates to dossiersService.listDossiers with q', async () => { + dossiersServiceMock.listDossiers.mockResolvedValue([ + { + type: 'famille', + numero_dossier: '2026-000043', + libelle: 'Claire MARTIN', + emails: ['claire@test.fr'], + user_ids: ['u1'], + statut: StatutUtilisateurType.ACTIF, + a_valider: false, + date_reference: null, + }, + ]); + + const res = await controller.list('martin'); + expect(dossiersServiceMock.listDossiers).toHaveBeenCalledWith('martin'); + expect(res).toHaveLength(1); + expect(res[0].numero_dossier).toBe('2026-000043'); + }); + + it('getDossier delegates to getDossierByNumero', async () => { + dossiersServiceMock.getDossierByNumero.mockResolvedValue({ + type: 'family', + dossier: { numero_dossier: '2026-000001' }, + }); + const res = await controller.getDossier('2026-000001'); + expect(dossiersServiceMock.getDossierByNumero).toHaveBeenCalledWith('2026-000001'); + expect(res.type).toBe('family'); + }); +}); diff --git a/backend/src/routes/dossiers/dossiers.controller.ts b/backend/src/routes/dossiers/dossiers.controller.ts index 2f3f677..4849f9b 100644 --- a/backend/src/routes/dossiers/dossiers.controller.ts +++ b/backend/src/routes/dossiers/dossiers.controller.ts @@ -1,18 +1,46 @@ -import { Controller, Get, Param, UseGuards } from '@nestjs/common'; -import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiParam, + ApiQuery, + 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'; +import { DossierListItemDto } from './dto/dossier-list-item.dto'; @ApiTags('Dossiers') +@ApiBearerAuth('access-token') @Controller('dossiers') @UseGuards(AuthGuard, RolesGuard) export class DossiersController { constructor(private readonly dossiersService: DossiersService) {} + @Get() + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) + @ApiOperation({ + summary: 'Liste unifiée des dossiers (familles + AM) — ticket #153', + description: + '1 entrée = 1 numero_dossier. Types `famille` | `assistante_maternelle`. ' + + 'Filtre optionnel `q` (n°, nom, email). Tri : à valider d’abord, puis n° décroissant.', + }) + @ApiQuery({ + name: 'q', + required: false, + description: 'Recherche libre : n° dossier, libellé, email…', + }) + @ApiResponse({ status: 200, type: [DossierListItemDto] }) + @ApiResponse({ status: 403, description: 'Accès refusé' }) + list(@Query('q') q?: string): Promise { + return this.dossiersService.listDossiers(q); + } + @Get(':numeroDossier') @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) @ApiOperation({ summary: 'Dossier complet par numéro (AM ou famille) – Ticket #119' }) diff --git a/backend/src/routes/dossiers/dossiers.service.spec.ts b/backend/src/routes/dossiers/dossiers.service.spec.ts new file mode 100644 index 0000000..ecdf772 --- /dev/null +++ b/backend/src/routes/dossiers/dossiers.service.spec.ts @@ -0,0 +1,142 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { DossiersService } from './dossiers.service'; +import { Parents } from 'src/entities/parents.entity'; +import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; +import { ParentsService } from '../parents/parents.service'; +import { StatutUtilisateurType } from 'src/entities/users.entity'; + +describe('DossiersService.listDossiers', () => { + let service: DossiersService; + const parentsQb = { + innerJoinAndSelect: jest.fn().mockReturnThis(), + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn(), + }; + const amQb = { + innerJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn(), + }; + const parentsRepo = { + createQueryBuilder: jest.fn(() => parentsQb), + findOne: jest.fn(), + }; + const amRepo = { + createQueryBuilder: jest.fn(() => amQb), + findOne: jest.fn(), + }; + const parentsService = { + getDossierFamilleByNumero: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DossiersService, + { provide: getRepositoryToken(Parents), useValue: parentsRepo }, + { provide: getRepositoryToken(AssistanteMaternelle), useValue: amRepo }, + { provide: ParentsService, useValue: parentsService }, + ], + }).compile(); + + service = module.get(DossiersService); + jest.clearAllMocks(); + parentsRepo.createQueryBuilder.mockReturnValue(parentsQb); + amRepo.createQueryBuilder.mockReturnValue(amQb); + }); + + it('aggregates famille (pivot+co-parent) and AM, sorts a_valider first', async () => { + parentsQb.getMany.mockResolvedValue([ + { + user_id: 'p1', + numero_dossier: '2026-000010', + user: { + id: 'p1', + email: 'claire@test.fr', + prenom: 'Claire', + nom: 'Martin', + statut: StatutUtilisateurType.ACTIF, + cree_le: new Date('2026-01-01'), + }, + co_parent: { + id: 'p2', + email: 'thomas@test.fr', + prenom: 'Thomas', + nom: 'Martin', + statut: StatutUtilisateurType.ACTIF, + cree_le: new Date('2026-01-02'), + }, + }, + { + user_id: 'p3', + numero_dossier: '2026-000020', + user: { + id: 'p3', + email: 'pending@test.fr', + prenom: 'Paul', + nom: 'Pending', + statut: StatutUtilisateurType.EN_ATTENTE, + cree_le: new Date('2026-02-01'), + }, + co_parent: undefined, + }, + ]); + amQb.getMany.mockResolvedValue([ + { + user_id: 'am1', + numero_dossier: '2026-000015', + user: { + id: 'am1', + email: 'am@test.fr', + prenom: 'Marie', + nom: 'Dupont', + statut: StatutUtilisateurType.ACTIF, + cree_le: new Date('2026-01-15'), + }, + }, + ]); + + const list = await service.listDossiers(); + expect(list).toHaveLength(3); + expect(list[0].a_valider).toBe(true); + expect(list[0].type).toBe('famille'); + expect(list[0].numero_dossier).toBe('2026-000020'); + + const famille = list.find((i) => i.numero_dossier === '2026-000010')!; + expect(famille.type).toBe('famille'); + expect(famille.user_ids).toEqual(expect.arrayContaining(['p1', 'p2'])); + expect(famille.emails).toHaveLength(2); + expect(famille.libelle).toContain('MARTIN'); + + const am = list.find((i) => i.type === 'assistante_maternelle')!; + expect(am.numero_dossier).toBe('2026-000015'); + expect(am.libelle).toContain('Marie'); + }); + + it('filters with q', async () => { + parentsQb.getMany.mockResolvedValue([]); + amQb.getMany.mockResolvedValue([ + { + user_id: 'am1', + numero_dossier: '2026-000015', + user: { + id: 'am1', + email: 'am@test.fr', + prenom: 'Marie', + nom: 'Dupont', + statut: StatutUtilisateurType.ACTIF, + cree_le: new Date('2026-01-15'), + }, + }, + ]); + + const hit = await service.listDossiers('dupont'); + expect(hit).toHaveLength(1); + const miss = await service.listDossiers('zzz'); + expect(miss).toHaveLength(0); + }); +}); diff --git a/backend/src/routes/dossiers/dossiers.service.ts b/backend/src/routes/dossiers/dossiers.service.ts index f9715e6..cfc4711 100644 --- a/backend/src/routes/dossiers/dossiers.service.ts +++ b/backend/src/routes/dossiers/dossiers.service.ts @@ -3,12 +3,14 @@ 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 { StatutUtilisateurType, Users } from 'src/entities/users.entity'; import { ParentsService } from '../parents/parents.service'; import { DossierUnifieDto } from './dto/dossier-unifie.dto'; import { DossierAmCompletDto, DossierAmUserDto } from './dto/dossier-am-complet.dto'; +import { DossierListItemDto } from './dto/dossier-list-item.dto'; /** - * Endpoint unifié GET /dossiers/:numeroDossier – AM ou famille. Ticket #119. + * Dossiers unifiés — détail (#119) + liste (#153). */ @Injectable() export class DossiersService { @@ -20,6 +22,159 @@ export class DossiersService { private readonly parentsService: ParentsService, ) {} + /** + * Liste unifiée tous dossiers (familles + AM) ayant un numero_dossier. + * Ticket #153 — optionnel `q` filtre n° / nom / prénom / email (côté serveur). + */ + async listDossiers(q?: string): Promise { + const items: DossierListItemDto[] = [ + ...(await this.listFamilleItems()), + ...(await this.listAmItems()), + ]; + + const needle = (q ?? '').trim().toLowerCase(); + const filtered = needle + ? items.filter((item) => this.matchesQuery(item, needle)) + : items; + + filtered.sort((a, b) => { + // À valider d'abord, puis n° dossier décroissant + if (a.a_valider !== b.a_valider) return a.a_valider ? -1 : 1; + return b.numero_dossier.localeCompare(a.numero_dossier, 'fr'); + }); + + return filtered; + } + + private async listFamilleItems(): Promise { + const parents = await this.parentsRepository + .createQueryBuilder('p') + .innerJoinAndSelect('p.user', 'u') + .leftJoinAndSelect('p.co_parent', 'cp') + .where('p.numero_dossier IS NOT NULL') + .andWhere("TRIM(p.numero_dossier) <> ''") + .getMany(); + + const byNum = new Map(); + for (const p of parents) { + const num = (p.numero_dossier ?? '').trim(); + if (!num) continue; + const group = byNum.get(num) ?? []; + group.push(p); + byNum.set(num, group); + } + + const items: DossierListItemDto[] = []; + for (const [numero_dossier, group] of byNum) { + const usersMap = new Map(); + for (const p of group) { + if (p.user) usersMap.set(p.user.id, p.user); + if (p.co_parent) usersMap.set(p.co_parent.id, p.co_parent); + } + const users = [...usersMap.values()].sort((a, b) => { + const an = `${a.nom ?? ''} ${a.prenom ?? ''}`.toLowerCase(); + const bn = `${b.nom ?? ''} ${b.prenom ?? ''}`.toLowerCase(); + return an.localeCompare(bn, 'fr') || a.id.localeCompare(b.id); + }); + if (users.length === 0) continue; + + const names = users.map((u) => this.formatPersonName(u)).filter(Boolean); + const libelle = + names.length === 0 + ? `Dossier ${numero_dossier}` + : names.length === 1 + ? names[0] + : names.join(' & '); + + const emails = users.map((u) => u.email).filter(Boolean); + const user_ids = users.map((u) => u.id); + const a_valider = users.some((u) => u.statut === StatutUtilisateurType.EN_ATTENTE); + const statut = a_valider + ? StatutUtilisateurType.EN_ATTENTE + : (users[0].statut ?? StatutUtilisateurType.ACTIF); + const date_reference = this.minCreeLeIso(users); + + items.push({ + type: 'famille', + numero_dossier, + libelle, + emails, + user_ids, + statut, + a_valider, + date_reference, + }); + } + return items; + } + + private async listAmItems(): Promise { + const ams = await this.amRepository + .createQueryBuilder('am') + .innerJoinAndSelect('am.user', 'u') + .where('am.numero_dossier IS NOT NULL') + .andWhere("TRIM(am.numero_dossier) <> ''") + .getMany(); + + const byNum = new Map(); + for (const am of ams) { + const num = (am.numero_dossier ?? '').trim(); + if (!num || !am.user) continue; + // Un n° = une AM ; garder le premier + if (!byNum.has(num)) byNum.set(num, am); + } + + const items: DossierListItemDto[] = []; + for (const [numero_dossier, am] of byNum) { + const u = am.user!; + const libelle = this.formatPersonName(u) || `AM ${numero_dossier}`; + const a_valider = u.statut === StatutUtilisateurType.EN_ATTENTE; + items.push({ + type: 'assistante_maternelle', + numero_dossier, + libelle, + emails: u.email ? [u.email] : [], + user_ids: [u.id], + statut: u.statut ?? StatutUtilisateurType.ACTIF, + a_valider, + date_reference: this.minCreeLeIso([u]), + }); + } + return items; + } + + private matchesQuery(item: DossierListItemDto, needle: string): boolean { + const hay = [ + item.numero_dossier, + item.libelle, + ...item.emails, + item.statut, + item.type, + ] + .join(' ') + .toLowerCase(); + return hay.includes(needle); + } + + private formatPersonName(u: Users): string { + const prenom = (u.prenom ?? '').trim(); + const nom = (u.nom ?? '').trim(); + const nomFmt = nom ? nom.toUpperCase() : ''; + return [prenom, nomFmt].filter(Boolean).join(' '); + } + + private minCreeLeIso(users: Users[]): string | null { + let min: Date | null = null; + for (const u of users) { + const d = u.cree_le; + if (!d) continue; + const date = d instanceof Date ? d : new Date(d); + if (Number.isNaN(date.getTime())) continue; + if (!min || date < min) min = date; + } + return min ? min.toISOString() : null; + } + async getDossierByNumero(numeroDossier: string): Promise { const num = numeroDossier?.trim(); if (!num) { diff --git a/backend/src/routes/dossiers/dto/dossier-list-item.dto.ts b/backend/src/routes/dossiers/dto/dossier-list-item.dto.ts new file mode 100644 index 0000000..c510f7e --- /dev/null +++ b/backend/src/routes/dossiers/dto/dossier-list-item.dto.ts @@ -0,0 +1,52 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { StatutUtilisateurType } from 'src/entities/users.entity'; + +/** Ligne de liste GET /dossiers (#153). */ +export class DossierListItemDto { + @ApiProperty({ + enum: ['famille', 'assistante_maternelle'], + description: 'Type de dossier', + }) + type: 'famille' | 'assistante_maternelle'; + + @ApiProperty({ example: '2026-000043' }) + numero_dossier: string; + + @ApiProperty({ + example: 'Claire MARTIN & Thomas MARTIN', + description: 'Libellé affiché (noms)', + }) + libelle: string; + + @ApiProperty({ + type: [String], + example: ['claire@example.com', 'thomas@example.com'], + }) + emails: string[]; + + @ApiProperty({ + type: [String], + format: 'uuid', + description: 'IDs utilisateur liés au dossier (parents du foyer ou AM)', + }) + user_ids: string[]; + + @ApiProperty({ + enum: StatutUtilisateurType, + description: + 'Statut agrégé : en_attente si au moins un user en_attente, sinon statut du premier', + }) + statut: StatutUtilisateurType; + + @ApiProperty({ + description: 'True si le dossier est en attente de validation (section haute UI)', + }) + a_valider: boolean; + + @ApiPropertyOptional({ + nullable: true, + example: '2026-01-12T10:00:00.000Z', + description: 'Date de référence (MIN cree_le des users du dossier)', + }) + date_reference: string | null; +} diff --git a/docs/tmp/153-contrat-api-liste-dossiers.md b/docs/tmp/153-contrat-api-liste-dossiers.md new file mode 100644 index 0000000..b427a7f --- /dev/null +++ b/docs/tmp/153-contrat-api-liste-dossiers.md @@ -0,0 +1,77 @@ +# Mini-spec API — GET /dossiers (#153) + +Contrat pour le **plan front** (onglet permanent Dossiers). + +## Endpoint + +| | | +|--|--| +| **Méthode** | `GET` | +| **URL** | `{base}/api/v1/dossiers` | +| **Auth** | Bearer JWT | +| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` | +| **Query** | `q` (optionnel) — recherche n° / libellé / email | + +Complète `GET /dossiers/:numeroDossier` (#119) déjà existant. + +--- + +## Réponse 200 + +Tableau de lignes (1 entrée = 1 `numero_dossier`) : + +```json +[ + { + "type": "famille", + "numero_dossier": "2026-000043", + "libelle": "Claire MARTIN & Thomas MARTIN", + "emails": ["claire@test.fr", "thomas@test.fr"], + "user_ids": ["uuid-pivot", "uuid-co"], + "statut": "actif", + "a_valider": false, + "date_reference": "2026-01-12T10:00:00.000Z" + }, + { + "type": "assistante_maternelle", + "numero_dossier": "2026-000042", + "libelle": "Marie DUPONT", + "emails": ["marie@test.fr"], + "user_ids": ["uuid-am"], + "statut": "en_attente", + "a_valider": true, + "date_reference": "2026-02-01T08:00:00.000Z" + } +] +``` + +### Champs + +| Champ | Notes | +|-------|--------| +| `type` | `famille` \| `assistante_maternelle` | +| `numero_dossier` | Clé d’unité | +| `libelle` | Noms formatés (foyer : `A & B`) | +| `emails` / `user_ids` | Membres du foyer ou AM | +| `statut` | Agrégé : `en_attente` si au moins un user pending | +| `a_valider` | `true` si pending → section haute UI | +| `date_reference` | `MIN(cree_le)` des users | + +**Tri** : `a_valider` d’abord, puis `numero_dossier` décroissant. + +**Famille** : dédupliquée par `numero_dossier` (pivot + co-parent = 1 ligne). + +--- + +## Front + +- `UserService.getDossiers({ q? })` → cet endpoint +- Section haute : filtrer `a_valider == true` **ou** continuer pending APIs existantes +- Section basse : liste complète (ou hors pending selon règle UX) +- Clic → `GET /dossiers/:numero` (détail) / validation review + +Composition client `getParents`+`getAM` **plus nécessaire** si cet endpoint est déployé. + +## Branche + +`feature/153-onglet-dossiers` From 3fdd9133679f343f4932a867cf326ba37c9da0a9 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 24 Jul 2026 18:39:59 +0200 Subject: [PATCH 70/85] =?UTF-8?q?feat(#153):=20onglet=20permanent=20Dossie?= =?UTF-8?q?rs=20(pending=20+=20liste=20unifi=C3=A9e).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- frontend/lib/models/dossier_list_item.dart | 191 ++++++++ .../lib/widgets/admin/dossier_list_card.dart | 53 +++ .../admin/dossiers_management_widget.dart | 201 ++++++++ .../admin/pending_validation_widget.dart | 449 +++++++----------- .../widgets/admin/user_management_panel.dart | 136 +++--- 5 files changed, 680 insertions(+), 350 deletions(-) create mode 100644 frontend/lib/models/dossier_list_item.dart create mode 100644 frontend/lib/widgets/admin/dossier_list_card.dart create mode 100644 frontend/lib/widgets/admin/dossiers_management_widget.dart diff --git a/frontend/lib/models/dossier_list_item.dart b/frontend/lib/models/dossier_list_item.dart new file mode 100644 index 0000000..375c817 --- /dev/null +++ b/frontend/lib/models/dossier_list_item.dart @@ -0,0 +1,191 @@ +import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; +import 'package:p_tits_pas/models/parent_model.dart'; +import 'package:p_tits_pas/utils/name_format_utils.dart'; + +/// Ligne de liste unifiée dossiers (famille ou AM) — ticket #153. +enum DossierListType { famille, assistanteMaternelle } + +class DossierListItem { + final DossierListType type; + final String numeroDossier; + final String libelle; + final List emails; + final String? statut; + + const DossierListItem({ + required this.type, + required this.numeroDossier, + required this.libelle, + this.emails = const [], + this.statut, + }); + + bool get isFamille => type == DossierListType.famille; + bool get isAm => type == DossierListType.assistanteMaternelle; + + String get typeLabel => isFamille ? 'Famille' : 'AM'; + + /// Sous-titre carte : `NOM Prénom` ou `NOM Prénom - NOM Prénom`. + String get namesLine => libelle; + + String get emailsLine => emails.where((e) => e.trim().isNotEmpty).join(' · '); + + /// Titre carte : numéro de dossier seul. + String get titleLine => numeroDossier; + + bool matchesQuery(String query) { + final q = query.trim().toLowerCase(); + if (q.isEmpty) return true; + if (numeroDossier.toLowerCase().contains(q)) return true; + if (libelle.toLowerCase().contains(q)) return true; + for (final e in emails) { + if (e.toLowerCase().contains(q)) return true; + } + return false; + } + + /// Une ligne par `numero_dossier` (foyer dédupliqué). + static List fromParents(List parents) { + final byDossier = >{}; + for (final p in parents) { + final num = (p.user.numeroDossier ?? '').trim(); + if (num.isEmpty) continue; + byDossier.putIfAbsent(num, () => []).add(p); + } + + final items = []; + for (final entry in byDossier.entries) { + final seenIds = {}; + final names = []; + final emails = []; + final statuts = []; + + void consider( + String? id, + String? nom, + String? prenom, + String? email, + String? statut, + ) { + final uid = (id ?? '').trim(); + if (uid.isEmpty || !seenIds.add(uid)) return; + final label = formatDossierPersonLabel( + nom: nom, + prenom: prenom, + email: email, + ); + if (label.isNotEmpty) names.add(label); + final e = (email ?? '').trim(); + if (e.isNotEmpty) emails.add(e); + final s = (statut ?? '').trim(); + if (s.isNotEmpty) statuts.add(s); + } + + for (final p in entry.value) { + consider( + p.user.id, + p.user.nom, + p.user.prenom, + p.user.email, + p.user.statut, + ); + final co = p.coParent; + if (co != null) { + consider(co.id, co.nom, co.prenom, co.email, co.statut); + } + } + + items.add( + DossierListItem( + type: DossierListType.famille, + numeroDossier: entry.key, + libelle: names.isNotEmpty ? names.join(' - ') : 'Famille', + emails: emails, + statut: _preferStatut(statuts), + ), + ); + } + return items; + } + + static List fromAssistantes( + List ams, + ) { + final byDossier = {}; + for (final am in ams) { + final num = (am.user.numeroDossier ?? '').trim(); + if (num.isEmpty) continue; + byDossier.putIfAbsent(num, () => am); + } + + return byDossier.entries.map((e) { + final u = e.value.user; + final name = formatDossierPersonLabel( + nom: u.nom, + prenom: u.prenom, + email: u.email, + ); + return DossierListItem( + type: DossierListType.assistanteMaternelle, + numeroDossier: e.key, + libelle: name.isNotEmpty ? name : 'AM', + emails: u.email.trim().isEmpty ? const [] : [u.email.trim()], + statut: u.statut?.trim(), + ); + }).toList(); + } + + /// Priorité affichage : en_attente > suspendu > refuse > actif > autre. + static String? _preferStatut(List raw) { + if (raw.isEmpty) return null; + const order = ['en_attente', 'suspendu', 'refuse', 'actif']; + for (final wanted in order) { + for (final s in raw) { + if (s.toLowerCase() == wanted) return s; + } + } + return raw.first; + } +} + +/// Affichage carte dossier : `NOM Prénom` (repli email). +String formatDossierPersonLabel({ + String? nom, + String? prenom, + String? email, +}) { + final n = (nom ?? '').trim().toUpperCase(); + final p = formatPersonNameCase(prenom ?? ''); + if (n.isNotEmpty && p.isNotEmpty) return '$n $p'; + if (n.isNotEmpty) return n; + if (p.isNotEmpty) return p; + return (email ?? '').trim(); +} + +/// Reformate un libellé famille API (`A & B` / `Famille …`) en `NOM Prénom - …`. +String formatDossierFamilyNamesLine(String libelle) { + var raw = libelle.trim(); + if (raw.isEmpty) return ''; + raw = raw.replaceFirst(RegExp(r'^famille\s+', caseSensitive: false), ''); + raw = raw + .replaceAll(RegExp(r'\s+&\s+'), ' - ') + .replaceAll(RegExp(r'\s+et\s+', caseSensitive: false), ' - '); + final parts = raw + .split(RegExp(r'\s+-\s+')) + .map((part) => _formatLoosePersonSegment(part.trim())) + .where((s) => s.isNotEmpty) + .toList(); + return parts.join(' - '); +} + +/// Segment libre type « martin sophie » ou « DURAND Amélie » → `NOM Prénom`. +String _formatLoosePersonSegment(String segment) { + final words = + segment.split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toList(); + if (words.isEmpty) return ''; + if (words.length == 1) return words.first.toUpperCase(); + // Convention affichage : premier mot = NOM, reste = prénom(s). + final nom = words.first.toUpperCase(); + final prenom = formatPersonNameCase(words.sublist(1).join(' ')); + return '$nom $prenom'; +} diff --git a/frontend/lib/widgets/admin/dossier_list_card.dart b/frontend/lib/widgets/admin/dossier_list_card.dart new file mode 100644 index 0000000..d0e3f67 --- /dev/null +++ b/frontend/lib/widgets/admin/dossier_list_card.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; + +/// Carte dossier unifiée (#153) — pending et liste complète. +class DossierListCard extends StatelessWidget { + final String numeroDossier; + final String namesLine; + final bool isFamille; + final VoidCallback onOpen; + + static const Color familleBg = Color(0xFFF3EEF8); + static const Color familleBorder = Color(0xFFB39DDB); + static const Color familleAccent = Color(0xFF7E57C2); + static const Color amBg = Color(0xFFE8F5F3); + static const Color amBorder = Color(0xFF80CBC4); + static const Color amAccent = Color(0xFF00897B); + + const DossierListCard({ + super.key, + required this.numeroDossier, + required this.namesLine, + required this.isFamille, + required this.onOpen, + }); + + @override + Widget build(BuildContext context) { + final bg = isFamille ? familleBg : amBg; + final border = isFamille ? familleBorder : amBorder; + final accent = isFamille ? familleAccent : amAccent; + final num = numeroDossier.trim().isEmpty ? '–' : numeroDossier.trim(); + final names = namesLine.trim(); + + return AdminUserCard( + title: num, + subtitleLines: names.isEmpty ? const [] : [names], + fallbackIcon: + isFamille ? Icons.family_restroom_outlined : Icons.person_outline, + backgroundColor: bg, + borderColor: border, + titleColor: accent, + infoColor: Colors.black87, + onCardTap: onOpen, + actions: [ + IconButton( + tooltip: 'Ouvrir', + icon: Icon(Icons.open_in_new, size: 20, color: accent), + onPressed: onOpen, + ), + ], + ); + } +} diff --git a/frontend/lib/widgets/admin/dossiers_management_widget.dart b/frontend/lib/widgets/admin/dossiers_management_widget.dart new file mode 100644 index 0000000..06f6113 --- /dev/null +++ b/frontend/lib/widgets/admin/dossiers_management_widget.dart @@ -0,0 +1,201 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/dossier_list_item.dart'; +import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/widgets/admin/dossier_list_card.dart'; +import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart'; +import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart'; + +/// Onglet permanent « Dossiers » (#153) : pending en haut + liste unifiée en bas. +class DossiersManagementWidget extends StatefulWidget { + final String searchQuery; + final VoidCallback? onRefresh; + + const DossiersManagementWidget({ + super.key, + this.searchQuery = '', + this.onRefresh, + }); + + @override + State createState() => + _DossiersManagementWidgetState(); +} + +class _DossiersManagementWidgetState extends State { + bool _loading = true; + String? _error; + List _all = []; + Set _pendingNumeros = {}; + int _pendingRefreshTick = 0; + + @override + void initState() { + super.initState(); + _loadAll(); + } + + Future _loadAll() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final parents = await UserService.getParents(); + final ams = await UserService.getAssistantesMaternelles(); + if (!mounted) return; + final items = [ + ...DossierListItem.fromParents(parents), + ...DossierListItem.fromAssistantes(ams), + ]; + items.sort((a, b) { + final byNum = a.numeroDossier.compareTo(b.numeroDossier); + if (byNum != 0) return byNum; + return a.typeLabel.compareTo(b.typeLabel); + }); + setState(() { + _all = items; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Erreur inconnue'; + _loading = false; + }); + } + } + + Future _refreshEverything() async { + setState(() => _pendingRefreshTick++); + await _loadAll(); + widget.onRefresh?.call(); + } + + void _openDossier(String numeroDossier) { + final num = numeroDossier.trim(); + if (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(); + _refreshEverything(); + }, + ), + ); + } + + @override + Widget build(BuildContext context) { + final query = widget.searchQuery; + // Pending uniquement en haut — exclus de « Tous les dossiers ». + final filtered = _all + .where((d) => !_pendingNumeros.contains(d.numeroDossier)) + .where((d) => (d.statut ?? '').toLowerCase() != 'en_attente') + .where((d) => d.matchesQuery(query)) + .toList(growable: false); + + return RefreshIndicator( + onRefresh: _refreshEverything, + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: PendingValidationWidget( + key: ValueKey('pending-$_pendingRefreshTick'), + searchQuery: query, + compactWhenEmpty: true, + onPendingNumerosChanged: (nums) { + if (!mounted) return; + setState(() => _pendingNumeros = nums); + }, + onRefresh: () { + _loadAll(); + widget.onRefresh?.call(); + }, + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text( + 'Tous les dossiers', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + ), + ), + if (_loading) + const SliverFillRemaining( + hasScrollBody: false, + child: Center(child: CircularProgressIndicator()), + ) + else if (_error != null && _error!.isNotEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: 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: _loadAll, + child: const Text('Réessayer'), + ), + ], + ), + ), + ), + ) + else if (filtered.isEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 32), + child: Text( + query.trim().isEmpty + ? 'Aucun dossier pour le moment.\n' + 'Pour créer un dossier → onglet Parents (+ Parents) ' + 'ou Assistantes maternelles (+ Asmat).' + : 'Aucun dossier ne correspond à la recherche.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey.shade600, height: 1.4), + ), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final item = filtered[index]; + return DossierListCard( + numeroDossier: item.numeroDossier, + namesLine: item.namesLine, + isFamille: item.isFamille, + onOpen: () => _openDossier(item.numeroDossier), + ); + }, + childCount: filtered.length, + ), + ), + ), + ], + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/pending_validation_widget.dart b/frontend/lib/widgets/admin/pending_validation_widget.dart index 361a54c..db30a71 100644 --- a/frontend/lib/widgets/admin/pending_validation_widget.dart +++ b/frontend/lib/widgets/admin/pending_validation_widget.dart @@ -1,19 +1,32 @@ import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; +import 'package:p_tits_pas/models/dossier_list_item.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/dossier_list_card.dart'; import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart'; -/// Onglet « À valider » : deux listes (AM en attente, familles en attente). Ticket #107. +/// Section « dossiers à valider » (liste unifiée AM + familles). Ticket #107 / #153. class PendingValidationWidget extends StatefulWidget { final VoidCallback? onRefresh; + /// Filtre client (n°, nom, email) — onglet Dossiers (#153). + final String searchQuery; + /// Si true et liste vide : message court (pas de grand vide centré). + final bool compactWhenEmpty; + /// Numéros des dossiers pending (pour exclure de « Tous les dossiers »). + final ValueChanged>? onPendingNumerosChanged; - const PendingValidationWidget({super.key, this.onRefresh}); + const PendingValidationWidget({ + super.key, + this.onRefresh, + this.searchQuery = '', + this.compactWhenEmpty = false, + this.onPendingNumerosChanged, + }); @override - State createState() => _PendingValidationWidgetState(); + State createState() => + _PendingValidationWidgetState(); } class _PendingValidationWidgetState extends State { @@ -21,6 +34,8 @@ class _PendingValidationWidgetState extends State { String? _error; List _pendingAM = []; List _pendingFamilies = []; + /// Noms enrichis via GET /dossiers/:numero (libelle API = noms seuls). + final Map _familyNamesByNumero = {}; @override void initState() { @@ -34,24 +49,77 @@ class _PendingValidationWidgetState extends State { _error = null; }); try { - final am = await UserService.getPendingUsers(role: 'assistante_maternelle'); + final am = + await UserService.getPendingUsers(role: 'assistante_maternelle'); final families = await UserService.getPendingFamilies(); + final namesByNumero = await _enrichFamilyNames(families); if (!mounted) return; setState(() { _pendingAM = am; _pendingFamilies = families; + _familyNamesByNumero + ..clear() + ..addAll(namesByNumero); _isLoading = false; }); + _emitPendingNumeros(); } catch (e) { if (!mounted) return; setState(() { - _error = e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur inconnue'; + _error = e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Erreur inconnue'; _isLoading = false; }); + widget.onPendingNumerosChanged?.call(const {}); } } - void _onOpenValidation({String? type, String? id, String? numeroDossier}) { + /// Complète `NOM Prénom` via le détail dossier (sans changer le back). + Future> _enrichFamilyNames( + List families, + ) async { + final out = {}; + await Future.wait(families.map((f) async { + final num = (f.numeroDossier ?? '').trim(); + if (num.isEmpty) return; + try { + final dossier = await UserService.getDossier(num); + if (!dossier.isFamily) return; + final labels = []; + final seen = {}; + for (final p in dossier.asFamily.parents) { + final id = p.id.trim(); + if (id.isNotEmpty && !seen.add(id)) continue; + final label = formatDossierPersonLabel( + nom: p.nom, + prenom: p.prenom, + email: p.email, + ); + if (label.isNotEmpty) labels.add(label); + } + if (labels.isNotEmpty) out[num] = labels.join(' - '); + } catch (_) { + // Repli libellé API ci-dessous. + } + })); + return out; + } + + void _emitPendingNumeros() { + final nums = {}; + for (final u in _pendingAM) { + final n = (u.numeroDossier ?? '').trim(); + if (n.isNotEmpty) nums.add(n); + } + for (final f in _pendingFamilies) { + final n = (f.numeroDossier ?? '').trim(); + if (n.isNotEmpty) nums.add(n); + } + widget.onPendingNumerosChanged?.call(nums); + } + + void _onOpenValidation({String? numeroDossier}) { final num = numeroDossier?.trim(); if (num == null || num.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( @@ -73,9 +141,48 @@ class _PendingValidationWidgetState extends State { ); } + bool _matchesQuery(String haystack) { + final q = widget.searchQuery.trim().toLowerCase(); + if (q.isEmpty) return true; + return haystack.toLowerCase().contains(q); + } + + List get _filteredAM { + return _pendingAM.where((u) { + final bits = [ + u.numeroDossier ?? '', + u.fullName, + u.email, + u.nom ?? '', + u.prenom ?? '', + ].join(' '); + return _matchesQuery(bits); + }).toList(); + } + + List get _filteredFamilies { + return _pendingFamilies.where((f) { + final num = (f.numeroDossier ?? '').trim(); + final enriched = _familyNamesByNumero[num] ?? ''; + final bits = [ + f.numeroDossier ?? '', + f.libelle, + enriched, + f.emails.join(' '), + ].join(' '); + return _matchesQuery(bits); + }).toList(); + } + @override Widget build(BuildContext context) { if (_isLoading) { + if (widget.compactWhenEmpty) { + return const Padding( + padding: EdgeInsets.all(24), + child: Center(child: CircularProgressIndicator()), + ); + } return const Center(child: CircularProgressIndicator()); } if (_error != null && _error!.isNotEmpty) { @@ -97,14 +204,32 @@ class _PendingValidationWidgetState extends State { ); } - final hasAM = _pendingAM.isNotEmpty; - final hasFamilies = _pendingFamilies.isNotEmpty; - if (!hasAM && !hasFamilies) { + final pendingAM = _filteredAM; + final pendingFamilies = _filteredFamilies; + final cards = [ + ...pendingAM.map(_buildAMCard), + ...pendingFamilies.map(_buildFamilyCard), + ]; + + if (cards.isEmpty) { + if (widget.compactWhenEmpty) { + final searching = widget.searchQuery.trim().isNotEmpty; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Text( + searching + ? 'Aucun dossier en attente ne correspond à la recherche.' + : 'Aucun dossier en attente.', + style: TextStyle(color: Colors.grey.shade600, fontSize: 13), + ), + ); + } return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.check_circle_outline, size: 64, color: Colors.grey.shade400), + Icon(Icons.check_circle_outline, + size: 64, color: Colors.grey.shade400), const SizedBox(height: 16), Text( 'Aucun dossier en attente de validation', @@ -117,6 +242,28 @@ class _PendingValidationWidgetState extends State { ); } + final list = Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Dossiers à valider', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + const SizedBox(height: 8), + ...cards, + ], + ); + + if (widget.compactWhenEmpty) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: list, + ); + } + return RefreshIndicator( onRefresh: () async { await _load(); @@ -125,275 +272,39 @@ class _PendingValidationWidgetState extends State { 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)), - ], - ], - ), + child: list, ), ); } - Widget _sectionTitle(String title) { - return Text( - title, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: Colors.black87, - ), + String _amNamesLine(AppUser user) { + return formatDossierPersonLabel( + nom: user.nom, + prenom: user.prenom, + email: user.email, ); } - - /// 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 _PendingValidationRow( - icon: Icons.person_outline, - title: 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, - ), + return DossierListCard( + numeroDossier: user.numeroDossier ?? '', + namesLine: _amNamesLine(user), + isFamille: false, + onOpen: () => _onOpenValidation(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 _PendingValidationRow( - icon: Icons.family_restroom_outlined, - title: 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, - ), + final num = (family.numeroDossier ?? '').trim(); + final enriched = num.isNotEmpty ? _familyNamesByNumero[num] : null; + final names = (enriched != null && enriched.isNotEmpty) + ? enriched + : formatDossierFamilyNamesLine(family.libelle); + return DossierListCard( + numeroDossier: family.numeroDossier ?? '', + namesLine: names, + isFamille: true, + onOpen: () => _onOpenValidation(numeroDossier: family.numeroDossier), ); } } - -/// Ligne « À valider » : survol comme [AdminUserCard], icône « Ouvrir » visible au hover uniquement. -class _PendingValidationRow extends StatefulWidget { - final IconData icon; - final Widget title; - final String? subtitle; - final TextStyle? subtitleStyle; - final VoidCallback onOpen; - - const _PendingValidationRow({ - required this.icon, - required this.title, - this.subtitle, - this.subtitleStyle, - required this.onOpen, - }); - - @override - State<_PendingValidationRow> createState() => _PendingValidationRowState(); -} - -class _PendingValidationRowState extends State<_PendingValidationRow> { - bool _isHovered = false; - - @override - Widget build(BuildContext context) { - final subStyle = widget.subtitleStyle ?? - TextStyle( - fontSize: 12, - color: Colors.grey.shade600, - ); - return MouseRegion( - onEnter: (_) => setState(() => _isHovered = true), - onExit: (_) => setState(() => _isHovered = false), - cursor: SystemMouseCursors.click, - child: Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(10), - child: InkWell( - onTap: widget.onOpen, - borderRadius: BorderRadius.circular(10), - hoverColor: const Color(0x149CC5C0), - child: 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.center, - children: [ - Icon(widget.icon, color: Colors.grey.shade600, size: 28), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - widget.title, - if (widget.subtitle != null && - widget.subtitle!.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - widget.subtitle!, - style: subStyle, - ), - ], - ], - ), - ), - SizedBox( - width: 52, - child: Center( - child: AnimatedOpacity( - duration: const Duration(milliseconds: 120), - opacity: _isHovered ? 1 : 0, - child: IgnorePointer( - ignoring: !_isHovered, - child: IconButtonTheme( - data: IconButtonThemeData( - style: IconButton.styleFrom( - padding: const EdgeInsets.all(0), - minimumSize: const Size(48, 48), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ), - child: IconButton( - onPressed: widget.onOpen, - icon: const Icon(Icons.open_in_new), - iconSize: 34, - tooltip: 'Ouvrir', - ), - ), - ), - ), - ), - ), - ], - ), - ), - ), - ), - ), - ); - } -} - diff --git a/frontend/lib/widgets/admin/user_management_panel.dart b/frontend/lib/widgets/admin/user_management_panel.dart index 7be38c7..6f4cb82 100644 --- a/frontend/lib/widgets/admin/user_management_panel.dart +++ b/frontend/lib/widgets/admin/user_management_panel.dart @@ -1,19 +1,18 @@ 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/am_dossier_create_modal.dart'; import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart'; +import 'package:p_tits_pas/widgets/admin/dossiers_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/enfant_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/parent_dossier_create_modal.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). + /// Afficher l'onglet Administrateurs (sinon sans Administrateurs). final bool showAdministrateursTab; const UserManagementPanel({ @@ -32,45 +31,17 @@ class _UserManagementPanelState extends State { int _adminRefreshTick = 0; int _enfantRefreshTick = 0; int _amRefreshTick = 0; + int _dossiersRefreshTick = 0; final TextEditingController _searchController = TextEditingController(); final TextEditingController _amCapacityController = TextEditingController(); String? _parentStatus; String? _enfantStatus; - 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 @@ -87,13 +58,19 @@ class _UserManagementPanelState extends State { setState(() {}); } + /// Ordre #153 : Dossiers | Parents | Enfants | AM | Gestionnaires | (Admin). List get _tabLabels { - const base = ['Parents', 'Enfants', '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; + const base = [ + 'Dossiers', + 'Parents', + 'Enfants', + 'Assistantes maternelles', + 'Gestionnaires', + ]; + if (widget.showAdministrateursTab) { + return [...base, 'Administrateurs']; + } + return base; } void _onSubTabChange(int index) { @@ -107,23 +84,21 @@ class _UserManagementPanelState extends State { }); } - /// Index du contenu : -1 = À valider, 0 = Parents, 1 = Enfants, 2 = AM, 3 = Gestionnaires, 4 = Admin. - int get _contentIndexOffset => (_hasPending && !_pendingLoading) ? 1 : 0; + bool get _isDossiersTab => _subIndex == 0; String _searchHintForTab() { - final contentIndex = _subIndex - _contentIndexOffset; - switch (contentIndex) { - case -1: - return 'À valider (pas de recherche)'; + switch (_subIndex) { case 0: - return 'Rechercher un parent...'; + return 'Rechercher un dossier (n°, nom, email)…'; case 1: - return 'Rechercher un enfant...'; + return 'Rechercher un parent...'; case 2: - return 'Rechercher une assistante...'; + return 'Rechercher un enfant...'; case 3: - return 'Rechercher un gestionnaire...'; + return 'Rechercher une assistante...'; case 4: + return 'Rechercher un gestionnaire...'; + case 5: return 'Rechercher un administrateur...'; default: return 'Rechercher...'; @@ -131,7 +106,8 @@ class _UserManagementPanelState extends State { } Widget? _subBarFilterControl() { - if (_subIndex == _contentIndexOffset + 0) { + // Parents + if (_subIndex == 1) { return DropdownButtonHideUnderline( child: DropdownButton( value: _parentStatus, @@ -186,7 +162,8 @@ class _UserManagementPanelState extends State { ); } - if (_subIndex == _contentIndexOffset + 1) { + // Enfants + if (_subIndex == 2) { return DropdownButtonHideUnderline( child: DropdownButton( value: _enfantStatus, @@ -241,7 +218,8 @@ class _UserManagementPanelState extends State { ); } - if (_subIndex == _contentIndexOffset + 2) { + // AM + if (_subIndex == 3) { return TextField( controller: _amCapacityController, decoration: const InputDecoration( @@ -258,35 +236,36 @@ class _UserManagementPanelState extends State { } Widget _buildBody() { - final contentIndex = _subIndex - _contentIndexOffset; - if (_hasPending && !_pendingLoading && contentIndex == -1) { - return PendingValidationWidget(onRefresh: _loadPending); - } - switch (contentIndex) { + switch (_subIndex) { case 0: + return DossiersManagementWidget( + key: ValueKey('dossiers-$_dossiersRefreshTick'), + searchQuery: _searchController.text, + ); + case 1: return ParentManagementWidget( key: ValueKey('parents-$_parentRefreshTick'), searchQuery: _searchController.text, statusFilter: _parentStatus, ); - case 1: + case 2: return EnfantManagementWidget( key: ValueKey('enfants-$_enfantRefreshTick'), searchQuery: _searchController.text, statusFilter: _enfantStatus, ); - case 2: + case 3: return AssistanteMaternelleManagementWidget( key: ValueKey('ams-$_amRefreshTick'), searchQuery: _searchController.text, capacityMin: int.tryParse(_amCapacityController.text), ); - case 3: + case 4: return GestionnaireManagementWidget( key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'), searchQuery: _searchController.text, ); - case 4: + case 5: return AdminManagementWidget( key: ValueKey('admins-$_adminRefreshTick'), searchQuery: _searchController.text, @@ -299,7 +278,6 @@ class _UserManagementPanelState extends State { @override Widget build(BuildContext context) { final labels = _tabLabels; - final isAValiderTab = _hasPending && !_pendingLoading && _subIndex == 0; return Column( children: [ DashboardUserManagementSubBar( @@ -308,7 +286,8 @@ class _UserManagementPanelState extends State { searchController: _searchController, searchHint: _searchHintForTab(), filterControl: _subBarFilterControl(), - onAddPressed: isAValiderTab ? null : _handleAddPressed, + // Pas de « Créer » sur l’onglet Dossiers (#153). + onAddPressed: _isDossiersTab ? null : _handleAddPressed, addLabel: 'Ajouter', subTabCount: labels.length, tabLabels: labels, @@ -319,9 +298,8 @@ class _UserManagementPanelState extends State { } Future _handleAddPressed() async { - final contentIndex = _subIndex - _contentIndexOffset; - - if (contentIndex == 0) { + // 1 Parents, 2 Enfants, 3 AM, 4 Gestionnaires, 5 Admin + if (_subIndex == 1) { await showDialog( context: context, barrierDismissible: false, @@ -331,7 +309,10 @@ class _UserManagementPanelState extends State { onSuccess: () { Navigator.of(dialogContext).pop(); if (!mounted) return; - setState(() => _parentRefreshTick++); + setState(() { + _parentRefreshTick++; + _dossiersRefreshTick++; + }); }, ); }, @@ -339,7 +320,7 @@ class _UserManagementPanelState extends State { return; } - if (contentIndex == 1) { + if (_subIndex == 2) { await showDialog( context: context, barrierDismissible: false, @@ -355,7 +336,7 @@ class _UserManagementPanelState extends State { return; } - if (contentIndex == 2) { + if (_subIndex == 3) { await showDialog( context: context, barrierDismissible: false, @@ -365,7 +346,10 @@ class _UserManagementPanelState extends State { onSuccess: () { Navigator.of(dialogContext).pop(); if (!mounted) return; - setState(() => _amRefreshTick++); + setState(() { + _amRefreshTick++; + _dossiersRefreshTick++; + }); }, ); }, @@ -373,7 +357,7 @@ class _UserManagementPanelState extends State { return; } - if (contentIndex == 3) { + if (_subIndex == 4) { final created = await showDialog( context: context, barrierDismissible: false, @@ -391,7 +375,7 @@ class _UserManagementPanelState extends State { return; } - if (contentIndex == 4) { + if (_subIndex == 5) { final created = await showDialog( context: context, barrierDismissible: false, @@ -409,16 +393,6 @@ class _UserManagementPanelState extends State { _adminRefreshTick++; }); } - return; } - - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'La création parent sera disponible avec le ticket #129.', - ), - ), - ); } } From 2ce9e9215f1d740107f5a3a3eb20690d6184d4eb Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 24 Jul 2026 18:41:25 +0200 Subject: [PATCH 71/85] =?UTF-8?q?fix(#153):=20libell=C3=A9=20pending-famil?= =?UTF-8?q?ies=20en=20NOM=20Pr=C3=A9nom.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inclut le prénom dans string_agg et expose nom/prenom dans parents[]. Co-authored-by: Cursor --- .../routes/parents/dto/pending-family.dto.ts | 11 ++++++++- backend/src/routes/parents/parents.service.ts | 24 +++++++++++++++++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/backend/src/routes/parents/dto/pending-family.dto.ts b/backend/src/routes/parents/dto/pending-family.dto.ts index 86389b2..68dd50c 100644 --- a/backend/src/routes/parents/dto/pending-family.dto.ts +++ b/backend/src/routes/parents/dto/pending-family.dto.ts @@ -7,6 +7,12 @@ export class ParentPendingSummaryDto { @ApiProperty() email: string; + @ApiPropertyOptional({ nullable: true }) + nom?: string | null; + + @ApiPropertyOptional({ nullable: true }) + prenom?: string | null; + @ApiPropertyOptional({ nullable: true }) telephone?: string | null; @@ -18,7 +24,10 @@ export class ParentPendingSummaryDto { } export class PendingFamilyDto { - @ApiProperty({ example: 'Famille Dupont', description: 'Libellé affiché pour la famille' }) + @ApiProperty({ + example: 'MARTIN Claire - MARTIN Thomas', + description: 'Libellé affiché : NOM Prénom (séparés par « - » si co-parent)', + }) libelle: string; @ApiProperty({ diff --git a/backend/src/routes/parents/parents.service.ts b/backend/src/routes/parents/parents.service.ts index 15c0581..77dc3c6 100644 --- a/backend/src/routes/parents/parents.service.ts +++ b/backend/src/routes/parents/parents.service.ts @@ -251,7 +251,15 @@ export class ParentsService { 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, + string_agg( + UPPER(TRIM(u.nom)) + || CASE + WHEN u.prenom IS NOT NULL AND TRIM(u.prenom) <> '' + THEN ' ' || INITCAP(TRIM(u.prenom)) + ELSE '' + END, + ' - ' ORDER BY u.nom, u.prenom, u.id + ) 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, @@ -267,6 +275,8 @@ export class ParentsService { json_build_object( 'id', u.id::text, 'email', u.email, + 'nom', u.nom, + 'prenom', u.prenom, 'telephone', u.telephone, 'code_postal', u.code_postal, 'ville', u.ville @@ -317,11 +327,21 @@ export class ParentsService { return []; } - private normalizeParents(parents: unknown): { id: string; email: string; telephone: string | null; code_postal: string | null; ville: string | null }[] { + private normalizeParents(parents: unknown): { + id: string; + email: string; + nom: string | null; + prenom: string | null; + 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 ?? ''), + nom: p?.nom != null ? String(p.nom) : null, + prenom: p?.prenom != null ? String(p.prenom) : null, 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, From 0029c5ab869b440c98a513ffe84e9d2218fddd73 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 24 Jul 2026 19:39:55 +0200 Subject: [PATCH 72/85] fix(#153): peaufiner cartes dossiers et recherche. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cartes neutres avec accent icône, photo AM si dispo, hint court et infobulle de critères sur la barre de recherche. Co-authored-by: Cursor --- frontend/lib/models/dossier_list_item.dart | 5 ++ .../widgets/admin/common/admin_user_card.dart | 10 +++- .../lib/widgets/admin/dashboard_admin.dart | 57 +++++++++++++------ .../lib/widgets/admin/dossier_list_card.dart | 27 ++++----- .../admin/dossiers_management_widget.dart | 1 + .../admin/pending_validation_widget.dart | 1 + .../widgets/admin/user_management_panel.dart | 8 ++- 7 files changed, 77 insertions(+), 32 deletions(-) diff --git a/frontend/lib/models/dossier_list_item.dart b/frontend/lib/models/dossier_list_item.dart index 375c817..25ec22f 100644 --- a/frontend/lib/models/dossier_list_item.dart +++ b/frontend/lib/models/dossier_list_item.dart @@ -11,6 +11,8 @@ class DossierListItem { final String libelle; final List emails; final String? statut; + /// Photo profil (AM) — affichée à la place de l’icône si présente. + final String? photoUrl; const DossierListItem({ required this.type, @@ -18,6 +20,7 @@ class DossierListItem { required this.libelle, this.emails = const [], this.statut, + this.photoUrl, }); bool get isFamille => type == DossierListType.famille; @@ -125,12 +128,14 @@ class DossierListItem { prenom: u.prenom, email: u.email, ); + final photo = (u.photoUrl ?? '').trim(); return DossierListItem( type: DossierListType.assistanteMaternelle, numeroDossier: e.key, libelle: name.isNotEmpty ? name : 'AM', emails: u.email.trim().isEmpty ? const [] : [u.email.trim()], statut: u.statut?.trim(), + photoUrl: photo.isEmpty ? null : photo, ); }).toList(); } diff --git a/frontend/lib/widgets/admin/common/admin_user_card.dart b/frontend/lib/widgets/admin/common/admin_user_card.dart index f7cbc1b..640d293 100644 --- a/frontend/lib/widgets/admin/common/admin_user_card.dart +++ b/frontend/lib/widgets/admin/common/admin_user_card.dart @@ -12,6 +12,10 @@ class AdminUserCard extends StatefulWidget { final Color? backgroundColor; final Color? titleColor; final Color? infoColor; + /// Fond du cercle avatar / icône (défaut lavande admin). + final Color? avatarBackgroundColor; + /// Couleur de l’icône fallback (défaut violet admin). + final Color? avatarIconColor; final String? vigilanceTooltip; final VoidCallback? onCardTap; final EdgeInsetsGeometry? margin; @@ -28,6 +32,8 @@ class AdminUserCard extends StatefulWidget { this.backgroundColor, this.titleColor, this.infoColor, + this.avatarBackgroundColor, + this.avatarIconColor, this.vigilanceTooltip, this.onCardTap, this.margin, @@ -159,8 +165,8 @@ class _AdminUserCardState extends State { Widget _buildAvatar(String url) { const size = 28.0; - const bg = Color(0xFFEDE5FA); - const iconColor = Color(0xFF6B3FA0); + final bg = widget.avatarBackgroundColor ?? const Color(0xFFEDE5FA); + final iconColor = widget.avatarIconColor ?? const Color(0xFF6B3FA0); if (url.isEmpty) { return CircleAvatar( diff --git a/frontend/lib/widgets/admin/dashboard_admin.dart b/frontend/lib/widgets/admin/dashboard_admin.dart index 47c0983..b0cc6fa 100644 --- a/frontend/lib/widgets/admin/dashboard_admin.dart +++ b/frontend/lib/widgets/admin/dashboard_admin.dart @@ -8,6 +8,8 @@ class DashboardUserManagementSubBar extends StatelessWidget { final ValueChanged onSubTabChange; final TextEditingController searchController; final String searchHint; + /// Infobulle au survol de la barre de recherche (ex. critères de recherche). + final String? searchTooltip; final Widget? filterControl; final VoidCallback? onAddPressed; final String addLabel; @@ -22,12 +24,16 @@ class DashboardUserManagementSubBar extends StatelessWidget { 'Administrateurs', ]; + /// Aligné sur la taille des libellés d’onglets. + static const double _searchFontSize = 13; + const DashboardUserManagementSubBar({ Key? key, required this.selectedSubIndex, required this.onSubTabChange, required this.searchController, required this.searchHint, + this.searchTooltip, this.filterControl, this.onAddPressed, this.addLabel = '+ Ajouter', @@ -54,22 +60,7 @@ class DashboardUserManagementSubBar extends StatelessWidget { _buildSubNavItem(context, labels[i], i), ], const SizedBox(width: 36), - _pillField( - width: 320, - child: TextField( - controller: searchController, - decoration: InputDecoration( - hintText: searchHint, - prefixIcon: const Icon(Icons.search, size: 18), - border: InputBorder.none, - isDense: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 8, - ), - ), - ), - ), + _buildSearchField(), if (filterControl != null) ...[ const SizedBox(width: 12), _pillField(width: 150, child: filterControl!), @@ -81,6 +72,40 @@ class DashboardUserManagementSubBar extends StatelessWidget { ); } + Widget _buildSearchField() { + final field = _pillField( + width: 320, + child: TextField( + controller: searchController, + style: const TextStyle(fontSize: _searchFontSize), + decoration: InputDecoration( + hintText: searchHint, + hintStyle: TextStyle( + fontSize: _searchFontSize, + fontStyle: FontStyle.normal, + fontWeight: FontWeight.normal, + color: Colors.black45, + ), + prefixIcon: const Icon(Icons.search, size: 18), + border: InputBorder.none, + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + ), + ), + ); + final tip = (searchTooltip ?? '').trim(); + if (tip.isEmpty) return field; + return Tooltip( + message: tip, + preferBelow: false, + waitDuration: const Duration(milliseconds: 400), + child: field, + ); + } + Widget _pillField({required double width, required Widget child}) { return Container( width: width, diff --git a/frontend/lib/widgets/admin/dossier_list_card.dart b/frontend/lib/widgets/admin/dossier_list_card.dart index d0e3f67..b824eae 100644 --- a/frontend/lib/widgets/admin/dossier_list_card.dart +++ b/frontend/lib/widgets/admin/dossier_list_card.dart @@ -1,19 +1,20 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; -/// Carte dossier unifiée (#153) — pending et liste complète. +/// Carte dossier unifiée (#153) — fond neutre, accent couleur sur l’icône. class DossierListCard extends StatelessWidget { final String numeroDossier; final String namesLine; final bool isFamille; final VoidCallback onOpen; + /// Photo AM (si absente → icône fallback). + final String? photoUrl; - static const Color familleBg = Color(0xFFF3EEF8); - static const Color familleBorder = Color(0xFFB39DDB); - static const Color familleAccent = Color(0xFF7E57C2); - static const Color amBg = Color(0xFFE8F5F3); - static const Color amBorder = Color(0xFF80CBC4); - static const Color amAccent = Color(0xFF00897B); + /// Lavande — Famille / Parents. + static const Color familleAccent = Color(0xFFB289C9); + + /// Menthe logo — AM. + static const Color amAccent = Color(0xFF5A9D94); const DossierListCard({ super.key, @@ -21,24 +22,24 @@ class DossierListCard extends StatelessWidget { required this.namesLine, required this.isFamille, required this.onOpen, + this.photoUrl, }); @override Widget build(BuildContext context) { - final bg = isFamille ? familleBg : amBg; - final border = isFamille ? familleBorder : amBorder; final accent = isFamille ? familleAccent : amAccent; final num = numeroDossier.trim().isEmpty ? '–' : numeroDossier.trim(); final names = namesLine.trim(); + final avatar = (photoUrl ?? '').trim(); return AdminUserCard( title: num, subtitleLines: names.isEmpty ? const [] : [names], + avatarUrl: !isFamille && avatar.isNotEmpty ? avatar : null, fallbackIcon: - isFamille ? Icons.family_restroom_outlined : Icons.person_outline, - backgroundColor: bg, - borderColor: border, - titleColor: accent, + isFamille ? Icons.family_restroom_outlined : Icons.face, + // N° = titre neutre (comme Parents / AM) ; accent = icône seule. + avatarIconColor: accent, infoColor: Colors.black87, onCardTap: onOpen, actions: [ diff --git a/frontend/lib/widgets/admin/dossiers_management_widget.dart b/frontend/lib/widgets/admin/dossiers_management_widget.dart index 06f6113..62ec3ab 100644 --- a/frontend/lib/widgets/admin/dossiers_management_widget.dart +++ b/frontend/lib/widgets/admin/dossiers_management_widget.dart @@ -187,6 +187,7 @@ class _DossiersManagementWidgetState extends State { numeroDossier: item.numeroDossier, namesLine: item.namesLine, isFamille: item.isFamille, + photoUrl: item.photoUrl, onOpen: () => _openDossier(item.numeroDossier), ); }, diff --git a/frontend/lib/widgets/admin/pending_validation_widget.dart b/frontend/lib/widgets/admin/pending_validation_widget.dart index db30a71..78296bd 100644 --- a/frontend/lib/widgets/admin/pending_validation_widget.dart +++ b/frontend/lib/widgets/admin/pending_validation_widget.dart @@ -290,6 +290,7 @@ class _PendingValidationWidgetState extends State { numeroDossier: user.numeroDossier ?? '', namesLine: _amNamesLine(user), isFamille: false, + photoUrl: user.photoUrl, onOpen: () => _onOpenValidation(numeroDossier: user.numeroDossier), ); } diff --git a/frontend/lib/widgets/admin/user_management_panel.dart b/frontend/lib/widgets/admin/user_management_panel.dart index 6f4cb82..32e6017 100644 --- a/frontend/lib/widgets/admin/user_management_panel.dart +++ b/frontend/lib/widgets/admin/user_management_panel.dart @@ -89,7 +89,7 @@ class _UserManagementPanelState extends State { String _searchHintForTab() { switch (_subIndex) { case 0: - return 'Rechercher un dossier (n°, nom, email)…'; + return 'Rechercher un dossier'; case 1: return 'Rechercher un parent...'; case 2: @@ -105,6 +105,11 @@ class _UserManagementPanelState extends State { } } + String? _searchTooltipForTab() { + if (_subIndex != 0) return null; + return 'Recherche possible : n° de dossier, nom, prénom ou e-mail.'; + } + Widget? _subBarFilterControl() { // Parents if (_subIndex == 1) { @@ -285,6 +290,7 @@ class _UserManagementPanelState extends State { onSubTabChange: _onSubTabChange, searchController: _searchController, searchHint: _searchHintForTab(), + searchTooltip: _searchTooltipForTab(), filterControl: _subBarFilterControl(), // Pas de « Créer » sur l’onglet Dossiers (#153). onAddPressed: _isDossiersTab ? null : _handleAddPressed, From 530e896b66dd55fa377bc67e236d72406ccae27b Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Sun, 2 Aug 2026 23:34:19 +0200 Subject: [PATCH 73/85] =?UTF-8?q?feat(#135):=20POST=20/parents/:id/co-pare?= =?UTF-8?q?nt=20=E2=80=94=20ajout=20co-parent=20foyer.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API staff pour foyer mono-parent : compte actif, liens + enfants, mail création MDP. Mini-specs front/back dans docs/tmp. Co-authored-by: Cursor --- backend/src/routes/auth/auth.service.ts | 162 ++++++++++++++++++ .../dto/staff-add-co-parent-response.dto.ts | 23 +++ .../parents/dto/staff-add-co-parent.dto.ts | 69 ++++++++ .../routes/parents/parents.controller.spec.ts | 24 +++ .../src/routes/parents/parents.controller.ts | 24 +++ docs/tmp/135-contrat-api-ajout-co-parent.md | 73 ++++++++ .../135-mini-spec-front-edition-dossier.md | 83 +++++++++ 7 files changed, 458 insertions(+) create mode 100644 backend/src/routes/parents/dto/staff-add-co-parent-response.dto.ts create mode 100644 backend/src/routes/parents/dto/staff-add-co-parent.dto.ts create mode 100644 docs/tmp/135-contrat-api-ajout-co-parent.md create mode 100644 docs/tmp/135-mini-spec-front-edition-dossier.md diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index 423b9b4..bfbd28e 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -36,6 +36,7 @@ import { MailService } from 'src/modules/mail/mail.service'; import { ParentsService } from '../parents/parents.service'; import { DossiersService } from '../dossiers/dossiers.service'; import { DossierAmCompletDto } from '../dossiers/dto/dossier-am-complet.dto'; +import { StaffAddCoParentDto } from '../parents/dto/staff-add-co-parent.dto'; @Injectable() export class AuthService { @@ -718,6 +719,167 @@ export class AuthService { }); } + /** + * Ajoute un co-parent à un foyer existant (mono-parent) — ticket #135. + * Compte actif + mail création MDP + liens Parents bidirectionnels + enfants du foyer. + */ + async addCoParentStaff(pivotUserId: string, dto: StaffAddCoParentDto) { + const pivotParent = await this.parentsRepo.findOne({ + where: { user_id: pivotUserId }, + relations: ['user', 'co_parent', 'parentChildren'], + }); + if (!pivotParent?.user) { + throw new NotFoundException('Parent introuvable'); + } + + if (pivotParent.co_parent) { + throw new BadRequestException('Ce foyer a déjà un co-parent.'); + } + + const numeroDossier = pivotParent.numero_dossier?.trim() || pivotParent.user.numero_dossier?.trim(); + if (!numeroDossier) { + throw new BadRequestException("Ce parent n'a pas de numéro de dossier."); + } + + const sameDossierCount = await this.parentsRepo.count({ + where: { numero_dossier: numeroDossier }, + }); + if (sameDossierCount >= 2) { + throw new BadRequestException('Ce dossier a déjà deux responsables.'); + } + + const email = dto.email.trim().toLowerCase(); + if (pivotParent.user.email.trim().toLowerCase() === email) { + throw new BadRequestException( + "L'email du co-parent doit être différent de celui du parent principal.", + ); + } + + const emailExiste = await this.usersService.findByEmailOrNull(dto.email); + if (emailExiste) { + throw new ConflictException("L'email du co-parent est déjà utilisé"); + } + + const memeAdresse = dto.meme_adresse ?? true; + if (!memeAdresse) { + if (!dto.adresse?.trim() || !dto.ville?.trim() || !dto.code_postal?.trim()) { + throw new BadRequestException( + "Adresse, code postal et ville du co-parent sont requis si meme_adresse est faux.", + ); + } + } + + const joursExpirationToken = await this.appConfigService.get( + 'password_reset_token_expiry_days', + 7, + ); + const tokenCreationMdp = crypto.randomUUID(); + const dateExpiration = new Date(); + dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken); + + let coParent: Users; + + try { + coParent = await this.usersRepo.manager.transaction(async (manager) => { + const pivotUser = await manager.findOne(Users, { + where: { id: pivotUserId }, + }); + if (!pivotUser) { + throw new NotFoundException('Parent introuvable'); + } + + const pivotEntite = await manager.findOne(Parents, { + where: { user_id: pivotUserId }, + relations: ['parentChildren'], + }); + if (!pivotEntite) { + throw new NotFoundException('Parent introuvable'); + } + + const coUser = manager.create(Users, { + email: dto.email.trim(), + prenom: dto.prenom, + nom: dto.nom, + role: RoleType.PARENT, + statut: StatutUtilisateurType.ACTIF, + telephone: dto.telephone, + adresse: memeAdresse ? pivotUser.adresse : dto.adresse, + code_postal: memeAdresse ? pivotUser.code_postal : dto.code_postal, + ville: memeAdresse ? pivotUser.ville : dto.ville, + token_creation_mdp: tokenCreationMdp, + token_creation_mdp_expire_le: dateExpiration, + numero_dossier: numeroDossier, + }); + const coUserSaved = await manager.save(Users, coUser); + + pivotEntite.co_parent = coUserSaved; + pivotEntite.numero_dossier = numeroDossier; + await manager.save(Parents, pivotEntite); + + const coEntite = manager.create(Parents, { + user_id: coUserSaved.id, + numero_dossier: numeroDossier, + }); + coEntite.user = coUserSaved; + coEntite.co_parent = pivotUser; + await manager.save(Parents, coEntite); + + const enfantIds = (pivotEntite.parentChildren ?? []) + .map((pc) => pc.enfantId) + .filter(Boolean); + for (const enfantId of enfantIds) { + const existing = await manager.findOne(ParentsChildren, { + where: { parentId: coUserSaved.id, enfantId }, + }); + if (existing) continue; + await manager.save( + ParentsChildren, + manager.create(ParentsChildren, { + parentId: coUserSaved.id, + enfantId, + }), + ); + } + + return coUserSaved; + }); + } catch (err) { + if (this.isPostgresUniqueViolation(err)) { + throw new ConflictException( + 'Un compte avec cet email existe déjà (contrainte unique en base).', + ); + } + throw err; + } + + try { + await this.mailService.sendValidatedAccountPasswordSetupEmail( + { + email: coParent.email, + prenom: coParent.prenom ?? '', + nom: coParent.nom ?? '', + token: tokenCreationMdp, + numeroDossier, + }, + 'parent', + ); + } catch (err) { + this.logger.error( + '[addCoParentStaff] Échec envoi email création MDP (co-parent conservé)', + err instanceof Error ? err.stack : String(err), + ); + } + + return { + message: + 'Co-parent ajouté au foyer. Un e-mail de création de mot de passe a été envoyé.', + numero_dossier: numeroDossier, + parent_user_id: pivotUserId, + co_parent_user_id: coParent.id, + statut: StatutUtilisateurType.ACTIF, + }; + } + /** * Cœur partagé création dossier AM (#156). * - public : statut en_attente + mail pending diff --git a/backend/src/routes/parents/dto/staff-add-co-parent-response.dto.ts b/backend/src/routes/parents/dto/staff-add-co-parent-response.dto.ts new file mode 100644 index 0000000..6abf6af --- /dev/null +++ b/backend/src/routes/parents/dto/staff-add-co-parent-response.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { StatutUtilisateurType } from 'src/entities/users.entity'; + +/** Réponse 201 POST /parents/:id/co-parent (#135). */ +export class StaffAddCoParentResponseDto { + @ApiProperty() + message: string; + + @ApiProperty({ example: '2026-000043' }) + numero_dossier: string; + + @ApiProperty({ format: 'uuid', description: 'UUID du parent pivot' }) + parent_user_id: string; + + @ApiProperty({ format: 'uuid', description: 'UUID du co-parent créé' }) + co_parent_user_id: string; + + @ApiProperty({ + enum: StatutUtilisateurType, + example: StatutUtilisateurType.ACTIF, + }) + statut: StatutUtilisateurType; +} diff --git a/backend/src/routes/parents/dto/staff-add-co-parent.dto.ts b/backend/src/routes/parents/dto/staff-add-co-parent.dto.ts new file mode 100644 index 0000000..0f7345c --- /dev/null +++ b/backend/src/routes/parents/dto/staff-add-co-parent.dto.ts @@ -0,0 +1,69 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsBoolean, + IsEmail, + IsNotEmpty, + IsOptional, + IsString, + Matches, + MaxLength, + MinLength, +} from 'class-validator'; + +/** + * Ajout d’un co-parent sur un foyer existant (staff) — ticket #135. + * Corps sans préfixe `co_parent_*` (l’URL cible déjà le pivot). + */ +export class StaffAddCoParentDto { + @ApiProperty({ example: 'thomas.martin@ptits-pas.fr' }) + @IsEmail({}, { message: 'Email invalide' }) + @IsNotEmpty({ message: "L'email est requis" }) + email: string; + + @ApiProperty({ example: 'Thomas' }) + @IsString() + @IsNotEmpty({ message: 'Le prénom est requis' }) + @MinLength(2) + @MaxLength(100) + prenom: string; + + @ApiProperty({ example: 'MARTIN' }) + @IsString() + @IsNotEmpty({ message: 'Le nom est requis' }) + @MinLength(2) + @MaxLength(100) + nom: string; + + @ApiProperty({ example: '0678456789' }) + @IsString() + @IsNotEmpty({ message: 'Le téléphone est requis' }) + @Matches(/^(\+33|0)[1-9](\d{2}){4}$/, { + message: 'Le numéro de téléphone doit être valide (ex: 0689567890 ou +33689567890)', + }) + telephone: string; + + @ApiPropertyOptional({ + example: true, + description: 'Si true, copie l’adresse du parent pivot', + }) + @IsOptional() + @IsBoolean() + meme_adresse?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + adresse?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(10) + code_postal?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(150) + ville?: string; +} diff --git a/backend/src/routes/parents/parents.controller.spec.ts b/backend/src/routes/parents/parents.controller.spec.ts index 0015373..4452cd6 100644 --- a/backend/src/routes/parents/parents.controller.spec.ts +++ b/backend/src/routes/parents/parents.controller.spec.ts @@ -11,6 +11,7 @@ describe('ParentsController', () => { let controller: ParentsController; const authServiceMock = { createParentDossierStaff: jest.fn(), + addCoParentStaff: jest.fn(), }; const parentsServiceMock = {}; const userServiceMock = {}; @@ -77,4 +78,27 @@ describe('ParentsController', () => { expect(res.enfant_ids).toEqual(['e1']); expect(res.statut).toBe(StatutUtilisateurType.ACTIF); }); + + it('addCoParent delegates to authService.addCoParentStaff', async () => { + authServiceMock.addCoParentStaff.mockResolvedValue({ + message: 'ok', + numero_dossier: '2026-000043', + parent_user_id: 'p1', + co_parent_user_id: 'p2', + statut: StatutUtilisateurType.ACTIF, + }); + + const body = { + email: 'coparent@test.fr', + prenom: 'Thomas', + nom: 'MARTIN', + telephone: '0678456789', + meme_adresse: true, + }; + + const res = await controller.addCoParent('p1', body as any); + expect(authServiceMock.addCoParentStaff).toHaveBeenCalledWith('p1', body); + expect(res.co_parent_user_id).toBe('p2'); + expect(res.statut).toBe(StatutUtilisateurType.ACTIF); + }); }); diff --git a/backend/src/routes/parents/parents.controller.ts b/backend/src/routes/parents/parents.controller.ts index 6d821ff..1567509 100644 --- a/backend/src/routes/parents/parents.controller.ts +++ b/backend/src/routes/parents/parents.controller.ts @@ -30,6 +30,8 @@ import { UpdateParentsDto } from '../user/dto/update_parent.dto'; import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto'; import { StaffCreateParentDossierDto } from './dto/staff-create-parent-dossier.dto'; import { StaffCreateParentDossierResponseDto } from './dto/staff-create-parent-dossier-response.dto'; +import { StaffAddCoParentDto } from './dto/staff-add-co-parent.dto'; +import { StaffAddCoParentResponseDto } from './dto/staff-add-co-parent-response.dto'; import { RegisterParentCompletDto } from '../auth/dto/register-parent-complet.dto'; import { AuthGuard } from 'src/common/guards/auth.guard'; import { RolesGuard } from 'src/common/guards/roles.guard'; @@ -176,6 +178,28 @@ export class ParentsController { return mapParentForApi(parent); } + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) + @Post(':id/co-parent') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: 'Ajouter un co-parent à un foyer existant (staff) — ticket #135', + description: + 'Foyer mono-parent uniquement. Crée le co-parent actif, liens foyer + enfants, ' + + 'e-mail de création de mot de passe. Ne pas utiliser POST /auth/register/parent.', + }) + @ApiParam({ name: 'id', description: 'UUID utilisateur du parent pivot' }) + @ApiBody({ type: StaffAddCoParentDto }) + @ApiResponse({ status: 201, type: StaffAddCoParentResponseDto }) + @ApiResponse({ status: 400, description: 'Foyer déjà à 2 parents / validation' }) + @ApiResponse({ status: 404, description: 'Parent introuvable' }) + @ApiResponse({ status: 409, description: 'Email déjà pris' }) + async addCoParent( + @Param('id') id: string, + @Body() dto: StaffAddCoParentDto, + ): Promise { + return this.authService.addCoParentStaff(id, dto); + } + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @Post(':id/enfants/:enfantId') @ApiOperation({ summary: 'Rattacher un enfant à un parent — ticket #115' }) diff --git a/docs/tmp/135-contrat-api-ajout-co-parent.md b/docs/tmp/135-contrat-api-ajout-co-parent.md new file mode 100644 index 0000000..dd47421 --- /dev/null +++ b/docs/tmp/135-contrat-api-ajout-co-parent.md @@ -0,0 +1,73 @@ +# Mini-spec API — POST /parents/:id/co-parent (#135) + +Contrat back pour l’ajout d’un **2ᵉ parent** sur un foyer mono-parent (staff). + +## Endpoint + +| | | +|--|--| +| **Méthode** | `POST` | +| **URL** | `{base}/api/v1/parents/{parentUserId}/co-parent` | +| **Auth** | Bearer JWT | +| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` | +| **Succès** | **201** | + +`parentUserId` = UUID du **parent pivot** (déjà dans le dossier). + +Ne **pas** appeler `POST /auth/register/parent` ni `POST /parents/dossier`. + +--- + +## Body (JSON) + +| Champ | Type | Obligatoire | Notes | +|-------|------|-------------|--------| +| `email` | string | oui | unique | +| `prenom` | string | oui | | +| `nom` | string | oui | | +| `telephone` | string | oui | `0X…` ou `+33…` | +| `meme_adresse` | bool | non | défaut **true** → copie adresse du pivot | +| `adresse` | string | si `meme_adresse=false` | | +| `code_postal` | string | si `meme_adresse=false` | | +| `ville` | string | si `meme_adresse=false` | | + +--- + +## Comportement 201 + +- User co-parent **actif** + token création MDP +- Fiche `parents` + liens pivot ↔ co-parent + même `numero_dossier` +- Enfants du foyer rattachés au co-parent +- E-mail **création MDP** (pas mail « en attente ») + +```json +{ + "message": "Co-parent ajouté au foyer. Un e-mail de création de mot de passe a été envoyé.", + "numero_dossier": "2026-000043", + "parent_user_id": "uuid-pivot", + "co_parent_user_id": "uuid-co", + "statut": "actif" +} +``` + +## Erreurs + +| Code | Cas | +|------|-----| +| 400 | Déjà un co-parent / 2 responsables / validation adresse | +| 401 | Token invalide | +| 403 | Rôle non staff | +| 404 | Pivot introuvable | +| 409 | Email déjà pris | + +## Réemploi édition identité + +| Endpoint | Usage | +|----------|--------| +| `GET /dossiers/:numero` | Préremplir wizard edit | +| `PATCH /parents/:id/fiche` | Sauver identité pivot / co-parent existant | +| `PATCH /assistantes-maternelles/:id/fiche` | Édition AM | + +## Branche + +`feature/135-edition-dossier` diff --git a/docs/tmp/135-mini-spec-front-edition-dossier.md b/docs/tmp/135-mini-spec-front-edition-dossier.md new file mode 100644 index 0000000..f20113b --- /dev/null +++ b/docs/tmp/135-mini-spec-front-edition-dossier.md @@ -0,0 +1,83 @@ +# Mini-spec front — Mode édition dossier + ajout 2ᵉ parent (#135) + +Branche : `feature/135-edition-dossier` +Ticket : **#135** (full-stack) + +Prérequis : **#153** (liste Dossiers) livré. + +--- + +## Objectif + +1. Clic sur un dossier (liste #153) → ouvrir le wizard en mode **`edit`** +2. Foyer **mono-parent** : page co-parent → **switch** ajouter un 2ᵉ parent +3. Sauvegarder les champs via APIs existantes + nouvel endpoint co-parent + +--- + +## Modes wizard + +| Mode | Famille | AM | +|------|---------|-----| +| `review` | déjà | déjà | +| `create` | déjà (#129) | déjà (#156) | +| **`edit`** | **à faire** | **à faire** | + +Factories : `ParentDossierWizard.edit(...)` / `AmDossierWizard.edit(...)` +Préremplir via `UserService.getDossierByNumero(numero)`. + +--- + +## APIs + +| Action | Endpoint | +|--------|----------| +| Charger | `GET /dossiers/:numero` | +| Sauver parent | `PATCH /parents/:id/fiche` | +| Sauver AM | `PATCH /assistantes-maternelles/:id/fiche` | +| **Ajouter co-parent** | **`POST /parents/:pivotUserId/co-parent`** — voir `docs/tmp/135-contrat-api-ajout-co-parent.md` | + +Body co-parent : + +```json +{ + "email": "thomas@…", + "prenom": "Thomas", + "nom": "MARTIN", + "telephone": "0678456789", + "meme_adresse": true +} +``` + +`UserService.addCoParent(pivotUserId, body)` → cet endpoint. + +--- + +## UX + +- Depuis `DossiersManagementWidget` / carte liste : clic → edit (plus seulement review pending) +- Pending : garder validation (review) ; dossiers actifs → edit +- Mono-parent : switch « Ajouter un co-parent » (comme create) → au save, `POST …/co-parent` si nouveau +- Déjà 2 parents : éditer les deux fiches ; pas de 3ᵉ +- Pas de bouton créer dans l’onglet Dossiers + +--- + +## Hors scope + +- Famille N responsables (#139) +- Suppressions (#154) +- Création dossier initial (#129 / #156) + +--- + +## Critères d’acceptation + +- [ ] Clic dossier actif → wizard edit prérempli +- [ ] PATCH fiche enregistre les modifs +- [ ] Mono-parent + switch → co-parent créé (actif + mail MDP) +- [ ] review / create inchangés + +## Branche + +`feature/135-edition-dossier` From c8c9cfbc4dad14d952d0c7c5d8369987a2dd7624 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Mon, 3 Aug 2026 16:07:23 +0200 Subject: [PATCH 74/85] =?UTF-8?q?feat(#135):=20mode=20=C3=A9dition=20dossi?= =?UTF-8?q?er=20(PATCH=20fiche,=20co-parent,=20enfants).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wizard edit famille/AM depuis la liste Dossiers ; save parents + co-parent ; enfants existants en PATCH (photo multipart) sans POST doublon. Co-authored-by: Cursor --- .../src/routes/enfants/enfants.controller.ts | 10 +- backend/src/routes/enfants/enfants.service.ts | 14 +- frontend/lib/models/dossier_unifie.dart | 7 +- frontend/lib/services/user_service.dart | 104 +++- .../lib/widgets/admin/am_dossier_wizard.dart | 187 ++++++- .../admin/dossiers_management_widget.dart | 1 + .../widgets/admin/parent_dossier_wizard.dart | 507 ++++++++++++++++-- .../admin/validation_dossier_modal.dart | 27 +- 8 files changed, 788 insertions(+), 69 deletions(-) diff --git a/backend/src/routes/enfants/enfants.controller.ts b/backend/src/routes/enfants/enfants.controller.ts index 492c885..8b44228 100644 --- a/backend/src/routes/enfants/enfants.controller.ts +++ b/backend/src/routes/enfants/enfants.controller.ts @@ -141,12 +141,20 @@ export class EnfantsController { RoleType.GESTIONNAIRE, ) @Patch(':id') + @ApiOperation({ + summary: 'Mettre à jour un enfant', + description: + 'JSON sans photo OK ; avec nouvelle photo → multipart (champ fichier `photo`, max 5 Mo).', + }) + @ApiConsumes('application/json', 'multipart/form-data') + @UseInterceptors(OptionalEnfantPhotoInterceptor) update( @Param('id', new ParseUUIDPipe()) id: string, @Body() dto: UpdateEnfantsDto, + @UploadedFile() photo: Express.Multer.File, @User() currentUser: Users, ) { - return this.enfantsService.update(id, dto, currentUser); + return this.enfantsService.update(id, dto, currentUser, photo); } @Roles(RoleType.SUPER_ADMIN) diff --git a/backend/src/routes/enfants/enfants.service.ts b/backend/src/routes/enfants/enfants.service.ts index 4e0d393..43a4861 100644 --- a/backend/src/routes/enfants/enfants.service.ts +++ b/backend/src/routes/enfants/enfants.service.ts @@ -195,7 +195,12 @@ export class EnfantsService { // Mise à jour - async update(id: string, dto: Partial, currentUser: Users): Promise { + async update( + id: string, + dto: Partial, + currentUser: Users, + photoFile?: Express.Multer.File, + ): Promise { const child = await this.childrenRepository.findOne({ where: { id } }); if (!child) throw new NotFoundException('Enfant introuvable'); @@ -205,6 +210,13 @@ export class EnfantsService { patch.consent_photo = dto.consent_photo; patch.consent_photo_at = dto.consent_photo ? new Date() : null!; } + if (photoFile) { + patch.photo_url = `/uploads/photos/${photoFile.filename}`; + if (dto.consent_photo !== false) { + patch.consent_photo = true; + patch.consent_photo_at = new Date(); + } + } await this.childrenRepository.update(id, patch); return this.findOne(id, currentUser); diff --git a/frontend/lib/models/dossier_unifie.dart b/frontend/lib/models/dossier_unifie.dart index 78b2fb3..0e364fd 100644 --- a/frontend/lib/models/dossier_unifie.dart +++ b/frontend/lib/models/dossier_unifie.dart @@ -215,8 +215,13 @@ class EnfantDossier { factory EnfantDossier.fromJson(Map json) { final rawPhoto = json['photo_url'] ?? json['photoUrl']; final resolvedPhoto = _optionalPhotoUrl(rawPhoto); + final rawId = json['id'] ?? + json['enfant_id'] ?? + json['enfantId'] ?? + json['child_id'] ?? + json['childId']; return EnfantDossier( - id: json['id']?.toString() ?? '', + id: rawId?.toString().trim() ?? '', firstName: (json['first_name'] ?? json['prenom'])?.toString(), lastName: (json['last_name'] ?? json['nom'])?.toString(), birthDate: json['birth_date']?.toString(), diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index be87d9e..4c078aa 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -525,17 +525,58 @@ class UserService { return enfant.copyWith(parentLinks: enriched); } + /// Mise à jour enfant. Avec [photoBytes] : multipart (champ `photo`), sinon JSON. static Future updateEnfant({ required String enfantId, required Map body, + List? photoBytes, + String? photoFilename, }) async { - final response = await http.patch( - Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'), - headers: await _headers(), - body: jsonEncode(body), - ); + final id = enfantId.trim(); + if (id.isEmpty) { + throw Exception('Identifiant enfant manquant.'); + } + final hasPhoto = photoBytes != null && photoBytes.isNotEmpty; + final http.Response response; + if (hasPhoto) { + final token = await TokenService.getToken(); + final req = http.MultipartRequest( + 'PATCH', + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$id'), + ); + req.headers['Accept'] = 'application/json'; + if (token != null) { + req.headers['Authorization'] = 'Bearer $token'; + } + body.forEach((key, value) { + if (value == null) return; + req.fields[key] = value is bool + ? (value ? 'true' : 'false') + : value.toString(); + }); + final name = (photoFilename ?? '').trim(); + final filename = name.isNotEmpty ? name : 'photo.jpg'; + req.files.add( + http.MultipartFile.fromBytes( + 'photo', + photoBytes, + filename: filename, + contentType: _imageMediaType(filename, photoBytes), + ), + ); + final streamed = await req.send(); + response = await http.Response.fromStream(streamed); + } else { + response = await http.patch( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$id'), + headers: await _headers(), + body: jsonEncode(body), + ); + } if (response.statusCode != 200) { - throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour enfant')); + throw Exception( + _extractErrorMessage(response.body, 'Erreur mise à jour enfant'), + ); } final enfant = EnfantAdminModel.fromJson( jsonDecode(response.body) as Map, @@ -645,6 +686,57 @@ class UserService { return fallback; } + /// Ajout d’un co-parent sur foyer mono-parent (staff, #135). + /// `POST /parents/:pivotUserId/co-parent` — succès 201. + static Future> addCoParent( + String pivotUserId, { + required Map body, + }) async { + final id = pivotUserId.trim(); + if (id.isEmpty) { + throw Exception('Identifiant du parent pivot manquant.'); + } + final http.Response response; + try { + response = await http.post( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}/$id/co-parent'), + headers: await _headers(), + body: jsonEncode(body), + ); + } on http.ClientException { + throw Exception( + 'Connexion au serveur impossible. Vérifiez votre réseau puis réessayez.', + ); + } + + if (response.statusCode == 200 || response.statusCode == 201) { + if (response.body.trim().isEmpty) return {}; + try { + final decoded = jsonDecode(response.body); + if (decoded is Map) { + return Map.from(decoded); + } + } catch (_) {} + return {}; + } + + final message = _extractErrorMessage( + response.body, + 'Erreur ajout co-parent', + ); + if (response.statusCode == 409) { + throw Exception( + message.isNotEmpty ? message : 'Conflit : e-mail déjà utilisé.', + ); + } + if (response.statusCode == 400) { + throw Exception( + message.isNotEmpty ? message : 'Données invalides (400).', + ); + } + throw Exception(message); + } + /// Création dossier famille actif côté staff (#129). /// `POST /parents/dossier` — body aligné sur register parent complet. /// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur, par parent créé). diff --git a/frontend/lib/widgets/admin/am_dossier_wizard.dart b/frontend/lib/widgets/admin/am_dossier_wizard.dart index affb5ea..dd6b0cb 100644 --- a/frontend/lib/widgets/admin/am_dossier_wizard.dart +++ b/frontend/lib/widgets/admin/am_dossier_wizard.dart @@ -23,9 +23,9 @@ import 'package:p_tits_pas/widgets/admin/validation_valider_confirm_dialog.dart' import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'package:p_tits_pas/widgets/common/identity_block.dart'; -enum AmDossierWizardMode { review, create } +enum AmDossierWizardMode { review, create, edit } -/// Wizard dossier AM — modes [review] (validation pending) et [create] (staff #156). +/// Wizard dossier AM — [review] (#107), [create] (#156), [edit] (#135). class AmDossierWizard extends StatefulWidget { final AmDossierWizardMode mode; final DossierAM? dossier; @@ -74,7 +74,25 @@ class AmDossierWizard extends StatefulWidget { ); } + factory AmDossierWizard.edit({ + Key? key, + required DossierAM dossier, + required VoidCallback onClose, + required VoidCallback onSuccess, + void Function(int step, int total)? onStepChanged, + }) { + return AmDossierWizard._( + key: key, + mode: AmDossierWizardMode.edit, + dossier: dossier, + onClose: onClose, + onSuccess: onSuccess, + onStepChanged: onStepChanged, + ); + } + bool get isCreate => mode == AmDossierWizardMode.create; + bool get isEdit => mode == AmDossierWizardMode.edit; /// Hauteur corps modale AM — dérivée de [ValidationFormMetrics] (4 lignes). static double get shellBodyHeight => @@ -118,9 +136,11 @@ class _AmDossierWizardState extends State { String? _photoFilename; bool get _isCreate => widget.isCreate; + bool get _isEdit => widget.isEdit; + bool get _isEditable => _isCreate || _isEdit; DossierAM get _dossier => widget.dossier!; bool get _isEnAttente => - !_isCreate && _dossier.user.statut == 'en_attente'; + !_isCreate && !_isEdit && _dossier.user.statut == 'en_attente'; static String _v(String? s) => (s != null && s.trim().isNotEmpty) ? s.trim() : '–'; @@ -151,9 +171,33 @@ class _AmDossierWizardState extends State { _capaciteCtrl = TextEditingController(text: '1'); _placesCtrl = TextEditingController(text: '1'); _presentationCtrl = TextEditingController(); + if (_isEdit) { + _prefillFromDossier(); + } WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep()); } + void _prefillFromDossier() { + final u = _dossier.user; + _nomCtrl.text = (u.nom ?? '').trim(); + _prenomCtrl.text = (u.prenom ?? '').trim(); + _telCtrl.text = (u.telephone ?? '').trim(); + _emailCtrl.text = u.email.trim(); + _adresseCtrl.text = (u.adresse ?? '').trim(); + _cpCtrl.text = (u.codePostal ?? '').trim(); + _villeCtrl.text = (u.ville ?? '').trim(); + _nirCtrl.text = (_dossier.nir ?? '').trim(); + _dateNaissanceCtrl.text = formatIsoDateFrInput(u.dateNaissance); + _lieuNaissanceVilleCtrl.text = (u.lieuNaissanceVille ?? '').trim(); + final pays = (u.lieuNaissancePays ?? '').trim(); + _lieuNaissancePaysCtrl.text = pays.isEmpty ? 'France' : pays; + _agrementCtrl.text = (_dossier.numeroAgrement ?? '').trim(); + _dateAgrementCtrl.text = formatIsoDateFrInput(_dossier.dateAgrement); + _capaciteCtrl.text = '${_dossier.nbMaxEnfants ?? 1}'; + _placesCtrl.text = '${_dossier.placesDisponibles ?? 0}'; + _presentationCtrl.text = (_dossier.presentation ?? '').trim(); + } + @override void dispose() { _nomCtrl.dispose(); @@ -373,8 +417,8 @@ class _AmDossierWizardState extends State { return null; } - String? _validateStep1() { - if (_photoBytes == null || _photoBytes!.isEmpty) { + String? _validateStep1({required bool requirePhoto}) { + if (requirePhoto && (_photoBytes == null || _photoBytes!.isEmpty)) { return 'Une photo de profil est requise.'; } final birthIso = parseFrDateToIso(_dateNaissanceCtrl.text); @@ -407,12 +451,12 @@ class _AmDossierWizardState extends State { } String? _validateCurrentStep() { - if (!_isCreate) return null; + if (!_isEditable) return null; switch (_step) { case 0: return _validateStep0(); case 1: - return _validateStep1(); + return _validateStep1(requirePhoto: _isCreate); default: return null; } @@ -511,7 +555,7 @@ class _AmDossierWizardState extends State { ); return; } - final err1 = _validateStep1(); + final err1 = _validateStep1(requirePhoto: true); if (err1 != null) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(err1), backgroundColor: Colors.red.shade700), @@ -554,6 +598,94 @@ class _AmDossierWizardState extends State { } } + Future _saveEdit() async { + if (_submitting || !_isEdit) return; + final err0 = _validateStep0(); + if (err0 != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(err0), backgroundColor: Colors.red.shade700), + ); + return; + } + final err1 = _validateStep1(requirePhoto: false); + if (err1 != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(err1), backgroundColor: Colors.red.shade700), + ); + return; + } + + final amId = _dossier.user.id.trim(); + if (amId.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Identifiant AM manquant.'), + backgroundColor: Colors.red.shade700, + ), + ); + return; + } + + final ok = await showValidationValiderConfirmDialog( + context, + body: 'Enregistrer les modifications de la fiche assistante maternelle ?', + ); + if (!mounted || !ok) return; + + final birthIso = parseFrDateToIso(_dateNaissanceCtrl.text); + final agrementIso = parseFrDateToIso(_dateAgrementCtrl.text); + final capa = int.tryParse(_capaciteCtrl.text.trim()); + final places = int.tryParse(_placesCtrl.text.trim()); + final biography = _presentationCtrl.text.trim(); + + setState(() => _submitting = true); + try { + await UserService.updateAmFiche( + amUserId: amId, + body: { + 'nom': formatPersonNameCase(_nomCtrl.text), + 'prenom': formatPersonNameCase(_prenomCtrl.text), + 'email': normalizeEmailText(_emailCtrl.text), + 'telephone': normalizePhone(_telCtrl.text), + 'adresse': _adresseCtrl.text.trim(), + 'ville': formatPersonNameCase(_villeCtrl.text), + 'code_postal': _cpCtrl.text.trim(), + 'approval_number': _agrementCtrl.text.trim(), + 'nir': nirToRaw(_nirCtrl.text), + if (birthIso != null) 'date_naissance': birthIso, + 'lieu_naissance_ville': + formatPersonNameCase(_lieuNaissanceVilleCtrl.text), + 'lieu_naissance_pays': + formatPersonNameCase(_lieuNaissancePaysCtrl.text), + if (agrementIso != null) 'agreement_date': agrementIso, + if (capa != null) 'max_children': capa, + if (places != null) 'places_available': places, + 'biography': biography, + }, + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Fiche AM enregistrée.'), + duration: Duration(seconds: 4), + ), + ); + 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); + } + } + @override Widget build(BuildContext context) { if (_showRefusForm) { @@ -587,7 +719,7 @@ class _AmDossierWizardState extends State { } Widget _buildStep0() { - if (_isCreate) { + if (_isEditable) { return IdentityBlock.editable( title: 'Identité et coordonnées', nomController: _nomCtrl, @@ -620,7 +752,7 @@ class _AmDossierWizardState extends State { .clamp(_photoColumnMinWidth, 360.0); if (photoW > maxPhotoW) photoW = maxPhotoW; - final form = _isCreate + final form = _isEditable ? _buildCreateProFields() : ValidationDetailSection( title: 'Dossier professionnel', @@ -628,20 +760,25 @@ class _AmDossierWizardState extends State { rowLayout: _photoProRowLayout, ); + final Widget photo; + if (_isCreate) { + photo = AdminAmPhotoFrame( + imageBytes: _photoBytes, + onTap: _pickPhoto, + onClear: _photoBytes != null ? _clearPhoto : null, + emptyLabel: 'Ajouter une photo', + ); + } else if (_isEdit) { + // Édition : photo existante en lecture ; remplacement hors scope PATCH fiche. + photo = _buildPhotoSectionReview(_dossier.user); + } else { + photo = _buildPhotoSectionReview(_dossier.user); + } + return Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - SizedBox( - width: photoW, - child: _isCreate - ? AdminAmPhotoFrame( - imageBytes: _photoBytes, - onTap: _pickPhoto, - onClear: _photoBytes != null ? _clearPhoto : null, - emptyLabel: 'Ajouter une photo', - ) - : _buildPhotoSectionReview(_dossier.user), - ), + SizedBox(width: photoW, child: photo), const SizedBox(width: _photoProGap), Expanded( child: Align( @@ -721,7 +858,7 @@ class _AmDossierWizardState extends State { } Widget _buildStep2() { - if (_isCreate) { + if (_isEditable) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -832,6 +969,12 @@ class _AmDossierWizardState extends State { onPressed: _submitting ? null : _createAndValidate, child: Text(_submitting ? 'Envoi...' : 'Créer et valider'), ), + ] else if (_isEdit) ...[ + ElevatedButton( + style: ValidationModalTheme.primaryElevatedStyle, + onPressed: _submitting ? null : _saveEdit, + child: Text(_submitting ? 'Envoi...' : 'Enregistrer'), + ), ] else if (_isEnAttente) ...[ OutlinedButton( onPressed: _submitting ? null : _refuser, diff --git a/frontend/lib/widgets/admin/dossiers_management_widget.dart b/frontend/lib/widgets/admin/dossiers_management_widget.dart index 62ec3ab..4cafa2f 100644 --- a/frontend/lib/widgets/admin/dossiers_management_widget.dart +++ b/frontend/lib/widgets/admin/dossiers_management_widget.dart @@ -85,6 +85,7 @@ class _DossiersManagementWidgetState extends State { context: context, builder: (context) => ValidationDossierModal( numeroDossier: num, + openAsEdit: true, onClose: () => Navigator.of(context).pop(), onSuccess: () { Navigator.of(context).pop(); diff --git a/frontend/lib/widgets/admin/parent_dossier_wizard.dart b/frontend/lib/widgets/admin/parent_dossier_wizard.dart index 262a8a1..b9c7bf1 100644 --- a/frontend/lib/widgets/admin/parent_dossier_wizard.dart +++ b/frontend/lib/widgets/admin/parent_dossier_wizard.dart @@ -22,10 +22,15 @@ import 'package:p_tits_pas/widgets/admin/validation_valider_confirm_dialog.dart' import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'package:p_tits_pas/widgets/common/identity_block.dart'; -enum ParentDossierWizardMode { review, create } +enum ParentDossierWizardMode { review, create, edit } -/// Enfant en cours de saisie (mode création). Contrôleurs propres, à disposer. +/// Enfant en cours de saisie (création / édition). Contrôleurs propres, à disposer. class _CreateChild { + /// Id existant en base (mode edit) — null = nouvel enfant → POST. + String? existingChildId; + String? existingPhotoUrl; + /// Statut d’origine (hors à naître) pour ne pas écraser garde/scolarise. + String? existingStatus; final TextEditingController prenomCtrl = TextEditingController(); final TextEditingController nomCtrl = TextEditingController(); final TextEditingController dateCtrl = TextEditingController(); @@ -34,6 +39,8 @@ class _CreateChild { Uint8List? photoBytes; String? photoFilename; + bool get hasExistingId => (existingChildId ?? '').trim().isNotEmpty; + void dispose() { prenomCtrl.dispose(); nomCtrl.dispose(); @@ -41,8 +48,7 @@ class _CreateChild { } } -/// Wizard dossier famille — modes [review] (validation dossier en attente, ticket #107) -/// et [create] (création dossier famille actif par le staff, ticket #129). +/// Wizard dossier famille — [review] (#107), [create] (#129), [edit] (#135). class ParentDossierWizard extends StatefulWidget { final ParentDossierWizardMode mode; final DossierFamille? dossier; @@ -91,7 +97,25 @@ class ParentDossierWizard extends StatefulWidget { ); } + factory ParentDossierWizard.edit({ + Key? key, + required DossierFamille dossier, + required VoidCallback onClose, + required VoidCallback onSuccess, + void Function(int step, int total)? onStepChanged, + }) { + return ParentDossierWizard._( + key: key, + mode: ParentDossierWizardMode.edit, + dossier: dossier, + onClose: onClose, + onSuccess: onSuccess, + onStepChanged: onStepChanged, + ); + } + bool get isCreate => mode == ParentDossierWizardMode.create; + bool get isEdit => mode == ParentDossierWizardMode.edit; /// Hauteur corps modale famille — 4 lignes TF + marge pour le bandeau switch. static double get shellBodyHeight => @@ -141,16 +165,27 @@ class _ParentDossierWizardState extends State { final List<_CreateChild> _children = []; late final TextEditingController _presentationCtrl; + /// Nb de parents déjà en base au moment de l’ouverture (mode edit). + int _initialParentCount = 0; + + /// Enfants existants retirés en edit → DELETE au save. + final List _removedEnfantIds = []; + bool get _isCreate => widget.isCreate; + bool get _isEdit => widget.isEdit; + bool get _isEditable => _isCreate || _isEdit; DossierFamille get _dossier => widget.dossier!; - bool get _isEnAttente => !_isCreate && _dossier.isEnAttente; + bool get _isEnAttente => !_isCreate && !_isEdit && _dossier.isEnAttente; String? get _firstParentId { if (_isCreate) return null; return _dossier.parents.isNotEmpty ? _dossier.parents.first.id : null; } + /// Co-parent déjà présent au chargement (edit) — pas un ajout via POST. + bool get _hadExistingCoParent => _isEdit && _initialParentCount >= 2; + static String _v(String? s) => (s != null && s.trim().isNotEmpty) ? s.trim() : 'Non défini'; @@ -184,11 +219,81 @@ class _ParentDossierWizardState extends State { _presentationCtrl = TextEditingController(); if (_isCreate) { _children.add(_CreateChild()); + } else if (_isEdit) { + _prefillFromDossier(); } WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep()); } + void _prefillFromDossier() { + final parents = _dossier.parents; + _initialParentCount = parents.length; + + if (parents.isNotEmpty) { + final p1 = parents.first; + _p1NomCtrl.text = (p1.nom ?? '').trim(); + _p1PrenomCtrl.text = (p1.prenom ?? '').trim(); + _p1TelCtrl.text = (p1.telephone ?? '').trim(); + _p1EmailCtrl.text = (p1.email).trim(); + _p1AdresseCtrl.text = (p1.adresse ?? '').trim(); + _p1CpCtrl.text = (p1.codePostal ?? '').trim(); + _p1VilleCtrl.text = (p1.ville ?? '').trim(); + + if (parents.length >= 2) { + _hasCoParent = true; + final p2 = parents[1]; + _p2NomCtrl.text = (p2.nom ?? '').trim(); + _p2PrenomCtrl.text = (p2.prenom ?? '').trim(); + _p2TelCtrl.text = (p2.telephone ?? '').trim(); + _p2EmailCtrl.text = (p2.email).trim(); + _p2AdresseCtrl.text = (p2.adresse ?? '').trim(); + _p2CpCtrl.text = (p2.codePostal ?? '').trim(); + _p2VilleCtrl.text = (p2.ville ?? '').trim(); + } else { + _hasCoParent = false; + } + } + + _prefillChildrenFromDossier(); + } + + void _prefillChildrenFromDossier() { + for (final c in _children) { + c.dispose(); + } + _children.clear(); + _removedEnfantIds.clear(); + + final enfants = _dossier.enfants; + if (enfants.isEmpty) { + _children.add(_CreateChild()); + return; + } + + for (final e in enfants) { + final status = (e.status ?? '').trim().toLowerCase(); + final id = e.id.trim(); + final child = _CreateChild() + ..existingChildId = id.isEmpty ? null : id + ..existingPhotoUrl = e.photoUrl + ..existingStatus = status.isEmpty ? null : status + ..isUnborn = status == 'a_naitre'; + child.prenomCtrl.text = (e.firstName ?? '').trim(); + child.nomCtrl.text = (e.lastName ?? '').trim(); + final g = (e.gender ?? '').trim(); + final gUp = g.toUpperCase(); + if (gUp == 'H' || gUp == 'F') { + child.genre = gUp; + } else if (g == 'Autre' || child.isUnborn) { + child.genre = child.isUnborn ? 'Autre' : g; + } + final dateSrc = child.isUnborn ? e.dueDate : e.birthDate; + child.dateCtrl.text = formatIsoDateFrInput(dateSrc); + _children.add(child); + } + } + @override void dispose() { _enfantsScrollController.removeListener(_syncEnfantsScrollFades); @@ -291,7 +396,7 @@ class _ParentDossierWizardState extends State { } Widget _buildStep0() { - if (_isCreate) { + if (_isEditable) { return IdentityBlock.editable( title: 'Parent principal', nomController: _p1NomCtrl, @@ -310,13 +415,15 @@ class _ParentDossierWizardState extends State { } Widget _buildStep1() { - if (_isCreate) { - return _buildCoParentStepCreate(); + if (_isEditable) { + return _buildCoParentStepEditable( + allowToggle: !_hadExistingCoParent, + ); } return _buildParent2Step(); } - Widget _buildCoParentStepCreate() { + Widget _buildCoParentStepEditable({required bool allowToggle}) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -330,19 +437,21 @@ class _ParentDossierWizardState extends State { color: Colors.black87, ), ), - const Spacer(), - const Text( - 'Ajouter un co-parent', - style: TextStyle(fontSize: 14, color: Colors.black87), - ), - Transform.scale( - scale: 0.75, - child: Switch( - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - value: _hasCoParent, - onChanged: (v) => setState(() => _hasCoParent = v), + if (allowToggle) ...[ + const Spacer(), + const Text( + 'Ajouter un co-parent', + style: TextStyle(fontSize: 14, color: Colors.black87), ), - ), + Transform.scale( + scale: 0.75, + child: Switch( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + value: _hasCoParent, + onChanged: (v) => setState(() => _hasCoParent = v), + ), + ), + ], ], ), const SizedBox(height: 8), @@ -539,7 +648,7 @@ class _ParentDossierWizardState extends State { } Widget _buildEnfantsStep() { - if (_isCreate) { + if (_isCreate || _isEdit) { return _buildEnfantsStepCreate(); } final enfants = _dossier.enfants; @@ -618,6 +727,10 @@ class _ParentDossierWizardState extends State { if (_children.length <= 1) return; setState(() { final removed = _children.removeAt(index); + final existingId = (removed.existingChildId ?? '').trim(); + if (existingId.isNotEmpty) { + _removedEnfantIds.add(existingId); + } removed.dispose(); }); } @@ -706,7 +819,8 @@ class _ParentDossierWizardState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( - padding: const EdgeInsets.fromLTRB(12, 12, 14, 8), + // Réserve l’angle haut-droit pour la croix de suppression. + padding: EdgeInsets.fromLTRB(12, 12, canRemove ? 36 : 14, 8), child: ValidationLabeledField( label: 'Prénom', field: ValidationEditableField( @@ -747,6 +861,9 @@ class _ParentDossierWizardState extends State { width: pw, height: ph, child: AdminAmPhotoFrame( + photoUrl: child.photoBytes == null + ? child.existingPhotoUrl + : null, imageBytes: child.photoBytes, onTap: () => _pickChildPhoto(index), onClear: child.photoBytes != null @@ -833,19 +950,28 @@ class _ParentDossierWizardState extends State { ), if (canRemove) Positioned( - top: 4, - right: 4, + top: 6, + right: 6, child: Material( - color: Colors.transparent, - child: IconButton( - tooltip: 'Retirer cet enfant', - visualDensity: VisualDensity.compact, - padding: EdgeInsets.zero, - constraints: - const BoxConstraints(minWidth: 32, minHeight: 32), - icon: Icon(Icons.close, - size: 18, color: Colors.grey.shade700), - onPressed: () => _removeChild(index), + color: Colors.white, + elevation: 1, + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + customBorder: const CircleBorder(), + onTap: () => _removeChild(index), + child: Tooltip( + message: 'Retirer cet enfant', + child: SizedBox( + width: 22, + height: 22, + child: Icon( + Icons.close, + size: 14, + color: Colors.grey.shade800, + ), + ), + ), ), ), ), @@ -1264,6 +1390,18 @@ class _ParentDossierWizardState extends State { } String? _validateCurrentStep() { + if (_isEdit) { + switch (_step) { + case 0: + return _validateP1(); + case 1: + return _validateP2(); + case 2: + return _validateEnfants(); + default: + return null; + } + } if (!_isCreate) return null; switch (_step) { case 0: @@ -1440,6 +1578,299 @@ class _ParentDossierWizardState extends State { } } + Map _parentFicheBody({ + required TextEditingController nom, + required TextEditingController prenom, + required TextEditingController email, + required TextEditingController tel, + required TextEditingController adresse, + required TextEditingController cp, + required TextEditingController ville, + }) { + return { + 'nom': formatPersonNameCase(nom.text), + 'prenom': formatPersonNameCase(prenom.text), + 'email': normalizeEmailText(email.text), + 'telephone': normalizePhone(tel.text), + 'adresse': adresse.text.trim(), + 'ville': formatPersonNameCase(ville.text), + 'code_postal': cp.text.trim(), + }; + } + + Future _saveEdit() async { + if (_submitting || !_isEdit) return; + final err0 = _validateP1(); + if (err0 != null) { + _showError(err0); + return; + } + final err1 = _validateP2(); + if (err1 != null) { + _showError(err1); + return; + } + final err2 = _validateEnfants(); + if (err2 != null) { + _showError(err2); + return; + } + + final pivotId = (_firstParentId ?? '').trim(); + if (pivotId.isEmpty) { + _showError('Identifiant du parent principal manquant.'); + return; + } + + final addingCoParent = _hasCoParent && !_hadExistingCoParent; + final ok = await showValidationValiderConfirmDialog( + context, + body: addingCoParent + ? 'Enregistrer le dossier et ajouter le co-parent ? Un e-mail de création de mot de passe lui sera envoyé.' + : 'Enregistrer les modifications du dossier famille ?', + ); + if (!mounted || !ok) return; + + setState(() => _submitting = true); + try { + await UserService.updateParentFiche( + parentUserId: pivotId, + body: _parentFicheBody( + nom: _p1NomCtrl, + prenom: _p1PrenomCtrl, + email: _p1EmailCtrl, + tel: _p1TelCtrl, + adresse: _p1AdresseCtrl, + cp: _p1CpCtrl, + ville: _p1VilleCtrl, + ), + ); + + if (_hadExistingCoParent) { + final coId = _dossier.parents[1].id.trim(); + if (coId.isEmpty) { + throw Exception('Identifiant du co-parent manquant.'); + } + await UserService.updateParentFiche( + parentUserId: coId, + body: _parentFicheBody( + nom: _p2NomCtrl, + prenom: _p2PrenomCtrl, + email: _p2EmailCtrl, + tel: _p2TelCtrl, + adresse: _p2AdresseCtrl, + cp: _p2CpCtrl, + ville: _p2VilleCtrl, + ), + ); + } else if (addingCoParent) { + if (_sameAddress) _copyP1AddressToP2(); + final body = { + 'email': normalizeEmailText(_p2EmailCtrl.text), + 'prenom': formatPersonNameCase(_p2PrenomCtrl.text), + 'nom': formatPersonNameCase(_p2NomCtrl.text), + 'telephone': normalizePhone(_p2TelCtrl.text), + 'meme_adresse': _sameAddress, + }; + if (!_sameAddress) { + body['adresse'] = _p2AdresseCtrl.text.trim(); + body['code_postal'] = _p2CpCtrl.text.trim(); + body['ville'] = formatPersonNameCase(_p2VilleCtrl.text); + } + await UserService.addCoParent(pivotId, body: body); + } + + await _saveEnfantsEdit(pivotId); + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + addingCoParent + ? 'Dossier enregistré. Co-parent ajouté.' + : 'Dossier enregistré.', + ), + duration: const Duration(seconds: 4), + ), + ); + widget.onSuccess(); + } catch (e) { + if (!mounted) return; + _showError( + e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur', + ); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + Map _enfantStaffBody(_CreateChild c) { + final prenom = formatPersonNameCase(c.prenomCtrl.text); + final nomRaw = c.nomCtrl.text.trim(); + final nom = nomRaw.isNotEmpty + ? formatPersonNameCase(nomRaw) + : formatPersonNameCase(_p1NomCtrl.text); + final dateIso = parseFrDateToIso(c.dateCtrl.text); + final existingStatus = (c.existingStatus ?? '').trim().toLowerCase(); + final status = c.isUnborn + ? 'a_naitre' + : (existingStatus.isNotEmpty && existingStatus != 'a_naitre' + ? existingStatus + : 'sans_garde'); + final gender = c.genre ?? 'H'; + + final map = { + 'status': status, + 'gender': gender, + 'consent_photo': true, + 'is_multiple': false, + }; + if (prenom.length >= 2) map['first_name'] = prenom; + if (nom.length >= 2) map['last_name'] = nom; + if (c.isUnborn) { + if (dateIso != null) map['due_date'] = dateIso; + } else if (dateIso != null) { + map['birth_date'] = dateIso; + } + return map; + } + + /// Empreinte identité pour rattacher un brouillon sans id à un enfant du GET. + String _enfantIdentityKey({ + required String prenom, + required String nom, + required String? dateIso, + required bool isUnborn, + }) { + final pn = formatPersonNameCase(prenom).toLowerCase().trim(); + final nm = formatPersonNameCase(nom).toLowerCase().trim(); + final d = _normalizeDateKey(dateIso); + final kind = isUnborn ? 'due' : 'birth'; + return '$pn|$nm|$kind|$d'; + } + + String _normalizeDateKey(String? raw) { + final s = (raw ?? '').trim(); + if (s.isEmpty) return ''; + final fromFr = parseFrDateToIso(s); + if (fromFr != null) return fromFr; + try { + final dt = DateTime.parse(s); + final y = dt.year.toString().padLeft(4, '0'); + final m = dt.month.toString().padLeft(2, '0'); + final d = dt.day.toString().padLeft(2, '0'); + return '$y-$m-$d'; + } catch (_) { + return s; + } + } + + String _enfantKeyFromCreate(_CreateChild c) { + final prenom = formatPersonNameCase(c.prenomCtrl.text); + final nomRaw = c.nomCtrl.text.trim(); + final nom = nomRaw.isNotEmpty + ? formatPersonNameCase(nomRaw) + : formatPersonNameCase(_p1NomCtrl.text); + return _enfantIdentityKey( + prenom: prenom, + nom: nom, + dateIso: parseFrDateToIso(c.dateCtrl.text), + isUnborn: c.isUnborn, + ); + } + + String _enfantKeyFromDossier(EnfantDossier e) { + final isUnborn = (e.status ?? '').trim().toLowerCase() == 'a_naitre'; + final nomRaw = (e.lastName ?? '').trim(); + final nom = nomRaw.isNotEmpty ? nomRaw : (_p1NomCtrl.text.trim()); + return _enfantIdentityKey( + prenom: e.firstName ?? '', + nom: nom, + dateIso: isUnborn ? e.dueDate : e.birthDate, + isUnborn: isUnborn, + ); + } + + bool _isBlankChildDraft(_CreateChild c) { + if (c.hasExistingId) return false; + final prenom = c.prenomCtrl.text.trim(); + final nom = c.nomCtrl.text.trim(); + final date = c.dateCtrl.text.trim(); + return prenom.isEmpty && nom.isEmpty && date.isEmpty && c.genre == null; + } + + /// Résout l’id enfant : tracker edit, sinon match sur le GET dossier. + String? _resolveExistingChildId( + _CreateChild c, + Map dossierIdByIdentity, + Set alreadyUsedIds, + ) { + final tracked = (c.existingChildId ?? '').trim(); + if (tracked.isNotEmpty && !alreadyUsedIds.contains(tracked)) { + return tracked; + } + final key = _enfantKeyFromCreate(c); + final matched = (dossierIdByIdentity[key] ?? '').trim(); + if (matched.isNotEmpty && !alreadyUsedIds.contains(matched)) { + return matched; + } + return null; + } + + Future _saveEnfantsEdit(String pivotUserId) async { + // Index id connus du GET (ne jamais POST pour ceux-là). + final dossierIdByIdentity = {}; + for (final e in _dossier.enfants) { + final id = e.id.trim(); + if (id.isEmpty) continue; + dossierIdByIdentity[_enfantKeyFromDossier(e)] = id; + } + + for (final id in _removedEnfantIds) { + await UserService.deleteEnfant(id); + } + + final usedIds = {..._removedEnfantIds}; + for (final c in _children) { + if (_isBlankChildDraft(c)) continue; + + final existingId = _resolveExistingChildId( + c, + dossierIdByIdentity, + usedIds, + ); + final body = _enfantStaffBody(c); + + if (existingId != null && existingId.isNotEmpty) { + // Ré-attache l’id au modèle (si match identité a récupéré un id perdu). + c.existingChildId = existingId; + final bytes = c.photoBytes; + await UserService.updateEnfant( + enfantId: existingId, + body: body, + photoBytes: (bytes != null && bytes.isNotEmpty) ? bytes : null, + photoFilename: c.photoFilename, + ); + usedIds.add(existingId); + continue; + } + + // Uniquement les vrais nouveaux enfants (pas d’id dossier). + final bytes = c.photoBytes; + final created = await UserService.createEnfant( + parentUserId: pivotUserId, + body: body, + photoBytes: (bytes != null && bytes.isNotEmpty) ? bytes : null, + photoFilename: c.photoFilename, + ); + final newId = created.id.trim(); + if (newId.isNotEmpty) { + c.existingChildId = newId; + usedIds.add(newId); + } + } + } + Widget _buildNavigation() { if (_step == 3) { return Row( @@ -1463,6 +1894,12 @@ class _ParentDossierWizardState extends State { onPressed: _submitting ? null : _createAndValidate, child: Text(_submitting ? 'Envoi...' : 'Créer et valider'), ), + ] else if (_isEdit) ...[ + ElevatedButton( + style: ValidationModalTheme.primaryElevatedStyle, + onPressed: _submitting ? null : _saveEdit, + child: Text(_submitting ? 'Envoi...' : 'Enregistrer'), + ), ] else if (_isEnAttente && _firstParentId != null) ...[ OutlinedButton( onPressed: _submitting ? null : _refuser, diff --git a/frontend/lib/widgets/admin/validation_dossier_modal.dart b/frontend/lib/widgets/admin/validation_dossier_modal.dart index 7bc3faf..fefac12 100644 --- a/frontend/lib/widgets/admin/validation_dossier_modal.dart +++ b/frontend/lib/widgets/admin/validation_dossier_modal.dart @@ -2,20 +2,25 @@ 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/am_dossier_wizard.dart'; +import 'package:p_tits_pas/widgets/admin/parent_dossier_wizard.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. +/// Modale (dialog) : charge le dossier par numéro puis affiche le wizard AM ou Famille. +/// Ticket #107 / #119 (review), #135 (`openAsEdit`). class ValidationDossierModal extends StatefulWidget { final String numeroDossier; final VoidCallback onClose; final VoidCallback? onSuccess; + /// Liste Dossiers actifs → mode edit (#135). Pending reste en review (défaut). + final bool openAsEdit; const ValidationDossierModal({ super.key, required this.numeroDossier, required this.onClose, this.onSuccess, + this.openAsEdit = false, }); @override @@ -77,12 +82,12 @@ class _ValidationDossierModalState extends State { /// Largeur modale = 1,5 × 620. static const double _modalWidth = 930; // 620 * 1.5 - static const double _familyBodyHeight = 435; double get _bodyHeight { final d = _dossier; if (d != null && d.isAm) return AmDossierWizard.shellBodyHeight; - return _familyBodyHeight; + // Aligné create (#129) / edit (#135) — évite overflow IdentityBlock (8px). + return ParentDossierWizard.shellBodyHeight; } @override @@ -162,6 +167,14 @@ class _ValidationDossierModalState extends State { } final d = _dossier!; if (d.isAm) { + if (widget.openAsEdit) { + return AmDossierWizard.edit( + dossier: d.asAm, + onClose: widget.onClose, + onSuccess: _onSuccess, + onStepChanged: _onStepChanged, + ); + } return ValidationAmWizard( dossier: d.asAm, onClose: widget.onClose, @@ -169,6 +182,14 @@ class _ValidationDossierModalState extends State { onStepChanged: _onStepChanged, ); } + if (widget.openAsEdit) { + return ParentDossierWizard.edit( + dossier: d.asFamily, + onClose: widget.onClose, + onSuccess: _onSuccess, + onStepChanged: _onStepChanged, + ); + } return ValidationFamilyWizard( dossier: d.asFamily, onClose: widget.onClose, From 7f23356273d02e4ca7d99b4c0fbce1b8fdf6e249 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Wed, 9 Sep 2026 17:57:52 +0200 Subject: [PATCH 75/85] =?UTF-8?q?feat(#159):=20suppressions=20m=C3=A9tier?= =?UTF-8?q?=20dossiers/parents/enfants/AM/staff.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SuppressionService + DELETE /dossiers/:numero, cascades DELETE /users et DELETE /enfants?deleteDossier, flag sans_enfant, specs front #160. Co-authored-by: Cursor --- backend/src/app.module.ts | 2 + .../dossiers/dossiers.controller.spec.ts | 24 +- .../routes/dossiers/dossiers.controller.ts | 38 +- .../src/routes/dossiers/dossiers.module.ts | 2 + .../routes/dossiers/dossiers.service.spec.ts | 6 + .../src/routes/dossiers/dossiers.service.ts | 15 +- .../dossiers/dto/dossier-list-item.dto.ts | 6 + .../src/routes/enfants/enfants.controller.ts | 36 +- backend/src/routes/enfants/enfants.module.ts | 11 +- .../suppressions/suppression.service.spec.ts | 175 ++++++++ .../suppressions/suppression.service.ts | 420 ++++++++++++++++++ .../suppressions/suppressions.module.ts | 25 ++ backend/src/routes/user/user.controller.ts | 22 +- backend/src/routes/user/user.module.ts | 2 + backend/src/routes/user/user.service.ts | 1 + docs/tmp/154-matrice-suppression.md | 35 ++ docs/tmp/154-mini-spec-front-suppression.md | 169 +++++++ 17 files changed, 970 insertions(+), 19 deletions(-) create mode 100644 backend/src/routes/suppressions/suppression.service.spec.ts create mode 100644 backend/src/routes/suppressions/suppression.service.ts create mode 100644 backend/src/routes/suppressions/suppressions.module.ts create mode 100644 docs/tmp/154-matrice-suppression.md create mode 100644 docs/tmp/154-mini-spec-front-suppression.md diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index ce8dbc2..715f83c 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -18,6 +18,7 @@ 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'; +import { SuppressionsModule } from './routes/suppressions/suppressions.module'; @Module({ imports: [ @@ -57,6 +58,7 @@ import { DossiersModule } from './routes/dossiers/dossiers.module'; DocumentsLegauxModule, RelaisModule, DossiersModule, + SuppressionsModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/routes/dossiers/dossiers.controller.spec.ts b/backend/src/routes/dossiers/dossiers.controller.spec.ts index 165e1e3..8cab3f5 100644 --- a/backend/src/routes/dossiers/dossiers.controller.spec.ts +++ b/backend/src/routes/dossiers/dossiers.controller.spec.ts @@ -1,6 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { DossiersController } from './dossiers.controller'; import { DossiersService } from './dossiers.service'; +import { SuppressionService } from '../suppressions/suppression.service'; import { AuthGuard } from 'src/common/guards/auth.guard'; import { RolesGuard } from 'src/common/guards/roles.guard'; import { StatutUtilisateurType } from 'src/entities/users.entity'; @@ -11,11 +12,17 @@ describe('DossiersController', () => { listDossiers: jest.fn(), getDossierByNumero: jest.fn(), }; + const suppressionServiceMock = { + deleteDossier: jest.fn(), + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [DossiersController], - providers: [{ provide: DossiersService, useValue: dossiersServiceMock }], + providers: [ + { provide: DossiersService, useValue: dossiersServiceMock }, + { provide: SuppressionService, useValue: suppressionServiceMock }, + ], }) .overrideGuard(AuthGuard) .useValue({ canActivate: () => true }) @@ -60,4 +67,19 @@ describe('DossiersController', () => { expect(dossiersServiceMock.getDossierByNumero).toHaveBeenCalledWith('2026-000001'); expect(res.type).toBe('family'); }); + + it('remove delegates to suppressionService.deleteDossier', async () => { + const user = { id: 'u1', role: 'gestionnaire' } as never; + suppressionServiceMock.deleteDossier.mockResolvedValue({ + type: 'famille', + deleted_user_ids: [], + deleted_enfant_ids: [], + message: 'ok', + }); + await controller.remove('2026-000001', user); + expect(suppressionServiceMock.deleteDossier).toHaveBeenCalledWith( + '2026-000001', + user, + ); + }); }); diff --git a/backend/src/routes/dossiers/dossiers.controller.ts b/backend/src/routes/dossiers/dossiers.controller.ts index 4849f9b..3009a0e 100644 --- a/backend/src/routes/dossiers/dossiers.controller.ts +++ b/backend/src/routes/dossiers/dossiers.controller.ts @@ -1,4 +1,11 @@ -import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; +import { + Controller, + Delete, + Get, + Param, + Query, + UseGuards, +} from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, @@ -8,10 +15,12 @@ import { ApiTags, } from '@nestjs/swagger'; import { Roles } from 'src/common/decorators/roles.decorator'; -import { RoleType } from 'src/entities/users.entity'; +import { RoleType, Users } from 'src/entities/users.entity'; 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 { DossiersService } from './dossiers.service'; +import { SuppressionService } from '../suppressions/suppression.service'; import { DossierUnifieDto } from './dto/dossier-unifie.dto'; import { DossierListItemDto } from './dto/dossier-list-item.dto'; @@ -20,7 +29,10 @@ import { DossierListItemDto } from './dto/dossier-list-item.dto'; @Controller('dossiers') @UseGuards(AuthGuard, RolesGuard) export class DossiersController { - constructor(private readonly dossiersService: DossiersService) {} + constructor( + private readonly dossiersService: DossiersService, + private readonly suppressionService: SuppressionService, + ) {} @Get() @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) @@ -28,7 +40,8 @@ export class DossiersController { summary: 'Liste unifiée des dossiers (familles + AM) — ticket #153', description: '1 entrée = 1 numero_dossier. Types `famille` | `assistante_maternelle`. ' + - 'Filtre optionnel `q` (n°, nom, email). Tri : à valider d’abord, puis n° décroissant.', + 'Filtre optionnel `q` (n°, nom, email). Tri : à valider d’abord, puis n° décroissant. ' + + '`sans_enfant` (#159) pour dossiers famille sans enfant.', }) @ApiQuery({ name: 'q', @@ -51,4 +64,21 @@ export class DossiersController { getDossier(@Param('numeroDossier') numeroDossier: string): Promise { return this.dossiersService.getDossierByNumero(numeroDossier); } + + @Delete(':numeroDossier') + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) + @ApiOperation({ + summary: 'Supprimer un dossier (famille ou AM) — #159', + description: + 'Famille : parents + enfants. AM : compte AM + dossier AM (enfants conservés, placements clos).', + }) + @ApiParam({ name: 'numeroDossier', description: 'Numéro de dossier' }) + @ApiResponse({ status: 200, description: 'Résultat de suppression' }) + @ApiResponse({ status: 404, description: 'Dossier introuvable' }) + remove( + @Param('numeroDossier') numeroDossier: string, + @User() currentUser: Users, + ) { + return this.suppressionService.deleteDossier(numeroDossier, currentUser); + } } diff --git a/backend/src/routes/dossiers/dossiers.module.ts b/backend/src/routes/dossiers/dossiers.module.ts index e6ba196..d130bb2 100644 --- a/backend/src/routes/dossiers/dossiers.module.ts +++ b/backend/src/routes/dossiers/dossiers.module.ts @@ -5,6 +5,7 @@ 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 { SuppressionsModule } from '../suppressions/suppressions.module'; import { DossiersController } from './dossiers.controller'; import { DossiersService } from './dossiers.service'; @@ -12,6 +13,7 @@ import { DossiersService } from './dossiers.service'; imports: [ TypeOrmModule.forFeature([Parents, AssistanteMaternelle]), ParentsModule, + SuppressionsModule, JwtModule.registerAsync({ imports: [ConfigModule], useFactory: (config: ConfigService) => ({ diff --git a/backend/src/routes/dossiers/dossiers.service.spec.ts b/backend/src/routes/dossiers/dossiers.service.spec.ts index ecdf772..5519c26 100644 --- a/backend/src/routes/dossiers/dossiers.service.spec.ts +++ b/backend/src/routes/dossiers/dossiers.service.spec.ts @@ -4,6 +4,7 @@ import { DossiersService } from './dossiers.service'; import { Parents } from 'src/entities/parents.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { ParentsService } from '../parents/parents.service'; +import { SuppressionService } from '../suppressions/suppression.service'; import { StatutUtilisateurType } from 'src/entities/users.entity'; describe('DossiersService.listDossiers', () => { @@ -32,6 +33,9 @@ describe('DossiersService.listDossiers', () => { const parentsService = { getDossierFamilleByNumero: jest.fn(), }; + const suppressionService = { + countEnfantsForNumero: jest.fn().mockResolvedValue(1), + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -40,6 +44,7 @@ describe('DossiersService.listDossiers', () => { { provide: getRepositoryToken(Parents), useValue: parentsRepo }, { provide: getRepositoryToken(AssistanteMaternelle), useValue: amRepo }, { provide: ParentsService, useValue: parentsService }, + { provide: SuppressionService, useValue: suppressionService }, ], }).compile(); @@ -47,6 +52,7 @@ describe('DossiersService.listDossiers', () => { jest.clearAllMocks(); parentsRepo.createQueryBuilder.mockReturnValue(parentsQb); amRepo.createQueryBuilder.mockReturnValue(amQb); + suppressionService.countEnfantsForNumero.mockResolvedValue(1); }); it('aggregates famille (pivot+co-parent) and AM, sorts a_valider first', async () => { diff --git a/backend/src/routes/dossiers/dossiers.service.ts b/backend/src/routes/dossiers/dossiers.service.ts index cfc4711..75ce244 100644 --- a/backend/src/routes/dossiers/dossiers.service.ts +++ b/backend/src/routes/dossiers/dossiers.service.ts @@ -5,12 +5,13 @@ import { Parents } from 'src/entities/parents.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { StatutUtilisateurType, Users } from 'src/entities/users.entity'; import { ParentsService } from '../parents/parents.service'; +import { SuppressionService } from '../suppressions/suppression.service'; import { DossierUnifieDto } from './dto/dossier-unifie.dto'; import { DossierAmCompletDto, DossierAmUserDto } from './dto/dossier-am-complet.dto'; import { DossierListItemDto } from './dto/dossier-list-item.dto'; /** - * Dossiers unifiés — détail (#119) + liste (#153). + * Dossiers unifiés — détail (#119) + liste (#153) + sans_enfant (#159). */ @Injectable() export class DossiersService { @@ -20,6 +21,7 @@ export class DossiersService { @InjectRepository(AssistanteMaternelle) private readonly amRepository: Repository, private readonly parentsService: ParentsService, + private readonly suppressionService: SuppressionService, ) {} /** @@ -43,6 +45,17 @@ export class DossiersService { return b.numero_dossier.localeCompare(a.numero_dossier, 'fr'); }); + for (const item of filtered) { + if (item.type === 'famille') { + const n = await this.suppressionService.countEnfantsForNumero( + item.numero_dossier, + ); + item.sans_enfant = n === 0; + } else { + item.sans_enfant = false; + } + } + return filtered; } diff --git a/backend/src/routes/dossiers/dto/dossier-list-item.dto.ts b/backend/src/routes/dossiers/dto/dossier-list-item.dto.ts index c510f7e..fb38384 100644 --- a/backend/src/routes/dossiers/dto/dossier-list-item.dto.ts +++ b/backend/src/routes/dossiers/dto/dossier-list-item.dto.ts @@ -49,4 +49,10 @@ export class DossierListItemDto { description: 'Date de référence (MIN cree_le des users du dossier)', }) date_reference: string | null; + + @ApiPropertyOptional({ + description: + 'True si dossier famille sans enfant lié (#159). Omis ou false pour AM.', + }) + sans_enfant?: boolean; } diff --git a/backend/src/routes/enfants/enfants.controller.ts b/backend/src/routes/enfants/enfants.controller.ts index 8b44228..b6b1e8e 100644 --- a/backend/src/routes/enfants/enfants.controller.ts +++ b/backend/src/routes/enfants/enfants.controller.ts @@ -13,6 +13,7 @@ import { ParseUUIDPipe, Patch, Post, + Query, UploadedFile, UseGuards, UseInterceptors, @@ -23,6 +24,7 @@ import { ApiBody, ApiConsumes, ApiOperation, + ApiQuery, ApiTags, } from '@nestjs/swagger'; import { diskStorage } from 'multer'; @@ -36,6 +38,7 @@ import { User } from 'src/common/decorators/user.decorator'; import { AuthGuard } from 'src/common/guards/auth.guard'; import { Roles } from 'src/common/decorators/roles.decorator'; import { RolesGuard } from 'src/common/guards/roles.guard'; +import { SuppressionService } from '../suppressions/suppression.service'; const photoMulterOptions = { storage: diskStorage({ @@ -83,7 +86,10 @@ class OptionalEnfantPhotoInterceptor implements NestInterceptor { @UseGuards(AuthGuard, RolesGuard) @Controller('enfants') export class EnfantsController { - constructor(private readonly enfantsService: EnfantsService) { } + constructor( + private readonly enfantsService: EnfantsService, + private readonly suppressionService: SuppressionService, + ) { } @Roles( RoleType.PARENT, @@ -157,9 +163,31 @@ export class EnfantsController { return this.enfantsService.update(id, dto, currentUser, photo); } - @Roles(RoleType.SUPER_ADMIN) + @Roles( + RoleType.SUPER_ADMIN, + RoleType.ADMINISTRATEUR, + RoleType.GESTIONNAIRE, + ) @Delete(':id') - remove(@Param('id', new ParseUUIDPipe()) id: string) { - return this.enfantsService.remove(id); + @ApiOperation({ + summary: 'Supprimer un enfant (#159)', + description: + 'Query `deleteDossier=true` si dernier enfant et suppression du dossier famille souhaitée.', + }) + @ApiQuery({ + name: 'deleteDossier', + required: false, + description: 'Si true et dernier enfant : cascade dossier famille', + }) + remove( + @Param('id', new ParseUUIDPipe()) id: string, + @Query('deleteDossier') deleteDossier: string | undefined, + @User() currentUser: Users, + ) { + const flag = + deleteDossier === 'true' || + deleteDossier === '1' || + deleteDossier === 'yes'; + return this.suppressionService.deleteEnfant(id, flag, currentUser); } } diff --git a/backend/src/routes/enfants/enfants.module.ts b/backend/src/routes/enfants/enfants.module.ts index 2b9385b..4565cb7 100644 --- a/backend/src/routes/enfants/enfants.module.ts +++ b/backend/src/routes/enfants/enfants.module.ts @@ -6,13 +6,16 @@ import { Children } from 'src/entities/children.entity'; import { Parents } from 'src/entities/parents.entity'; import { ParentsChildren } from 'src/entities/parents_children.entity'; import { AuthModule } from '../auth/auth.module'; +import { SuppressionsModule } from '../suppressions/suppressions.module'; @Module({ - imports: [TypeOrmModule.forFeature([Children, Parents, ParentsChildren]), - AuthModule - + imports: [ + TypeOrmModule.forFeature([Children, Parents, ParentsChildren]), + AuthModule, + SuppressionsModule, ], controllers: [EnfantsController], - providers: [EnfantsService] + providers: [EnfantsService], + exports: [EnfantsService], }) export class EnfantsModule { } diff --git a/backend/src/routes/suppressions/suppression.service.spec.ts b/backend/src/routes/suppressions/suppression.service.spec.ts new file mode 100644 index 0000000..0e38008 --- /dev/null +++ b/backend/src/routes/suppressions/suppression.service.spec.ts @@ -0,0 +1,175 @@ +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { SuppressionService } from './suppression.service'; +import { RoleType } from 'src/entities/users.entity'; + +describe('SuppressionService (#159)', () => { + const dataSource = { + transaction: jest.fn(async (cb: (m: unknown) => Promise) => + cb({ + delete: jest.fn(), + query: jest.fn(), + }), + ), + }; + + const usersRepository = { + findOne: jest.fn(), + delete: jest.fn(), + count: jest.fn(), + }; + const parentsRepository = { + findOne: jest.fn(), + find: jest.fn(), + query: jest.fn(), + }; + const amRepository = { + findOne: jest.fn(), + }; + const childrenRepository = { + findOne: jest.fn(), + delete: jest.fn(), + save: jest.fn(), + }; + const parentsChildrenRepository = { + find: jest.fn(), + }; + const amChildrenRepository = { + find: jest.fn(), + save: jest.fn(), + count: jest.fn(), + }; + + let service: SuppressionService; + + const staff = { + id: 'staff-1', + role: RoleType.GESTIONNAIRE, + } as never; + + const admin = { + id: 'admin-1', + role: RoleType.ADMINISTRATEUR, + } as never; + + const superAdmin = { + id: 'sa-1', + role: RoleType.SUPER_ADMIN, + } as never; + + beforeEach(() => { + jest.clearAllMocks(); + service = new SuppressionService( + dataSource as never, + usersRepository as never, + parentsRepository as never, + amRepository as never, + childrenRepository as never, + parentsChildrenRepository as never, + amChildrenRepository as never, + ); + }); + + it('refuse self-delete', async () => { + usersRepository.findOne.mockResolvedValue({ + id: 'admin-1', + role: RoleType.ADMINISTRATEUR, + }); + await expect(service.deleteUser('admin-1', admin)).rejects.toBeInstanceOf( + ForbiddenException, + ); + }); + + it('refuse gestionnaire deleting another gestionnaire', async () => { + usersRepository.findOne.mockResolvedValue({ + id: 'g2', + role: RoleType.GESTIONNAIRE, + }); + await expect(service.deleteUser('g2', staff)).rejects.toBeInstanceOf( + ForbiddenException, + ); + }); + + it('dernier admin : refus si pas super_admin', async () => { + usersRepository.findOne.mockResolvedValue({ + id: 'admin-2', + role: RoleType.ADMINISTRATEUR, + }); + usersRepository.count.mockResolvedValue(1); + await expect(service.deleteUser('admin-2', admin)).rejects.toBeInstanceOf( + ForbiddenException, + ); + }); + + it('dernier admin : OK pour super_admin', async () => { + usersRepository.findOne.mockResolvedValue({ + id: 'admin-2', + role: RoleType.ADMINISTRATEUR, + }); + usersRepository.count.mockResolvedValue(1); + usersRepository.delete.mockResolvedValue({ affected: 1 }); + const res = await service.deleteUser('admin-2', superAdmin); + expect(res.deleted_user_ids).toEqual(['admin-2']); + }); + + it('delete AM : clos placements, pas d’enfants deleted', async () => { + usersRepository.findOne.mockResolvedValue({ + id: 'am-1', + role: RoleType.ASSISTANTE_MATERNELLE, + }); + amRepository.findOne.mockResolvedValue({ + user_id: 'am-1', + numero_dossier: '2026-000015', + }); + amChildrenRepository.find.mockResolvedValue([ + { + amId: 'am-1', + enfantId: 'e1', + child: { id: 'e1', status: 'garde' }, + }, + ]); + amChildrenRepository.count.mockResolvedValue(0); + amChildrenRepository.save.mockImplementation(async (x) => x); + childrenRepository.save.mockResolvedValue({}); + usersRepository.delete.mockResolvedValue({ affected: 1 }); + + const res = await service.deleteUser('am-1', staff); + expect(res.deleted_enfant_ids).toEqual([]); + expect(res.deleted_user_ids).toEqual(['am-1']); + expect(res.type).toBe('assistante_maternelle'); + }); + + it('delete dossier famille introuvable', async () => { + parentsRepository.findOne.mockResolvedValue(null); + amRepository.findOne.mockResolvedValue(null); + await expect( + service.deleteDossier('2026-999999', staff), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('co-parent : delete user seul', async () => { + usersRepository.findOne.mockResolvedValue({ + id: 'p2', + role: RoleType.PARENT, + }); + parentsRepository.findOne.mockResolvedValue({ + user_id: 'p2', + numero_dossier: '2026-000010', + co_parent: { id: 'p1' }, + }); + parentsRepository.query.mockResolvedValue([ + { id: 'p1' }, + { id: 'p2' }, + ]); + dataSource.transaction.mockImplementation(async (cb) => + cb({ + delete: jest.fn(), + query: jest.fn(), + }), + ); + + const res = await service.deleteUser('p2', staff); + expect(res.deleted_enfant_ids).toEqual([]); + expect(res.deleted_user_ids).toEqual(['p2']); + expect(res.message).toMatch(/co-parent/i); + }); +}); diff --git a/backend/src/routes/suppressions/suppression.service.ts b/backend/src/routes/suppressions/suppression.service.ts new file mode 100644 index 0000000..f401bfc --- /dev/null +++ b/backend/src/routes/suppressions/suppression.service.ts @@ -0,0 +1,420 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, In, IsNull, Repository } from 'typeorm'; +import { RoleType, Users } from 'src/entities/users.entity'; +import { Parents } from 'src/entities/parents.entity'; +import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; +import { Children, StatutEnfantType } from 'src/entities/children.entity'; +import { ParentsChildren } from 'src/entities/parents_children.entity'; +import { AmChildren } from 'src/entities/am_children.entity'; + +export type SuppressionResult = { + type?: 'famille' | 'assistante_maternelle'; + numero_dossier?: string; + deleted_user_ids: string[]; + deleted_enfant_ids: string[]; + dossier_supprime?: boolean; + message: string; +}; + +const STAFF_METIER: RoleType[] = [ + RoleType.GESTIONNAIRE, + RoleType.ADMINISTRATEUR, + RoleType.SUPER_ADMIN, +]; + +/** + * Cascades de suppression métier — tickets #154 / #159. + */ +@Injectable() +export class SuppressionService { + constructor( + private readonly dataSource: DataSource, + @InjectRepository(Users) + private readonly usersRepository: Repository, + @InjectRepository(Parents) + private readonly parentsRepository: Repository, + @InjectRepository(AssistanteMaternelle) + private readonly amRepository: Repository, + @InjectRepository(Children) + private readonly childrenRepository: Repository, + @InjectRepository(ParentsChildren) + private readonly parentsChildrenRepository: Repository, + @InjectRepository(AmChildren) + private readonly amChildrenRepository: Repository, + ) {} + + assertStaffMetier(currentUser: Users): void { + if (!STAFF_METIER.includes(currentUser.role)) { + throw new ForbiddenException('Accès refusé'); + } + } + + async deleteDossier( + numeroDossier: string, + currentUser: Users, + ): Promise { + this.assertStaffMetier(currentUser); + const num = numeroDossier?.trim(); + if (!num) { + throw new BadRequestException('Numéro de dossier requis.'); + } + + const parentHit = await this.parentsRepository.findOne({ + where: { numero_dossier: num }, + }); + if (parentHit) { + return this.deleteFamilleByNumero(num); + } + + const amHit = await this.amRepository.findOne({ + where: { numero_dossier: num }, + relations: ['user'], + }); + if (amHit?.user) { + return this.deleteAmUser(amHit.user.id); + } + + throw new NotFoundException('Aucun dossier trouvé pour ce numéro.'); + } + + async deleteUser( + id: string, + currentUser: Users, + ): Promise { + const target = await this.usersRepository.findOne({ where: { id } }); + if (!target) { + throw new NotFoundException('Utilisateur introuvable'); + } + + if (target.id === currentUser.id) { + throw new ForbiddenException('Vous ne pouvez pas supprimer votre propre compte.'); + } + if (target.role === RoleType.SUPER_ADMIN) { + throw new ForbiddenException('Le super administrateur ne peut pas être supprimé.'); + } + + if (target.role === RoleType.PARENT) { + this.assertStaffMetier(currentUser); + return this.deleteParentUser(target.id); + } + if (target.role === RoleType.ASSISTANTE_MATERNELLE) { + this.assertStaffMetier(currentUser); + return this.deleteAmUser(target.id); + } + if (target.role === RoleType.GESTIONNAIRE) { + if ( + currentUser.role !== RoleType.ADMINISTRATEUR && + currentUser.role !== RoleType.SUPER_ADMIN + ) { + throw new ForbiddenException( + 'Seul un administrateur peut supprimer un gestionnaire.', + ); + } + await this.usersRepository.delete(target.id); + return { + deleted_user_ids: [target.id], + deleted_enfant_ids: [], + message: 'Gestionnaire supprimé.', + }; + } + if (target.role === RoleType.ADMINISTRATEUR) { + await this.assertCanDeleteAdministrateur(target, currentUser); + await this.usersRepository.delete(target.id); + return { + deleted_user_ids: [target.id], + deleted_enfant_ids: [], + message: 'Administrateur supprimé.', + }; + } + + throw new BadRequestException('Type d’utilisateur non supprimable via cet endpoint.'); + } + + async deleteEnfant( + enfantId: string, + deleteDossier: boolean, + currentUser: Users, + ): Promise { + this.assertStaffMetier(currentUser); + + const child = await this.childrenRepository.findOne({ + where: { id: enfantId }, + relations: ['parentLinks', 'parentLinks.parent'], + }); + if (!child) { + throw new NotFoundException('Enfant introuvable'); + } + + const parentIds = (child.parentLinks ?? []) + .map((l) => l.parentId ?? l.parent?.user_id) + .filter(Boolean) as string[]; + + let numero: string | undefined; + if (parentIds.length > 0) { + const parents = await this.parentsRepository.find({ + where: { user_id: In(parentIds) }, + }); + numero = parents.map((p) => p.numero_dossier?.trim()).find((n) => !!n); + } + + if (!numero) { + await this.closePlacementsForEnfants([enfantId]); + await this.childrenRepository.delete(enfantId); + return { + deleted_user_ids: [], + deleted_enfant_ids: [enfantId], + dossier_supprime: false, + message: 'Enfant supprimé.', + }; + } + + const siblingIds = await this.listEnfantIdsForNumero(numero); + const isLast = siblingIds.length <= 1; + + if (isLast && deleteDossier) { + const result = await this.deleteFamilleByNumero(numero); + return { + ...result, + dossier_supprime: true, + message: 'Dernier enfant et dossier famille supprimés.', + }; + } + + await this.closePlacementsForEnfants([enfantId]); + await this.childrenRepository.delete(enfantId); + return { + deleted_user_ids: [], + deleted_enfant_ids: [enfantId], + dossier_supprime: false, + numero_dossier: numero, + type: 'famille', + message: isLast + ? 'Dernier enfant supprimé. Le dossier famille reste sans enfant.' + : 'Enfant supprimé du dossier famille.', + }; + } + + /** Compte enfants liés à un numero_dossier famille (pour flag sans_enfant). */ + async countEnfantsForNumero(numeroDossier: string): Promise { + const ids = await this.listEnfantIdsForNumero(numeroDossier); + return ids.length; + } + + private async assertCanDeleteAdministrateur( + target: Users, + currentUser: Users, + ): Promise { + if ( + currentUser.role !== RoleType.ADMINISTRATEUR && + currentUser.role !== RoleType.SUPER_ADMIN + ) { + throw new ForbiddenException( + 'Seul un administrateur peut supprimer un administrateur.', + ); + } + const adminCount = await this.usersRepository.count({ + where: { role: RoleType.ADMINISTRATEUR }, + }); + if (adminCount <= 1) { + if (currentUser.role !== RoleType.SUPER_ADMIN) { + throw new ForbiddenException( + 'Seul le super administrateur peut supprimer le dernier administrateur.', + ); + } + } + } + + private async deleteParentUser(userId: string): Promise { + const parent = await this.parentsRepository.findOne({ + where: { user_id: userId }, + relations: ['co_parent'], + }); + if (!parent) { + // Compte parent sans fiche — hard delete user + await this.usersRepository.delete(userId); + return { + deleted_user_ids: [userId], + deleted_enfant_ids: [], + message: 'Parent supprimé.', + }; + } + + const numero = parent.numero_dossier?.trim(); + const foyerIds = numero + ? await this.listParentUserIdsForNumero(numero) + : [userId]; + + const isLast = foyerIds.filter((id) => id !== userId).length === 0; + + if (!isLast) { + // Co-parent : retirer liens enfants de ce parent, clear co_parent refs, delete user + await this.dataSource.transaction(async (manager) => { + await manager.delete(ParentsChildren, { parentId: userId }); + await manager.query( + `UPDATE parents SET id_co_parent = NULL WHERE id_co_parent = $1 OR id_utilisateur = $1`, + [userId], + ); + await manager.delete(Users, { id: userId }); + }); + return { + deleted_user_ids: [userId], + deleted_enfant_ids: [], + numero_dossier: numero, + type: 'famille', + message: 'Parent retiré du dossier (co-parent).', + }; + } + + // Dernier parent : + enfants + const enfantIds = numero + ? await this.listEnfantIdsForNumero(numero) + : await this.listEnfantIdsForParent(userId); + await this.closePlacementsForEnfants(enfantIds); + await this.dataSource.transaction(async (manager) => { + if (enfantIds.length) { + await manager.delete(Children, { id: In(enfantIds) }); + } + await manager.query( + `UPDATE parents SET id_co_parent = NULL WHERE id_utilisateur = $1 OR id_co_parent = $1`, + [userId], + ); + await manager.delete(Users, { id: userId }); + }); + return { + deleted_user_ids: [userId], + deleted_enfant_ids: enfantIds, + numero_dossier: numero, + type: 'famille', + message: 'Dernier parent et enfants rattachés supprimés.', + }; + } + + private async deleteFamilleByNumero(numero: string): Promise { + const parentIds = await this.listParentUserIdsForNumero(numero); + if (parentIds.length === 0) { + throw new NotFoundException('Aucun parent pour ce dossier.'); + } + const enfantIds = await this.listEnfantIdsForNumero(numero); + await this.closePlacementsForEnfants(enfantIds); + + await this.dataSource.transaction(async (manager) => { + if (enfantIds.length) { + await manager.delete(Children, { id: In(enfantIds) }); + } + await manager.query( + `UPDATE parents SET id_co_parent = NULL WHERE id_utilisateur = ANY($1::uuid[]) OR id_co_parent = ANY($1::uuid[])`, + [parentIds], + ); + await manager.delete(Users, { id: In(parentIds) }); + }); + + return { + type: 'famille', + numero_dossier: numero, + deleted_user_ids: parentIds, + deleted_enfant_ids: enfantIds, + message: 'Dossier famille supprimé.', + }; + } + + private async deleteAmUser(userId: string): Promise { + const am = await this.amRepository.findOne({ where: { user_id: userId } }); + const numero = am?.numero_dossier?.trim(); + + const active = await this.amChildrenRepository.find({ + where: { amId: userId, date_fin: IsNull() }, + relations: ['child'], + }); + const now = new Date(); + for (const link of active) { + link.date_fin = now; + await this.amChildrenRepository.save(link); + if (link.child) { + await this.applySansGarde(link.child); + } + } + + await this.usersRepository.delete(userId); + return { + type: 'assistante_maternelle', + numero_dossier: numero, + deleted_user_ids: [userId], + deleted_enfant_ids: [], + message: 'Dossier assistante maternelle supprimé.', + }; + } + + private async applySansGarde(child: Children): Promise { + if ( + child.status === StatutEnfantType.A_NAITRE || + child.status === StatutEnfantType.SCOLARISE + ) { + return; + } + const remaining = await this.amChildrenRepository.count({ + where: { enfantId: child.id, date_fin: IsNull() }, + }); + if (remaining === 0) { + child.status = StatutEnfantType.SANS_GARDE; + await this.childrenRepository.save(child); + } + } + + private async closePlacementsForEnfants(enfantIds: string[]): Promise { + if (!enfantIds.length) return; + const links = await this.amChildrenRepository.find({ + where: { enfantId: In(enfantIds), date_fin: IsNull() }, + relations: ['child'], + }); + const now = new Date(); + for (const link of links) { + link.date_fin = now; + await this.amChildrenRepository.save(link); + if (link.child) { + await this.applySansGarde(link.child); + } + } + } + + private async listParentUserIdsForNumero(numero: string): Promise { + const rows: Array<{ id: string }> = await this.parentsRepository.query( + ` + SELECT DISTINCT x.id::text AS id FROM ( + SELECT id_utilisateur AS id FROM parents WHERE TRIM(numero_dossier) = $1 + UNION + SELECT id_co_parent AS id FROM parents + WHERE TRIM(numero_dossier) = $1 AND id_co_parent IS NOT NULL + UNION + SELECT p2.id_utilisateur AS id FROM parents p1 + JOIN parents p2 ON p2.id_utilisateur = p1.id_co_parent + WHERE TRIM(p1.numero_dossier) = $1 + ) x WHERE x.id IS NOT NULL + `, + [numero], + ); + return rows.map((r) => r.id); + } + + private async listEnfantIdsForNumero(numero: string): Promise { + const parentIds = await this.listParentUserIdsForNumero(numero); + if (!parentIds.length) return []; + return this.listEnfantIdsForParents(parentIds); + } + + private async listEnfantIdsForParent(parentId: string): Promise { + return this.listEnfantIdsForParents([parentId]); + } + + private async listEnfantIdsForParents(parentIds: string[]): Promise { + const links = await this.parentsChildrenRepository.find({ + where: { parentId: In(parentIds) }, + }); + return [...new Set(links.map((l) => l.enfantId))]; + } +} diff --git a/backend/src/routes/suppressions/suppressions.module.ts b/backend/src/routes/suppressions/suppressions.module.ts new file mode 100644 index 0000000..4b210eb --- /dev/null +++ b/backend/src/routes/suppressions/suppressions.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Users } from 'src/entities/users.entity'; +import { Parents } from 'src/entities/parents.entity'; +import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; +import { Children } from 'src/entities/children.entity'; +import { ParentsChildren } from 'src/entities/parents_children.entity'; +import { AmChildren } from 'src/entities/am_children.entity'; +import { SuppressionService } from './suppression.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Users, + Parents, + AssistanteMaternelle, + Children, + ParentsChildren, + AmChildren, + ]), + ], + providers: [SuppressionService], + exports: [SuppressionService], +}) +export class SuppressionsModule {} diff --git a/backend/src/routes/user/user.controller.ts b/backend/src/routes/user/user.controller.ts index 90b8535..c419ac2 100644 --- a/backend/src/routes/user/user.controller.ts +++ b/backend/src/routes/user/user.controller.ts @@ -10,13 +10,17 @@ 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'; +import { SuppressionService } from '../suppressions/suppression.service'; @ApiTags('Utilisateurs') @ApiBearerAuth('access-token') @UseGuards(AuthGuard, RolesGuard) @Controller('users') export class UserController { - constructor(private readonly userService: UserService) { } + constructor( + private readonly userService: UserService, + private readonly suppressionService: SuppressionService, + ) { } // Création d'un administrateur (réservée aux super admins) @Post('admin') @@ -146,12 +150,20 @@ export class UserController { return this.userService.suspendUser(id, currentUser, comment); } - // Supprimer un utilisateur (super_admin uniquement) + // Supprimer un utilisateur — cascades métier #159 @Delete(':id') - @Roles(RoleType.SUPER_ADMIN) - @ApiOperation({ summary: 'Supprimer un utilisateur' }) + @Roles( + RoleType.SUPER_ADMIN, + RoleType.ADMINISTRATEUR, + RoleType.GESTIONNAIRE, + ) + @ApiOperation({ + summary: 'Supprimer un utilisateur (cascades métier #159)', + description: + 'Parent / AM / staff selon matrice. Gestionnaire ne peut pas supprimer un autre gestionnaire. Self interdit.', + }) @ApiParam({ name: 'id', description: "UUID de l'utilisateur" }) remove(@Param('id') id: string, @User() currentUser: Users) { - return this.userService.remove(id, currentUser); + return this.suppressionService.deleteUser(id, currentUser); } } diff --git a/backend/src/routes/user/user.module.ts b/backend/src/routes/user/user.module.ts index 62598f8..839763f 100644 --- a/backend/src/routes/user/user.module.ts +++ b/backend/src/routes/user/user.module.ts @@ -12,6 +12,7 @@ import { Parents } from 'src/entities/parents.entity'; import { GestionnairesModule } from './gestionnaires/gestionnaires.module'; import { MailModule } from 'src/modules/mail/mail.module'; import { AppConfigModule } from 'src/modules/config/config.module'; +import { SuppressionsModule } from '../suppressions/suppressions.module'; @Module({ imports: [TypeOrmModule.forFeature( @@ -26,6 +27,7 @@ import { AppConfigModule } from 'src/modules/config/config.module'; GestionnairesModule, MailModule, AppConfigModule, + SuppressionsModule, ], controllers: [UserController], providers: [UserService], diff --git a/backend/src/routes/user/user.service.ts b/backend/src/routes/user/user.service.ts index be700a3..50a0c52 100644 --- a/backend/src/routes/user/user.service.ts +++ b/backend/src/routes/user/user.service.ts @@ -520,6 +520,7 @@ export class UserService { } async remove(id: string, currentUser: Users): Promise { + // Délégué historiquement ; préférer SuppressionService via controller (#159). if (currentUser.role !== RoleType.SUPER_ADMIN) { throw new ForbiddenException('Accès réservé aux super admins'); } diff --git a/docs/tmp/154-matrice-suppression.md b/docs/tmp/154-matrice-suppression.md new file mode 100644 index 0000000..fc1da2f --- /dev/null +++ b/docs/tmp/154-matrice-suppression.md @@ -0,0 +1,35 @@ +# Matrice suppression — #154 / back **#159** / front **#160** + +**Statut** : cadrage PO validé (sept. 2026) +**Milestone** : 0.1.0 +**Email** : pas d’email de suppression (cas rare) + +## Droits + +| Cible | Qui peut supprimer | +|-------|-------------------| +| Dossier / parent / enfant / AM | `GESTIONNAIRE`, `ADMINISTRATEUR`, `SUPER_ADMIN` | +| Gestionnaire (user) | `ADMINISTRATEUR`, `SUPER_ADMIN` | +| Administrateur (user) | Autre admin OK ; **self interdit** ; **dernier admin** = `SUPER_ADMIN` only ; `SUPER_ADMIN` non supprimable | + +## Matrice métier + +| Point d’entrée | Action | Effet | +|----------------|--------|--------| +| Dossiers | Delete dossier **famille** | Tous **parents** + tous **enfants** ; clore placements AM des enfants | +| Dossiers / AM | Delete dossier **AM** ou compte AM | **Compte AM + dossier AM** ; enfants **conservés** ; placements **clos** | +| Parents | Co-parent (autre parent reste) | Compte parent seul ; dossier + enfants restent | +| Parents | Dernier parent | Parent + **enfants** rattachés | +| Enfants | Pas dernier | Enfant seul (retiré du dossier) | +| Enfants | Dernier + `deleteDossier=true` | Cascade dossier famille (parents + enfants) | +| Enfants | Dernier + `deleteDossier=false` | Enfant seul ; dossier peut apparaître **`sans_enfant`** | +| Pending / validé | — | **Mêmes règles** (pas de différenciation) | + +## Warning + +- `sans_enfant` sur liste `GET /dossiers` (dossier famille sans enfant lié). +- Miroir de `sans_responsable` (#157) côté enfants. + +## Hors scope + +Soft-delete RGPD, audit (#128), famille N (#139), restriction admin-only métier (plus tard). diff --git a/docs/tmp/154-mini-spec-front-suppression.md b/docs/tmp/154-mini-spec-front-suppression.md new file mode 100644 index 0000000..271b26d --- /dev/null +++ b/docs/tmp/154-mini-spec-front-suppression.md @@ -0,0 +1,169 @@ +# Mini-spec front — Suppressions dashboard + +**Ticket front** : **#160** — https://git.ptits-pas.fr/jmartin/petitspas/issues/160 +**Ticket back** : **#159** — https://git.ptits-pas.fr/jmartin/petitspas/issues/159 +**Epic** : #154 (complète #133) +**Branche back** : `feature/159-suppressions-backend` +**Doc matrice** : [154-matrice-suppression.md](./154-matrice-suppression.md) + +Travail **en parallèle** : ce contrat est la source de vérité UI ↔ API. + +--- + +## UX commune + +Sur chaque ligne / carte des listes : + +- Icône **poubelle** en bout de ligne +- Clic → **dialog de confirmation** (texte d’impact) → DELETE → **refresh** liste +- Pending = **mêmes** règles que validés +- **Pas** d’email + +| Liste | Poubelle visible si | +|-------|---------------------| +| Dossiers, Parents, Enfants, AM | gestionnaire **ou** admin | +| Gestionnaires | **admin** only | +| Administrateurs | admin+ ; **pas** sur sa propre ligne ; dernier admin : UI warning + réservé super_admin | + +--- + +## Contrat API + +Base : auth Bearer. Erreurs : `400` / `403` / `404` / `409` avec `message` FR. + +### 1. `DELETE /dossiers/:numeroDossier` + +- **Famille** → supprime tous parents + enfants du n° ; clos placements AM des enfants. +- **AM** → compte AM + dossier AM ; enfants gardés ; placements clos. + +**Réponse 200** (exemple) : + +```json +{ + "type": "famille", + "numero_dossier": "2026-000043", + "deleted_user_ids": ["…"], + "deleted_enfant_ids": ["…"], + "message": "Dossier famille supprimé." +} +``` + +ou + +```json +{ + "type": "assistante_maternelle", + "numero_dossier": "2026-000015", + "deleted_user_ids": ["…"], + "deleted_enfant_ids": [], + "message": "Dossier assistante maternelle supprimé." +} +``` + +**Dialog UI** : lister libellé + n° + « X parent(s), Y enfant(s) » (ou « compte AM, enfants conservés »). + +--- + +### 2. `DELETE /users/:id` + +Comportement selon le **rôle** de la cible : + +| Cible | Effet | +|-------|--------| +| Parent **co-parent** | Delete ce user seul | +| Parent **dernier** du dossier | Delete user + enfants du foyer | +| AM | Delete user AM + dossier AM ; enfants conservés ; placements clos | +| Gestionnaire | Admin only ; self → 403 | +| Administrateur | Self → 403 ; dernier admin → super_admin only sinon 403 ; super_admin → 403 | + +**Réponse 200** : + +```json +{ + "deleted_user_ids": ["…"], + "deleted_enfant_ids": ["…"], + "message": "…" +} +``` + +**Dialogs** : + +- Co-parent : « Ce parent sera retiré / supprimé du dossier {n°}. Les enfants restent avec le co-parent. » +- Dernier parent : « Dernier parent du dossier {n°}. Les enfants rattachés seront aussi supprimés. » +- AM : « Le compte et le dossier AM seront supprimés. Les enfants accueillis ne seront pas supprimés. » + +Optionnel (si exposé) : `GET /users/:id/suppression-impact` — sinon calculer depuis données déjà en liste / détail dossier. + +--- + +### 3. `DELETE /enfants/:id?deleteDossier=true|false` + +- Pas dernier enfant → delete enfant (`deleteDossier` ignoré ou false). +- Dernier enfant + `deleteDossier=false` → delete enfant ; dossier famille peut passer `sans_enfant`. +- Dernier enfant + `deleteDossier=true` → cascade dossier famille (parents + enfants). + +**Réponse 200** : + +```json +{ + "deleted_enfant_ids": ["…"], + "deleted_user_ids": ["…"], + "dossier_supprime": false, + "message": "…" +} +``` + +**Dialog** : + +- Standard : « L’enfant sera supprimé du dossier de {famille} ({n°}). » +- Dernier : proposer **deux actions** : + 1. Supprimer l’enfant seulement (`deleteDossier=false`) + 2. Supprimer aussi le dossier / parents (`deleteDossier=true`) + +Pour savoir si dernier : compter enfants du `numero_dossier` (détail dossier ou champ impact API). + +--- + +### 4. `GET /dossiers` — flag `sans_enfant` + +Chaque item famille peut exposer : + +```json +"sans_enfant": true +``` + +- `true` si dossier **famille** sans enfant lié. +- AM : `false` ou omis. + +**UI** : badge / warning vigilance (comme `sans_responsable` / alertes AM). + +--- + +## UserService (Flutter) — signatures cibles + +```dart +Future deleteDossier(String numeroDossier); +Future> deleteUser(String userId); +Future> deleteEnfant(String enfantId, {bool deleteDossier = false}); +``` + +(Adapter le parsing au JSON réel une fois le back mergé ; en parallèle, stubber sur ce contrat.) + +--- + +## Fichiers front probables + +- Cartes listes : `admin_user_card.dart`, `admin_enfant_user_card.dart`, cartes dossiers +- Listes : `dossiers_management_widget.dart`, `parent_managmant_widget.dart`, `enfant_management_widget.dart`, `assistante_maternelle_management_widget.dart`, `gestionnaire_management_widget.dart`, `admin_management_widget.dart` +- `user_service.dart` + +--- + +## Critères front (#160) + +- [ ] Poubelle selon droits +- [ ] Confirmations avec impact +- [ ] Refresh après succès +- [ ] Dernier enfant : choix dossier oui/non +- [ ] Warning `sans_enfant` +- [ ] Self-admin / dernier admin gérés côté UI (masquer ou message 403) From e235b30140c77b97e9553d03ba711deb44e32b4c Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 10 Sep 2026 22:16:05 +0200 Subject: [PATCH 76/85] =?UTF-8?q?feat(#160):=20suppressions=20dashboard=20?= =?UTF-8?q?=E2=80=94=20poubelle,=20confirmations=20et=20sans=5Fenfant.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- frontend/lib/models/dossier_list_item.dart | 43 ++ .../creation/admin_create.dart | 34 +- .../creation/gestionnaires_create.dart | 40 +- frontend/lib/services/user_service.dart | 101 +++- frontend/lib/utils/staff_deletion_rights.dart | 53 ++ .../admin/admin_management_widget.dart | 68 +++ ...sistante_maternelle_management_widget.dart | 56 +++ .../common/admin_child_detail_modal.dart | 126 +++-- .../common/suppression_confirm_dialog.dart | 467 ++++++++++++++++++ .../lib/widgets/admin/dossier_list_card.dart | 33 +- .../admin/dossiers_management_widget.dart | 128 ++++- .../admin/enfant_management_widget.dart | 139 ++++++ .../admin/gestionnaire_management_widget.dart | 59 +++ .../admin/parent_managmant_widget.dart | 75 +++ .../admin/pending_validation_widget.dart | 115 ++++- .../admin/relais_management_panel.dart | 29 +- 16 files changed, 1456 insertions(+), 110 deletions(-) create mode 100644 frontend/lib/utils/staff_deletion_rights.dart create mode 100644 frontend/lib/widgets/admin/common/suppression_confirm_dialog.dart diff --git a/frontend/lib/models/dossier_list_item.dart b/frontend/lib/models/dossier_list_item.dart index 25ec22f..4d35c35 100644 --- a/frontend/lib/models/dossier_list_item.dart +++ b/frontend/lib/models/dossier_list_item.dart @@ -13,6 +13,10 @@ class DossierListItem { final String? statut; /// Photo profil (AM) — affichée à la place de l’icône si présente. final String? photoUrl; + /// Dossier famille sans enfant lié (#159 / #160). + final bool sansEnfant; + /// Nombre d’enfants du foyer (famille uniquement ; null pour AM). + final int? enfantsCount; const DossierListItem({ required this.type, @@ -21,8 +25,32 @@ class DossierListItem { this.emails = const [], this.statut, this.photoUrl, + this.sansEnfant = false, + this.enfantsCount, }); + DossierListItem copyWith({ + DossierListType? type, + String? numeroDossier, + String? libelle, + List? emails, + String? statut, + String? photoUrl, + bool? sansEnfant, + int? enfantsCount, + }) { + return DossierListItem( + type: type ?? this.type, + numeroDossier: numeroDossier ?? this.numeroDossier, + libelle: libelle ?? this.libelle, + emails: emails ?? this.emails, + statut: statut ?? this.statut, + photoUrl: photoUrl ?? this.photoUrl, + sansEnfant: sansEnfant ?? this.sansEnfant, + enfantsCount: enfantsCount ?? this.enfantsCount, + ); + } + bool get isFamille => type == DossierListType.famille; bool get isAm => type == DossierListType.assistanteMaternelle; @@ -98,6 +126,19 @@ class DossierListItem { } } + final childIds = {}; + var maxCountFallback = 0; + for (final p in entry.value) { + for (final c in p.children) { + final id = c.id.trim(); + if (id.isNotEmpty) childIds.add(id); + } + final n = p.children.isNotEmpty ? p.children.length : p.childrenCount; + if (n > maxCountFallback) maxCountFallback = n; + } + final enfantsCount = + childIds.isNotEmpty ? childIds.length : maxCountFallback; + items.add( DossierListItem( type: DossierListType.famille, @@ -105,6 +146,8 @@ class DossierListItem { libelle: names.isNotEmpty ? names.join(' - ') : 'Famille', emails: emails, statut: _preferStatut(statuts), + enfantsCount: enfantsCount, + sansEnfant: enfantsCount == 0, ), ); } diff --git a/frontend/lib/screens/administrateurs/creation/admin_create.dart b/frontend/lib/screens/administrateurs/creation/admin_create.dart index 1db28b2..1334519 100644 --- a/frontend/lib/screens/administrateurs/creation/admin_create.dart +++ b/frontend/lib/screens/administrateurs/creation/admin_create.dart @@ -3,6 +3,7 @@ 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/admin/common/suppression_confirm_dialog.dart'; import 'package:p_tits_pas/widgets/email_text_field.dart'; import 'package:p_tits_pas/widgets/french_phone_field.dart'; @@ -151,30 +152,19 @@ class _AdminCreateDialogState extends State { Future _delete() async { if (!_isEditMode || _isSubmitting) return; - final confirmed = await showDialog( - context: context, - builder: (ctx) { - return AlertDialog( - title: const Text('Confirmer la suppression'), - content: Text( - 'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(false), - child: const Text('Annuler'), - ), - FilledButton( - onPressed: () => Navigator.of(ctx).pop(true), - style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700), - child: const Text('Supprimer'), - ), - ], - ); - }, + final name = widget.initialUser!.fullName.isEmpty + ? widget.initialUser!.email + : widget.initialUser!.fullName; + final confirmed = await showSuppressionConfirmDialog( + context, + title: 'Supprimer l\'administrateur', + people: [SuppressionPersonLine.administrateur(name)], + footnotes: const [ + 'Le compte sera définitivement supprimé.', + ], ); - if (confirmed != true) return; + if (!confirmed) return; setState(() { _isSubmitting = true; diff --git a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart index 9a24de6..628bf89 100644 --- a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart +++ b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart @@ -8,6 +8,7 @@ import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/relais_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; class AdminUserFormDialog extends StatefulWidget { final AppUser? initialUser; @@ -378,30 +379,25 @@ class _AdminUserFormDialogState extends State { if (!_canDeleteTarget) return; if (!_isEditMode || _isSubmitting) return; - final confirmed = await showDialog( - context: context, - builder: (ctx) { - return AlertDialog( - title: const Text('Confirmer la suppression'), - content: Text( - 'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(false), - child: const Text('Annuler'), - ), - FilledButton( - onPressed: () => Navigator.of(ctx).pop(true), - style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700), - child: const Text('Supprimer'), - ), - ], - ); - }, + final name = widget.initialUser!.fullName.isEmpty + ? widget.initialUser!.email + : widget.initialUser!.fullName; + final confirmed = await showSuppressionConfirmDialog( + context, + title: widget.adminMode + ? 'Supprimer l\'administrateur' + : 'Supprimer le gestionnaire', + people: [ + widget.adminMode + ? SuppressionPersonLine.administrateur(name) + : SuppressionPersonLine.gestionnaire(name), + ], + footnotes: const [ + 'Le compte sera définitivement supprimé.', + ], ); - if (confirmed != true) return; + if (!confirmed) return; setState(() { _isSubmitting = true; diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index 4c078aa..95e5ede 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -646,14 +646,93 @@ class UserService { return enrichEnfantParentNames(enfant); } - static Future deleteEnfant(String enfantId) async { + /// DELETE /enfants/:id?deleteDossier= — ticket #159 / #160. + static Future> deleteEnfant( + String enfantId, { + bool deleteDossier = false, + }) async { + final uri = Uri.parse( + '${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId', + ).replace( + queryParameters: { + 'deleteDossier': deleteDossier ? 'true' : 'false', + }, + ); + final response = await http.delete(uri, headers: await _headers()); + if (response.statusCode != 200 && response.statusCode != 204) { + throw Exception( + _extractErrorMessage(response.body, 'Erreur suppression enfant'), + ); + } + return _parseSuppressionBody(response.body); + } + + /// DELETE /dossiers/:numeroDossier — ticket #159 / #160. + static Future> deleteDossier( + String numeroDossier, + ) async { + final num = numeroDossier.trim(); + if (num.isEmpty) { + throw Exception('Numéro de dossier manquant.'); + } + final encoded = Uri.encodeComponent(num); final response = await http.delete( - Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'), + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}/$encoded'), headers: await _headers(), ); if (response.statusCode != 200 && response.statusCode != 204) { - throw Exception(_extractErrorMessage(response.body, 'Erreur suppression enfant')); + throw Exception( + _extractErrorMessage(response.body, 'Erreur suppression dossier'), + ); } + return _parseSuppressionBody(response.body); + } + + /// Liste unifiée GET /dossiers — flags `sans_enfant` (#159). + static Future>> listDossiers({String? q}) async { + final uri = Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}') + .replace( + queryParameters: (q != null && q.trim().isNotEmpty) + ? {'q': q.trim()} + : null, + ); + final response = await http.get(uri, headers: await _headers()); + if (response.statusCode != 200) { + throw Exception( + _extractErrorMessage(response.body, 'Erreur chargement dossiers'), + ); + } + final decoded = jsonDecode(response.body); + if (decoded is! List) return const []; + return decoded + .whereType() + .map((e) => Map.from(e)) + .toList(); + } + + /// Map `numero_dossier` → `sans_enfant` (familles uniquement). + static Future> getSansEnfantByNumero() async { + final rows = await listDossiers(); + final out = {}; + for (final row in rows) { + final num = (row['numero_dossier'] ?? '').toString().trim(); + if (num.isEmpty) continue; + final type = (row['type'] ?? '').toString().toLowerCase(); + if (type != 'famille') continue; + out[num] = row['sans_enfant'] == true; + } + return out; + } + + static Map _parseSuppressionBody(String body) { + if (body.trim().isEmpty) return {}; + try { + final decoded = jsonDecode(body); + if (decoded is Map) { + return Map.from(decoded); + } + } catch (_) {} + return {}; } /// AM dont la liste d'enfants actifs contient [enfantId] (API actuelle). @@ -1224,22 +1303,18 @@ class UserService { return AppUser.fromJson(data); } - static Future deleteUser(String userId) async { + /// DELETE /users/:id — cascades métier #159 / #160. + static Future> deleteUser(String userId) async { final response = await http.delete( Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId'), headers: await _headers(), ); if (response.statusCode != 200 && response.statusCode != 204) { - final decoded = jsonDecode(response.body); - if (decoded is Map) { - final message = decoded['message']; - if (message is List && message.isNotEmpty) { - throw Exception(message.join(' - ')); - } - throw Exception(_toStr(message) ?? 'Erreur suppression utilisateur'); - } - throw Exception('Erreur suppression utilisateur'); + throw Exception( + _extractErrorMessage(response.body, 'Erreur suppression utilisateur'), + ); } + return _parseSuppressionBody(response.body); } } diff --git a/frontend/lib/utils/staff_deletion_rights.dart b/frontend/lib/utils/staff_deletion_rights.dart new file mode 100644 index 0000000..eae8a11 --- /dev/null +++ b/frontend/lib/utils/staff_deletion_rights.dart @@ -0,0 +1,53 @@ +/// Droits d’affichage poubelle — tickets #154 / #160. +bool canDeleteMetier(String? role) { + final r = (role ?? '').trim().toLowerCase(); + return r == 'gestionnaire' || + r == 'administrateur' || + r == 'super_admin'; +} + +bool canDeleteGestionnaire(String? role) { + final r = (role ?? '').trim().toLowerCase(); + return r == 'administrateur' || r == 'super_admin'; +} + +bool canDeleteAdministrateur({ + required String? currentRole, + required String? currentUserId, + required String targetUserId, + required String targetRole, + required int adminCount, +}) { + final me = (currentRole ?? '').trim().toLowerCase(); + if (me != 'administrateur' && me != 'super_admin') return false; + if (targetUserId.trim().isEmpty) return false; + if (currentUserId != null && + currentUserId.trim() == targetUserId.trim()) { + return false; + } + final target = targetRole.trim().toLowerCase(); + if (target == 'super_admin') return false; + if (adminCount <= 1 && me != 'super_admin') return false; + return true; +} + +String? adminDeleteBlockedReason({ + required String? currentRole, + required String? currentUserId, + required String targetUserId, + required String targetRole, + required int adminCount, +}) { + if (currentUserId != null && + currentUserId.trim() == targetUserId.trim()) { + return 'Vous ne pouvez pas supprimer votre propre compte.'; + } + if (targetRole.trim().toLowerCase() == 'super_admin') { + return 'Le super administrateur ne peut pas être supprimé.'; + } + if (adminCount <= 1 && + (currentRole ?? '').trim().toLowerCase() != 'super_admin') { + return 'Seul un super administrateur peut supprimer le dernier administrateur.'; + } + return null; +} diff --git a/frontend/lib/widgets/admin/admin_management_widget.dart b/frontend/lib/widgets/admin/admin_management_widget.dart index d0a2bed..08d40a3 100644 --- a/frontend/lib/widgets/admin/admin_management_widget.dart +++ b/frontend/lib/widgets/admin/admin_management_widget.dart @@ -4,7 +4,9 @@ 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'; +import 'package:p_tits_pas/utils/staff_deletion_rights.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; +import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; class AdminManagementWidget extends StatefulWidget { @@ -24,6 +26,7 @@ class _AdminManagementWidgetState extends State { String? _error; List _admins = []; String? _currentUserRole; + String? _currentUserId; @override void initState() { @@ -62,6 +65,7 @@ class _AdminManagementWidgetState extends State { if (cached != null) { setState(() { _currentUserRole = (cached.role).toLowerCase(); + _currentUserId = cached.id; }); return; } @@ -69,6 +73,7 @@ class _AdminManagementWidgetState extends State { if (!mounted || refreshed == null) return; setState(() { _currentUserRole = (refreshed.role).toLowerCase(); + _currentUserId = refreshed.id; }); } @@ -79,6 +84,16 @@ class _AdminManagementWidgetState extends State { return _currentUserRole == 'super_admin'; } + bool _canDeleteAdmin(AppUser target) { + return canDeleteAdministrateur( + currentRole: _currentUserRole, + currentUserId: _currentUserId, + targetUserId: target.id, + targetRole: target.role, + adminCount: _admins.length, + ); + } + Future _openAdminEditDialog(AppUser user) async { final canEdit = _canEditAdmin(user); final changed = await showDialog( @@ -98,6 +113,56 @@ class _AdminManagementWidgetState extends State { } } + Future _confirmDelete(AppUser user) async { + final blocked = adminDeleteBlockedReason( + currentRole: _currentUserRole, + currentUserId: _currentUserId, + targetUserId: user.id, + targetRole: user.role, + adminCount: _admins.length, + ); + if (blocked != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(blocked)), + ); + return; + } + + final name = user.fullName.isNotEmpty ? user.fullName : user.email; + final isLast = _admins.length <= 1; + final confirmed = await showSuppressionConfirmDialog( + context, + title: 'Supprimer l\'administrateur', + people: [SuppressionPersonLine.administrateur(name)], + footnotes: [ + if (isLast) + 'Attention : c’est le dernier administrateur.', + 'Le compte sera définitivement supprimé.', + ], + ); + if (!confirmed || !mounted) return; + + try { + final result = await UserService.deleteUser(user.id); + if (!mounted) return; + final msg = + (result['message'] ?? 'Administrateur supprimé.').toString(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + await _loadAdmins(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Erreur suppression', + ), + ), + ); + } + } + @override Widget build(BuildContext context) { final query = widget.searchQuery.toLowerCase(); @@ -117,6 +182,7 @@ class _AdminManagementWidgetState extends State { final user = filteredAdmins[index]; final isSuperAdmin = _isSuperAdmin(user); final canEdit = _canEditAdmin(user); + final canDelete = _canDeleteAdmin(user); return AdminUserCard( title: user.fullName, fallbackIcon: isSuperAdmin @@ -148,6 +214,8 @@ class _AdminManagementWidgetState extends State { _openAdminEditDialog(user); }, ), + if (canDelete) + suppressionIconButton(onPressed: () => _confirmDelete(user)), ], ); }, diff --git a/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart b/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart index dc28b11..b48212b 100644 --- a/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart +++ b/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart @@ -1,9 +1,13 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; +import 'package:p_tits_pas/models/dossier_list_item.dart'; import 'package:p_tits_pas/utils/am_vigilance.dart'; +import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/utils/staff_deletion_rights.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; +import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; class AssistanteMaternelleManagementWidget extends StatefulWidget { @@ -26,16 +30,24 @@ class _AssistanteMaternelleManagementWidgetState bool _isLoading = false; String? _error; List _assistantes = []; + bool _canDelete = false; @override void initState() { super.initState(); + _loadRights(); _loadAssistantes(); } @override void dispose() => super.dispose(); + Future _loadRights() async { + final user = await AuthService.getCurrentUser(); + if (!mounted) return; + setState(() => _canDelete = canDeleteMetier(user?.role)); + } + Future _loadAssistantes() async { setState(() { _isLoading = true; @@ -57,6 +69,46 @@ class _AssistanteMaternelleManagementWidgetState } } + Future _confirmDelete(AssistanteMaternelleModel am) async { + final num = (am.user.numeroDossier ?? '').trim(); + final name = formatDossierPersonLabel( + nom: am.user.nom, + prenom: am.user.prenom, + email: am.user.email, + ); + final confirmed = await showSuppressionConfirmDialog( + context, + title: 'Supprimer l\'assistante maternelle', + subtitle: num.isEmpty ? null : 'Dossier $num', + people: [SuppressionPersonLine.am(name)], + footnotes: const [ + 'Le compte et le dossier AM seront supprimés.', + 'Les enfants accueillis ne seront pas supprimés ' + '(placements clos).', + ], + ); + if (!confirmed || !mounted) return; + + try { + final result = await UserService.deleteUser(am.user.id); + if (!mounted) return; + final msg = (result['message'] ?? 'AM supprimée.').toString(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + await _loadAssistantes(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Erreur suppression', + ), + ), + ); + } + } + @override Widget build(BuildContext context) { final query = widget.searchQuery.toLowerCase(); @@ -96,6 +148,10 @@ class _AssistanteMaternelleManagementWidgetState _openAssistanteDetails(assistante); }, ), + if (_canDelete) + suppressionIconButton( + onPressed: () => _confirmDelete(assistante), + ), ], ); }, diff --git a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart index eae7d4d..28f3374 100644 --- a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:image_picker/image_picker.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; +import 'package:p_tits_pas/models/dossier_list_item.dart'; import 'package:p_tits_pas/models/enfant_admin_model.dart'; import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/services/auth_service.dart'; @@ -9,11 +10,13 @@ import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/utils/date_display_utils.dart'; import 'package:p_tits_pas/utils/enfant_status_utils.dart'; import 'package:p_tits_pas/utils/name_format_utils.dart'; +import 'package:p_tits_pas/utils/staff_deletion_rights.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_select_am_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_select_famille_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; @@ -97,8 +100,7 @@ class _AdminChildDetailModalState extends State { _isUnborn ? 'Date prévisionnelle' : 'Date de naissance'; bool get _canDelete => - !widget.isCreating && - (_currentUserRole ?? '').toLowerCase() == 'super_admin'; + !widget.isCreating && canDeleteMetier(_currentUserRole); bool get _busy => _saving || _deleting; @@ -506,37 +508,99 @@ class _AdminChildDetailModalState extends State { if (!_canDelete || _deleting) return; final name = _headerTitle(); - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Supprimer l\'enfant'), - content: Text( - 'Supprimer définitivement la fiche de $name ?\n' - 'Cette action est irréversible.', + final enfant = widget.enfant; + if (enfant == null) return; + + bool deleteDossierFlag = false; + String? numero; + String familleLabel = ''; + bool isLast = false; + + String? amLabel; + final linked = _linkedAm; + if (linked != null) { + final label = formatDossierPersonLabel( + nom: linked.user.nom, + prenom: linked.user.prenom, + email: linked.user.email, + ); + if (label.isNotEmpty) amLabel = label; + } else { + try { + final am = await UserService.findAmForEnfant(enfant.id); + if (am != null) { + final label = formatDossierPersonLabel( + nom: am.user.nom, + prenom: am.user.prenom, + email: am.user.email, + ); + if (label.isNotEmpty) amLabel = label; + } + } catch (_) {} + } + + final parentId = enfant.parentLinks + .map((l) => l.parentId.trim()) + .firstWhere((id) => id.isNotEmpty, orElse: () => ''); + if (parentId.isNotEmpty) { + try { + final parent = await UserService.getParent(parentId); + final num = (parent.user.numeroDossier ?? '').trim(); + numero = num; + familleLabel = parent.user.fullName.isNotEmpty + ? parent.user.fullName + : parent.user.email; + if (num.isNotEmpty) { + final dossier = await UserService.getDossier(num); + if (dossier.isFamily) { + isLast = dossier.asFamily.enfants.length <= 1; + final names = dossier.asFamily.parents + .map((p) => formatDossierPersonLabel( + nom: p.nom, + prenom: p.prenom, + email: p.email, + )) + .where((s) => s.isNotEmpty) + .join(' - '); + if (names.isNotEmpty) familleLabel = names; + } + } + } catch (_) {} + } + + if (!mounted) return; + + if (isLast && (numero ?? '').isNotEmpty) { + final choice = await showDernierEnfantSuppressionDialog( + context, + enfantName: name, + familleLabel: familleLabel, + numeroDossier: numero!, + amLabel: amLabel, + ); + if (choice == null || !mounted) return; + deleteDossierFlag = + choice == DernierEnfantSuppressionChoice.dossierAussi; + } else { + final confirmed = await showSuppressionConfirmDialog( + context, + title: 'Supprimer l\'enfant', + subtitle: (numero ?? '').isEmpty ? null : 'Dossier $numero', + people: [SuppressionPersonLine.enfant(name)], + footnotes: enfantSuppressionFootnotes( + numeroDossier: numero, + familleLabel: familleLabel, + amLabel: amLabel, ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('Annuler'), - ), - ElevatedButton( - onPressed: () => Navigator.pop(ctx, true), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red.shade700, - foregroundColor: Colors.white, - ), - child: const Text('Supprimer'), - ), - ], - ), - ); - if (confirmed != true || !mounted) return; + ); + if (!confirmed || !mounted) return; + } setState(() => _deleting = true); try { final id = widget.enfant?.id; if (id == null || id.isEmpty) return; - await UserService.deleteEnfant(id); + await UserService.deleteEnfant(id, deleteDossier: deleteDossierFlag); if (!mounted) return; widget.onDeleted?.call(); widget.onSaved?.call(); @@ -548,7 +612,13 @@ class _AdminChildDetailModalState extends State { if (!mounted) return; setState(() => _deleting = false); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + SnackBar( + content: Text( + e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Erreur suppression', + ), + ), ); } } diff --git a/frontend/lib/widgets/admin/common/suppression_confirm_dialog.dart b/frontend/lib/widgets/admin/common/suppression_confirm_dialog.dart new file mode 100644 index 0000000..38bf785 --- /dev/null +++ b/frontend/lib/widgets/admin/common/suppression_confirm_dialog.dart @@ -0,0 +1,467 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/dossier_list_item.dart'; + +/// Choix pour le dernier enfant d’un dossier famille (#160). +enum DernierEnfantSuppressionChoice { + enfantSeul, + dossierAussi, +} + +/// Ligne d’impact (une personne / une fiche). +class SuppressionPersonLine { + final String label; + final IconData icon; + final String? role; + + const SuppressionPersonLine({ + required this.label, + this.icon = Icons.person_outline, + this.role, + }); + + factory SuppressionPersonLine.parent(String label) => SuppressionPersonLine( + label: label, + icon: Icons.supervisor_account_outlined, + role: 'Parent', + ); + + factory SuppressionPersonLine.enfant(String label) => SuppressionPersonLine( + label: label, + icon: Icons.child_care_outlined, + role: 'Enfant', + ); + + factory SuppressionPersonLine.am(String label) => SuppressionPersonLine( + label: label, + icon: Icons.face, + role: 'AM', + ); + + factory SuppressionPersonLine.gestionnaire(String label) => + SuppressionPersonLine( + label: label, + icon: Icons.assignment_ind_outlined, + role: 'Gestionnaire', + ); + + factory SuppressionPersonLine.administrateur(String label) => + SuppressionPersonLine( + label: label, + icon: Icons.manage_accounts_outlined, + role: 'Admin', + ); + + factory SuppressionPersonLine.relais(String label) => SuppressionPersonLine( + label: label, + icon: Icons.apartment_outlined, + role: 'Relais', + ); +} + +/// Widget unique pour toutes les boîtes de confirmation de suppression (#160). +class SuppressionConfirmDialog extends StatelessWidget { + final String title; + final String? subtitle; + final String? message; + final List people; + final List footnotes; + final List actions; + + const SuppressionConfirmDialog({ + super.key, + required this.title, + this.subtitle, + this.message, + this.people = const [], + this.footnotes = const [], + required this.actions, + }); + + /// Variante oui/non standard (Annuler / Supprimer). + static SuppressionConfirmDialog yesNo({ + required String title, + String? subtitle, + String? message, + List people = const [], + List footnotes = const [], + String confirmLabel = 'Supprimer', + required VoidCallback onCancel, + required VoidCallback onConfirm, + }) { + return SuppressionConfirmDialog( + title: title, + subtitle: subtitle, + message: message, + people: people, + footnotes: footnotes, + actions: [ + TextButton(onPressed: onCancel, child: const Text('Annuler')), + FilledButton( + onPressed: onConfirm, + style: _dangerButtonStyle, + child: Text(confirmLabel), + ), + ], + ); + } + + static ButtonStyle get _dangerButtonStyle => FilledButton.styleFrom( + backgroundColor: Colors.red.shade700, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AlertDialog( + backgroundColor: const Color(0xFFF7F2FB), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + titlePadding: const EdgeInsets.fromLTRB(24, 20, 24, 0), + contentPadding: const EdgeInsets.fromLTRB(24, 12, 24, 8), + actionsPadding: const EdgeInsets.fromLTRB(16, 4, 16, 14), + title: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.delete_outline, + color: Colors.red.shade700, + size: 22, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + color: const Color(0xFF2F2F2F), + fontSize: 20, + ), + ), + if ((subtitle ?? '').trim().isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + subtitle!.trim(), + style: theme.textTheme.bodyMedium?.copyWith( + color: const Color(0xFF6D4EA1), + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ), + ), + ], + ), + content: SizedBox( + width: 420, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if ((message ?? '').trim().isNotEmpty) ...[ + Text( + message!.trim(), + style: theme.textTheme.bodyMedium?.copyWith( + color: Colors.black87, + height: 1.35, + ), + ), + if (people.isNotEmpty || footnotes.isNotEmpty) + const SizedBox(height: 12), + ], + if (people.isNotEmpty) ...[ + Container( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE5D8F2)), + ), + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + children: [ + for (var i = 0; i < people.length; i++) ...[ + if (i > 0) + Divider( + height: 1, + indent: 44, + endIndent: 12, + color: Colors.grey.shade200, + ), + _SuppressionPersonRow(line: people[i]), + ], + ], + ), + ), + if (footnotes.isNotEmpty) const SizedBox(height: 12), + ], + for (final note in footnotes) + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + Icons.info_outline, + size: 16, + color: Colors.orange.shade800, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + note, + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.black54, + height: 1.35, + ), + ), + ), + ], + ), + ), + ], + ), + ), + actions: actions, + ); + } +} + +class _SuppressionPersonRow extends StatelessWidget { + final SuppressionPersonLine line; + + const _SuppressionPersonRow({required this.line}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + Icon(line.icon, size: 18, color: const Color(0xFF6D4EA1)), + const SizedBox(width: 10), + Expanded( + child: Text( + line.label, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: Color(0xFF2F2F2F), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if ((line.role ?? '').trim().isNotEmpty) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFFEDE5FA), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + line.role!.trim(), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Color(0xFF6D4EA1), + ), + ), + ), + ], + ], + ), + ); + } +} + +/// Affiche [SuppressionConfirmDialog] et renvoie `true` si confirmé. +Future showSuppressionConfirmDialog( + BuildContext context, { + required String title, + String? subtitle, + String? message, + List people = const [], + List footnotes = const [], + String confirmLabel = 'Supprimer', +}) async { + final result = await showDialog( + context: context, + builder: (ctx) => SuppressionConfirmDialog.yesNo( + title: title, + subtitle: subtitle, + message: message, + people: people, + footnotes: footnotes, + confirmLabel: confirmLabel, + onCancel: () => Navigator.of(ctx).pop(false), + onConfirm: () => Navigator.of(ctx).pop(true), + ), + ); + return result == true; +} + +/// Confirmation delete dossier famille / AM avec liste nominative. +Future showDossierSuppressionConfirmDialog( + BuildContext context, { + required String numeroDossier, + required bool isFamille, + required List people, + String? fallbackSummary, +}) { + final num = numeroDossier.trim(); + if (isFamille) { + return showSuppressionConfirmDialog( + context, + title: 'Supprimer le dossier', + subtitle: 'Dossier famille $num', + people: people, + footnotes: people.isEmpty && (fallbackSummary ?? '').isNotEmpty + ? [fallbackSummary!] + : const [ + 'Tous les comptes et fiches listés seront définitivement ' + 'supprimés.', + 'Les placements AM des enfants seront clos.', + ], + ); + } + return showSuppressionConfirmDialog( + context, + title: 'Supprimer le dossier', + subtitle: 'Dossier AM $num', + people: people, + footnotes: const [ + 'Le compte et le dossier AM seront supprimés.', + 'Les enfants accueillis ne seront pas supprimés ' + '(placements clos).', + ], + ); +} + +/// Dialog dernier enfant : deux actions métier. +Future showDernierEnfantSuppressionDialog( + BuildContext context, { + required String enfantName, + required String familleLabel, + required String numeroDossier, + String? amLabel, +}) { + final am = (amLabel ?? '').trim(); + return showDialog( + context: context, + builder: (ctx) => SuppressionConfirmDialog( + title: 'Dernier enfant du dossier', + subtitle: 'Dossier $numeroDossier' + '${familleLabel.isEmpty ? '' : ' · $familleLabel'}', + people: [SuppressionPersonLine.enfant(enfantName)], + footnotes: [ + '« Enfant seulement » : le dossier reste sans enfant.', + '« Dossier aussi » : parents et dossier sont également ' + 'supprimés.', + if (am.isNotEmpty) 'Le placement chez $am sera clos.', + ], + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Annuler'), + ), + OutlinedButton( + onPressed: () => Navigator.of(ctx).pop( + DernierEnfantSuppressionChoice.enfantSeul, + ), + child: const Text('Enfant seulement'), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop( + DernierEnfantSuppressionChoice.dossierAussi, + ), + style: SuppressionConfirmDialog._dangerButtonStyle, + child: const Text('Dossier aussi'), + ), + ], + ), + ); +} + +/// Notes d’impact pour suppression d’un enfant (AM optionnelle). +List enfantSuppressionFootnotes({ + required String? numeroDossier, + required String familleLabel, + String? amLabel, +}) { + final notes = []; + final num = (numeroDossier ?? '').trim(); + final famille = familleLabel.trim(); + final am = (amLabel ?? '').trim(); + if (num.isEmpty) { + notes.add('Supprimer définitivement cette fiche enfant.'); + } else { + notes.add( + 'L’enfant sera retiré du dossier de ' + '${famille.isEmpty ? 'la famille' : famille}.', + ); + } + if (am.isNotEmpty) { + notes.add('Le placement chez $am sera clos.'); + } + return notes; +} + +/// Bouton poubelle compact pour les cartes liste. +Widget suppressionIconButton({ + required VoidCallback? onPressed, + String tooltip = 'Supprimer', +}) { + return IconButton( + icon: Icon(Icons.delete_outline, color: Colors.red.shade700), + tooltip: tooltip, + onPressed: onPressed, + ); +} + +/// Construit les lignes parents / enfants depuis un dossier unifié. +List suppressionPeopleFromDossier({ + required bool isFamille, + required List<({String nom, String prenom, String email})> parents, + required List<({String nom, String prenom})> enfants, + String? amName, +}) { + final lines = []; + if (isFamille) { + for (final p in parents) { + final label = formatDossierPersonLabel( + nom: p.nom, + prenom: p.prenom, + email: p.email, + ); + if (label.isEmpty) continue; + lines.add(SuppressionPersonLine.parent(label)); + } + for (final e in enfants) { + final label = formatDossierPersonLabel(nom: e.nom, prenom: e.prenom); + if (label.isEmpty) continue; + lines.add(SuppressionPersonLine.enfant(label)); + } + } else if ((amName ?? '').trim().isNotEmpty) { + lines.add(SuppressionPersonLine.am(amName!.trim())); + } + return lines; +} diff --git a/frontend/lib/widgets/admin/dossier_list_card.dart b/frontend/lib/widgets/admin/dossier_list_card.dart index b824eae..7608df2 100644 --- a/frontend/lib/widgets/admin/dossier_list_card.dart +++ b/frontend/lib/widgets/admin/dossier_list_card.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; +import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; /// Carte dossier unifiée (#153) — fond neutre, accent couleur sur l’icône. class DossierListCard extends StatelessWidget { @@ -9,6 +10,12 @@ class DossierListCard extends StatelessWidget { final VoidCallback onOpen; /// Photo AM (si absente → icône fallback). final String? photoUrl; + final VoidCallback? onDelete; + /// Warning vigilance (ex. dossier sans enfant #160). + final String? vigilanceTooltip; + /// Nombre d’enfants (famille) — affiché à côté des noms. + final int? enfantsCount; + final bool sansEnfant; /// Lavande — Famille / Parents. static const Color familleAccent = Color(0xFFB289C9); @@ -23,6 +30,10 @@ class DossierListCard extends StatelessWidget { required this.isFamille, required this.onOpen, this.photoUrl, + this.onDelete, + this.vigilanceTooltip, + this.enfantsCount, + this.sansEnfant = false, }); @override @@ -31,23 +42,41 @@ class DossierListCard extends StatelessWidget { final num = numeroDossier.trim().isEmpty ? '–' : numeroDossier.trim(); final names = namesLine.trim(); final avatar = (photoUrl ?? '').trim(); + final emptyKids = isFamille && (sansEnfant || enfantsCount == 0); + final count = enfantsCount; + final countLabel = (!isFamille || count == null) + ? null + : (count <= 1 ? '$count enfant' : '$count enfants'); + + final subtitle = [ + if (names.isNotEmpty) names, + if (emptyKids) 'Sans enfant', + if (!emptyKids && countLabel != null) countLabel, + ]; return AdminUserCard( title: num, - subtitleLines: names.isEmpty ? const [] : [names], + subtitleLines: subtitle, avatarUrl: !isFamille && avatar.isNotEmpty ? avatar : null, fallbackIcon: isFamille ? Icons.family_restroom_outlined : Icons.face, // N° = titre neutre (comme Parents / AM) ; accent = icône seule. avatarIconColor: accent, - infoColor: Colors.black87, + infoColor: emptyKids ? Colors.red.shade700 : Colors.black87, onCardTap: onOpen, + vigilanceTooltip: emptyKids + ? (vigilanceTooltip ?? + 'Aucun enfant rattaché à ce dossier famille') + : vigilanceTooltip, + borderColor: emptyKids ? Colors.red.shade300 : null, actions: [ IconButton( tooltip: 'Ouvrir', icon: Icon(Icons.open_in_new, size: 20, color: accent), onPressed: onOpen, ), + if (onDelete != null) + suppressionIconButton(onPressed: onDelete), ], ); } diff --git a/frontend/lib/widgets/admin/dossiers_management_widget.dart b/frontend/lib/widgets/admin/dossiers_management_widget.dart index 4cafa2f..a616525 100644 --- a/frontend/lib/widgets/admin/dossiers_management_widget.dart +++ b/frontend/lib/widgets/admin/dossiers_management_widget.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/dossier_list_item.dart'; +import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/utils/staff_deletion_rights.dart'; import 'package:p_tits_pas/widgets/admin/dossier_list_card.dart'; import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart'; +import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart'; /// Onglet permanent « Dossiers » (#153) : pending en haut + liste unifiée en bas. @@ -27,13 +30,21 @@ class _DossiersManagementWidgetState extends State { List _all = []; Set _pendingNumeros = {}; int _pendingRefreshTick = 0; + bool _canDelete = false; @override void initState() { super.initState(); + _loadRights(); _loadAll(); } + Future _loadRights() async { + final user = await AuthService.getCurrentUser(); + if (!mounted) return; + setState(() => _canDelete = canDeleteMetier(user?.role)); + } + Future _loadAll() async { setState(() { _loading = true; @@ -42,12 +53,29 @@ class _DossiersManagementWidgetState extends State { try { final parents = await UserService.getParents(); final ams = await UserService.getAssistantesMaternelles(); + Map sansEnfant = {}; + try { + sansEnfant = await UserService.getSansEnfantByNumero(); + } catch (_) { + // Flag optionnel : ne bloque pas la liste. + } if (!mounted) return; final items = [ ...DossierListItem.fromParents(parents), ...DossierListItem.fromAssistantes(ams), - ]; + ].map((item) { + if (!item.isFamille) return item; + final apiFlag = sansEnfant[item.numeroDossier] == true; + final localEmpty = (item.enfantsCount ?? 0) == 0; + return item.copyWith( + sansEnfant: apiFlag || localEmpty || item.sansEnfant, + ); + }).toList(); items.sort((a, b) { + // Dossiers sans enfant en tête (#160), comme orphelins #157. + final ae = a.sansEnfant ? 0 : 1; + final be = b.sansEnfant ? 0 : 1; + if (ae != be) return ae.compareTo(be); final byNum = a.numeroDossier.compareTo(b.numeroDossier); if (byNum != 0) return byNum; return a.typeLabel.compareTo(b.typeLabel); @@ -95,6 +123,95 @@ class _DossiersManagementWidgetState extends State { ); } + Future _confirmDeleteDossier(DossierListItem item) async { + final num = item.numeroDossier.trim(); + if (num.isEmpty) return; + + var people = []; + String? fallbackSummary; + try { + final dossier = await UserService.getDossier(num); + if (dossier.isFamily) { + final f = dossier.asFamily; + people = suppressionPeopleFromDossier( + isFamille: true, + parents: f.parents + .map((p) => ( + nom: p.nom ?? '', + prenom: p.prenom ?? '', + email: p.email, + )) + .toList(), + enfants: f.enfants + .map((e) => ( + nom: e.lastName ?? '', + prenom: e.firstName ?? '', + )) + .toList(), + ); + } else { + final am = dossier.asAm.user; + people = suppressionPeopleFromDossier( + isFamille: false, + parents: const [], + enfants: const [], + amName: formatDossierPersonLabel( + nom: am.nom, + prenom: am.prenom, + email: am.email, + ), + ); + } + } catch (_) { + fallbackSummary = item.isFamille + ? 'Tous les parents et enfants rattachés seront supprimés.' + : 'Le compte AM sera supprimé ; les enfants accueillis ' + 'seront conservés.'; + if (item.namesLine.trim().isNotEmpty) { + for (final part in item.namesLine.split(' - ')) { + final label = part.trim(); + if (label.isEmpty) continue; + people.add(SuppressionPersonLine( + label: label, + icon: item.isFamille + ? Icons.supervisor_account_outlined + : Icons.face, + role: item.isFamille ? 'Parent' : 'AM', + )); + } + } + } + + if (!mounted) return; + final confirmed = await showDossierSuppressionConfirmDialog( + context, + numeroDossier: num, + isFamille: item.isFamille, + people: people, + fallbackSummary: fallbackSummary, + ); + if (!confirmed || !mounted) return; + + try { + final result = await UserService.deleteDossier(num); + if (!mounted) return; + final msg = (result['message'] ?? 'Dossier supprimé.').toString(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + await _refreshEverything(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Erreur suppression', + ), + ), + ); + } + } + @override Widget build(BuildContext context) { final query = widget.searchQuery; @@ -115,6 +232,7 @@ class _DossiersManagementWidgetState extends State { key: ValueKey('pending-$_pendingRefreshTick'), searchQuery: query, compactWhenEmpty: true, + canDelete: _canDelete, onPendingNumerosChanged: (nums) { if (!mounted) return; setState(() => _pendingNumeros = nums); @@ -189,7 +307,15 @@ class _DossiersManagementWidgetState extends State { namesLine: item.namesLine, isFamille: item.isFamille, photoUrl: item.photoUrl, + sansEnfant: item.sansEnfant, + enfantsCount: item.enfantsCount, + vigilanceTooltip: item.sansEnfant + ? 'Aucun enfant rattaché à ce dossier famille' + : null, onOpen: () => _openDossier(item.numeroDossier), + onDelete: _canDelete + ? () => _confirmDeleteDossier(item) + : null, ); }, childCount: filtered.length, diff --git a/frontend/lib/widgets/admin/enfant_management_widget.dart b/frontend/lib/widgets/admin/enfant_management_widget.dart index 5ef03f1..6731ff7 100644 --- a/frontend/lib/widgets/admin/enfant_management_widget.dart +++ b/frontend/lib/widgets/admin/enfant_management_widget.dart @@ -1,9 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/dossier_list_item.dart'; import 'package:p_tits_pas/utils/enfant_status_utils.dart'; import 'package:p_tits_pas/models/enfant_admin_model.dart'; +import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/utils/staff_deletion_rights.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart'; +import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; /// Onglet liste globale des enfants (doc 28 §6.2, ticket #137). @@ -25,13 +29,21 @@ class _EnfantManagementWidgetState extends State { bool _isLoading = false; String? _error; List _enfants = []; + bool _canDelete = false; @override void initState() { super.initState(); + _loadRights(); _loadEnfants(); } + Future _loadRights() async { + final user = await AuthService.getCurrentUser(); + if (!mounted) return; + setState(() => _canDelete = canDeleteMetier(user?.role)); + } + Future _loadEnfants() async { setState(() { _isLoading = true; @@ -59,10 +71,133 @@ class _EnfantManagementWidgetState extends State { builder: (ctx) => AdminChildDetailModal( enfant: enfant, onSaved: _loadEnfants, + onDeleted: _loadEnfants, ), ); } + Future<({String? numero, String famille, bool isLast, String? amLabel})> + _resolveContext( + EnfantAdminModel enfant, + ) async { + String? amLabel; + try { + final am = await UserService.findAmForEnfant(enfant.id); + if (am != null) { + final label = formatDossierPersonLabel( + nom: am.user.nom, + prenom: am.user.prenom, + email: am.user.email, + ); + if (label.isNotEmpty) amLabel = label; + } + } catch (_) {} + + final parentId = enfant.parentLinks + .map((l) => l.parentId.trim()) + .firstWhere((id) => id.isNotEmpty, orElse: () => ''); + if (parentId.isEmpty) { + return (numero: null, famille: '', isLast: true, amLabel: amLabel); + } + try { + final parent = await UserService.getParent(parentId); + final num = (parent.user.numeroDossier ?? '').trim(); + final famille = parent.user.fullName.isNotEmpty + ? parent.user.fullName + : parent.user.email; + if (num.isEmpty) { + return ( + numero: null, + famille: famille, + isLast: true, + amLabel: amLabel, + ); + } + final dossier = await UserService.getDossier(num); + if (!dossier.isFamily) { + return ( + numero: num, + famille: famille, + isLast: true, + amLabel: amLabel, + ); + } + final n = dossier.asFamily.enfants.length; + final names = dossier.asFamily.parents + .map((p) => formatDossierPersonLabel( + nom: p.nom, + prenom: p.prenom, + email: p.email, + )) + .where((s) => s.isNotEmpty) + .join(' - '); + return ( + numero: num, + famille: names.isNotEmpty ? names : famille, + isLast: n <= 1, + amLabel: amLabel, + ); + } catch (_) { + return (numero: null, famille: '', isLast: false, amLabel: amLabel); + } + } + + Future _confirmDelete(EnfantAdminModel enfant) async { + final ctx = await _resolveContext(enfant); + if (!mounted) return; + + bool deleteDossier = false; + if (ctx.isLast && (ctx.numero ?? '').isNotEmpty) { + final choice = await showDernierEnfantSuppressionDialog( + context, + enfantName: enfant.fullName, + familleLabel: ctx.famille, + numeroDossier: ctx.numero!, + amLabel: ctx.amLabel, + ); + if (choice == null || !mounted) return; + deleteDossier = + choice == DernierEnfantSuppressionChoice.dossierAussi; + } else { + final confirmed = await showSuppressionConfirmDialog( + context, + title: 'Supprimer l\'enfant', + subtitle: (ctx.numero ?? '').isEmpty + ? null + : 'Dossier ${ctx.numero}', + people: [SuppressionPersonLine.enfant(enfant.fullName)], + footnotes: enfantSuppressionFootnotes( + numeroDossier: ctx.numero, + familleLabel: ctx.famille, + amLabel: ctx.amLabel, + ), + ); + if (!confirmed || !mounted) return; + } + + try { + final result = await UserService.deleteEnfant( + enfant.id, + deleteDossier: deleteDossier, + ); + if (!mounted) return; + final msg = (result['message'] ?? 'Enfant supprimé.').toString(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + await _loadEnfants(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Erreur suppression', + ), + ), + ); + } + } + @override Widget build(BuildContext context) { final query = widget.searchQuery.toLowerCase(); @@ -98,6 +233,10 @@ class _EnfantManagementWidgetState extends State { tooltip: 'Voir / modifier', onPressed: () => _openEnfant(enfant), ), + if (_canDelete) + suppressionIconButton( + onPressed: () => _confirmDelete(enfant), + ), ], ); }, diff --git a/frontend/lib/widgets/admin/gestionnaire_management_widget.dart b/frontend/lib/widgets/admin/gestionnaire_management_widget.dart index 1ce89d6..f5cda69 100644 --- a/frontend/lib/widgets/admin/gestionnaire_management_widget.dart +++ b/frontend/lib/widgets/admin/gestionnaire_management_widget.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/user.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'; +import 'package:p_tits_pas/utils/staff_deletion_rights.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; +import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; class GestionnaireManagementWidget extends StatefulWidget { @@ -23,16 +26,28 @@ class _GestionnaireManagementWidgetState bool _isLoading = false; String? _error; List _gestionnaires = []; + bool _canDelete = false; + String? _currentUserId; @override void initState() { super.initState(); + _loadRights(); _loadGestionnaires(); } @override void dispose() => super.dispose(); + Future _loadRights() async { + final user = await AuthService.getCurrentUser(); + if (!mounted) return; + setState(() { + _canDelete = canDeleteGestionnaire(user?.role); + _currentUserId = user?.id; + }); + } + Future _loadGestionnaires() async { setState(() { _isLoading = true; @@ -67,6 +82,46 @@ class _GestionnaireManagementWidgetState } } + Future _confirmDelete(AppUser user) async { + if (_currentUserId != null && _currentUserId == user.id) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Vous ne pouvez pas supprimer votre propre compte.'), + ), + ); + return; + } + final name = user.fullName.isNotEmpty ? user.fullName : user.email; + final confirmed = await showSuppressionConfirmDialog( + context, + title: 'Supprimer le gestionnaire', + people: [SuppressionPersonLine.gestionnaire(name)], + footnotes: const [ + 'Le compte sera définitivement supprimé.', + ], + ); + if (!confirmed || !mounted) return; + + try { + final result = await UserService.deleteUser(user.id); + if (!mounted) return; + final msg = (result['message'] ?? 'Gestionnaire supprimé.').toString(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + await _loadGestionnaires(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Erreur suppression', + ), + ), + ); + } + } + @override Widget build(BuildContext context) { final query = widget.searchQuery.toLowerCase(); @@ -84,6 +139,8 @@ class _GestionnaireManagementWidgetState itemCount: filteredGestionnaires.length, itemBuilder: (context, index) { final user = filteredGestionnaires[index]; + final isSelf = + _currentUserId != null && _currentUserId == user.id; return AdminUserCard( title: user.fullName, fallbackIcon: Icons.assignment_ind_outlined, @@ -102,6 +159,8 @@ class _GestionnaireManagementWidgetState _openGestionnaireEditDialog(user); }, ), + if (_canDelete && !isSelf) + suppressionIconButton(onPressed: () => _confirmDelete(user)), ], ); }, diff --git a/frontend/lib/widgets/admin/parent_managmant_widget.dart b/frontend/lib/widgets/admin/parent_managmant_widget.dart index 24e0ef5..69b2683 100644 --- a/frontend/lib/widgets/admin/parent_managmant_widget.dart +++ b/frontend/lib/widgets/admin/parent_managmant_widget.dart @@ -1,8 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/dossier_list_item.dart'; import 'package:p_tits_pas/models/parent_model.dart'; +import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/utils/staff_deletion_rights.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; +import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; class ParentManagementWidget extends StatefulWidget { @@ -23,16 +27,24 @@ class _ParentManagementWidgetState extends State { bool _isLoading = false; String? _error; List _parents = []; + bool _canDelete = false; @override void initState() { super.initState(); + _loadRights(); _loadParents(); } @override void dispose() => super.dispose(); + Future _loadRights() async { + final user = await AuthService.getCurrentUser(); + if (!mounted) return; + setState(() => _canDelete = canDeleteMetier(user?.role)); + } + Future _loadParents() async { setState(() { _isLoading = true; @@ -54,6 +66,67 @@ class _ParentManagementWidgetState extends State { } } + bool _isLastParent(ParentModel parent) { + final co = parent.coParent?.id.trim(); + if (co != null && co.isNotEmpty) return false; + final num = (parent.user.numeroDossier ?? '').trim(); + if (num.isEmpty) return true; + return !_parents.any((other) { + if (other.user.id == parent.user.id) return false; + return (other.user.numeroDossier ?? '').trim() == num; + }); + } + + Future _confirmDelete(ParentModel parent) async { + final num = (parent.user.numeroDossier ?? '').trim(); + final name = formatDossierPersonLabel( + nom: parent.user.nom, + prenom: parent.user.prenom, + email: parent.user.email, + ); + final last = _isLastParent(parent); + final enfants = ParentModel.foyerChildrenCount(parent, _parents); + final footnotes = last + ? [ + if (num.isNotEmpty) 'Dernier parent du dossier $num.', + if (num.isEmpty) 'Dernier parent du dossier.', + 'Les $enfants enfant(s) rattaché(s) seront aussi supprimés.', + ] + : [ + if (num.isNotEmpty) + 'Ce parent sera retiré du dossier $num.', + 'Les enfants restent avec le co-parent.', + ]; + + final confirmed = await showSuppressionConfirmDialog( + context, + title: 'Supprimer le parent', + subtitle: num.isEmpty ? null : 'Dossier $num', + people: [SuppressionPersonLine.parent(name)], + footnotes: footnotes, + ); + if (!confirmed || !mounted) return; + + try { + final result = await UserService.deleteUser(parent.user.id); + if (!mounted) return; + final msg = (result['message'] ?? 'Parent supprimé.').toString(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + await _loadParents(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Erreur suppression', + ), + ), + ); + } + } + @override Widget build(BuildContext context) { final query = widget.searchQuery.toLowerCase(); @@ -90,6 +163,8 @@ class _ParentManagementWidgetState extends State { _openParentDetails(parent); }, ), + if (_canDelete) + suppressionIconButton(onPressed: () => _confirmDelete(parent)), ], ); }, diff --git a/frontend/lib/widgets/admin/pending_validation_widget.dart b/frontend/lib/widgets/admin/pending_validation_widget.dart index 78296bd..4a71085 100644 --- a/frontend/lib/widgets/admin/pending_validation_widget.dart +++ b/frontend/lib/widgets/admin/pending_validation_widget.dart @@ -4,6 +4,7 @@ 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/widgets/admin/dossier_list_card.dart'; +import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart'; /// Section « dossiers à valider » (liste unifiée AM + familles). Ticket #107 / #153. @@ -15,6 +16,8 @@ class PendingValidationWidget extends StatefulWidget { final bool compactWhenEmpty; /// Numéros des dossiers pending (pour exclure de « Tous les dossiers »). final ValueChanged>? onPendingNumerosChanged; + /// Afficher la poubelle (#160) — mêmes règles que dossiers validés. + final bool canDelete; const PendingValidationWidget({ super.key, @@ -22,6 +25,7 @@ class PendingValidationWidget extends StatefulWidget { this.searchQuery = '', this.compactWhenEmpty = false, this.onPendingNumerosChanged, + this.canDelete = false, }); @override @@ -141,6 +145,97 @@ class _PendingValidationWidgetState extends State { ); } + Future _confirmDeletePending({ + required String numeroDossier, + required String namesLine, + required bool isFamille, + }) async { + final num = numeroDossier.trim(); + if (num.isEmpty) return; + + var people = []; + String? fallbackSummary; + try { + final dossier = await UserService.getDossier(num); + if (dossier.isFamily) { + final f = dossier.asFamily; + people = suppressionPeopleFromDossier( + isFamille: true, + parents: f.parents + .map((p) => ( + nom: p.nom ?? '', + prenom: p.prenom ?? '', + email: p.email, + )) + .toList(), + enfants: f.enfants + .map((e) => ( + nom: e.lastName ?? '', + prenom: e.firstName ?? '', + )) + .toList(), + ); + } else { + final am = dossier.asAm.user; + people = suppressionPeopleFromDossier( + isFamille: false, + parents: const [], + enfants: const [], + amName: formatDossierPersonLabel( + nom: am.nom, + prenom: am.prenom, + email: am.email, + ), + ); + } + } catch (_) { + fallbackSummary = isFamille + ? 'Tous les parents et enfants rattachés seront supprimés.' + : 'Le compte AM sera supprimé ; les enfants accueillis ' + 'seront conservés.'; + for (final part in namesLine.split(' - ')) { + final label = part.trim(); + if (label.isEmpty) continue; + people.add(SuppressionPersonLine( + label: label, + icon: isFamille + ? Icons.supervisor_account_outlined + : Icons.face, + role: isFamille ? 'Parent' : 'AM', + )); + } + } + + if (!mounted) return; + final confirmed = await showDossierSuppressionConfirmDialog( + context, + numeroDossier: num, + isFamille: isFamille, + people: people, + fallbackSummary: fallbackSummary, + ); + if (!confirmed || !mounted) return; + try { + final result = await UserService.deleteDossier(num); + if (!mounted) return; + final msg = (result['message'] ?? 'Dossier supprimé.').toString(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + await _load(); + widget.onRefresh?.call(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Erreur suppression', + ), + ), + ); + } + } + bool _matchesQuery(String haystack) { final q = widget.searchQuery.trim().toLowerCase(); if (q.isEmpty) return true; @@ -286,12 +381,21 @@ class _PendingValidationWidgetState extends State { } Widget _buildAMCard(AppUser user) { + final names = _amNamesLine(user); + final num = user.numeroDossier ?? ''; return DossierListCard( - numeroDossier: user.numeroDossier ?? '', - namesLine: _amNamesLine(user), + numeroDossier: num, + namesLine: names, isFamille: false, photoUrl: user.photoUrl, onOpen: () => _onOpenValidation(numeroDossier: user.numeroDossier), + onDelete: widget.canDelete + ? () => _confirmDeletePending( + numeroDossier: num, + namesLine: names, + isFamille: false, + ) + : null, ); } @@ -306,6 +410,13 @@ class _PendingValidationWidgetState extends State { namesLine: names, isFamille: true, onOpen: () => _onOpenValidation(numeroDossier: family.numeroDossier), + onDelete: widget.canDelete + ? () => _confirmDeletePending( + numeroDossier: num, + namesLine: names, + isFamille: true, + ) + : null, ); } } diff --git a/frontend/lib/widgets/admin/relais_management_panel.dart b/frontend/lib/widgets/admin/relais_management_panel.dart index 614ecee..f63b5fe 100644 --- a/frontend/lib/widgets/admin/relais_management_panel.dart +++ b/frontend/lib/widgets/admin/relais_management_panel.dart @@ -3,6 +3,7 @@ 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'; +import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; class RelaisManagementPanel extends StatefulWidget { const RelaisManagementPanel({super.key}); @@ -56,28 +57,16 @@ class _RelaisManagementPanelState extends State { try { if (result.action == _RelaisDialogAction.delete) { if (relais == null) return; - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Supprimer le relais'), - content: Text('Confirmer la suppression de "${relais.nom}" ?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(false), - child: const Text('Annuler'), - ), - FilledButton( - onPressed: () => Navigator.of(ctx).pop(true), - style: FilledButton.styleFrom( - backgroundColor: Colors.red.shade700, - ), - child: const Text('Supprimer'), - ), - ], - ), + final confirmed = await showSuppressionConfirmDialog( + context, + title: 'Supprimer le relais', + people: [SuppressionPersonLine.relais(relais.nom)], + footnotes: const [ + 'Cette action est irréversible.', + ], ); - if (confirmed != true) return; + if (!confirmed) return; await RelaisService.deleteRelais(relais.id); } else if (relais == null) { await RelaisService.createRelais(result.payload!); From 97afbbcf9a174da2cc16f91e9a1fa202b8de9a53 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 10 Sep 2026 22:16:06 +0200 Subject: [PATCH 77/85] fix(am): hauteur modale fiche AM par onglet (sans scroll inutile). Co-authored-by: Cursor --- .../admin/common/admin_am_edit_modal.dart | 75 ++++++++----------- 1 file changed, 31 insertions(+), 44 deletions(-) diff --git a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart index 7059c2f..5855e69 100644 --- a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart @@ -69,29 +69,6 @@ class _AdminAmEditModalState extends State static const double _proTabHeight = 300; static const List _photoProRowLayout = [2, 2, 2]; - /// Hauteur onglet enfants : champs + titre + grille 2×2 (+ alerte places si besoin). - double _childrenTabHeight() { - const capacityFields = 72.0; - const titleSection = 40.0; - const inconsistencyExtra = 46.0; - var h = capacityFields + - titleSection + - AdminAmChildrenCapacityGrid.fixedHeight; - if (_placesInconsistent()) h += inconsistencyExtra; - return h + 4; - } - - double _tabViewHeight(int index) { - switch (index) { - case 1: - return _proTabHeight; - case 2: - return _childrenTabHeight(); - default: - return 292; - } - } - @override void initState() { super.initState(); @@ -541,17 +518,15 @@ class _AdminAmEditModalState extends State Widget _identityTab() { - return SingleChildScrollView( - child: IdentityBlock.editable( - title: 'Identité et coordonnées', - nomController: _nomCtrl, - prenomController: _prenomCtrl, - telephoneController: _telCtrl, - emailController: _emailCtrl, - adresseController: _adresseCtrl, - codePostalController: _cpCtrl, - villeController: _villeCtrl, - ), + return IdentityBlock.editable( + title: 'Identité et coordonnées', + nomController: _nomCtrl, + prenomController: _prenomCtrl, + telephoneController: _telCtrl, + emailController: _emailCtrl, + adresseController: _adresseCtrl, + codePostalController: _cpCtrl, + villeController: _villeCtrl, ); } @@ -610,7 +585,9 @@ class _AdminAmEditModalState extends State return LayoutBuilder( builder: (context, c) { final maxRowW = c.maxWidth; - final maxRowH = c.maxHeight; + // Hauteur bornée : l’onglet pro a une hauteur fixe (photo ID). + final maxRowH = + c.maxHeight.isFinite ? c.maxHeight : _proTabHeight; final bodyH = maxRowH; final idealPhotoW = bodyH * AdminAmPhotoFrame.idPhotoAspectRatio + 16; final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth) @@ -659,6 +636,18 @@ class _AdminAmEditModalState extends State ); } + /// Corps de l’onglet actif — hauteur naturelle (pas de TabBarView). + Widget _buildActiveTabBody() { + switch (_tabCtrl.index) { + case 1: + return SizedBox(height: _proTabHeight, child: _proTab()); + case 2: + return _childrenTab(); + default: + return _identityTab(); + } + } + Widget _childrenCapacityFields() { final inconsistent = _placesInconsistent(); return Column( @@ -867,15 +856,13 @@ class _AdminAmEditModalState extends State const Divider(height: 1), Padding( padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), - child: SizedBox( - height: _tabViewHeight(_tabCtrl.index), - child: TabBarView( - controller: _tabCtrl, - children: [ - _identityTab(), - _proTab(), - _childrenTab(), - ], + child: AnimatedSize( + duration: const Duration(milliseconds: 180), + curve: Curves.easeInOut, + alignment: Alignment.topCenter, + child: KeyedSubtree( + key: ValueKey(_tabCtrl.index), + child: _buildActiveTabBody(), ), ), ), From 67336c64fe43fbc787b92954b4fc2b8eaada3326 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 10 Sep 2026 22:38:57 +0200 Subject: [PATCH 78/85] fix(#160): un gestionnaire ne peut plus supprimer un autre depuis la fiche. Co-authored-by: Cursor --- .../creation/gestionnaires_create.dart | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart index 628bf89..38b67e2 100644 --- a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart +++ b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart @@ -8,6 +8,7 @@ import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/relais_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/utils/staff_deletion_rights.dart'; import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; class AdminUserFormDialog extends StatefulWidget { @@ -44,6 +45,7 @@ class _AdminUserFormDialogState extends State { List _relais = []; String? _selectedRelaisId; String? _currentUserId; + String? _currentUserRole; bool get _isEditMode => widget.initialUser != null; bool get _isSuperAdminTarget => (widget.initialUser?.role ?? '').toLowerCase() == 'super_admin'; @@ -51,7 +53,12 @@ class _AdminUserFormDialogState extends State { _isEditMode && _currentUserId != null && widget.initialUser!.id == _currentUserId; - bool get _canDeleteTarget => !_isSuperAdminTarget && !_isSelfTarget; + /// Gestionnaire : pas de delete staff. Admin+ seulement (#154 / #160). + bool get _canDeleteTarget { + if (!_isEditMode || widget.readOnly) return false; + if (_isSelfTarget || _isSuperAdminTarget) return false; + return canDeleteGestionnaire(_currentUserRole); + } bool get _isLockedAdminIdentity => _isEditMode && widget.adminMode && _isSuperAdminTarget; String get _targetRoleKey { @@ -126,6 +133,7 @@ class _AdminUserFormDialogState extends State { if (cached != null) { setState(() { _currentUserId = cached.id; + _currentUserRole = cached.role; }); return; } @@ -133,6 +141,7 @@ class _AdminUserFormDialogState extends State { if (!mounted || refreshed == null) return; setState(() { _currentUserId = refreshed.id; + _currentUserRole = refreshed.role; }); } From 9cd180bf6a9be6d41a671188979891d3e84156b0 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 10 Sep 2026 22:54:49 +0200 Subject: [PATCH 79/85] =?UTF-8?q?feat(#161):=20admin=20peut=20cr=C3=A9er?= =?UTF-8?q?=20gestionnaire=20et=20administrateur.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assouplit POST /gestionnaires, PATCH /gestionnaires/:id et POST /users/admin (+ check createAdmin) pour ADMINISTRATEUR. Masque « Ajouter » staff sur le dashboard gestionnaire. Tests droits Roles + createAdmin. Co-authored-by: Cursor --- .../gestionnaires.controller.spec.ts | 29 ++++--- .../gestionnaires/gestionnaires.controller.ts | 10 +-- .../src/routes/user/user.controller.spec.ts | 25 ++---- backend/src/routes/user/user.controller.ts | 6 +- backend/src/routes/user/user.service.spec.ts | 87 +++++++++++++++++-- backend/src/routes/user/user.service.ts | 10 ++- .../gestionnaire_dashboard_screen.dart | 5 +- frontend/lib/utils/staff_deletion_rights.dart | 6 ++ .../widgets/admin/user_management_panel.dart | 19 +++- 9 files changed, 146 insertions(+), 51 deletions(-) diff --git a/backend/src/routes/user/gestionnaires/gestionnaires.controller.spec.ts b/backend/src/routes/user/gestionnaires/gestionnaires.controller.spec.ts index 6dee426..f572a58 100644 --- a/backend/src/routes/user/gestionnaires/gestionnaires.controller.spec.ts +++ b/backend/src/routes/user/gestionnaires/gestionnaires.controller.spec.ts @@ -1,20 +1,21 @@ -import { Test, TestingModule } from '@nestjs/testing'; +import 'reflect-metadata'; import { GestionnairesController } from './gestionnaires.controller'; -import { GestionnairesService } from './gestionnaires.service'; +import { RoleType } from 'src/entities/users.entity'; -describe('GestionnairesController', () => { - let controller: GestionnairesController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [GestionnairesController], - providers: [GestionnairesService], - }).compile(); - - controller = module.get(GestionnairesController); +describe('GestionnairesController roles (#161)', () => { + it('POST /gestionnaires autorise SUPER_ADMIN et ADMINISTRATEUR', () => { + const roles = Reflect.getMetadata('roles', GestionnairesController.prototype.create); + expect(roles).toEqual( + expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]), + ); + expect(roles).not.toContain(RoleType.GESTIONNAIRE); }); - it('should be defined', () => { - expect(controller).toBeDefined(); + it('PATCH /gestionnaires/:id autorise SUPER_ADMIN et ADMINISTRATEUR', () => { + const roles = Reflect.getMetadata('roles', GestionnairesController.prototype.update); + expect(roles).toEqual( + expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]), + ); + expect(roles).not.toContain(RoleType.GESTIONNAIRE); }); }); diff --git a/backend/src/routes/user/gestionnaires/gestionnaires.controller.ts b/backend/src/routes/user/gestionnaires/gestionnaires.controller.ts index 8fd23c6..cc0fe62 100644 --- a/backend/src/routes/user/gestionnaires/gestionnaires.controller.ts +++ b/backend/src/routes/user/gestionnaires/gestionnaires.controller.ts @@ -25,10 +25,10 @@ import { AuthGuard } from 'src/common/guards/auth.guard'; export class GestionnairesController { constructor(private readonly gestionnairesService: GestionnairesService) { } - @Roles(RoleType.SUPER_ADMIN) + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) @ApiResponse({ status: 201, description: 'Le gestionnaire a été créé avec succès.', type: Users }) @ApiResponse({ status: 409, description: 'Conflit. L\'email est déjà utilisé.' }) - @ApiOperation({ summary: 'Création d\'un gestionnaire' }) + @ApiOperation({ summary: 'Création d\'un gestionnaire (admin / super admin)' }) @ApiBody({ type: CreateGestionnaireDto }) @Post() create(@Body() dto: CreateGestionnaireDto): Promise { @@ -43,7 +43,7 @@ export class GestionnairesController { return this.gestionnairesService.findAll(); } - @Roles(RoleType.GESTIONNAIRE, RoleType.SUPER_ADMIN) + @Roles(RoleType.GESTIONNAIRE, RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) @ApiOperation({ summary: 'Récupérer un gestionnaire par ID' }) @ApiResponse({ status: 400, description: 'ID invalide' }) @ApiResponse({ status: 403, description: 'Accès refusé' }) @@ -56,8 +56,8 @@ export class GestionnairesController { return this.gestionnairesService.findOne(id); } - @Roles(RoleType.SUPER_ADMIN) - @ApiOperation({ summary: 'Mettre à jour un gestionnaire' }) + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) + @ApiOperation({ summary: 'Mettre à jour un gestionnaire (admin / super admin)' }) @ApiResponse({ status: 200, description: 'Le gestionnaire a été mis à jour avec succès.', type: Users }) @ApiResponse({ status: 404, description: 'Gestionnaire non trouvé' }) @ApiResponse({ status: 403, description: 'Accès refusé' }) diff --git a/backend/src/routes/user/user.controller.spec.ts b/backend/src/routes/user/user.controller.spec.ts index 1f38440..c630d2a 100644 --- a/backend/src/routes/user/user.controller.spec.ts +++ b/backend/src/routes/user/user.controller.spec.ts @@ -1,20 +1,13 @@ -import { Test, TestingModule } from '@nestjs/testing'; +import 'reflect-metadata'; import { UserController } from './user.controller'; -import { UserService } from './user.service'; +import { RoleType } from 'src/entities/users.entity'; -describe('UserController', () => { - let controller: UserController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [UserController], - providers: [UserService], - }).compile(); - - controller = module.get(UserController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); +describe('UserController roles (#161)', () => { + it('POST /users/admin autorise SUPER_ADMIN et ADMINISTRATEUR', () => { + const roles = Reflect.getMetadata('roles', UserController.prototype.createAdmin); + expect(roles).toEqual( + expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]), + ); + expect(roles).not.toContain(RoleType.GESTIONNAIRE); }); }); diff --git a/backend/src/routes/user/user.controller.ts b/backend/src/routes/user/user.controller.ts index c419ac2..72f7f70 100644 --- a/backend/src/routes/user/user.controller.ts +++ b/backend/src/routes/user/user.controller.ts @@ -22,10 +22,10 @@ export class UserController { private readonly suppressionService: SuppressionService, ) { } - // Création d'un administrateur (réservée aux super admins) + // Création d'un administrateur (admin + super admin) — #161 @Post('admin') - @Roles(RoleType.SUPER_ADMIN) - @ApiOperation({ summary: 'Créer un nouvel administrateur (super admin seulement)' }) + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) + @ApiOperation({ summary: 'Créer un nouvel administrateur (admin / super admin)' }) createAdmin( @Body() dto: CreateAdminDto, @User() currentUser: Users diff --git a/backend/src/routes/user/user.service.spec.ts b/backend/src/routes/user/user.service.spec.ts index 873de8a..81b4551 100644 --- a/backend/src/routes/user/user.service.spec.ts +++ b/backend/src/routes/user/user.service.spec.ts @@ -1,18 +1,87 @@ -import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { UserService } from './user.service'; +import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity'; + +describe('UserService.createAdmin (#161)', () => { + const usersRepository = { + findOneBy: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; -describe('UserService', () => { let service: UserService; - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [UserService], - }).compile(); + const dto = { + email: 'nouveau.admin@ptits-pas.fr', + password: 'Password1!', + prenom: 'Nina', + nom: 'Admin', + telephone: '0601020304', + }; - service = module.get(UserService); + beforeEach(() => { + jest.clearAllMocks(); + service = new UserService( + usersRepository as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + ); }); - it('should be defined', () => { - expect(service).toBeDefined(); + it('autorise un administrateur à créer un admin', async () => { + usersRepository.findOneBy.mockResolvedValue(null); + usersRepository.create.mockImplementation((data) => data); + usersRepository.save.mockImplementation(async (entity) => ({ + id: 'new-admin', + ...entity, + })); + + const result = await service.createAdmin(dto as never, { + id: 'admin-1', + role: RoleType.ADMINISTRATEUR, + } as never); + + expect(result.role).toBe(RoleType.ADMINISTRATEUR); + expect(result.statut).toBe(StatutUtilisateurType.ACTIF); + expect(usersRepository.save).toHaveBeenCalled(); + }); + + it('autorise un super_admin à créer un admin', async () => { + usersRepository.findOneBy.mockResolvedValue(null); + usersRepository.create.mockImplementation((data) => data); + usersRepository.save.mockImplementation(async (entity) => ({ + id: 'new-admin', + ...entity, + })); + + await expect( + service.createAdmin(dto as never, { + id: 'sa-1', + role: RoleType.SUPER_ADMIN, + } as never), + ).resolves.toMatchObject({ role: RoleType.ADMINISTRATEUR }); + }); + + it('refuse un gestionnaire (403 métier)', async () => { + await expect( + service.createAdmin(dto as never, { + id: 'gest-1', + role: RoleType.GESTIONNAIRE, + } as never), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(usersRepository.save).not.toHaveBeenCalled(); + }); + + it('refuse un email déjà utilisé', async () => { + usersRepository.findOneBy.mockResolvedValue({ id: 'exists' }); + await expect( + service.createAdmin(dto as never, { + id: 'admin-1', + role: RoleType.ADMINISTRATEUR, + } as never), + ).rejects.toBeInstanceOf(BadRequestException); }); }); diff --git a/backend/src/routes/user/user.service.ts b/backend/src/routes/user/user.service.ts index 50a0c52..c10d96a 100644 --- a/backend/src/routes/user/user.service.ts +++ b/backend/src/routes/user/user.service.ts @@ -117,8 +117,14 @@ export class UserService { } async createAdmin(dto: CreateAdminDto, currentUser: Users): Promise { - if (currentUser.role !== RoleType.SUPER_ADMIN) { - throw new ForbiddenException('Seuls les super administrateurs peuvent créer un administrateur'); + // #161 — admin et super_admin peuvent créer un administrateur + if ( + currentUser.role !== RoleType.SUPER_ADMIN && + currentUser.role !== RoleType.ADMINISTRATEUR + ) { + throw new ForbiddenException( + 'Seuls les administrateurs et super administrateurs peuvent créer un administrateur', + ); } const exist = await this.usersRepository.findOneBy({ email: dto.email }); diff --git a/frontend/lib/screens/gestionnaire/gestionnaire_dashboard_screen.dart b/frontend/lib/screens/gestionnaire/gestionnaire_dashboard_screen.dart index 921ad0f..2b2ccf5 100644 --- a/frontend/lib/screens/gestionnaire/gestionnaire_dashboard_screen.dart +++ b/frontend/lib/screens/gestionnaire/gestionnaire_dashboard_screen.dart @@ -62,7 +62,10 @@ class _GestionnaireDashboardScreenState extends State { bool get _isDossiersTab => _subIndex == 0; + bool get _isStaffAccountsTab => + _subIndex == 4 || (widget.showAdministrateursTab && _subIndex == 5); + + bool get _canShowAddButton { + if (_isDossiersTab) return false; + if (_isStaffAccountsTab && !widget.allowStaffAccountCreation) return false; + return true; + } + String _searchHintForTab() { switch (_subIndex) { case 0: @@ -293,7 +306,8 @@ class _UserManagementPanelState extends State { searchTooltip: _searchTooltipForTab(), filterControl: _subBarFilterControl(), // Pas de « Créer » sur l’onglet Dossiers (#153). - onAddPressed: _isDossiersTab ? null : _handleAddPressed, + // Pas de création staff pour le dashboard gestionnaire (#161). + onAddPressed: _canShowAddButton ? _handleAddPressed : null, addLabel: 'Ajouter', subTabCount: labels.length, tabLabels: labels, @@ -305,6 +319,9 @@ class _UserManagementPanelState extends State { Future _handleAddPressed() async { // 1 Parents, 2 Enfants, 3 AM, 4 Gestionnaires, 5 Admin + if (_isStaffAccountsTab && !widget.allowStaffAccountCreation) { + return; + } if (_subIndex == 1) { await showDialog( context: context, From 946d8edcd25d267f13cdd18662904a4e4ac09d20 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 10 Sep 2026 23:08:36 +0200 Subject: [PATCH 80/85] =?UTF-8?q?feat(#152):=20suppression=20compl=C3=A8te?= =?UTF-8?q?=20grossesse=20multiple=20/=20est=5Fmultiple.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retire le champ partout : BDD (migration + seed), entity/DTO/services Nest, modèles et payloads Flutter, scripts de test et docs. Back et front alignés (forbidNonWhitelisted). Aucun fantôme de compat. Co-authored-by: Cursor --- backend/prisma/schema.prisma | 1 - backend/src/entities/children.entity.ts | 3 - backend/src/routes/auth/auth.service.ts | 4 -- .../routes/auth/dto/enfant-inscription.dto.ts | 5 -- .../routes/enfants/dto/create_enfants.dto.ts | 5 -- .../enfants/dto/enfants_response.dto.ts | 3 - backend/src/routes/enfants/enfants.service.ts | 1 - .../dto/dossier-famille-complet.dto.ts | 3 - backend/src/routes/parents/parents.service.ts | 1 - database/BDD.sql | 3 +- database/bdd/data_test/enfants.csv | 20 +++--- .../2026_drop_enfants_est_multiple.sql | 2 + database/seed/02_seed.sql | 8 +-- database/seed/03_seed_test_data.sql | 14 ++-- docs/10_DATABASE.md | 1 - docs/99_REGLES-CODAGE.md | 3 - docs/tmp/152-mini-spec-front-est-multiple.md | 65 +++++++++++++++++++ frontend/lib/models/dossier_unifie.dart | 4 -- frontend/lib/models/enfant_admin_model.dart | 7 -- .../models/parent_user_registration_data.dart | 2 - .../lib/models/user_registration_data.dart | 4 -- .../auth/parent_register_step3_screen.dart | 1 - .../utils/parent_registration_payload.dart | 1 - frontend/lib/utils/reprise_mapper.dart | 1 - .../common/admin_child_detail_modal.dart | 4 -- .../widgets/admin/parent_dossier_wizard.dart | 2 - .../register-parent-durand-rousseau-test.mjs | 6 +- .../scripts/register-parent-lecomte-test.mjs | 3 +- tests/scripts/register-parent-martin-test.mjs | 9 +-- 29 files changed, 95 insertions(+), 91 deletions(-) create mode 100644 database/migrations/2026_drop_enfants_est_multiple.sql create mode 100644 docs/tmp/152-mini-spec-front-est-multiple.md diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index c462f65..82ba85c 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -33,7 +33,6 @@ model Child { dateOfBirth DateTime photoUrl String? photoConsent Boolean @default(false) - isMultiple Boolean @default(false) isUnborn Boolean @default(false) parentId String parent Parent @relation(fields: [parentId], references: [id]) diff --git a/backend/src/entities/children.entity.ts b/backend/src/entities/children.entity.ts index 3c1db67..074a383 100644 --- a/backend/src/entities/children.entity.ts +++ b/backend/src/entities/children.entity.ts @@ -63,9 +63,6 @@ export class Children { @Column({ type: 'timestamptz', nullable: true, name: 'date_consentement_photo' }) consent_photo_at?: Date; - @Column({ default: false, name: 'est_multiple', type: 'boolean' }) - is_multiple: boolean; - // Lien via table de jointure enfants_parents @OneToMany(() => ParentsChildren, pc => pc.child) parentLinks: ParentsChildren[]; diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index bfbd28e..2737fcb 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -564,7 +564,6 @@ export class AuthService { enfant.status = enfantDto.date_naissance ? StatutEnfantType.SANS_GARDE : StatutEnfantType.A_NAITRE; enfant.consent_photo = !!enfantDto.consent_photo; enfant.consent_photo_at = enfant.consent_photo ? new Date() : null!; - enfant.is_multiple = enfantDto.grossesse_multiple || false; const enfantEnregistre = await manager.save(Children, enfant); enfantsEnregistres.push(enfantEnregistre); @@ -1387,9 +1386,6 @@ export class AuthService { enfant.status = StatutEnfantType.A_NAITRE; } } - if (enfantDto.grossesse_multiple !== undefined) { - enfant.is_multiple = enfantDto.grossesse_multiple; - } if (enfantDto.consent_photo !== undefined) { enfant.consent_photo = !!enfantDto.consent_photo; enfant.consent_photo_at = enfant.consent_photo diff --git a/backend/src/routes/auth/dto/enfant-inscription.dto.ts b/backend/src/routes/auth/dto/enfant-inscription.dto.ts index dce0f51..78243b4 100644 --- a/backend/src/routes/auth/dto/enfant-inscription.dto.ts +++ b/backend/src/routes/auth/dto/enfant-inscription.dto.ts @@ -55,11 +55,6 @@ export class EnfantInscriptionDto { @IsString() photo_filename?: string; - @ApiProperty({ example: false, required: false, description: 'Grossesse multiple (jumeaux, triplés, etc.)' }) - @IsOptional() - @IsBoolean() - grossesse_multiple?: boolean; - @ApiProperty({ example: true, required: false, diff --git a/backend/src/routes/enfants/dto/create_enfants.dto.ts b/backend/src/routes/enfants/dto/create_enfants.dto.ts index 0d66d46..b779966 100644 --- a/backend/src/routes/enfants/dto/create_enfants.dto.ts +++ b/backend/src/routes/enfants/dto/create_enfants.dto.ts @@ -74,11 +74,6 @@ export class CreateEnfantsDto { @IsDateString() consent_photo_at?: string; - @ApiProperty({ default: false }) - @Transform(toBoolean) - @IsBoolean() - is_multiple: boolean; - /** * Parent pivot du foyer — obligatoire pour staff (gestionnaire/admin). * Ignoré / interdit en externe pour un PARENT (ticket #132). diff --git a/backend/src/routes/enfants/dto/enfants_response.dto.ts b/backend/src/routes/enfants/dto/enfants_response.dto.ts index 41f0b1e..3e9a5e9 100644 --- a/backend/src/routes/enfants/dto/enfants_response.dto.ts +++ b/backend/src/routes/enfants/dto/enfants_response.dto.ts @@ -29,9 +29,6 @@ export class EnfantResponseDto { @ApiProperty({ example: false }) consent_photo: boolean; - @ApiProperty({ example: false }) - is_multiple: boolean; - @ApiProperty({ example: 'UUID-parent' }) parent_id: string; } diff --git a/backend/src/routes/enfants/enfants.service.ts b/backend/src/routes/enfants/enfants.service.ts index 43a4861..204602d 100644 --- a/backend/src/routes/enfants/enfants.service.ts +++ b/backend/src/routes/enfants/enfants.service.ts @@ -92,7 +92,6 @@ export class EnfantsService { photo_url: photoUrl, consent_photo: !!dto.consent_photo, consent_photo_at: consentAt, - is_multiple: !!dto.is_multiple, }); await this.childrenRepository.save(child); 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 64b565c..d051b0d 100644 --- a/backend/src/routes/parents/dto/dossier-famille-complet.dto.ts +++ b/backend/src/routes/parents/dto/dossier-famille-complet.dto.ts @@ -54,9 +54,6 @@ export class DossierFamilleEnfantDto { description: 'Consentement affichage photo (colonne consentement_photo)', }) consent_photo?: boolean; - - @ApiProperty({ required: false, description: 'Grossesse multiple (est_multiple)' }) - est_multiple?: 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 77dc3c6..8adf1ef 100644 --- a/backend/src/routes/parents/parents.service.ts +++ b/backend/src/routes/parents/parents.service.ts @@ -370,7 +370,6 @@ export class ParentsService { status: child.status, photo_url: child.photo_url ?? undefined, consent_photo: child.consent_photo, - est_multiple: child.is_multiple, }; } diff --git a/database/BDD.sql b/database/BDD.sql index 9cd9f5e..c4e63ab 100644 --- a/database/BDD.sql +++ b/database/BDD.sql @@ -174,8 +174,7 @@ CREATE TABLE enfants ( date_prevue_naissance DATE, photo_url TEXT, consentement_photo BOOLEAN DEFAULT false, - date_consentement_photo TIMESTAMPTZ, - est_multiple BOOLEAN DEFAULT false + date_consentement_photo TIMESTAMPTZ ); -- ========================================================== diff --git a/database/bdd/data_test/enfants.csv b/database/bdd/data_test/enfants.csv index 43a2275..db655e5 100644 --- a/database/bdd/data_test/enfants.csv +++ b/database/bdd/data_test/enfants.csv @@ -1,10 +1,10 @@ -"id","statut","prenom","nom","genre","date_naissance","date_prevue_naissance","photo_url","consentement_photo","date_consentement_photo","est_multiple" -"5e8574b7-63e6-4d48-9af3-8d3bf7a6a6cf","sans_garde","Emma","Dupont","F","2020-06-01",,,False,,False -"a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d","sans_garde",,,,"2020-01-01","2025-01-01",,False,,False -"e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c","sans_garde","Emma","Martin",,"2023-02-15",,,False,,False -"e2b3c4d5-e6f7-4a8b-9c1d-2e3f4a5b6c7d","sans_garde","Noah","Martin",,"2023-02-15",,,False,,False -"e3c4d5e6-f7a8-4b9c-1d2e-3f4a5b6c7d8e","sans_garde","Léa","Martin",,"2023-02-15",,,False,,False -"e4d5e6f7-a8b9-4c1d-2e3f-4a5b6c7d8e9f","sans_garde","Chloé","Rousseau",,"2022-04-20",,,False,,False -"e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a","sans_garde","Hugo","Rousseau",,"2024-03-10",,,False,,False -"e6f7a8b9-c1d2-4e3f-5a6b-7c8d9e0f1a2b","sans_garde","Maxime","Lecomte",,"2023-04-15",,,False,,False -"edd19cd1-bb67-4f14-8a37-c66b75c94537","scolarise","Lucas","Durand","H","2018-09-15",,,False,,False +"id","statut","prenom","nom","genre","date_naissance","date_prevue_naissance","photo_url","consentement_photo","date_consentement_photo" +"5e8574b7-63e6-4d48-9af3-8d3bf7a6a6cf","sans_garde","Emma","Dupont","F","2020-06-01",,,False, +"a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d","sans_garde",,,,"2020-01-01","2025-01-01",,False, +"e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c","sans_garde","Emma","Martin",,"2023-02-15",,,False, +"e2b3c4d5-e6f7-4a8b-9c1d-2e3f4a5b6c7d","sans_garde","Noah","Martin",,"2023-02-15",,,False, +"e3c4d5e6-f7a8-4b9c-1d2e-3f4a5b6c7d8e","sans_garde","Léa","Martin",,"2023-02-15",,,False, +"e4d5e6f7-a8b9-4c1d-2e3f-4a5b6c7d8e9f","sans_garde","Chloé","Rousseau",,"2022-04-20",,,False, +"e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a","sans_garde","Hugo","Rousseau",,"2024-03-10",,,False, +"e6f7a8b9-c1d2-4e3f-5a6b-7c8d9e0f1a2b","sans_garde","Maxime","Lecomte",,"2023-04-15",,,False, +"edd19cd1-bb67-4f14-8a37-c66b75c94537","scolarise","Lucas","Durand","H","2018-09-15",,,False, diff --git a/database/migrations/2026_drop_enfants_est_multiple.sql b/database/migrations/2026_drop_enfants_est_multiple.sql new file mode 100644 index 0000000..8375e2a --- /dev/null +++ b/database/migrations/2026_drop_enfants_est_multiple.sql @@ -0,0 +1,2 @@ +-- #152 — Suppression grossesse multiple / est_multiple +ALTER TABLE enfants DROP COLUMN IF EXISTS est_multiple; diff --git a/database/seed/02_seed.sql b/database/seed/02_seed.sql index bd95d05..7fd21c7 100644 --- a/database/seed/02_seed.sql +++ b/database/seed/02_seed.sql @@ -69,12 +69,12 @@ ON CONFLICT (id_utilisateur) DO NOTHING; -- - child B : à naître (statut = 'a_naitre' et date_prevue_naissance requise) -- ------------------------------------------------------------ -INSERT INTO enfants (id, prenom, nom, statut, date_naissance, jumeau_multiple) -VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Léo', 'Parent', 'sans_garde', '2022-04-12', false) +INSERT INTO enfants (id, prenom, nom, statut, date_naissance) +VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Léo', 'Parent', 'sans_garde', '2022-04-12') ON CONFLICT (id) DO NOTHING; -INSERT INTO enfants (id, prenom, nom, statut, date_prevue_naissance, jumeau_multiple) -VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Mila', 'Parent', 'a_naitre', '2026-02-15', false) +INSERT INTO enfants (id, prenom, nom, statut, date_prevue_naissance) +VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Mila', 'Parent', 'a_naitre', '2026-02-15') ON CONFLICT (id) DO NOTHING; -- ------------------------------------------------------------ diff --git a/database/seed/03_seed_test_data.sql b/database/seed/03_seed_test_data.sql index 8a54c7f..f53342e 100644 --- a/database/seed/03_seed_test_data.sql +++ b/database/seed/03_seed_test_data.sql @@ -49,14 +49,14 @@ VALUES ON CONFLICT (id_utilisateur) DO NOTHING; -- ========== ENFANTS ========== -INSERT INTO enfants (id, prenom, nom, genre, date_naissance, statut, est_multiple) +INSERT INTO enfants (id, prenom, nom, genre, date_naissance, statut) VALUES - ('e0000001-0001-0001-0001-000000000001', 'Emma', 'MARTIN', 'F', '2023-02-15', 'sans_garde', true), - ('e0000002-0002-0002-0002-000000000002', 'Noah', 'MARTIN', 'H', '2023-02-15', 'sans_garde', true), - ('e0000003-0003-0003-0003-000000000003', 'Léa', 'MARTIN', 'F', '2023-02-15', 'sans_garde', true), - ('e0000004-0004-0004-0004-000000000004', 'Chloé', 'ROUSSEAU', 'F', '2022-04-20', 'sans_garde', false), - ('e0000005-0005-0005-0005-000000000005', 'Hugo', 'ROUSSEAU', 'H', '2024-03-10', 'sans_garde', false), - ('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'sans_garde', false) + ('e0000001-0001-0001-0001-000000000001', 'Emma', 'MARTIN', 'F', '2023-02-15', 'sans_garde'), + ('e0000002-0002-0002-0002-000000000002', 'Noah', 'MARTIN', 'H', '2023-02-15', 'sans_garde'), + ('e0000003-0003-0003-0003-000000000003', 'Léa', 'MARTIN', 'F', '2023-02-15', 'sans_garde'), + ('e0000004-0004-0004-0004-000000000004', 'Chloé', 'ROUSSEAU', 'F', '2022-04-20', 'sans_garde'), + ('e0000005-0005-0005-0005-000000000005', 'Hugo', 'ROUSSEAU', 'H', '2024-03-10', 'sans_garde'), + ('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'sans_garde') ON CONFLICT (id) DO NOTHING; -- ========== ENFANTS_PARENTS (liaison N:N) ========== diff --git a/docs/10_DATABASE.md b/docs/10_DATABASE.md index 1a95975..4a142ed 100644 --- a/docs/10_DATABASE.md +++ b/docs/10_DATABASE.md @@ -117,7 +117,6 @@ Table des enfants pris en charge. | `photo_url` | TEXT | | URL de la photo | | `consentement_photo` | BOOLEAN | DEFAULT false | Consentement photo | | `date_consentement_photo` | TIMESTAMPTZ | | Date du consentement | -| `est_multiple` | BOOLEAN | DEFAULT false | Indique si grossesse multiple | --- diff --git a/docs/99_REGLES-CODAGE.md b/docs/99_REGLES-CODAGE.md index 04e10af..fe90fac 100644 --- a/docs/99_REGLES-CODAGE.md +++ b/docs/99_REGLES-CODAGE.md @@ -276,9 +276,6 @@ export class Enfants { @Column({ name: 'consentement_photo', type: 'boolean', default: false }) consentementPhoto: boolean; - @Column({ name: 'est_multiple', type: 'boolean', default: false }) - estMultiple: boolean; - @Column({ type: 'enum', enum: StatutEnfantType, diff --git a/docs/tmp/152-mini-spec-front-est-multiple.md b/docs/tmp/152-mini-spec-front-est-multiple.md new file mode 100644 index 0000000..0576312 --- /dev/null +++ b/docs/tmp/152-mini-spec-front-est-multiple.md @@ -0,0 +1,65 @@ +# Mini-spec — Suppression complète grossesse multiple / `est_multiple` + +**Ticket** : **#152** — https://git.ptits-pas.fr/jmartin/petitspas/issues/152 +**Branche** : `feature/152-remove-est-multiple` (depuis `develop`) +**Périmètre** : **full stack** — BDD + back + front + scripts + docs. **Aucun fantôme.** + +--- + +## Décision + +On **supprime tout**. Pas de DTO « ignorés », pas de compat payload. + +`forbidNonWhitelisted: true` ⇒ back et front **partent ensemble** (même feature / même déploiement). + +--- + +## Alias retirés + +`est_multiple` · `is_multiple` · `grossesse_multiple` · `multipleBirth` · `estMultiple` · `isMultiple` · `jumeau_multiple` + +--- + +## Back / BDD (fait sur la branche) + +- [x] Migration `database/migrations/2026_drop_enfants_est_multiple.sql` +- [x] `BDD.sql`, seeds, CSV test +- [x] Entity `Children` sans colonne +- [x] DTO create/inscription/réponse/dossier famille **sans** le champ +- [x] Services auth / enfants / parents : plus de mapping +- [x] Prisma legacy `isMultiple` retiré + +## Front (fait sur la branche) + +- [x] Modèles admin / dossier / inscription +- [x] Payloads inscription + reprise +- [x] Modale enfant + wizard dossier famille +- [x] Step3 inscription parent + +## Scripts / docs + +- [x] `tests/scripts/register-parent-*.mjs` +- [x] `docs/10_DATABASE.md`, `docs/99_REGLES-CODAGE.md` +- Docs tmp/archive #112 : mentions historiques OK (archive) + +--- + +## Déploiement + +1. Appliquer la migration SQL sur la BDD vivante +2. Deploy back **et** front de cette branche +3. Smoke : création enfant staff, inscription parent, reprise, wizard famille + +## Vérif + +```bash +rg -n 'est_multiple|is_multiple|grossesse_multiple|multipleBirth|estMultiple|isMultiple|jumeau_multiple' \ + backend/src frontend/lib database tests/scripts docs/10_DATABASE.md docs/99_REGLES-CODAGE.md +``` +→ **0** hit (hors ce fichier mini-spec / archives). + +## Hors scope + +- Métier futur « fratrie / jumeaux » → nouveau ticket +- #155 rename Admin* +- Ticket modales staff diff --git a/frontend/lib/models/dossier_unifie.dart b/frontend/lib/models/dossier_unifie.dart index 0e364fd..d5b4d7b 100644 --- a/frontend/lib/models/dossier_unifie.dart +++ b/frontend/lib/models/dossier_unifie.dart @@ -185,7 +185,6 @@ class EnfantDossier { final String? dueDate; final String? photoUrl; final bool consentPhoto; - final bool estMultiple; EnfantDossier({ required this.id, @@ -197,7 +196,6 @@ class EnfantDossier { this.dueDate, this.photoUrl, this.consentPhoto = false, - this.estMultiple = false, }); String get fullName => '${firstName ?? ''} ${lastName ?? ''}'.trim(); @@ -231,8 +229,6 @@ class EnfantDossier { photoUrl: resolvedPhoto, consentPhoto: json['consent_photo'] == true || json['consentPhoto'] == true, - estMultiple: - json['est_multiple'] == true || json['estMultiple'] == true, ); } } diff --git a/frontend/lib/models/enfant_admin_model.dart b/frontend/lib/models/enfant_admin_model.dart index e835d26..0248534 100644 --- a/frontend/lib/models/enfant_admin_model.dart +++ b/frontend/lib/models/enfant_admin_model.dart @@ -12,7 +12,6 @@ class EnfantAdminModel { final String status; final String? photoUrl; final bool consentPhoto; - final bool isMultiple; final List parentLinks; /// Flag API #157 (sinon déduit de [parentLinks]). final bool? sansResponsable; @@ -27,7 +26,6 @@ class EnfantAdminModel { required this.status, this.photoUrl, this.consentPhoto = false, - this.isMultiple = false, this.parentLinks = const [], this.sansResponsable, }); @@ -56,7 +54,6 @@ class EnfantAdminModel { String? status, String? photoUrl, bool? consentPhoto, - bool? isMultiple, List? parentLinks, bool? sansResponsable, }) { @@ -70,7 +67,6 @@ class EnfantAdminModel { status: status ?? this.status, photoUrl: photoUrl ?? this.photoUrl, consentPhoto: consentPhoto ?? this.consentPhoto, - isMultiple: isMultiple ?? this.isMultiple, parentLinks: parentLinks ?? this.parentLinks, sansResponsable: sansResponsable ?? this.sansResponsable, ); @@ -111,8 +107,6 @@ class EnfantAdminModel { ), photoUrl: photoUrl, consentPhoto: consentPhoto, - isMultiple: _parseBool(json['is_multiple']) || - _parseBool(json['est_multiple']), parentLinks: links, sansResponsable: sansResponsable, ); @@ -127,7 +121,6 @@ class EnfantAdminModel { if (birthDate != null && birthDate!.isNotEmpty) 'birth_date': birthDate, if (dueDate != null && dueDate!.isNotEmpty) 'due_date': dueDate, 'consent_photo': consentPhoto, - 'is_multiple': isMultiple, }; } diff --git a/frontend/lib/models/parent_user_registration_data.dart b/frontend/lib/models/parent_user_registration_data.dart index d2c6954..645c5f6 100644 --- a/frontend/lib/models/parent_user_registration_data.dart +++ b/frontend/lib/models/parent_user_registration_data.dart @@ -30,7 +30,6 @@ class ChildData { String lastName; String dob; // Date de naissance ou prévisionnelle bool photoConsent; - bool multipleBirth; bool isUnbornChild; File? imageFile; CardColorVertical cardColor; // Nouveau champ pour la couleur de la carte @@ -40,7 +39,6 @@ class ChildData { this.lastName = '', this.dob = '', this.photoConsent = false, - this.multipleBirth = false, this.isUnbornChild = false, this.imageFile, required this.cardColor, // Rendre requis dans le constructeur diff --git a/frontend/lib/models/user_registration_data.dart b/frontend/lib/models/user_registration_data.dart index bedd247..d7b296c 100644 --- a/frontend/lib/models/user_registration_data.dart +++ b/frontend/lib/models/user_registration_data.dart @@ -38,7 +38,6 @@ class ChildData { /// 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). @@ -55,7 +54,6 @@ class ChildData { this.dob = '', this.genre = '', this.photoConsent = false, - this.multipleBirth = false, this.isUnbornChild = false, this.imageFile, this.imageBytes, @@ -70,7 +68,6 @@ class ChildData { String? dob, String? genre, bool? photoConsent, - bool? multipleBirth, bool? isUnbornChild, Object? imageFile = _unsetImage, Object? imageBytes = _unsetImageBytes, @@ -84,7 +81,6 @@ class ChildData { 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: diff --git a/frontend/lib/screens/auth/parent_register_step3_screen.dart b/frontend/lib/screens/auth/parent_register_step3_screen.dart index ec5ed5d..7e99b0d 100644 --- a/frontend/lib/screens/auth/parent_register_step3_screen.dart +++ b/frontend/lib/screens/auth/parent_register_step3_screen.dart @@ -121,7 +121,6 @@ class _ParentRegisterStep3ScreenState extends State { dob: '', isUnbornChild: false, photoConsent: false, - multipleBirth: false, cardColor: cardColor, ); registrationData.addChild(newChild); diff --git a/frontend/lib/utils/parent_registration_payload.dart b/frontend/lib/utils/parent_registration_payload.dart index 467cd35..f82d5df 100644 --- a/frontend/lib/utils/parent_registration_payload.dart +++ b/frontend/lib/utils/parent_registration_payload.dart @@ -166,7 +166,6 @@ class ParentRegistrationPayload { static Map _childToJson(ChildData c, int index, String parentNom) { final map = { 'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre', - 'grossesse_multiple': c.multipleBirth, 'consent_photo': c.photoConsent, }; diff --git a/frontend/lib/utils/reprise_mapper.dart b/frontend/lib/utils/reprise_mapper.dart index f33b53d..7a9730e 100644 --- a/frontend/lib/utils/reprise_mapper.dart +++ b/frontend/lib/utils/reprise_mapper.dart @@ -44,7 +44,6 @@ class RepriseMapper { dob: dob, genre: e.gender ?? '', photoConsent: e.consentPhoto, - multipleBirth: e.estMultiple, isUnbornChild: isUnborn, cardColor: _childCardColors[index % _childCardColors.length], repriseChildId: e.id, diff --git a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart index 28f3374..30aec24 100644 --- a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -55,7 +55,6 @@ class _AdminChildDetailModalState extends State { late String _status; late String? _gender; late bool _consentPhoto; - late bool _isMultiple; bool _dirty = false; bool _saving = false; bool _deleting = false; @@ -142,7 +141,6 @@ class _AdminChildDetailModalState extends State { _gender = _normalizeGender(e?.gender, allowUnknown: _isUnborn); } _consentPhoto = e?.consentPhoto ?? false; - _isMultiple = e?.isMultiple ?? false; for (final c in [_prenomCtrl, _nomCtrl]) { c.addListener(_onNameChanged); } @@ -383,7 +381,6 @@ class _AdminChildDetailModalState extends State { else if (_dateToIso(_birthCtrl.text) != null) 'birth_date': _dateToIso(_birthCtrl.text), 'consent_photo': _consentPhoto, - 'is_multiple': _isMultiple, }, ); if (!mounted) return; @@ -460,7 +457,6 @@ class _AdminChildDetailModalState extends State { 'birth_date': _dateToIso(_birthCtrl.text), }, 'consent_photo': _consentPhoto, - 'is_multiple': _isMultiple, }, ); if (!mounted) return; diff --git a/frontend/lib/widgets/admin/parent_dossier_wizard.dart b/frontend/lib/widgets/admin/parent_dossier_wizard.dart index b9c7bf1..7ff061a 100644 --- a/frontend/lib/widgets/admin/parent_dossier_wizard.dart +++ b/frontend/lib/widgets/admin/parent_dossier_wizard.dart @@ -1466,7 +1466,6 @@ class _ParentDossierWizardState extends State { final map = { 'genre': c.genre, 'consent_photo': true, - 'grossesse_multiple': false, }; if (prenom.length >= 2) { @@ -1723,7 +1722,6 @@ class _ParentDossierWizardState extends State { 'status': status, 'gender': gender, 'consent_photo': true, - 'is_multiple': false, }; if (prenom.length >= 2) map['first_name'] = prenom; if (nom.length >= 2) map['last_name'] = nom; diff --git a/tests/scripts/register-parent-durand-rousseau-test.mjs b/tests/scripts/register-parent-durand-rousseau-test.mjs index 8dcfcbb..e8862eb 100644 --- a/tests/scripts/register-parent-durand-rousseau-test.mjs +++ b/tests/scripts/register-parent-durand-rousseau-test.mjs @@ -74,8 +74,7 @@ try { date_naissance: '2022-04-20', genre: 'F', photo_base64: toDataUri(chloeJpg), - photo_filename: 'chloe_rousseau.jpg', - grossesse_multiple: false, + photo_filename: 'chloe_rousseau.jpg' }, { prenom: 'Hugo', @@ -83,8 +82,7 @@ try { date_naissance: '2024-03-10', genre: 'H', photo_base64: toDataUri(hugoJpg), - photo_filename: 'hugo_rousseau.jpg', - grossesse_multiple: false, + photo_filename: 'hugo_rousseau.jpg' }, ], presentation_dossier: presentationDossier, diff --git a/tests/scripts/register-parent-lecomte-test.mjs b/tests/scripts/register-parent-lecomte-test.mjs index a03a7ab..fa7817b 100644 --- a/tests/scripts/register-parent-lecomte-test.mjs +++ b/tests/scripts/register-parent-lecomte-test.mjs @@ -40,8 +40,7 @@ const body = { date_naissance: '2023-04-15', genre: 'H', photo_base64: toDataUri(path.join(photosDir, 'lecomte-maxime.png')), - photo_filename: 'maxime_lecomte.png', - grossesse_multiple: false, + photo_filename: 'maxime_lecomte.png' }, ], presentation_dossier: presentationDossier, diff --git a/tests/scripts/register-parent-martin-test.mjs b/tests/scripts/register-parent-martin-test.mjs index 0232b38..46c5091 100644 --- a/tests/scripts/register-parent-martin-test.mjs +++ b/tests/scripts/register-parent-martin-test.mjs @@ -46,8 +46,7 @@ const body = { date_naissance: '2023-02-15', genre: 'F', photo_base64: toDataUri(path.join(photosDir, 'martin-emma.png')), - photo_filename: 'emma_martin.png', - grossesse_multiple: true, + photo_filename: 'emma_martin.png' }, { prenom: 'Noah', @@ -55,8 +54,7 @@ const body = { date_naissance: '2023-02-15', genre: 'H', photo_base64: toDataUri(path.join(photosDir, 'martin-noah.png')), - photo_filename: 'noah_martin.png', - grossesse_multiple: true, + photo_filename: 'noah_martin.png' }, { prenom: 'Léa', @@ -64,8 +62,7 @@ const body = { date_naissance: '2023-02-15', genre: 'F', photo_base64: toDataUri(path.join(photosDir, 'martin-lea.png')), - photo_filename: 'lea_martin.png', - grossesse_multiple: true, + photo_filename: 'lea_martin.png' }, ], presentation_dossier: presentationDossier, From a312d9c0fa32a7ad3d518f1cc528a860c6377871 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 10 Sep 2026 23:13:53 +0200 Subject: [PATCH 81/85] =?UTF-8?q?refactor(#155):=20option=20C=20=E2=80=94?= =?UTF-8?q?=20widgets=20partag=C3=A9s=20sans=20pr=C3=A9fixe=20Admin.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Déplace les composants Admin* du dashboard partagé vers widgets/dashboard/ (ChildDetailModal, AmEditModal, UserCard, Select*, …). Conserve AdminManagementWidget et screens/administrateurs. Rename mécanique, aucun changement de comportement. Co-authored-by: Cursor --- docs/tmp/155-mini-spec-rename-admin-prefix.md | 52 +++++++++++++++++++ .../admin_dashboardScreen.dart | 2 +- .../admin/admin_management_widget.dart | 4 +- .../lib/widgets/admin/am_dossier_wizard.dart | 28 +++++----- ...sistante_maternelle_management_widget.dart | 8 +-- .../lib/widgets/admin/common/user_list.dart | 4 +- .../common/validation_detail_section.dart | 4 +- .../lib/widgets/admin/dossier_list_card.dart | 4 +- .../admin/enfant_management_widget.dart | 8 +-- .../admin/gestionnaire_management_widget.dart | 4 +- .../widgets/admin/parent_dossier_wizard.dart | 4 +- .../admin/parent_managmant_widget.dart | 8 +-- .../widgets/admin/user_management_panel.dart | 6 +-- .../am_children_capacity_grid.dart} | 4 +- .../am_edit_modal.dart} | 30 +++++------ .../am_photo_frame.dart} | 4 +- .../child_detail_modal.dart} | 34 ++++++------ .../children_affiliation_panel.dart} | 8 +-- .../detail_modal.dart} | 10 ++-- .../enfant_user_card.dart} | 16 +++--- .../parent_edit_modal.dart} | 26 +++++----- .../select_am_modal.dart} | 20 +++---- .../select_enfant_modal.dart} | 16 +++--- .../select_famille_modal.dart} | 24 ++++----- .../select_list_modal.dart} | 20 +++---- .../status_capsule.dart} | 4 +- .../user_card.dart} | 8 +-- .../user_list_state.dart} | 4 +- .../user_management_sub_bar.dart} | 0 29 files changed, 208 insertions(+), 156 deletions(-) create mode 100644 docs/tmp/155-mini-spec-rename-admin-prefix.md rename frontend/lib/widgets/{admin/common/admin_am_children_capacity_grid.dart => dashboard/am_children_capacity_grid.dart} (99%) rename frontend/lib/widgets/{admin/common/admin_am_edit_modal.dart => dashboard/am_edit_modal.dart} (96%) rename frontend/lib/widgets/{admin/common/admin_am_photo_frame.dart => dashboard/am_photo_frame.dart} (98%) rename frontend/lib/widgets/{admin/common/admin_child_detail_modal.dart => dashboard/child_detail_modal.dart} (98%) rename frontend/lib/widgets/{admin/common/admin_children_affiliation_panel.dart => dashboard/children_affiliation_panel.dart} (91%) rename frontend/lib/widgets/{admin/common/admin_detail_modal.dart => dashboard/detail_modal.dart} (96%) rename frontend/lib/widgets/{admin/common/admin_enfant_user_card.dart => dashboard/enfant_user_card.dart} (91%) rename frontend/lib/widgets/{admin/common/admin_parent_edit_modal.dart => dashboard/parent_edit_modal.dart} (94%) rename frontend/lib/widgets/{admin/common/admin_select_am_modal.dart => dashboard/select_am_modal.dart} (92%) rename frontend/lib/widgets/{admin/common/admin_select_enfant_modal.dart => dashboard/select_enfant_modal.dart} (83%) rename frontend/lib/widgets/{admin/common/admin_select_famille_modal.dart => dashboard/select_famille_modal.dart} (88%) rename frontend/lib/widgets/{admin/common/admin_select_list_modal.dart => dashboard/select_list_modal.dart} (95%) rename frontend/lib/widgets/{admin/common/admin_status_capsule.dart => dashboard/status_capsule.dart} (95%) rename frontend/lib/widgets/{admin/common/admin_user_card.dart => dashboard/user_card.dart} (97%) rename frontend/lib/widgets/{admin/common/admin_list_state.dart => dashboard/user_list_state.dart} (92%) rename frontend/lib/widgets/{admin/dashboard_admin.dart => dashboard/user_management_sub_bar.dart} (100%) diff --git a/docs/tmp/155-mini-spec-rename-admin-prefix.md b/docs/tmp/155-mini-spec-rename-admin-prefix.md new file mode 100644 index 0000000..dc8344b --- /dev/null +++ b/docs/tmp/155-mini-spec-rename-admin-prefix.md @@ -0,0 +1,52 @@ +# Mini-spec — Rename préfixe `Admin*` dashboard partagé (#155) + +**Ticket** : **#155** +**Branche** : `feature/155-rename-admin-prefix-dashboard` (depuis `develop`) +**Décision naming** : **option C** — dossier neutre `widgets/dashboard/` + noms **sans** préfixe `Admin`. + +--- + +## Principe + +Les widgets partagés **admin + gestionnaire** ne doivent plus s’appeler `Admin*`. +On garde `Admin*` seulement là où c’est vraiment le rôle administrateur. + +## Gardé `Admin*` (hors rename) + +| Élément | Raison | +|---------|--------| +| `AdminManagementWidget` | Onglet **Administrateurs** | +| `screens/administrateurs/*` | `AdminDashboardScreen`, `AdminCreateDialog`, `AdminUserFormDialog` | +| `EnfantAdminModel` | Modèle API (pas un widget) — hors scope ticket | + +## Renames faits + +| Avant | Après | +|-------|--------| +| `widgets/admin/common/admin_child_detail_modal.dart` → `AdminChildDetailModal` | `widgets/dashboard/child_detail_modal.dart` → `ChildDetailModal` | +| `admin_am_edit_modal` → `AdminAmEditModal` | `am_edit_modal` → `AmEditModal` | +| `admin_parent_edit_modal` → `AdminParentEditModal` | `parent_edit_modal` → `ParentEditModal` | +| `admin_user_card` → `AdminUserCard` | `user_card` → `UserCard` | +| `admin_enfant_user_card` → `AdminEnfantUserCard` | `enfant_user_card` → `EnfantUserCard` | +| `admin_am_photo_frame` → `AdminAmPhotoFrame` | `am_photo_frame` → `AmPhotoFrame` | +| `admin_am_children_capacity_grid` | `am_children_capacity_grid` → `AmChildrenCapacityGrid` | +| `admin_children_affiliation_panel` | `children_affiliation_panel` → `ChildrenAffiliationPanel` | +| `admin_select_*` / `AdminSelect*` / `AdminFamilleFoyer` | `select_*` / `Select*` / `FamilleFoyer` | +| `admin_status_capsule` | `status_capsule` → `StatusCapsule` | +| `admin_list_state` → `AdminListState` | `user_list_state` → `UserListState` | +| `admin_detail_modal` → `AdminDetailModal` / `AdminDetailField` | `detail_modal` → `DetailModal` / `DetailField` | +| `dashboard_admin.dart` | `user_management_sub_bar.dart` (`DashboardUserManagementSubBar` inchangé) | + +Dossier `widgets/admin/` conserve les panels métier (`user_management_panel`, wizards, etc.) + `AdminManagementWidget`. + +## Hors scope + +- Refonte UX modales (ticket dédié) +- Rename API / back +- Déplacer tout `widgets/admin/` → `widgets/dashboard/` (panels) — possible follow-up + +## Critères + +- [x] Plus de préfixe `Admin` sur les composants **partagés** listés +- [ ] Build Flutter / recette dashboard admin + gestionnaire OK +- [x] Pas de changement comportemental (rename mécanique) diff --git a/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart b/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart index 20a9678..43c6f97 100644 --- a/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart +++ b/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/configuration_service.dart'; -import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart'; +import 'package:p_tits_pas/widgets/dashboard/user_management_sub_bar.dart'; import 'package:p_tits_pas/widgets/admin/parametres_panel.dart'; import 'package:p_tits_pas/widgets/admin/user_management_panel.dart'; import 'package:p_tits_pas/widgets/app_footer.dart'; diff --git a/frontend/lib/widgets/admin/admin_management_widget.dart b/frontend/lib/widgets/admin/admin_management_widget.dart index 08d40a3..3538efc 100644 --- a/frontend/lib/widgets/admin/admin_management_widget.dart +++ b/frontend/lib/widgets/admin/admin_management_widget.dart @@ -5,7 +5,7 @@ import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/utils/staff_deletion_rights.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; +import 'package:p_tits_pas/widgets/dashboard/user_card.dart'; import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; @@ -183,7 +183,7 @@ class _AdminManagementWidgetState extends State { final isSuperAdmin = _isSuperAdmin(user); final canEdit = _canEditAdmin(user); final canDelete = _canDeleteAdmin(user); - return AdminUserCard( + return UserCard( title: user.fullName, fallbackIcon: isSuperAdmin ? Icons.verified_user_outlined diff --git a/frontend/lib/widgets/admin/am_dossier_wizard.dart b/frontend/lib/widgets/admin/am_dossier_wizard.dart index dd6b0cb..e8f8b19 100644 --- a/frontend/lib/widgets/admin/am_dossier_wizard.dart +++ b/frontend/lib/widgets/admin/am_dossier_wizard.dart @@ -14,8 +14,8 @@ import 'package:p_tits_pas/utils/name_format_utils.dart'; import 'package:p_tits_pas/utils/nir_utils.dart'; import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/utils/postal_utils.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart'; +import 'package:p_tits_pas/widgets/dashboard/am_photo_frame.dart'; +import 'package:p_tits_pas/widgets/dashboard/detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; import 'package:p_tits_pas/widgets/admin/validation_refus_form.dart'; @@ -221,32 +221,32 @@ class _AmDossierWizardState extends State { void _emitStep() => widget.onStepChanged?.call(_step, _stepCount); - List _photoProFields(DossierAM d) { + List _photoProFields(DossierAM d) { final u = d.user; return [ - AdminDetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)), - AdminDetailField( + DetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)), + DetailField( label: 'Date de naissance', value: formatIsoDateFr(u.dateNaissance), ), - AdminDetailField( + DetailField( label: 'Ville de naissance', value: _v(u.lieuNaissanceVille), ), - AdminDetailField( + DetailField( label: 'Pays de naissance', value: _v(u.lieuNaissancePays), ), - AdminDetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)), - AdminDetailField( + DetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)), + DetailField( label: 'Date d’agrément', value: formatIsoDateFr(d.dateAgrement), ), - AdminDetailField( + DetailField( label: 'Capacité max (enfants)', value: d.nbMaxEnfants != null ? d.nbMaxEnfants.toString() : '–', ), - AdminDetailField( + DetailField( label: 'Places disponibles', value: d.placesDisponibles != null ? d.placesDisponibles.toString() @@ -741,14 +741,14 @@ class _AmDossierWizardState extends State { Widget _buildStep1() { // Modale calée sur les TF ; photo étirée sur toute la hauteur utile - // (largeur = [AdminAmPhotoFrame.columnWidthForHeight], cadre inclus). + // (largeur = [AmPhotoFrame.columnWidthForHeight], cadre inclus). return LayoutBuilder( builder: (context, c) { final maxRowW = c.maxWidth; final maxRowH = c.maxHeight.clamp(0.0, double.infinity); final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth) .clamp(0.0, double.infinity); - var photoW = AdminAmPhotoFrame.columnWidthForHeight(maxRowH) + var photoW = AmPhotoFrame.columnWidthForHeight(maxRowH) .clamp(_photoColumnMinWidth, 360.0); if (photoW > maxPhotoW) photoW = maxPhotoW; @@ -762,7 +762,7 @@ class _AmDossierWizardState extends State { final Widget photo; if (_isCreate) { - photo = AdminAmPhotoFrame( + photo = AmPhotoFrame( imageBytes: _photoBytes, onTap: _pickPhoto, onClear: _photoBytes != null ? _clearPhoto : null, diff --git a/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart b/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart index b48212b..8da3c30 100644 --- a/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart +++ b/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart @@ -5,8 +5,8 @@ import 'package:p_tits_pas/utils/am_vigilance.dart'; import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/utils/staff_deletion_rights.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; +import 'package:p_tits_pas/widgets/dashboard/am_edit_modal.dart'; +import 'package:p_tits_pas/widgets/dashboard/user_card.dart'; import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; @@ -130,7 +130,7 @@ class _AssistanteMaternelleManagementWidgetState itemBuilder: (context, index) { final assistante = filteredAssistantes[index]; final vigilance = amPlacesVigilanceMessage(assistante); - return AdminUserCard( + return UserCard( title: assistante.user.fullName, avatarUrl: assistante.user.photoUrl, fallbackIcon: Icons.face, @@ -161,7 +161,7 @@ class _AssistanteMaternelleManagementWidgetState void _openAssistanteDetails(AssistanteMaternelleModel assistante) { showDialog( context: context, - builder: (context) => AdminAmEditModal( + builder: (context) => AmEditModal( assistante: assistante, onSaved: _loadAssistantes, ), diff --git a/frontend/lib/widgets/admin/common/user_list.dart b/frontend/lib/widgets/admin/common/user_list.dart index 1df400c..3b439d5 100644 --- a/frontend/lib/widgets/admin/common/user_list.dart +++ b/frontend/lib/widgets/admin/common/user_list.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_list_state.dart'; +import 'package:p_tits_pas/widgets/dashboard/user_list_state.dart'; class UserList extends StatelessWidget { final bool isLoading; @@ -28,7 +28,7 @@ class UserList extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - AdminListState( + UserListState( isLoading: isLoading, error: error, isEmpty: isEmpty, diff --git a/frontend/lib/widgets/admin/common/validation_detail_section.dart b/frontend/lib/widgets/admin/common/validation_detail_section.dart index 80f5a42..5683859 100644 --- a/frontend/lib/widgets/admin/common/validation_detail_section.dart +++ b/frontend/lib/widgets/admin/common/validation_detail_section.dart @@ -4,7 +4,7 @@ import 'package:p_tits_pas/utils/email_utils.dart'; import 'package:p_tits_pas/utils/nir_utils.dart'; import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/utils/postal_utils.dart'; -import 'admin_detail_modal.dart'; +import 'package:p_tits_pas/widgets/dashboard/detail_modal.dart'; /// Réglages des formulaires validation / wizard AM — **jouer sur ces 3 leviers**. class ValidationFormMetrics { @@ -60,7 +60,7 @@ class ValidationFormMetrics { class ValidationDetailSection extends StatelessWidget { /// Si null ou vide, pas de bandeau titre (gain de place vertical, ex. wizard AM). final String? title; - final List fields; + 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; diff --git a/frontend/lib/widgets/admin/dossier_list_card.dart b/frontend/lib/widgets/admin/dossier_list_card.dart index 7608df2..ad66e88 100644 --- a/frontend/lib/widgets/admin/dossier_list_card.dart +++ b/frontend/lib/widgets/admin/dossier_list_card.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; +import 'package:p_tits_pas/widgets/dashboard/user_card.dart'; import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; /// Carte dossier unifiée (#153) — fond neutre, accent couleur sur l’icône. @@ -54,7 +54,7 @@ class DossierListCard extends StatelessWidget { if (!emptyKids && countLabel != null) countLabel, ]; - return AdminUserCard( + return UserCard( title: num, subtitleLines: subtitle, avatarUrl: !isFamille && avatar.isNotEmpty ? avatar : null, diff --git a/frontend/lib/widgets/admin/enfant_management_widget.dart b/frontend/lib/widgets/admin/enfant_management_widget.dart index 6731ff7..61f2ce9 100644 --- a/frontend/lib/widgets/admin/enfant_management_widget.dart +++ b/frontend/lib/widgets/admin/enfant_management_widget.dart @@ -5,8 +5,8 @@ import 'package:p_tits_pas/models/enfant_admin_model.dart'; import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/utils/staff_deletion_rights.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart'; +import 'package:p_tits_pas/widgets/dashboard/child_detail_modal.dart'; +import 'package:p_tits_pas/widgets/dashboard/enfant_user_card.dart'; import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; @@ -68,7 +68,7 @@ class _EnfantManagementWidgetState extends State { Future _openEnfant(EnfantAdminModel enfant) async { await showDialog( context: context, - builder: (ctx) => AdminChildDetailModal( + builder: (ctx) => ChildDetailModal( enfant: enfant, onSaved: _loadEnfants, onDeleted: _loadEnfants, @@ -224,7 +224,7 @@ class _EnfantManagementWidgetState extends State { itemCount: filtered.length, itemBuilder: (context, index) { final enfant = filtered[index]; - return AdminEnfantUserCard.fromEnfant( + return EnfantUserCard.fromEnfant( enfant, onCardTap: () => _openEnfant(enfant), actions: [ diff --git a/frontend/lib/widgets/admin/gestionnaire_management_widget.dart b/frontend/lib/widgets/admin/gestionnaire_management_widget.dart index f5cda69..468f43f 100644 --- a/frontend/lib/widgets/admin/gestionnaire_management_widget.dart +++ b/frontend/lib/widgets/admin/gestionnaire_management_widget.dart @@ -4,7 +4,7 @@ import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/utils/staff_deletion_rights.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; +import 'package:p_tits_pas/widgets/dashboard/user_card.dart'; import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; @@ -141,7 +141,7 @@ class _GestionnaireManagementWidgetState final user = filteredGestionnaires[index]; final isSelf = _currentUserId != null && _currentUserId == user.id; - return AdminUserCard( + return UserCard( title: user.fullName, fallbackIcon: Icons.assignment_ind_outlined, avatarUrl: user.photoUrl, diff --git a/frontend/lib/widgets/admin/parent_dossier_wizard.dart b/frontend/lib/widgets/admin/parent_dossier_wizard.dart index 7ff061a..e50ca2a 100644 --- a/frontend/lib/widgets/admin/parent_dossier_wizard.dart +++ b/frontend/lib/widgets/admin/parent_dossier_wizard.dart @@ -14,7 +14,7 @@ import 'package:p_tits_pas/utils/enfant_status_utils.dart'; import 'package:p_tits_pas/utils/name_format_utils.dart'; import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/utils/postal_utils.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart'; +import 'package:p_tits_pas/widgets/dashboard/am_photo_frame.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; import 'package:p_tits_pas/widgets/admin/validation_refus_form.dart'; @@ -860,7 +860,7 @@ class _ParentDossierWizardState extends State { child: SizedBox( width: pw, height: ph, - child: AdminAmPhotoFrame( + child: AmPhotoFrame( photoUrl: child.photoBytes == null ? child.existingPhotoUrl : null, diff --git a/frontend/lib/widgets/admin/parent_managmant_widget.dart b/frontend/lib/widgets/admin/parent_managmant_widget.dart index 69b2683..96d912a 100644 --- a/frontend/lib/widgets/admin/parent_managmant_widget.dart +++ b/frontend/lib/widgets/admin/parent_managmant_widget.dart @@ -4,8 +4,8 @@ import 'package:p_tits_pas/models/parent_model.dart'; import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/utils/staff_deletion_rights.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; +import 'package:p_tits_pas/widgets/dashboard/parent_edit_modal.dart'; +import 'package:p_tits_pas/widgets/dashboard/user_card.dart'; import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; @@ -146,7 +146,7 @@ class _ParentManagementWidgetState extends State { itemCount: filteredParents.length, itemBuilder: (context, index) { final parent = filteredParents[index]; - return AdminUserCard( + return UserCard( title: parent.user.fullName, fallbackIcon: Icons.supervisor_account_outlined, avatarUrl: parent.user.photoUrl, @@ -189,7 +189,7 @@ class _ParentManagementWidgetState extends State { void _openParentDetails(ParentModel parent) { showDialog( context: context, - builder: (context) => AdminParentEditModal( + builder: (context) => ParentEditModal( parent: parent, onSaved: _loadParents, ), diff --git a/frontend/lib/widgets/admin/user_management_panel.dart b/frontend/lib/widgets/admin/user_management_panel.dart index fb22c19..270cee3 100644 --- a/frontend/lib/widgets/admin/user_management_panel.dart +++ b/frontend/lib/widgets/admin/user_management_panel.dart @@ -3,8 +3,8 @@ import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/am_dossier_create_modal.dart'; import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; -import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart'; +import 'package:p_tits_pas/widgets/dashboard/child_detail_modal.dart'; +import 'package:p_tits_pas/widgets/dashboard/user_management_sub_bar.dart'; import 'package:p_tits_pas/widgets/admin/dossiers_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/enfant_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart'; @@ -348,7 +348,7 @@ class _UserManagementPanelState extends State { context: context, barrierDismissible: false, builder: (dialogContext) { - return AdminChildDetailModal.create( + return ChildDetailModal.create( onSaved: () { if (!mounted) return; setState(() => _enfantRefreshTick++); diff --git a/frontend/lib/widgets/admin/common/admin_am_children_capacity_grid.dart b/frontend/lib/widgets/dashboard/am_children_capacity_grid.dart similarity index 99% rename from frontend/lib/widgets/admin/common/admin_am_children_capacity_grid.dart rename to frontend/lib/widgets/dashboard/am_children_capacity_grid.dart index 5b28e3c..a475c06 100644 --- a/frontend/lib/widgets/admin/common/admin_am_children_capacity_grid.dart +++ b/frontend/lib/widgets/dashboard/am_children_capacity_grid.dart @@ -5,7 +5,7 @@ import 'package:p_tits_pas/utils/date_display_utils.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; /// Grille 2×2 des places d'accueil AM (max 4, limitée à [capacity]). -class AdminAmChildrenCapacityGrid extends StatelessWidget { +class AmChildrenCapacityGrid extends StatelessWidget { static const int _gridSlots = 4; static const double _slotHeight = 44; static const double _gridPadding = 10; @@ -23,7 +23,7 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget { /// Clic sur une case libre → même flux que « Rattacher un enfant » (#149). final VoidCallback? onAttachEmpty; - const AdminAmChildrenCapacityGrid({ + const AmChildrenCapacityGrid({ super.key, required this.children, required this.capacity, diff --git a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart b/frontend/lib/widgets/dashboard/am_edit_modal.dart similarity index 96% rename from frontend/lib/widgets/admin/common/admin_am_edit_modal.dart rename to frontend/lib/widgets/dashboard/am_edit_modal.dart index 5855e69..1ee2cf1 100644 --- a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart +++ b/frontend/lib/widgets/dashboard/am_edit_modal.dart @@ -7,31 +7,31 @@ import 'package:p_tits_pas/utils/date_display_utils.dart'; import 'package:p_tits_pas/utils/nir_utils.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_am_children_capacity_grid.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_select_enfant_modal.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart'; +import 'package:p_tits_pas/widgets/dashboard/am_children_capacity_grid.dart'; +import 'package:p_tits_pas/widgets/dashboard/am_photo_frame.dart'; +import 'package:p_tits_pas/widgets/dashboard/child_detail_modal.dart'; +import 'package:p_tits_pas/widgets/dashboard/select_enfant_modal.dart'; +import 'package:p_tits_pas/widgets/dashboard/status_capsule.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; import 'package:p_tits_pas/widgets/common/identity_block.dart'; import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; /// Fiche AM éditable (ticket #131) — identité | fiche pro (photo) | enfants. -class AdminAmEditModal extends StatefulWidget { +class AmEditModal extends StatefulWidget { final AssistanteMaternelleModel assistante; final VoidCallback? onSaved; - const AdminAmEditModal({ + const AmEditModal({ super.key, required this.assistante, this.onSaved, }); @override - State createState() => _AdminAmEditModalState(); + State createState() => _AmEditModalState(); } -class _AdminAmEditModalState extends State +class _AmEditModalState extends State with SingleTickerProviderStateMixin { late final TabController _tabCtrl; late final TextEditingController _nomCtrl; @@ -404,7 +404,7 @@ class _AdminAmEditModalState extends State if (!mounted) return; await showDialog( context: context, - builder: (ctx) => AdminChildDetailModal( + builder: (ctx) => ChildDetailModal( enfant: enfant, onSaved: _refreshChildrenDetails, ), @@ -450,7 +450,7 @@ class _AdminAmEditModalState extends State Future _attachChild() async { if (_capacityFull || !mounted) return; - final selected = await AdminSelectEnfantModal.show( + final selected = await SelectEnfantModal.show( context, excludeIds: _children.map((c) => c.id).toSet(), title: 'Rattacher un enfant', @@ -589,7 +589,7 @@ class _AdminAmEditModalState extends State final maxRowH = c.maxHeight.isFinite ? c.maxHeight : _proTabHeight; final bodyH = maxRowH; - final idealPhotoW = bodyH * AdminAmPhotoFrame.idPhotoAspectRatio + 16; + final idealPhotoW = bodyH * AmPhotoFrame.idPhotoAspectRatio + 16; final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth) .clamp(0.0, double.infinity); var photoW = idealPhotoW.clamp(_photoColumnMinWidth, 220.0); @@ -600,7 +600,7 @@ class _AdminAmEditModalState extends State children: [ SizedBox( width: photoW, - child: AdminAmPhotoFrame( + child: AmPhotoFrame( photoUrl: widget.assistante.user.photoUrl, ), ), @@ -728,7 +728,7 @@ class _AdminAmEditModalState extends State ), ), const SizedBox(height: 8), - AdminAmChildrenCapacityGrid( + AmChildrenCapacityGrid( children: _children, capacity: capacity, onOpen: _openChild, @@ -820,7 +820,7 @@ class _AdminAmEditModalState extends State const SizedBox(width: 12), SizedBox( width: 160, - child: AdminStatusCapsule( + child: StatusCapsule( statut: _statut, onChanged: (v) => setState(() { _statut = v; diff --git a/frontend/lib/widgets/admin/common/admin_am_photo_frame.dart b/frontend/lib/widgets/dashboard/am_photo_frame.dart similarity index 98% rename from frontend/lib/widgets/admin/common/admin_am_photo_frame.dart rename to frontend/lib/widgets/dashboard/am_photo_frame.dart index 3da8051..dd990fd 100644 --- a/frontend/lib/widgets/admin/common/admin_am_photo_frame.dart +++ b/frontend/lib/widgets/dashboard/am_photo_frame.dart @@ -5,7 +5,7 @@ import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; /// Cadre photo identité AM / enfant (35×45 mm) — même logique que [ValidationAmWizard]. -class AdminAmPhotoFrame extends StatelessWidget { +class AmPhotoFrame extends StatelessWidget { final String? photoUrl; final Uint8List? imageBytes; final VoidCallback? onTap; @@ -14,7 +14,7 @@ class AdminAmPhotoFrame extends StatelessWidget { static const double idPhotoAspectRatio = 35 / 45; - const AdminAmPhotoFrame({ + const AmPhotoFrame({ super.key, this.photoUrl, this.imageBytes, diff --git a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart b/frontend/lib/widgets/dashboard/child_detail_modal.dart similarity index 98% rename from frontend/lib/widgets/admin/common/admin_child_detail_modal.dart rename to frontend/lib/widgets/dashboard/child_detail_modal.dart index 30aec24..3701b27 100644 --- a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart +++ b/frontend/lib/widgets/dashboard/child_detail_modal.dart @@ -11,24 +11,24 @@ import 'package:p_tits_pas/utils/date_display_utils.dart'; import 'package:p_tits_pas/utils/enfant_status_utils.dart'; import 'package:p_tits_pas/utils/name_format_utils.dart'; import 'package:p_tits_pas/utils/staff_deletion_rights.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_select_am_modal.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_select_famille_modal.dart'; +import 'package:p_tits_pas/widgets/dashboard/am_edit_modal.dart'; +import 'package:p_tits_pas/widgets/dashboard/am_photo_frame.dart'; +import 'package:p_tits_pas/widgets/dashboard/parent_edit_modal.dart'; +import 'package:p_tits_pas/widgets/dashboard/select_am_modal.dart'; +import 'package:p_tits_pas/widgets/dashboard/select_famille_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; /// Fiche enfant consultation / édition (#138) ou création (#132). -class AdminChildDetailModal extends StatefulWidget { +class ChildDetailModal extends StatefulWidget { final EnfantAdminModel? enfant; final VoidCallback? onSaved; final VoidCallback? onDeleted; final bool isCreating; - const AdminChildDetailModal({ + const ChildDetailModal({ super.key, required EnfantAdminModel this.enfant, this.onSaved, @@ -36,7 +36,7 @@ class AdminChildDetailModal extends StatefulWidget { }) : isCreating = false; /// Création depuis l'onglet Enfants (#132). - const AdminChildDetailModal.create({ + const ChildDetailModal.create({ super.key, this.onSaved, }) : enfant = null, @@ -44,10 +44,10 @@ class AdminChildDetailModal extends StatefulWidget { isCreating = true; @override - State createState() => _AdminChildDetailModalState(); + State createState() => _ChildDetailModalState(); } -class _AdminChildDetailModalState extends State { +class _ChildDetailModalState extends State { late final TextEditingController _prenomCtrl; late final TextEditingController _nomCtrl; late final TextEditingController _birthCtrl; @@ -65,7 +65,7 @@ class _AdminChildDetailModalState extends State { String? _baselineAmUserId; /// Famille choisie en mode création (#132). - AdminFamilleFoyer? _selectedFamily; + FamilleFoyer? _selectedFamily; /// Liens parents locaux (édition) — mis à jour après rattachement foyer (#157). List? _localParentLinks; @@ -283,7 +283,7 @@ class _AdminChildDetailModalState extends State { if (!mounted) return; await showDialog( context: context, - builder: (ctx) => AdminParentEditModal( + builder: (ctx) => ParentEditModal( parent: parent, onSaved: () => widget.onSaved?.call(), ), @@ -624,7 +624,7 @@ class _AdminChildDetailModalState extends State { if (am == null) return; await showDialog( context: context, - builder: (ctx) => AdminAmEditModal( + builder: (ctx) => AmEditModal( assistante: am, onSaved: () async { await _reloadPlacementFromServer(); @@ -676,7 +676,7 @@ class _AdminChildDetailModalState extends State { } if (!mounted) return; - final selected = await AdminSelectAmModal.show( + final selected = await SelectAmModal.show( context, excludeIds: { if (_linkedAm != null) _linkedAm!.user.id, @@ -986,7 +986,7 @@ class _AdminChildDetailModalState extends State { final maxRowW = c.maxWidth; final maxRowH = c.maxHeight; final idealPhotoW = - maxRowH * AdminAmPhotoFrame.idPhotoAspectRatio + 16; + maxRowH * AmPhotoFrame.idPhotoAspectRatio + 16; final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth) .clamp(0.0, double.infinity); var photoW = idealPhotoW.clamp(_photoColumnMinWidth, _photoColumnMaxWidth); @@ -1001,7 +1001,7 @@ class _AdminChildDetailModalState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded( - child: AdminAmPhotoFrame( + child: AmPhotoFrame( photoUrl: widget.isCreating ? null : widget.enfant?.photoUrl, @@ -1329,7 +1329,7 @@ class _AdminChildDetailModalState extends State { Future _pickFamily() async { if (_busy) return; - final selected = await AdminSelectFamilleModal.show( + final selected = await SelectFamilleModal.show( context, title: 'Choisir une famille', ); diff --git a/frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart b/frontend/lib/widgets/dashboard/children_affiliation_panel.dart similarity index 91% rename from frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart rename to frontend/lib/widgets/dashboard/children_affiliation_panel.dart index 526d7f8..2cacec5 100644 --- a/frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart +++ b/frontend/lib/widgets/dashboard/children_affiliation_panel.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/parent_child_summary.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart'; +import 'package:p_tits_pas/widgets/dashboard/enfant_user_card.dart'; /// Liste scrollable d'enfants rattachés (fiche parent / fiche AM). -class AdminChildrenAffiliationPanel extends StatelessWidget { +class ChildrenAffiliationPanel extends StatelessWidget { final List children; final ScrollController scrollController; final void Function(ParentChildSummary child) onOpen; @@ -14,7 +14,7 @@ class AdminChildrenAffiliationPanel extends StatelessWidget { static const double _itemHeight = 58; static const double defaultViewportHeight = _itemHeight * 2.5 + 8; - const AdminChildrenAffiliationPanel({ + const ChildrenAffiliationPanel({ super.key, required this.children, required this.scrollController, @@ -62,7 +62,7 @@ class AdminChildrenAffiliationPanel extends StatelessWidget { itemCount: children.length, itemBuilder: (_, i) { final c = children[i]; - return AdminEnfantUserCard.fromSummary( + return EnfantUserCard.fromSummary( c, onCardTap: () => onOpen(c), actions: [ diff --git a/frontend/lib/widgets/admin/common/admin_detail_modal.dart b/frontend/lib/widgets/dashboard/detail_modal.dart similarity index 96% rename from frontend/lib/widgets/admin/common/admin_detail_modal.dart rename to frontend/lib/widgets/dashboard/detail_modal.dart index affc0d8..255d2bb 100644 --- a/frontend/lib/widgets/admin/common/admin_detail_modal.dart +++ b/frontend/lib/widgets/dashboard/detail_modal.dart @@ -1,23 +1,23 @@ import 'package:flutter/material.dart'; -class AdminDetailField { +class DetailField { final String label; final String value; - const AdminDetailField({ + const DetailField({ required this.label, required this.value, }); } -class AdminDetailModal extends StatelessWidget { +class DetailModal extends StatelessWidget { final String title; final String? subtitle; - final List fields; + final List fields; final VoidCallback onEdit; final VoidCallback onDelete; - const AdminDetailModal({ + const DetailModal({ super.key, required this.title, this.subtitle, diff --git a/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart b/frontend/lib/widgets/dashboard/enfant_user_card.dart similarity index 91% rename from frontend/lib/widgets/admin/common/admin_enfant_user_card.dart rename to frontend/lib/widgets/dashboard/enfant_user_card.dart index 04b22e5..347fccb 100644 --- a/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart +++ b/frontend/lib/widgets/dashboard/enfant_user_card.dart @@ -4,7 +4,7 @@ import 'package:p_tits_pas/models/parent_child_summary.dart'; import 'package:p_tits_pas/utils/date_display_utils.dart'; import 'package:p_tits_pas/utils/enfant_status_utils.dart'; import 'package:p_tits_pas/utils/enfant_vigilance.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; +import 'package:p_tits_pas/widgets/dashboard/user_card.dart'; List enfantAdminSubtitleLines({ required String status, @@ -29,7 +29,7 @@ List enfantAdminSubtitleLines({ } /// Carte enfant admin (photo, nom, âge) — même rendu onglet Enfants / fiche parent. -class AdminEnfantUserCard extends StatelessWidget { +class EnfantUserCard extends StatelessWidget { final String title; final String? photoUrl; final List subtitleLines; @@ -40,7 +40,7 @@ class AdminEnfantUserCard extends StatelessWidget { final Color? borderColor; final String? vigilanceTooltip; - const AdminEnfantUserCard({ + const EnfantUserCard({ super.key, required this.title, this.photoUrl, @@ -53,7 +53,7 @@ class AdminEnfantUserCard extends StatelessWidget { this.vigilanceTooltip, }); - factory AdminEnfantUserCard.fromEnfant( + factory EnfantUserCard.fromEnfant( EnfantAdminModel enfant, { List extraSubtitleLines = const [], List actions = const [], @@ -67,7 +67,7 @@ class AdminEnfantUserCard extends StatelessWidget { .map((l) => l.parentName ?? 'Parent') .join(', '); final orphan = enfantHasNoResponsable(enfant); - return AdminEnfantUserCard( + return EnfantUserCard( title: enfant.fullName, photoUrl: enfant.photoUrl, subtitleLines: enfantAdminSubtitleLines( @@ -92,7 +92,7 @@ class AdminEnfantUserCard extends StatelessWidget { ); } - factory AdminEnfantUserCard.fromSummary( + factory EnfantUserCard.fromSummary( ParentChildSummary child, { List extraSubtitleLines = const [], List actions = const [], @@ -100,7 +100,7 @@ class AdminEnfantUserCard extends StatelessWidget { EdgeInsetsGeometry? margin, EdgeInsetsGeometry? contentPadding, }) { - return AdminEnfantUserCard( + return EnfantUserCard( title: child.fullName, photoUrl: child.photoUrl, subtitleLines: enfantAdminSubtitleLines( @@ -118,7 +118,7 @@ class AdminEnfantUserCard extends StatelessWidget { @override Widget build(BuildContext context) { - return AdminUserCard( + return UserCard( title: title, fallbackIcon: Icons.child_care_outlined, avatarUrl: photoUrl, diff --git a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart b/frontend/lib/widgets/dashboard/parent_edit_modal.dart similarity index 94% rename from frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart rename to frontend/lib/widgets/dashboard/parent_edit_modal.dart index 101b6a5..c3c7093 100644 --- a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart +++ b/frontend/lib/widgets/dashboard/parent_edit_modal.dart @@ -4,30 +4,30 @@ import 'package:p_tits_pas/models/parent_child_summary.dart'; import 'package:p_tits_pas/models/parent_model.dart'; import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/services/user_service.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_children_affiliation_panel.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_select_enfant_modal.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart'; +import 'package:p_tits_pas/widgets/dashboard/child_detail_modal.dart'; +import 'package:p_tits_pas/widgets/dashboard/children_affiliation_panel.dart'; +import 'package:p_tits_pas/widgets/dashboard/select_enfant_modal.dart'; +import 'package:p_tits_pas/widgets/dashboard/status_capsule.dart'; import 'package:p_tits_pas/widgets/common/identity_block.dart'; import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; /// Fiche parent éditable (doc 28 §6.1, tickets #131 / #138). /// Shell et typo alignés sur [ValidationDossierModal] / wizards validation. -class AdminParentEditModal extends StatefulWidget { +class ParentEditModal extends StatefulWidget { final ParentModel parent; final VoidCallback? onSaved; - const AdminParentEditModal({ + const ParentEditModal({ super.key, required this.parent, this.onSaved, }); @override - State createState() => _AdminParentEditModalState(); + State createState() => _ParentEditModalState(); } -class _AdminParentEditModalState extends State { +class _ParentEditModalState extends State { late final TextEditingController _nomCtrl; late final TextEditingController _prenomCtrl; late final TextEditingController _emailCtrl; @@ -129,7 +129,7 @@ class _AdminParentEditModalState extends State { if (!mounted) return; await showDialog( context: context, - builder: (ctx) => AdminParentEditModal( + builder: (ctx) => ParentEditModal( parent: parent, onSaved: () async { try { @@ -244,7 +244,7 @@ class _AdminParentEditModalState extends State { if (!mounted) return; await showDialog( context: context, - builder: (ctx) => AdminChildDetailModal( + builder: (ctx) => ChildDetailModal( enfant: enfant, onSaved: _reloadChildren, ), @@ -322,7 +322,7 @@ class _AdminParentEditModalState extends State { Future _attachChild() async { if (!mounted) return; - final selected = await AdminSelectEnfantModal.show( + final selected = await SelectEnfantModal.show( context, excludeIds: _children.map((c) => c.id).toSet(), title: 'Rattacher un enfant', @@ -350,7 +350,7 @@ class _AdminParentEditModalState extends State { } Widget _childrenPanel() { - return AdminChildrenAffiliationPanel( + return ChildrenAffiliationPanel( children: _children, scrollController: _childrenScrollCtrl, onOpen: _openChild, @@ -437,7 +437,7 @@ class _AdminParentEditModalState extends State { const SizedBox(width: 12), SizedBox( width: 160, - child: AdminStatusCapsule( + child: StatusCapsule( statut: _statut, onChanged: (v) => setState(() { _statut = v; diff --git a/frontend/lib/widgets/admin/common/admin_select_am_modal.dart b/frontend/lib/widgets/dashboard/select_am_modal.dart similarity index 92% rename from frontend/lib/widgets/admin/common/admin_select_am_modal.dart rename to frontend/lib/widgets/dashboard/select_am_modal.dart index ab46601..b35c5a7 100644 --- a/frontend/lib/widgets/admin/common/admin_select_am_modal.dart +++ b/frontend/lib/widgets/dashboard/select_am_modal.dart @@ -3,9 +3,9 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/utils/am_vigilance.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; +import 'package:p_tits_pas/widgets/dashboard/am_edit_modal.dart'; +import 'package:p_tits_pas/widgets/dashboard/select_list_modal.dart'; +import 'package:p_tits_pas/widgets/dashboard/user_card.dart'; import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; List _amSelectSubtitleLines(AssistanteMaternelleModel am) { @@ -28,22 +28,22 @@ List _amSelectSubtitleLines(AssistanteMaternelleModel am) { } /// Sélection d'une AM à rattacher (fiche enfant) — ticket #147. -/// S'appuie sur [AdminSelectListModal] (shell partagé avec #146). -class AdminSelectAmModal { - AdminSelectAmModal._(); +/// S'appuie sur [SelectListModal] (shell partagé avec #146). +class SelectAmModal { + SelectAmModal._(); static Future show( BuildContext context, { Set excludeIds = const {}, String title = 'Choisir une assistante maternelle', }) { - return AdminSelectListModal.show( + return SelectListModal.show( context, title: title, searchHint: 'Rechercher par nom, prénom ou zone…', emptyMessage: 'Aucune assistante maternelle disponible', noResultsMessage: 'Aucune AM avec place libre pour cette recherche', - toggleFilter: const AdminSelectToggleFilter( + toggleFilter: const SelectToggleFilter( label: 'Libre', initialValue: true, whenEnabled: amHasFreePlace, @@ -76,7 +76,7 @@ class AdminSelectAmModal { _resolveAmSelection(ctx, am, reload), itemBuilder: (context, am, onSelect) { final full = !amHasFreePlace(am); - return AdminUserCard( + return UserCard( title: am.user.fullName, avatarUrl: am.user.photoUrl, fallbackIcon: Icons.face, @@ -150,7 +150,7 @@ class _AmNoPlaceWarningDialogState extends State<_AmNoPlaceWarningDialog> { if (!mounted) return; await showDialog( context: context, - builder: (ctx) => AdminAmEditModal( + builder: (ctx) => AmEditModal( assistante: _am, onSaved: () async { try { diff --git a/frontend/lib/widgets/admin/common/admin_select_enfant_modal.dart b/frontend/lib/widgets/dashboard/select_enfant_modal.dart similarity index 83% rename from frontend/lib/widgets/admin/common/admin_select_enfant_modal.dart rename to frontend/lib/widgets/dashboard/select_enfant_modal.dart index dafe5d4..785c04e 100644 --- a/frontend/lib/widgets/admin/common/admin_select_enfant_modal.dart +++ b/frontend/lib/widgets/dashboard/select_enfant_modal.dart @@ -2,13 +2,13 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/enfant_admin_model.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/utils/enfant_status_utils.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart'; +import 'package:p_tits_pas/widgets/dashboard/enfant_user_card.dart'; +import 'package:p_tits_pas/widgets/dashboard/select_list_modal.dart'; /// Sélection d'un enfant à rattacher (fiche AM / fiche parent) — ticket #146. -/// S'appuie sur [AdminSelectListModal] (shell partagé avec #147). -class AdminSelectEnfantModal { - AdminSelectEnfantModal._(); +/// S'appuie sur [SelectListModal] (shell partagé avec #147). +class SelectEnfantModal { + SelectEnfantModal._(); static Future show( BuildContext context, { @@ -17,7 +17,7 @@ class AdminSelectEnfantModal { /// Affiché uniquement depuis la fiche AM : filtre les enfants déjà en garde. bool showSansGardeFilter = false, }) { - return AdminSelectListModal.show( + return SelectListModal.show( context, title: title, searchHint: 'Rechercher par nom ou prénom…', @@ -26,7 +26,7 @@ class AdminSelectEnfantModal { ? 'Aucun enfant sans garde pour cette recherche' : 'Aucun résultat pour cette recherche', toggleFilter: showSansGardeFilter - ? AdminSelectToggleFilter( + ? SelectToggleFilter( label: 'Sans garde', initialValue: true, whenEnabled: (e) => @@ -50,7 +50,7 @@ class AdminSelectEnfantModal { return name.contains(q) || fn.contains(q) || ln.contains(q); }, itemBuilder: (context, e, onSelect) { - return AdminEnfantUserCard.fromEnfant( + return EnfantUserCard.fromEnfant( e, onCardTap: onSelect, margin: const EdgeInsets.only(bottom: 4), diff --git a/frontend/lib/widgets/admin/common/admin_select_famille_modal.dart b/frontend/lib/widgets/dashboard/select_famille_modal.dart similarity index 88% rename from frontend/lib/widgets/admin/common/admin_select_famille_modal.dart rename to frontend/lib/widgets/dashboard/select_famille_modal.dart index 4fbd3a3..abddc2e 100644 --- a/frontend/lib/widgets/admin/common/admin_select_famille_modal.dart +++ b/frontend/lib/widgets/dashboard/select_famille_modal.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/parent_model.dart'; import 'package:p_tits_pas/services/user_service.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; +import 'package:p_tits_pas/widgets/dashboard/select_list_modal.dart'; +import 'package:p_tits_pas/widgets/dashboard/user_card.dart'; /// Foyer / famille sélectionnable pour rattacher un nouvel enfant (#132 / #157). -class AdminFamilleFoyer { +class FamilleFoyer { /// Parent pivot pour `POST /enfants` (`parent_user_id`). final String pivotParentUserId; /// Co-parent éventuel (rattachement foyer #157). @@ -14,7 +14,7 @@ class AdminFamilleFoyer { final String displayTitle; final List parentNames; - const AdminFamilleFoyer({ + const FamilleFoyer({ required this.pivotParentUserId, required this.displayTitle, required this.parentNames, @@ -42,10 +42,10 @@ class AdminFamilleFoyer { } /// Construit la liste des foyers uniques à partir de `GET /parents`. -List buildFamilleFoyers(List parents) { +List buildFamilleFoyers(List parents) { final seenDossiers = {}; final seenUserIds = {}; - final foyers = []; + final foyers = []; for (final p in parents) { final dossier = (p.user.numeroDossier ?? '').trim(); @@ -70,7 +70,7 @@ List buildFamilleFoyers(List parents) { : (names.isNotEmpty ? names.first : 'Famille'); foyers.add( - AdminFamilleFoyer( + FamilleFoyer( pivotParentUserId: p.user.id, coParentUserId: co?.id, numeroDossier: dossier.isNotEmpty ? dossier : null, @@ -87,14 +87,14 @@ List buildFamilleFoyers(List parents) { } /// Sélection d'une famille / dossier pour créer un enfant — ticket #132. -class AdminSelectFamilleModal { - AdminSelectFamilleModal._(); +class SelectFamilleModal { + SelectFamilleModal._(); - static Future show( + static Future show( BuildContext context, { String title = 'Choisir une famille', }) { - return AdminSelectListModal.show( + return SelectListModal.show( context, title: title, searchHint: 'Rechercher par dossier, nom…', @@ -111,7 +111,7 @@ class AdminSelectFamilleModal { return dossier.contains(q) || title.contains(q) || names.contains(q); }, itemBuilder: (context, f, onSelect) { - return AdminUserCard( + return UserCard( title: f.displayTitle, fallbackIcon: Icons.family_restroom, subtitleLines: [ diff --git a/frontend/lib/widgets/admin/common/admin_select_list_modal.dart b/frontend/lib/widgets/dashboard/select_list_modal.dart similarity index 95% rename from frontend/lib/widgets/admin/common/admin_select_list_modal.dart rename to frontend/lib/widgets/dashboard/select_list_modal.dart index 4fdc70c..0335cc7 100644 --- a/frontend/lib/widgets/admin/common/admin_select_list_modal.dart +++ b/frontend/lib/widgets/dashboard/select_list_modal.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; /// Filtre optionnel (switch) sur la même ligne que la barre de recherche. -class AdminSelectToggleFilter { +class SelectToggleFilter { final String label; final bool initialValue; @@ -10,7 +10,7 @@ class AdminSelectToggleFilter { /// [whenEnabled] renvoie `true`. final bool Function(T item) whenEnabled; - const AdminSelectToggleFilter({ + const SelectToggleFilter({ required this.label, required this.whenEnabled, this.initialValue = true, @@ -19,7 +19,7 @@ class AdminSelectToggleFilter { /// Shell générique « rechercher + liste + sélection » pour les modales admin. /// Utilisé par la sélection d'enfant (#146) et la sélection d'AM (#147). -class AdminSelectListModal extends StatefulWidget { +class SelectListModal extends StatefulWidget { final String title; final String searchHint; final Future> Function() loadItems; @@ -40,7 +40,7 @@ class AdminSelectListModal extends StatefulWidget { final int minVisibleCards; /// Switch optionnel à droite du champ de recherche (ex. « Sans garde », « Libre »). - final AdminSelectToggleFilter? toggleFilter; + final SelectToggleFilter? toggleFilter; /// Si fourni, appelé avant de valider la sélection. /// Retourne l'élément à pop (éventuellement rafraîchi), ou `null` pour annuler. @@ -50,7 +50,7 @@ class AdminSelectListModal extends StatefulWidget { Future Function() reload, )? resolveSelect; - const AdminSelectListModal({ + const SelectListModal({ super.key, required this.title, required this.loadItems, @@ -82,7 +82,7 @@ class AdminSelectListModal extends StatefulWidget { double modalWidth = 930, double cardExtent = 52, int minVisibleCards = 8, - AdminSelectToggleFilter? toggleFilter, + SelectToggleFilter? toggleFilter, Future Function( BuildContext context, T item, @@ -91,7 +91,7 @@ class AdminSelectListModal extends StatefulWidget { }) { return showDialog( context: context, - builder: (ctx) => AdminSelectListModal( + builder: (ctx) => SelectListModal( title: title, loadItems: loadItems, matchesQuery: matchesQuery, @@ -109,11 +109,11 @@ class AdminSelectListModal extends StatefulWidget { } @override - State> createState() => - _AdminSelectListModalState(); + State> createState() => + _SelectListModalState(); } -class _AdminSelectListModalState extends State> { +class _SelectListModalState extends State> { final _searchCtrl = TextEditingController(); List _all = []; bool _loading = true; diff --git a/frontend/lib/widgets/admin/common/admin_status_capsule.dart b/frontend/lib/widgets/dashboard/status_capsule.dart similarity index 95% rename from frontend/lib/widgets/admin/common/admin_status_capsule.dart rename to frontend/lib/widgets/dashboard/status_capsule.dart index a42b015..e22b665 100644 --- a/frontend/lib/widgets/admin/common/admin_status_capsule.dart +++ b/frontend/lib/widgets/dashboard/status_capsule.dart @@ -1,13 +1,13 @@ import 'package:flutter/material.dart'; /// Gélule de sélection du statut utilisateur (fiches admin parent / AM). -class AdminStatusCapsule extends StatelessWidget { +class StatusCapsule extends StatelessWidget { final String statut; final ValueChanged? onChanged; static const statuts = ['actif', 'en_attente', 'suspendu', 'refuse']; - const AdminStatusCapsule({ + const StatusCapsule({ super.key, required this.statut, this.onChanged, diff --git a/frontend/lib/widgets/admin/common/admin_user_card.dart b/frontend/lib/widgets/dashboard/user_card.dart similarity index 97% rename from frontend/lib/widgets/admin/common/admin_user_card.dart rename to frontend/lib/widgets/dashboard/user_card.dart index 640d293..07798dd 100644 --- a/frontend/lib/widgets/admin/common/admin_user_card.dart +++ b/frontend/lib/widgets/dashboard/user_card.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; -class AdminUserCard extends StatefulWidget { +class UserCard extends StatefulWidget { final String title; final List subtitleLines; final String? avatarUrl; @@ -21,7 +21,7 @@ class AdminUserCard extends StatefulWidget { final EdgeInsetsGeometry? margin; final EdgeInsetsGeometry? contentPadding; - const AdminUserCard({ + const UserCard({ super.key, required this.title, required this.subtitleLines, @@ -41,10 +41,10 @@ class AdminUserCard extends StatefulWidget { }); @override - State createState() => _AdminUserCardState(); + State createState() => _UserCardState(); } -class _AdminUserCardState extends State { +class _UserCardState extends State { bool _isHovered = false; @override diff --git a/frontend/lib/widgets/admin/common/admin_list_state.dart b/frontend/lib/widgets/dashboard/user_list_state.dart similarity index 92% rename from frontend/lib/widgets/admin/common/admin_list_state.dart rename to frontend/lib/widgets/dashboard/user_list_state.dart index 41b497c..d7b9526 100644 --- a/frontend/lib/widgets/admin/common/admin_list_state.dart +++ b/frontend/lib/widgets/dashboard/user_list_state.dart @@ -1,13 +1,13 @@ import 'package:flutter/material.dart'; -class AdminListState extends StatelessWidget { +class UserListState extends StatelessWidget { final bool isLoading; final String? error; final bool isEmpty; final String emptyMessage; final Widget list; - const AdminListState({ + const UserListState({ super.key, required this.isLoading, required this.error, diff --git a/frontend/lib/widgets/admin/dashboard_admin.dart b/frontend/lib/widgets/dashboard/user_management_sub_bar.dart similarity index 100% rename from frontend/lib/widgets/admin/dashboard_admin.dart rename to frontend/lib/widgets/dashboard/user_management_sub_bar.dart From b474842e19e75732e3e9ff65b11de3a25e4a9bfd Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Mon, 14 Sep 2026 11:05:23 +0200 Subject: [PATCH 82/85] =?UTF-8?q?refactor(#155):=20phase=202=20=E2=80=94?= =?UTF-8?q?=20panels=20staff=20sous=20widgets/dashboard/.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Déplace les panels/wizards/validation/common partagés hors de widgets/admin/ ; ne conserve que AdminManagementWidget (variante A). Co-authored-by: Cursor --- .../administrateurs/admin_dashboardScreen.dart | 4 ++-- .../administrateurs/creation/admin_create.dart | 2 +- .../creation/gestionnaires_create.dart | 2 +- .../gestionnaire_dashboard_screen.dart | 2 +- .../lib/widgets/admin/admin_management_widget.dart | 4 ++-- frontend/lib/widgets/common/identity_block.dart | 2 +- .../am_dossier_create_modal.dart | 2 +- .../{admin => dashboard}/am_dossier_wizard.dart | 8 ++++---- frontend/lib/widgets/dashboard/am_edit_modal.dart | 4 ++-- .../assistante_maternelle_management_widget.dart | 4 ++-- .../lib/widgets/dashboard/child_detail_modal.dart | 6 +++--- .../common/suppression_confirm_dialog.dart | 0 .../{admin => dashboard}/common/user_list.dart | 0 .../common/validation_detail_section.dart | 0 .../{admin => dashboard}/dossier_list_card.dart | 2 +- .../dossiers_management_widget.dart | 8 ++++---- .../enfant_management_widget.dart | 4 ++-- .../gestionnaire_management_widget.dart | 4 ++-- .../{admin => dashboard}/parametres_panel.dart | 2 +- .../parent_dossier_create_modal.dart | 2 +- .../parent_dossier_wizard.dart | 8 ++++---- .../lib/widgets/dashboard/parent_edit_modal.dart | 2 +- .../parent_managmant_widget.dart | 4 ++-- .../pending_validation_widget.dart | 6 +++--- .../relais_management_panel.dart | 2 +- .../lib/widgets/dashboard/select_am_modal.dart | 2 +- .../lib/widgets/dashboard/select_list_modal.dart | 2 +- .../user_management_panel.dart | 14 +++++++------- .../{admin => dashboard}/validation_am_wizard.dart | 2 +- .../validation_dossier_modal.dart | 8 ++++---- .../validation_family_wizard.dart | 2 +- .../validation_modal_theme.dart | 0 .../validation_refus_form.dart | 0 .../validation_valider_confirm_dialog.dart | 0 34 files changed, 57 insertions(+), 57 deletions(-) rename frontend/lib/widgets/{admin => dashboard}/am_dossier_create_modal.dart (97%) rename frontend/lib/widgets/{admin => dashboard}/am_dossier_wizard.dart (99%) rename frontend/lib/widgets/{admin => dashboard}/assistante_maternelle_management_widget.dart (97%) rename frontend/lib/widgets/{admin => dashboard}/common/suppression_confirm_dialog.dart (100%) rename frontend/lib/widgets/{admin => dashboard}/common/user_list.dart (100%) rename frontend/lib/widgets/{admin => dashboard}/common/validation_detail_section.dart (100%) rename frontend/lib/widgets/{admin => dashboard}/dossier_list_card.dart (96%) rename frontend/lib/widgets/{admin => dashboard}/dossiers_management_widget.dart (97%) rename frontend/lib/widgets/{admin => dashboard}/enfant_management_widget.dart (97%) rename frontend/lib/widgets/{admin => dashboard}/gestionnaire_management_widget.dart (96%) rename frontend/lib/widgets/{admin => dashboard}/parametres_panel.dart (99%) rename frontend/lib/widgets/{admin => dashboard}/parent_dossier_create_modal.dart (97%) rename frontend/lib/widgets/{admin => dashboard}/parent_dossier_wizard.dart (99%) rename frontend/lib/widgets/{admin => dashboard}/parent_managmant_widget.dart (97%) rename frontend/lib/widgets/{admin => dashboard}/pending_validation_widget.dart (98%) rename frontend/lib/widgets/{admin => dashboard}/relais_management_panel.dart (99%) rename frontend/lib/widgets/{admin => dashboard}/user_management_panel.dart (95%) rename frontend/lib/widgets/{admin => dashboard}/validation_am_wizard.dart (91%) rename frontend/lib/widgets/{admin => dashboard}/validation_dossier_modal.dart (95%) rename frontend/lib/widgets/{admin => dashboard}/validation_family_wizard.dart (91%) rename frontend/lib/widgets/{admin => dashboard}/validation_modal_theme.dart (100%) rename frontend/lib/widgets/{admin => dashboard}/validation_refus_form.dart (100%) rename frontend/lib/widgets/{admin => dashboard}/validation_valider_confirm_dialog.dart (100%) diff --git a/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart b/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart index 43c6f97..9cad77d 100644 --- a/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart +++ b/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart @@ -3,8 +3,8 @@ import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/configuration_service.dart'; import 'package:p_tits_pas/widgets/dashboard/user_management_sub_bar.dart'; -import 'package:p_tits_pas/widgets/admin/parametres_panel.dart'; -import 'package:p_tits_pas/widgets/admin/user_management_panel.dart'; +import 'package:p_tits_pas/widgets/dashboard/parametres_panel.dart'; +import 'package:p_tits_pas/widgets/dashboard/user_management_panel.dart'; import 'package:p_tits_pas/widgets/app_footer.dart'; import 'package:p_tits_pas/widgets/dashboard/dashboard_bandeau.dart'; diff --git a/frontend/lib/screens/administrateurs/creation/admin_create.dart b/frontend/lib/screens/administrateurs/creation/admin_create.dart index 1334519..e18d202 100644 --- a/frontend/lib/screens/administrateurs/creation/admin_create.dart +++ b/frontend/lib/screens/administrateurs/creation/admin_create.dart @@ -3,7 +3,7 @@ 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/admin/common/suppression_confirm_dialog.dart'; +import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart'; import 'package:p_tits_pas/widgets/email_text_field.dart'; import 'package:p_tits_pas/widgets/french_phone_field.dart'; diff --git a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart index 38b67e2..89c1d55 100644 --- a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart +++ b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart @@ -9,7 +9,7 @@ import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/relais_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/utils/staff_deletion_rights.dart'; -import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; +import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart'; class AdminUserFormDialog extends StatefulWidget { final AppUser? initialUser; diff --git a/frontend/lib/screens/gestionnaire/gestionnaire_dashboard_screen.dart b/frontend/lib/screens/gestionnaire/gestionnaire_dashboard_screen.dart index 2b2ccf5..bcf5542 100644 --- a/frontend/lib/screens/gestionnaire/gestionnaire_dashboard_screen.dart +++ b/frontend/lib/screens/gestionnaire/gestionnaire_dashboard_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/services/auth_service.dart'; -import 'package:p_tits_pas/widgets/admin/user_management_panel.dart'; +import 'package:p_tits_pas/widgets/dashboard/user_management_panel.dart'; import 'package:p_tits_pas/widgets/app_footer.dart'; import 'package:p_tits_pas/widgets/dashboard/dashboard_bandeau.dart'; diff --git a/frontend/lib/widgets/admin/admin_management_widget.dart b/frontend/lib/widgets/admin/admin_management_widget.dart index 3538efc..facaa8b 100644 --- a/frontend/lib/widgets/admin/admin_management_widget.dart +++ b/frontend/lib/widgets/admin/admin_management_widget.dart @@ -6,8 +6,8 @@ import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/utils/staff_deletion_rights.dart'; import 'package:p_tits_pas/widgets/dashboard/user_card.dart'; -import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; -import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; +import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart'; +import 'package:p_tits_pas/widgets/dashboard/common/user_list.dart'; class AdminManagementWidget extends StatefulWidget { final String searchQuery; diff --git a/frontend/lib/widgets/common/identity_block.dart b/frontend/lib/widgets/common/identity_block.dart index 2aee3e8..d0b7e68 100644 --- a/frontend/lib/widgets/common/identity_block.dart +++ b/frontend/lib/widgets/common/identity_block.dart @@ -3,7 +3,7 @@ import 'package:p_tits_pas/models/dossier_unifie.dart'; import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/utils/name_format_utils.dart'; import 'package:p_tits_pas/utils/phone_utils.dart'; -import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; +import 'package:p_tits_pas/widgets/dashboard/common/validation_detail_section.dart'; /// Valeurs affichées dans un [IdentityBlock] en lecture seule. class IdentityValues { diff --git a/frontend/lib/widgets/admin/am_dossier_create_modal.dart b/frontend/lib/widgets/dashboard/am_dossier_create_modal.dart similarity index 97% rename from frontend/lib/widgets/admin/am_dossier_create_modal.dart rename to frontend/lib/widgets/dashboard/am_dossier_create_modal.dart index f221d93..1f463e6 100644 --- a/frontend/lib/widgets/admin/am_dossier_create_modal.dart +++ b/frontend/lib/widgets/dashboard/am_dossier_create_modal.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:p_tits_pas/widgets/admin/am_dossier_wizard.dart'; +import 'package:p_tits_pas/widgets/dashboard/am_dossier_wizard.dart'; /// Modale de création dossier AM (#156) — même shell que [ValidationDossierModal]. class AmDossierCreateModal extends StatefulWidget { diff --git a/frontend/lib/widgets/admin/am_dossier_wizard.dart b/frontend/lib/widgets/dashboard/am_dossier_wizard.dart similarity index 99% rename from frontend/lib/widgets/admin/am_dossier_wizard.dart rename to frontend/lib/widgets/dashboard/am_dossier_wizard.dart index e8f8b19..0efd238 100644 --- a/frontend/lib/widgets/admin/am_dossier_wizard.dart +++ b/frontend/lib/widgets/dashboard/am_dossier_wizard.dart @@ -16,10 +16,10 @@ import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/utils/postal_utils.dart'; import 'package:p_tits_pas/widgets/dashboard/am_photo_frame.dart'; import 'package:p_tits_pas/widgets/dashboard/detail_modal.dart'; -import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; -import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; -import 'package:p_tits_pas/widgets/admin/validation_refus_form.dart'; -import 'package:p_tits_pas/widgets/admin/validation_valider_confirm_dialog.dart'; +import 'package:p_tits_pas/widgets/dashboard/common/validation_detail_section.dart'; +import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart'; +import 'package:p_tits_pas/widgets/dashboard/validation_refus_form.dart'; +import 'package:p_tits_pas/widgets/dashboard/validation_valider_confirm_dialog.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'package:p_tits_pas/widgets/common/identity_block.dart'; diff --git a/frontend/lib/widgets/dashboard/am_edit_modal.dart b/frontend/lib/widgets/dashboard/am_edit_modal.dart index 1ee2cf1..900df65 100644 --- a/frontend/lib/widgets/dashboard/am_edit_modal.dart +++ b/frontend/lib/widgets/dashboard/am_edit_modal.dart @@ -12,9 +12,9 @@ import 'package:p_tits_pas/widgets/dashboard/am_photo_frame.dart'; import 'package:p_tits_pas/widgets/dashboard/child_detail_modal.dart'; import 'package:p_tits_pas/widgets/dashboard/select_enfant_modal.dart'; import 'package:p_tits_pas/widgets/dashboard/status_capsule.dart'; -import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; +import 'package:p_tits_pas/widgets/dashboard/common/validation_detail_section.dart'; import 'package:p_tits_pas/widgets/common/identity_block.dart'; -import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; +import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart'; /// Fiche AM éditable (ticket #131) — identité | fiche pro (photo) | enfants. class AmEditModal extends StatefulWidget { diff --git a/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart b/frontend/lib/widgets/dashboard/assistante_maternelle_management_widget.dart similarity index 97% rename from frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart rename to frontend/lib/widgets/dashboard/assistante_maternelle_management_widget.dart index 8da3c30..8d8958a 100644 --- a/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart +++ b/frontend/lib/widgets/dashboard/assistante_maternelle_management_widget.dart @@ -7,8 +7,8 @@ import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/utils/staff_deletion_rights.dart'; import 'package:p_tits_pas/widgets/dashboard/am_edit_modal.dart'; import 'package:p_tits_pas/widgets/dashboard/user_card.dart'; -import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; -import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; +import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart'; +import 'package:p_tits_pas/widgets/dashboard/common/user_list.dart'; class AssistanteMaternelleManagementWidget extends StatefulWidget { final String searchQuery; diff --git a/frontend/lib/widgets/dashboard/child_detail_modal.dart b/frontend/lib/widgets/dashboard/child_detail_modal.dart index 3701b27..087c374 100644 --- a/frontend/lib/widgets/dashboard/child_detail_modal.dart +++ b/frontend/lib/widgets/dashboard/child_detail_modal.dart @@ -16,9 +16,9 @@ import 'package:p_tits_pas/widgets/dashboard/am_photo_frame.dart'; import 'package:p_tits_pas/widgets/dashboard/parent_edit_modal.dart'; import 'package:p_tits_pas/widgets/dashboard/select_am_modal.dart'; import 'package:p_tits_pas/widgets/dashboard/select_famille_modal.dart'; -import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; -import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; -import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; +import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart'; +import 'package:p_tits_pas/widgets/dashboard/common/validation_detail_section.dart'; +import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; /// Fiche enfant consultation / édition (#138) ou création (#132). diff --git a/frontend/lib/widgets/admin/common/suppression_confirm_dialog.dart b/frontend/lib/widgets/dashboard/common/suppression_confirm_dialog.dart similarity index 100% rename from frontend/lib/widgets/admin/common/suppression_confirm_dialog.dart rename to frontend/lib/widgets/dashboard/common/suppression_confirm_dialog.dart diff --git a/frontend/lib/widgets/admin/common/user_list.dart b/frontend/lib/widgets/dashboard/common/user_list.dart similarity index 100% rename from frontend/lib/widgets/admin/common/user_list.dart rename to frontend/lib/widgets/dashboard/common/user_list.dart diff --git a/frontend/lib/widgets/admin/common/validation_detail_section.dart b/frontend/lib/widgets/dashboard/common/validation_detail_section.dart similarity index 100% rename from frontend/lib/widgets/admin/common/validation_detail_section.dart rename to frontend/lib/widgets/dashboard/common/validation_detail_section.dart diff --git a/frontend/lib/widgets/admin/dossier_list_card.dart b/frontend/lib/widgets/dashboard/dossier_list_card.dart similarity index 96% rename from frontend/lib/widgets/admin/dossier_list_card.dart rename to frontend/lib/widgets/dashboard/dossier_list_card.dart index ad66e88..149eec1 100644 --- a/frontend/lib/widgets/admin/dossier_list_card.dart +++ b/frontend/lib/widgets/dashboard/dossier_list_card.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/widgets/dashboard/user_card.dart'; -import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; +import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart'; /// Carte dossier unifiée (#153) — fond neutre, accent couleur sur l’icône. class DossierListCard extends StatelessWidget { diff --git a/frontend/lib/widgets/admin/dossiers_management_widget.dart b/frontend/lib/widgets/dashboard/dossiers_management_widget.dart similarity index 97% rename from frontend/lib/widgets/admin/dossiers_management_widget.dart rename to frontend/lib/widgets/dashboard/dossiers_management_widget.dart index a616525..74e9475 100644 --- a/frontend/lib/widgets/admin/dossiers_management_widget.dart +++ b/frontend/lib/widgets/dashboard/dossiers_management_widget.dart @@ -3,10 +3,10 @@ import 'package:p_tits_pas/models/dossier_list_item.dart'; import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/utils/staff_deletion_rights.dart'; -import 'package:p_tits_pas/widgets/admin/dossier_list_card.dart'; -import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart'; -import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; -import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart'; +import 'package:p_tits_pas/widgets/dashboard/dossier_list_card.dart'; +import 'package:p_tits_pas/widgets/dashboard/pending_validation_widget.dart'; +import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart'; +import 'package:p_tits_pas/widgets/dashboard/validation_dossier_modal.dart'; /// Onglet permanent « Dossiers » (#153) : pending en haut + liste unifiée en bas. class DossiersManagementWidget extends StatefulWidget { diff --git a/frontend/lib/widgets/admin/enfant_management_widget.dart b/frontend/lib/widgets/dashboard/enfant_management_widget.dart similarity index 97% rename from frontend/lib/widgets/admin/enfant_management_widget.dart rename to frontend/lib/widgets/dashboard/enfant_management_widget.dart index 61f2ce9..0b5d957 100644 --- a/frontend/lib/widgets/admin/enfant_management_widget.dart +++ b/frontend/lib/widgets/dashboard/enfant_management_widget.dart @@ -7,8 +7,8 @@ import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/utils/staff_deletion_rights.dart'; import 'package:p_tits_pas/widgets/dashboard/child_detail_modal.dart'; import 'package:p_tits_pas/widgets/dashboard/enfant_user_card.dart'; -import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; -import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; +import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart'; +import 'package:p_tits_pas/widgets/dashboard/common/user_list.dart'; /// Onglet liste globale des enfants (doc 28 §6.2, ticket #137). class EnfantManagementWidget extends StatefulWidget { diff --git a/frontend/lib/widgets/admin/gestionnaire_management_widget.dart b/frontend/lib/widgets/dashboard/gestionnaire_management_widget.dart similarity index 96% rename from frontend/lib/widgets/admin/gestionnaire_management_widget.dart rename to frontend/lib/widgets/dashboard/gestionnaire_management_widget.dart index 468f43f..3e824e7 100644 --- a/frontend/lib/widgets/admin/gestionnaire_management_widget.dart +++ b/frontend/lib/widgets/dashboard/gestionnaire_management_widget.dart @@ -5,8 +5,8 @@ import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/utils/staff_deletion_rights.dart'; import 'package:p_tits_pas/widgets/dashboard/user_card.dart'; -import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; -import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; +import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart'; +import 'package:p_tits_pas/widgets/dashboard/common/user_list.dart'; class GestionnaireManagementWidget extends StatefulWidget { final String searchQuery; diff --git a/frontend/lib/widgets/admin/parametres_panel.dart b/frontend/lib/widgets/dashboard/parametres_panel.dart similarity index 99% rename from frontend/lib/widgets/admin/parametres_panel.dart rename to frontend/lib/widgets/dashboard/parametres_panel.dart index b99fd50..1f3612f 100644 --- a/frontend/lib/widgets/admin/parametres_panel.dart +++ b/frontend/lib/widgets/dashboard/parametres_panel.dart @@ -2,7 +2,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'; +import 'package:p_tits_pas/widgets/dashboard/relais_management_panel.dart'; /// Panneau Paramètres admin : Email (SMTP), Personnalisation, Avancé. class ParametresPanel extends StatefulWidget { diff --git a/frontend/lib/widgets/admin/parent_dossier_create_modal.dart b/frontend/lib/widgets/dashboard/parent_dossier_create_modal.dart similarity index 97% rename from frontend/lib/widgets/admin/parent_dossier_create_modal.dart rename to frontend/lib/widgets/dashboard/parent_dossier_create_modal.dart index 10f1126..55ba4bc 100644 --- a/frontend/lib/widgets/admin/parent_dossier_create_modal.dart +++ b/frontend/lib/widgets/dashboard/parent_dossier_create_modal.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:p_tits_pas/widgets/admin/parent_dossier_wizard.dart'; +import 'package:p_tits_pas/widgets/dashboard/parent_dossier_wizard.dart'; /// Modale de création dossier famille (#129) — même shell que [AmDossierCreateModal]. class ParentDossierCreateModal extends StatefulWidget { diff --git a/frontend/lib/widgets/admin/parent_dossier_wizard.dart b/frontend/lib/widgets/dashboard/parent_dossier_wizard.dart similarity index 99% rename from frontend/lib/widgets/admin/parent_dossier_wizard.dart rename to frontend/lib/widgets/dashboard/parent_dossier_wizard.dart index e50ca2a..fd2d289 100644 --- a/frontend/lib/widgets/admin/parent_dossier_wizard.dart +++ b/frontend/lib/widgets/dashboard/parent_dossier_wizard.dart @@ -15,10 +15,10 @@ import 'package:p_tits_pas/utils/name_format_utils.dart'; import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/utils/postal_utils.dart'; import 'package:p_tits_pas/widgets/dashboard/am_photo_frame.dart'; -import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; -import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; -import 'package:p_tits_pas/widgets/admin/validation_refus_form.dart'; -import 'package:p_tits_pas/widgets/admin/validation_valider_confirm_dialog.dart'; +import 'package:p_tits_pas/widgets/dashboard/common/validation_detail_section.dart'; +import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart'; +import 'package:p_tits_pas/widgets/dashboard/validation_refus_form.dart'; +import 'package:p_tits_pas/widgets/dashboard/validation_valider_confirm_dialog.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'package:p_tits_pas/widgets/common/identity_block.dart'; diff --git a/frontend/lib/widgets/dashboard/parent_edit_modal.dart b/frontend/lib/widgets/dashboard/parent_edit_modal.dart index c3c7093..75a6633 100644 --- a/frontend/lib/widgets/dashboard/parent_edit_modal.dart +++ b/frontend/lib/widgets/dashboard/parent_edit_modal.dart @@ -9,7 +9,7 @@ import 'package:p_tits_pas/widgets/dashboard/children_affiliation_panel.dart'; import 'package:p_tits_pas/widgets/dashboard/select_enfant_modal.dart'; import 'package:p_tits_pas/widgets/dashboard/status_capsule.dart'; import 'package:p_tits_pas/widgets/common/identity_block.dart'; -import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; +import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart'; /// Fiche parent éditable (doc 28 §6.1, tickets #131 / #138). /// Shell et typo alignés sur [ValidationDossierModal] / wizards validation. diff --git a/frontend/lib/widgets/admin/parent_managmant_widget.dart b/frontend/lib/widgets/dashboard/parent_managmant_widget.dart similarity index 97% rename from frontend/lib/widgets/admin/parent_managmant_widget.dart rename to frontend/lib/widgets/dashboard/parent_managmant_widget.dart index 96d912a..938b5fc 100644 --- a/frontend/lib/widgets/admin/parent_managmant_widget.dart +++ b/frontend/lib/widgets/dashboard/parent_managmant_widget.dart @@ -6,8 +6,8 @@ import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/utils/staff_deletion_rights.dart'; import 'package:p_tits_pas/widgets/dashboard/parent_edit_modal.dart'; import 'package:p_tits_pas/widgets/dashboard/user_card.dart'; -import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; -import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; +import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart'; +import 'package:p_tits_pas/widgets/dashboard/common/user_list.dart'; class ParentManagementWidget extends StatefulWidget { final String searchQuery; diff --git a/frontend/lib/widgets/admin/pending_validation_widget.dart b/frontend/lib/widgets/dashboard/pending_validation_widget.dart similarity index 98% rename from frontend/lib/widgets/admin/pending_validation_widget.dart rename to frontend/lib/widgets/dashboard/pending_validation_widget.dart index 4a71085..1adbdcf 100644 --- a/frontend/lib/widgets/admin/pending_validation_widget.dart +++ b/frontend/lib/widgets/dashboard/pending_validation_widget.dart @@ -3,9 +3,9 @@ import 'package:p_tits_pas/models/dossier_list_item.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/widgets/admin/dossier_list_card.dart'; -import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; -import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart'; +import 'package:p_tits_pas/widgets/dashboard/dossier_list_card.dart'; +import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart'; +import 'package:p_tits_pas/widgets/dashboard/validation_dossier_modal.dart'; /// Section « dossiers à valider » (liste unifiée AM + familles). Ticket #107 / #153. class PendingValidationWidget extends StatefulWidget { diff --git a/frontend/lib/widgets/admin/relais_management_panel.dart b/frontend/lib/widgets/dashboard/relais_management_panel.dart similarity index 99% rename from frontend/lib/widgets/admin/relais_management_panel.dart rename to frontend/lib/widgets/dashboard/relais_management_panel.dart index f63b5fe..17275f8 100644 --- a/frontend/lib/widgets/admin/relais_management_panel.dart +++ b/frontend/lib/widgets/dashboard/relais_management_panel.dart @@ -3,7 +3,7 @@ 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'; -import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; +import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart'; class RelaisManagementPanel extends StatefulWidget { const RelaisManagementPanel({super.key}); diff --git a/frontend/lib/widgets/dashboard/select_am_modal.dart b/frontend/lib/widgets/dashboard/select_am_modal.dart index b35c5a7..c0bb7c3 100644 --- a/frontend/lib/widgets/dashboard/select_am_modal.dart +++ b/frontend/lib/widgets/dashboard/select_am_modal.dart @@ -6,7 +6,7 @@ import 'package:p_tits_pas/utils/am_vigilance.dart'; import 'package:p_tits_pas/widgets/dashboard/am_edit_modal.dart'; import 'package:p_tits_pas/widgets/dashboard/select_list_modal.dart'; import 'package:p_tits_pas/widgets/dashboard/user_card.dart'; -import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; +import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart'; List _amSelectSubtitleLines(AssistanteMaternelleModel am) { final lines = []; diff --git a/frontend/lib/widgets/dashboard/select_list_modal.dart b/frontend/lib/widgets/dashboard/select_list_modal.dart index 0335cc7..758859f 100644 --- a/frontend/lib/widgets/dashboard/select_list_modal.dart +++ b/frontend/lib/widgets/dashboard/select_list_modal.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; +import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart'; /// Filtre optionnel (switch) sur la même ligne que la barre de recherche. class SelectToggleFilter { diff --git a/frontend/lib/widgets/admin/user_management_panel.dart b/frontend/lib/widgets/dashboard/user_management_panel.dart similarity index 95% rename from frontend/lib/widgets/admin/user_management_panel.dart rename to frontend/lib/widgets/dashboard/user_management_panel.dart index 270cee3..800435c 100644 --- a/frontend/lib/widgets/admin/user_management_panel.dart +++ b/frontend/lib/widgets/dashboard/user_management_panel.dart @@ -1,15 +1,15 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart'; import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart'; -import 'package:p_tits_pas/widgets/admin/am_dossier_create_modal.dart'; -import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart'; +import 'package:p_tits_pas/widgets/dashboard/am_dossier_create_modal.dart'; +import 'package:p_tits_pas/widgets/dashboard/assistante_maternelle_management_widget.dart'; import 'package:p_tits_pas/widgets/dashboard/child_detail_modal.dart'; import 'package:p_tits_pas/widgets/dashboard/user_management_sub_bar.dart'; -import 'package:p_tits_pas/widgets/admin/dossiers_management_widget.dart'; -import 'package:p_tits_pas/widgets/admin/enfant_management_widget.dart'; -import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart'; -import 'package:p_tits_pas/widgets/admin/parent_dossier_create_modal.dart'; -import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart'; +import 'package:p_tits_pas/widgets/dashboard/dossiers_management_widget.dart'; +import 'package:p_tits_pas/widgets/dashboard/enfant_management_widget.dart'; +import 'package:p_tits_pas/widgets/dashboard/gestionnaire_management_widget.dart'; +import 'package:p_tits_pas/widgets/dashboard/parent_dossier_create_modal.dart'; +import 'package:p_tits_pas/widgets/dashboard/parent_managmant_widget.dart'; class UserManagementPanel extends StatefulWidget { /// Afficher l'onglet Administrateurs (sinon sans Administrateurs). diff --git a/frontend/lib/widgets/admin/validation_am_wizard.dart b/frontend/lib/widgets/dashboard/validation_am_wizard.dart similarity index 91% rename from frontend/lib/widgets/admin/validation_am_wizard.dart rename to frontend/lib/widgets/dashboard/validation_am_wizard.dart index 0edc9ac..315fec4 100644 --- a/frontend/lib/widgets/admin/validation_am_wizard.dart +++ b/frontend/lib/widgets/dashboard/validation_am_wizard.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart'; -import 'package:p_tits_pas/widgets/admin/am_dossier_wizard.dart'; +import 'package:p_tits_pas/widgets/dashboard/am_dossier_wizard.dart'; /// Wrapper historique (#107) — délègue à [AmDossierWizard.review]. class ValidationAmWizard extends StatelessWidget { diff --git a/frontend/lib/widgets/admin/validation_dossier_modal.dart b/frontend/lib/widgets/dashboard/validation_dossier_modal.dart similarity index 95% rename from frontend/lib/widgets/admin/validation_dossier_modal.dart rename to frontend/lib/widgets/dashboard/validation_dossier_modal.dart index fefac12..d17b4b5 100644 --- a/frontend/lib/widgets/admin/validation_dossier_modal.dart +++ b/frontend/lib/widgets/dashboard/validation_dossier_modal.dart @@ -1,10 +1,10 @@ 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/am_dossier_wizard.dart'; -import 'package:p_tits_pas/widgets/admin/parent_dossier_wizard.dart'; -import 'package:p_tits_pas/widgets/admin/validation_am_wizard.dart'; -import 'package:p_tits_pas/widgets/admin/validation_family_wizard.dart'; +import 'package:p_tits_pas/widgets/dashboard/am_dossier_wizard.dart'; +import 'package:p_tits_pas/widgets/dashboard/parent_dossier_wizard.dart'; +import 'package:p_tits_pas/widgets/dashboard/validation_am_wizard.dart'; +import 'package:p_tits_pas/widgets/dashboard/validation_family_wizard.dart'; /// Modale (dialog) : charge le dossier par numéro puis affiche le wizard AM ou Famille. /// Ticket #107 / #119 (review), #135 (`openAsEdit`). diff --git a/frontend/lib/widgets/admin/validation_family_wizard.dart b/frontend/lib/widgets/dashboard/validation_family_wizard.dart similarity index 91% rename from frontend/lib/widgets/admin/validation_family_wizard.dart rename to frontend/lib/widgets/dashboard/validation_family_wizard.dart index f72fc4b..a426f4d 100644 --- a/frontend/lib/widgets/admin/validation_family_wizard.dart +++ b/frontend/lib/widgets/dashboard/validation_family_wizard.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart'; -import 'package:p_tits_pas/widgets/admin/parent_dossier_wizard.dart'; +import 'package:p_tits_pas/widgets/dashboard/parent_dossier_wizard.dart'; /// Wrapper historique (#107) — délègue à [ParentDossierWizard.review]. class ValidationFamilyWizard extends StatelessWidget { diff --git a/frontend/lib/widgets/admin/validation_modal_theme.dart b/frontend/lib/widgets/dashboard/validation_modal_theme.dart similarity index 100% rename from frontend/lib/widgets/admin/validation_modal_theme.dart rename to frontend/lib/widgets/dashboard/validation_modal_theme.dart diff --git a/frontend/lib/widgets/admin/validation_refus_form.dart b/frontend/lib/widgets/dashboard/validation_refus_form.dart similarity index 100% rename from frontend/lib/widgets/admin/validation_refus_form.dart rename to frontend/lib/widgets/dashboard/validation_refus_form.dart diff --git a/frontend/lib/widgets/admin/validation_valider_confirm_dialog.dart b/frontend/lib/widgets/dashboard/validation_valider_confirm_dialog.dart similarity index 100% rename from frontend/lib/widgets/admin/validation_valider_confirm_dialog.dart rename to frontend/lib/widgets/dashboard/validation_valider_confirm_dialog.dart From 939e7777ac5ede2170802e6825bdba456cefd5b0 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Mon, 14 Sep 2026 11:05:47 +0200 Subject: [PATCH 83/85] =?UTF-8?q?docs(#155):=20mini-spec=20phase=202=20?= =?UTF-8?q?=E2=80=94=20move=20panels=20admin=20=E2=86=92=20dashboard.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- docs/tmp/155-mini-spec-rename-admin-prefix.md | 4 +- ...55-suite-move-admin-panels-to-dashboard.md | 203 ++++++++++++++++++ 2 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 docs/tmp/155-suite-move-admin-panels-to-dashboard.md diff --git a/docs/tmp/155-mini-spec-rename-admin-prefix.md b/docs/tmp/155-mini-spec-rename-admin-prefix.md index dc8344b..afe3c4f 100644 --- a/docs/tmp/155-mini-spec-rename-admin-prefix.md +++ b/docs/tmp/155-mini-spec-rename-admin-prefix.md @@ -37,7 +37,9 @@ On garde `Admin*` seulement là où c’est vraiment le rôle administrateur. | `admin_detail_modal` → `AdminDetailModal` / `AdminDetailField` | `detail_modal` → `DetailModal` / `DetailField` | | `dashboard_admin.dart` | `user_management_sub_bar.dart` (`DashboardUserManagementSubBar` inchangé) | -Dossier `widgets/admin/` conserve les panels métier (`user_management_panel`, wizards, etc.) + `AdminManagementWidget`. +Dossier `widgets/admin/` conserve encore les panels métier (`user_management_panel`, wizards, etc.) + `AdminManagementWidget`. + +**Phase 2** (même ticket #155) : déplacer ces panels → `widgets/dashboard/` — voir [155-suite-move-admin-panels-to-dashboard.md](./155-suite-move-admin-panels-to-dashboard.md). ## Hors scope diff --git a/docs/tmp/155-suite-move-admin-panels-to-dashboard.md b/docs/tmp/155-suite-move-admin-panels-to-dashboard.md new file mode 100644 index 0000000..e5a86db --- /dev/null +++ b/docs/tmp/155-suite-move-admin-panels-to-dashboard.md @@ -0,0 +1,203 @@ +# Mini-spec — Déplacer les panels `widgets/admin/` → `widgets/dashboard/` + +**Ticket** : **#155** (phase 2 — même ticket que le rename `Admin*`) +**Phase 1** : widgets `Admin*` → `widgets/dashboard/` (déjà sur `feature/155-rename-admin-prefix-dashboard`) +**Branche** : poursuivre / rebaser `feature/155-rename-admin-prefix-dashboard` (ou nouvelle branche depuis `develop` après merge phase 1) +**Nature** : rename / move mécanique — **zéro** changement UX / métier + +--- + +## Contexte + +Après la phase 1 (#155), la situation est **hybride** : + +| Emplacement | Contenu | +|-------------|---------| +| `widgets/dashboard/` | Composants partagés sans préfixe `Admin*` (modales, cartes, selects, sub-bar…) | +| `widgets/admin/` | **Panels** du dashboard staff (listes, wizards, validation, shell `UserManagementPanel`…) + `AdminManagementWidget` | + +Le dossier `admin/` laisse encore croire « réservé administrateur », alors que **gestionnaire** consomme les mêmes panels (`GestionnaireDashboardScreen` → `UserManagementPanel`). + +Ce ticket **termine l’option C** au niveau dossier : tout le dashboard staff vit sous `widgets/dashboard/`, sauf ce qui est **vraiment** rôle admin. + +--- + +## Objectif + +``` +frontend/lib/widgets/admin/ + ↓ git mv + update imports +frontend/lib/widgets/dashboard/… +``` + +Critère : un nouveau dev ne doit plus ouvrir `widgets/admin/` pour du code partagé admin+gestionnaire. + +--- + +## Cible d’arborescence (proposée) + +``` +widgets/dashboard/ + ├── (déjà là #155) child_detail_modal.dart, am_edit_modal.dart, user_card.dart, … + ├── user_management_panel.dart ← shell onglets + ├── user_management_sub_bar.dart ← déjà déplacé #155 + ├── dossiers_management_widget.dart + ├── dossier_list_card.dart + ├── parent_management_widget.dart ← corriger le typo managmant au passage ? + ├── enfant_management_widget.dart + ├── assistante_maternelle_management_widget.dart + ├── gestionnaire_management_widget.dart + ├── pending_validation_widget.dart + ├── parent_dossier_create_modal.dart + ├── parent_dossier_wizard.dart + ├── am_dossier_create_modal.dart + ├── am_dossier_wizard.dart + ├── validation_*.dart ← family/am wizards, refus, theme, confirm + ├── parametres_panel.dart ← utilisé par écran admin (OK dans dashboard) + ├── relais_management_panel.dart + ├── common/ ← sous-dossier optionnel + │ ├── suppression_confirm_dialog.dart + │ ├── user_list.dart + │ └── validation_detail_section.dart + └── … + +widgets/admin/ ← mince, rôle admin seulement + └── admin_management_widget.dart ← onglet Administrateurs +``` + +### Variante B (plus stricte) + +`AdminManagementWidget` + éventuels helpers purement admin → +`screens/administrateurs/widgets/` +et **suppression** du dossier `widgets/admin/`. + +**Reco** : **variante A** (garder `widgets/admin/` minimal avec seulement `AdminManagementWidget`) — moins de churn screens, clair. + +--- + +## Inventaire à déplacer (état actuel) + +### Racine `widgets/admin/` → `widgets/dashboard/` + +| Fichier actuel | Notes | +|----------------|--------| +| `user_management_panel.dart` | Shell partagé admin + gestionnaire | +| `dossiers_management_widget.dart` | | +| `dossier_list_card.dart` | | +| `parent_managmant_widget.dart` | Typo historique `managmant` — **option** : renommer → `parent_management_widget.dart` dans le même ticket ou ticket typo séparé | +| `enfant_management_widget.dart` | | +| `assistante_maternelle_management_widget.dart` | | +| `gestionnaire_management_widget.dart` | | +| `pending_validation_widget.dart` | | +| `parent_dossier_create_modal.dart` | | +| `parent_dossier_wizard.dart` | | +| `am_dossier_create_modal.dart` | | +| `am_dossier_wizard.dart` | | +| `validation_am_wizard.dart` | | +| `validation_family_wizard.dart` | | +| `validation_dossier_modal.dart` | | +| `validation_modal_theme.dart` | | +| `validation_refus_form.dart` | | +| `validation_valider_confirm_dialog.dart` | | +| `parametres_panel.dart` | Écran admin seulement, mais pas préfixé Admin — OK dashboard | +| `relais_management_panel.dart` | | + +### `widgets/admin/common/` → `widgets/dashboard/common/` (ou plat) + +| Fichier | Notes | +|---------|--------| +| `suppression_confirm_dialog.dart` | Partagé (y compris `screens/administrateurs/creation/*`) | +| `user_list.dart` | | +| `validation_detail_section.dart` | | + +### **Ne pas** déplacer + +| Fichier | Destination | +|---------|-------------| +| `admin_management_widget.dart` | Reste `widgets/admin/` (ou variante B → screens) | + +### Déjà fait (#155) — ne pas retraiter + +Tout ce qui est déjà sous `widgets/dashboard/` (`child_detail_modal`, `am_edit_modal`, `user_card`, `select_*`, `user_management_sub_bar`, …). + +--- + +## Consommateurs d’imports (à mettre à jour) + +### Screens +- `screens/administrateurs/admin_dashboardScreen.dart` — `UserManagementPanel`, `ParametresPanel` +- `screens/gestionnaire/gestionnaire_dashboard_screen.dart` — `UserManagementPanel` +- `screens/administrateurs/creation/admin_create.dart` — `suppression_confirm_dialog` +- `screens/administrateurs/creation/gestionnaires_create.dart` — idem + +### Widgets déjà en `dashboard/` +- `am_edit_modal`, `child_detail_modal`, `parent_edit_modal`, `select_*` — imports vers `widgets/admin/common/*` ou panels + +### Divers +- `widgets/common/identity_block.dart` (si import admin) +- Tous les fichiers **déplacés** entre eux (imports relatifs / package) + +### Hors scope rename classes +Sauf décision explicite sur le typo `parent_managmant_widget` → pas de rename de **classes** métier dans ce ticket (seulement chemins de fichiers + imports). +`AdminManagementWidget` **conserve** son nom. + +--- + +## Plan d’exécution + +1. Partir de `feature/155-rename-admin-prefix-dashboard` (phase 1) **ou** `develop` si phase 1 déjà mergée +2. `git mv` fichiers selon inventaire +3. Remplacer globalement + `package:p_tits_pas/widgets/admin/` → `package:p_tits_pas/widgets/dashboard/` + **sauf** `…/widgets/admin/admin_management_widget.dart` +4. Corriger imports relatifs cassés +5. Grep de contrôle (ci-dessous) +6. Build Flutter web (Docker) + smoke dashboard admin **et** gestionnaire +7. Merge → squash master si flux habituel + +--- + +## Vérifs + +```bash +# Plus de panels partagés sous admin (seul AdminManagement attendu) +find frontend/lib/widgets/admin -name '*.dart' + +# Plus d’imports panels vers l’ancien chemin (sauf AdminManagement) +rg -n "widgets/admin/(user_management|dossiers_|parent_|enfant_|assistante|gestionnaire|pending|validation_|parametres|relais|am_dossier|parent_dossier|dossier_list|common/)" frontend/lib + +# Screens OK +rg -n "widgets/admin/" frontend/lib/screens +``` + +Attendu screens : **0** hit vers panels ; éventuellement plus aucun hit `widgets/admin/` sauf si import explicite `AdminManagementWidget` depuis `user_management_panel` (chemin `widgets/admin/admin_management_widget.dart`). + +--- + +## Hors scope + +- Refonte UX des modales / panels (ticket dédié annoncé) +- Rename `EnfantAdminModel` +- Rename `screens/administrateurs/` +- Rename `AdminUserFormDialog` / `AdminCreateDialog` +- Changement API / back +- #152 (`est_multiple`) — autre branche + +--- + +## Critères d’acceptation + +- [ ] Inventaire déplacé selon tableau +- [ ] `widgets/admin/` ne contient plus que `admin_management_widget.dart` (variante A) +- [ ] Imports screens + widgets à jour +- [ ] Build Flutter OK +- [ ] Recette : dashboard **administrateur** et **gestionnaire** (listes, ouverture fiches, validation, création dossier) sans régression +- [ ] Aucun changement comportemental volontaire + +--- + +## Risques / notes + +- **Conflits de merge** si d’autres features touchent les panels → faire ce ticket quand la surface dashboard est calme (fin 0.1.0 OK) +- Typo `parent_managmant_widget` : soit inclus (bonus), soit ticket cleanup 1-ligne séparé +- Docs d’archive citant `widgets/admin/…` : pas obligatoire de mettre à jour ; `docs/27_BRIEFING-FRONTEND.md` oui si encore listé From 1d6261b3129c49390a16249c3582b0e72d9d29b1 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Mon, 14 Sep 2026 17:29:13 +0200 Subject: [PATCH 84/85] feat(#164): uniformise la modale staff (gestionnaire / admin). Shell 930 + Validation*Field, dirty-state, StaffUserFormModal sous widgets/dashboard/ ; retire AdminUserFormDialog. Co-authored-by: Cursor --- ...64-staff-modal-uniformisation-mini-spec.md | 14 + .../creation/gestionnaires_create.dart | 675 --------------- .../admin/admin_management_widget.dart | 4 +- .../gestionnaire_management_widget.dart | 4 +- .../dashboard/staff_user_form_modal.dart | 794 ++++++++++++++++++ .../dashboard/user_management_panel.dart | 6 +- 6 files changed, 815 insertions(+), 682 deletions(-) create mode 100644 docs/tmp/164-staff-modal-uniformisation-mini-spec.md delete mode 100644 frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart create mode 100644 frontend/lib/widgets/dashboard/staff_user_form_modal.dart diff --git a/docs/tmp/164-staff-modal-uniformisation-mini-spec.md b/docs/tmp/164-staff-modal-uniformisation-mini-spec.md new file mode 100644 index 0000000..f035ca8 --- /dev/null +++ b/docs/tmp/164-staff-modal-uniformisation-mini-spec.md @@ -0,0 +1,14 @@ +# Mini-spec — Uniformisation modale staff (Gestionnaire / Administrateur) + +**Ticket** : **#164** — https://git.ptits-pas.fr/jmartin/petitspas/issues/164 +**Branche** : `feature/164-staff-modal-uniformisation` (depuis `develop`) +**Périmètre** : **front only** — pas d’API / BDD +**Milestone** : **0.1.0** + +Voir le corps du ticket #164 pour la spec complète. + +## Livré + +- `frontend/lib/widgets/dashboard/staff_user_form_modal.dart` → `StaffUserFormModal` +- Ancien `AdminUserFormDialog` / `gestionnaires_create.dart` retiré +- Imports : `user_management_panel`, `gestionnaire_management_widget`, `admin_management_widget` diff --git a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart deleted file mode 100644 index 89c1d55..0000000 --- a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart +++ /dev/null @@ -1,675 +0,0 @@ -import 'package:flutter/material.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/auth_service.dart'; -import 'package:p_tits_pas/services/relais_service.dart'; -import 'package:p_tits_pas/services/user_service.dart'; -import 'package:p_tits_pas/utils/staff_deletion_rights.dart'; -import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart'; - -class AdminUserFormDialog extends StatefulWidget { - final AppUser? initialUser; - final bool withRelais; - final bool adminMode; - final bool readOnly; - - const AdminUserFormDialog({ - super.key, - this.initialUser, - this.withRelais = true, - this.adminMode = false, - this.readOnly = false, - }); - - @override - State createState() => _AdminUserFormDialogState(); -} - -class _AdminUserFormDialogState extends State { - final _formKey = GlobalKey(); - final _nomController = TextEditingController(); - final _prenomController = TextEditingController(); - final _emailController = TextEditingController(); - final _passwordController = TextEditingController(); - final _telephoneController = TextEditingController(); - final _passwordToggleFocusNode = - FocusNode(skipTraversal: true, canRequestFocus: false); - - bool _isSubmitting = false; - bool _obscurePassword = true; - bool _isLoadingRelais = true; - List _relais = []; - String? _selectedRelaisId; - String? _currentUserId; - String? _currentUserRole; - bool get _isEditMode => widget.initialUser != null; - bool get _isSuperAdminTarget => - (widget.initialUser?.role ?? '').toLowerCase() == 'super_admin'; - bool get _isSelfTarget => - _isEditMode && - _currentUserId != null && - widget.initialUser!.id == _currentUserId; - /// Gestionnaire : pas de delete staff. Admin+ seulement (#154 / #160). - bool get _canDeleteTarget { - if (!_isEditMode || widget.readOnly) return false; - if (_isSelfTarget || _isSuperAdminTarget) return false; - return canDeleteGestionnaire(_currentUserRole); - } - bool get _isLockedAdminIdentity => - _isEditMode && widget.adminMode && _isSuperAdminTarget; - String get _targetRoleKey { - if (widget.initialUser != null) { - return (widget.initialUser!.role).toLowerCase(); - } - return widget.adminMode ? 'administrateur' : 'gestionnaire'; - } - - String get _targetRoleLabel { - switch (_targetRoleKey) { - case 'super_admin': - return 'Super administrateur'; - case 'administrateur': - return 'Administrateur'; - case 'gestionnaire': - return 'Gestionnaire'; - case 'assistante_maternelle': - return 'Assistante maternelle'; - case 'parent': - return 'Parent'; - default: - return 'Utilisateur'; - } - } - - IconData get _targetRoleIcon { - switch (_targetRoleKey) { - case 'super_admin': - return Icons.verified_user_outlined; - case 'administrateur': - return Icons.admin_panel_settings_outlined; - case 'gestionnaire': - return Icons.assignment_ind_outlined; - case 'assistante_maternelle': - return Icons.child_care_outlined; - case 'parent': - return Icons.supervisor_account_outlined; - default: - return Icons.person_outline; - } - } - - @override - void initState() { - super.initState(); - final user = widget.initialUser; - if (user != null) { - _nomController.text = user.nom ?? ''; - _prenomController.text = user.prenom ?? ''; - _emailController.text = user.email; - _telephoneController.text = formatPhoneForDisplay(user.telephone ?? ''); - // En édition, on ne préremplit jamais le mot de passe. - _passwordController.clear(); - final initialRelaisId = user.relaisId?.trim(); - _selectedRelaisId = - (initialRelaisId == null || initialRelaisId.isEmpty) - ? null - : initialRelaisId; - } - if (widget.withRelais) { - _loadRelais(); - } else { - _isLoadingRelais = false; - } - _loadCurrentUserId(); - } - - Future _loadCurrentUserId() async { - final cached = await AuthService.getCurrentUser(); - if (!mounted) return; - if (cached != null) { - setState(() { - _currentUserId = cached.id; - _currentUserRole = cached.role; - }); - return; - } - final refreshed = await AuthService.refreshCurrentUser(); - if (!mounted || refreshed == null) return; - setState(() { - _currentUserId = refreshed.id; - _currentUserRole = refreshed.role; - }); - } - - @override - void dispose() { - _nomController.dispose(); - _prenomController.dispose(); - _emailController.dispose(); - _passwordController.dispose(); - _telephoneController.dispose(); - _passwordToggleFocusNode.dispose(); - super.dispose(); - } - - /// Fallback si GET /relais échoue : conserve le relais déjà connu sur l'utilisateur. - List _fallbackRelaisFromUser() { - final id = _selectedRelaisId?.trim(); - if (id == null || id.isEmpty) return const []; - final nom = (widget.initialUser?.relaisNom ?? '').trim(); - return [ - RelaisModel( - id: id, - nom: nom.isNotEmpty ? nom : 'Relais actuel', - adresse: '', - actif: true, - ), - ]; - } - - Future _loadRelais() async { - try { - final list = await RelaisService.getRelais(); - if (!mounted) return; - final uniqueById = {}; - for (final relais in list) { - uniqueById[relais.id] = relais; - } - - final filtered = uniqueById.values.where((r) => r.actif).toList(); - if (_selectedRelaisId != null && - !filtered.any((r) => r.id == _selectedRelaisId)) { - final selected = uniqueById[_selectedRelaisId!]; - if (selected != null) { - filtered.add(selected); - } else { - // Garder l'id sélectionné et afficher un item de secours (nom carte). - filtered.addAll(_fallbackRelaisFromUser()); - } - } - - setState(() { - _relais = filtered; - _isLoadingRelais = false; - }); - } catch (_) { - if (!mounted) return; - // Ne pas nullifier _selectedRelaisId (#151) — la carte a déjà le bon libellé. - setState(() { - _relais = _fallbackRelaisFromUser(); - _isLoadingRelais = false; - }); - } - } - - String? _required(String? value, String field) { - if (value == null || value.trim().isEmpty) { - return '$field est requis'; - } - return null; - } - - String? _validateEmail(String? value) { - final base = _required(value, 'Email'); - if (base != null) { - return base; - } - return validateEmail(value, allowEmpty: true); - } - - String? _validatePassword(String? value) { - if (_isEditMode && (value == null || value.trim().isEmpty)) { - return null; - } - final base = _required(value, 'Mot de passe'); - if (base != null) return base; - if (value!.trim().length < 6) return 'Minimum 6 caractères'; - return null; - } - - String? _validatePhone(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); - } - - String _toTitleCase(String raw) { - final trimmed = raw.trim(); - if (trimmed.isEmpty) return trimmed; - final words = trimmed.split(RegExp(r'\s+')); - final normalizedWords = words.map(_capitalizeComposedWord).toList(); - return normalizedWords.join(' '); - } - - String _capitalizeComposedWord(String word) { - if (word.isEmpty) return word; - final lower = word.toLowerCase(); - final 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(); - } - - Future _submit() async { - if (widget.readOnly) return; - if (_isSubmitting) return; - if (!_formKey.currentState!.validate()) return; - - setState(() { - _isSubmitting = true; - }); - - try { - final normalizedNom = _toTitleCase(_nomController.text); - final normalizedPrenom = _toTitleCase(_prenomController.text); - final normalizedPhone = normalizePhone(_telephoneController.text); - final passwordProvided = _passwordController.text.trim().isNotEmpty; - - if (_isEditMode) { - if (widget.adminMode) { - final lockedNom = _toTitleCase(widget.initialUser!.nom ?? ''); - final lockedPrenom = _toTitleCase(widget.initialUser!.prenom ?? ''); - await UserService.updateAdministrateur( - adminId: widget.initialUser!.id, - nom: _isLockedAdminIdentity ? lockedNom : normalizedNom, - prenom: _isLockedAdminIdentity ? lockedPrenom : normalizedPrenom, - email: _emailController.text.trim(), - telephone: normalizedPhone.isEmpty - ? normalizePhone(widget.initialUser!.telephone ?? '') - : normalizedPhone, - password: passwordProvided ? _passwordController.text : null, - ); - } else { - final currentUser = widget.initialUser!; - final initialNom = _toTitleCase(currentUser.nom ?? ''); - final initialPrenom = _toTitleCase(currentUser.prenom ?? ''); - final initialEmail = currentUser.email.trim(); - final initialPhone = normalizePhone(currentUser.telephone ?? ''); - - final onlyRelaisChanged = - normalizedNom == initialNom && - normalizedPrenom == initialPrenom && - _emailController.text.trim() == initialEmail && - normalizedPhone == initialPhone && - !passwordProvided; - - if (onlyRelaisChanged) { - await UserService.updateGestionnaireRelais( - gestionnaireId: currentUser.id, - relaisId: _selectedRelaisId, - ); - } else { - await UserService.updateGestionnaire( - gestionnaireId: currentUser.id, - nom: normalizedNom, - prenom: normalizedPrenom, - email: _emailController.text.trim(), - telephone: normalizedPhone.isEmpty ? initialPhone : normalizedPhone, - relaisId: _selectedRelaisId, - password: passwordProvided ? _passwordController.text : null, - ); - } - } - } else { - if (widget.adminMode) { - await UserService.createAdministrateur( - nom: normalizedNom, - prenom: normalizedPrenom, - email: _emailController.text.trim(), - password: _passwordController.text, - telephone: normalizePhone(_telephoneController.text), - ); - } else { - await UserService.createGestionnaire( - nom: normalizedNom, - prenom: normalizedPrenom, - email: _emailController.text.trim(), - password: _passwordController.text, - telephone: normalizePhone(_telephoneController.text), - relaisId: _selectedRelaisId, - ); - } - } - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - _isEditMode - ? (widget.adminMode - ? 'Administrateur modifié avec succès.' - : 'Gestionnaire modifié avec succès.') - : (widget.adminMode - ? 'Administrateur créé avec succès.' - : 'Gestionnaire créé avec succès.'), - ), - ), - ); - Navigator.of(context).pop(true); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - e.toString().replaceFirst('Exception: ', ''), - ), - backgroundColor: Colors.red.shade700, - ), - ); - } finally { - if (!mounted) return; - setState(() { - _isSubmitting = false; - }); - } - } - - Future _delete() async { - if (widget.readOnly) return; - if (!_canDeleteTarget) return; - if (!_isEditMode || _isSubmitting) return; - - final name = widget.initialUser!.fullName.isEmpty - ? widget.initialUser!.email - : widget.initialUser!.fullName; - final confirmed = await showSuppressionConfirmDialog( - context, - title: widget.adminMode - ? 'Supprimer l\'administrateur' - : 'Supprimer le gestionnaire', - people: [ - widget.adminMode - ? SuppressionPersonLine.administrateur(name) - : SuppressionPersonLine.gestionnaire(name), - ], - footnotes: const [ - 'Le compte sera définitivement supprimé.', - ], - ); - - if (!confirmed) return; - - setState(() { - _isSubmitting = true; - }); - try { - await UserService.deleteUser(widget.initialUser!.id); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Gestionnaire supprimé.')), - ); - Navigator.of(context).pop(true); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(e.toString().replaceFirst('Exception: ', '')), - backgroundColor: Colors.red.shade700, - ), - ); - setState(() { - _isSubmitting = false; - }); - } - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: Row( - children: [ - CircleAvatar( - radius: 16, - backgroundColor: const Color(0xFFEDE5FA), - child: Icon( - _targetRoleIcon, - size: 20, - color: const Color(0xFF6B3FA0), - ), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - _isEditMode - ? (widget.readOnly - ? 'Consulter un "$_targetRoleLabel"' - : 'Modifier un "$_targetRoleLabel"') - : 'Créer un "$_targetRoleLabel"', - ), - ), - if (_isEditMode && !widget.readOnly) - IconButton( - icon: const Icon(Icons.close), - tooltip: 'Fermer', - onPressed: _isSubmitting - ? null - : () => Navigator.of(context).pop(false), - ), - ], - ), - content: SizedBox( - width: 620, - child: Form( - key: _formKey, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Expanded(child: _buildPrenomField()), - const SizedBox(width: 12), - Expanded(child: _buildNomField()), - ], - ), - const SizedBox(height: 12), - _buildEmailField(), - const SizedBox(height: 12), - Row( - children: [ - Expanded(child: _buildPasswordField()), - const SizedBox(width: 12), - Expanded(child: _buildTelephoneField()), - ], - ), - if (widget.withRelais) ...[ - const SizedBox(height: 12), - _buildRelaisField(), - ], - ], - ), - ), - ), - ), - actions: [ - if (widget.readOnly) ...[ - FilledButton( - onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(false), - child: const Text('Fermer'), - ), - ] else if (_isEditMode) ...[ - if (_canDeleteTarget) - OutlinedButton( - onPressed: _isSubmitting ? null : _delete, - style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700), - child: const Text('Supprimer'), - ), - FilledButton.icon( - onPressed: _isSubmitting ? null : _submit, - icon: _isSubmitting - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.edit), - label: Text(_isSubmitting ? 'Modification...' : 'Modifier'), - ), - ] else ...[ - OutlinedButton( - onPressed: - _isSubmitting ? null : () => Navigator.of(context).pop(false), - child: const Text('Annuler'), - ), - FilledButton.icon( - onPressed: _isSubmitting ? null : _submit, - icon: _isSubmitting - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.person_add_alt_1), - label: Text(_isSubmitting ? 'Création...' : 'Créer'), - ), - ], - ], - ); - } - - Widget _buildNomField() { - return TextFormField( - controller: _nomController, - readOnly: widget.readOnly || _isLockedAdminIdentity, - textCapitalization: TextCapitalization.words, - decoration: const InputDecoration( - labelText: 'Nom', - border: OutlineInputBorder(), - ), - validator: (widget.readOnly || _isLockedAdminIdentity) - ? null - : (v) => _required(v, 'Nom'), - ); - } - - Widget _buildPrenomField() { - return TextFormField( - controller: _prenomController, - readOnly: widget.readOnly || _isLockedAdminIdentity, - textCapitalization: TextCapitalization.words, - decoration: const InputDecoration( - labelText: 'Prénom', - border: OutlineInputBorder(), - ), - validator: (widget.readOnly || _isLockedAdminIdentity) - ? null - : (v) => _required(v, 'Prénom'), - ); - } - - Widget _buildEmailField() { - return EmailTextFormField( - controller: _emailController, - readOnly: widget.readOnly, - label: 'Email', - validator: widget.readOnly ? null : _validateEmail, - ); - } - - Widget _buildPasswordField() { - return TextFormField( - controller: _passwordController, - readOnly: widget.readOnly, - obscureText: _obscurePassword, - enableSuggestions: false, - autocorrect: false, - autofillHints: _isEditMode - ? const [] - : const [AutofillHints.newPassword], - decoration: InputDecoration( - labelText: _isEditMode - ? 'Nouveau mot de passe' - : 'Mot de passe', - border: const OutlineInputBorder(), - suffixIcon: widget.readOnly - ? null - : ExcludeFocus( - child: IconButton( - focusNode: _passwordToggleFocusNode, - onPressed: () { - setState(() { - _obscurePassword = !_obscurePassword; - }); - }, - icon: Icon( - _obscurePassword ? Icons.visibility_off : Icons.visibility, - ), - ), - ), - ), - validator: widget.readOnly ? null : _validatePassword, - ); - } - - Widget _buildTelephoneField() { - return FrenchPhoneTextFormField( - controller: _telephoneController, - readOnly: widget.readOnly, - label: 'Téléphone (ex: 06 12 34 56 78)', - validator: widget.readOnly ? null : _validatePhone, - ); - } - - Widget _buildRelaisField() { - final selectedValue = _selectedRelaisId != null && - _relais.any((relais) => relais.id == _selectedRelaisId) - ? _selectedRelaisId - : null; - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - DropdownButtonFormField( - isExpanded: true, - value: selectedValue, - decoration: const InputDecoration( - labelText: 'Relais principal', - border: OutlineInputBorder(), - ), - items: [ - const DropdownMenuItem( - value: null, - child: Text('Aucun relais'), - ), - ..._relais.map( - (relais) => DropdownMenuItem( - value: relais.id, - child: Text(relais.nom), - ), - ), - ], - onChanged: (_isLoadingRelais || widget.readOnly) - ? null - : (value) { - setState(() { - _selectedRelaisId = value; - }); - }, - ), - if (_isLoadingRelais) ...[ - const SizedBox(height: 8), - const LinearProgressIndicator(minHeight: 2), - ], - ], - ); - } -} diff --git a/frontend/lib/widgets/admin/admin_management_widget.dart b/frontend/lib/widgets/admin/admin_management_widget.dart index facaa8b..95d186d 100644 --- a/frontend/lib/widgets/admin/admin_management_widget.dart +++ b/frontend/lib/widgets/admin/admin_management_widget.dart @@ -1,7 +1,7 @@ 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/widgets/dashboard/staff_user_form_modal.dart'; import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/utils/staff_deletion_rights.dart'; @@ -100,7 +100,7 @@ class _AdminManagementWidgetState extends State { context: context, barrierDismissible: false, builder: (dialogContext) { - return AdminUserFormDialog( + return StaffUserFormModal( initialUser: user, adminMode: true, withRelais: false, diff --git a/frontend/lib/widgets/dashboard/gestionnaire_management_widget.dart b/frontend/lib/widgets/dashboard/gestionnaire_management_widget.dart index 3e824e7..970c90d 100644 --- a/frontend/lib/widgets/dashboard/gestionnaire_management_widget.dart +++ b/frontend/lib/widgets/dashboard/gestionnaire_management_widget.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/user.dart'; -import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart'; +import 'package:p_tits_pas/widgets/dashboard/staff_user_form_modal.dart'; import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/utils/staff_deletion_rights.dart'; @@ -74,7 +74,7 @@ class _GestionnaireManagementWidgetState context: context, barrierDismissible: false, builder: (dialogContext) { - return AdminUserFormDialog(initialUser: user); + return StaffUserFormModal(initialUser: user); }, ); if (changed == true) { diff --git a/frontend/lib/widgets/dashboard/staff_user_form_modal.dart b/frontend/lib/widgets/dashboard/staff_user_form_modal.dart new file mode 100644 index 0000000..abcf2f0 --- /dev/null +++ b/frontend/lib/widgets/dashboard/staff_user_form_modal.dart @@ -0,0 +1,794 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/relais_model.dart'; +import 'package:p_tits_pas/models/user.dart'; +import 'package:p_tits_pas/services/auth_service.dart'; +import 'package:p_tits_pas/services/relais_service.dart'; +import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/utils/email_utils.dart'; +import 'package:p_tits_pas/utils/name_format_utils.dart'; +import 'package:p_tits_pas/utils/phone_utils.dart'; +import 'package:p_tits_pas/utils/staff_deletion_rights.dart'; +import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart'; +import 'package:p_tits_pas/widgets/dashboard/common/validation_detail_section.dart'; +import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart'; + +/// Modale création / édition / consultation staff (gestionnaire / admin) — #164. +class StaffUserFormModal extends StatefulWidget { + final AppUser? initialUser; + final bool withRelais; + final bool adminMode; + final bool readOnly; + + const StaffUserFormModal({ + super.key, + this.initialUser, + this.withRelais = true, + this.adminMode = false, + this.readOnly = false, + }); + + @override + State createState() => _StaffUserFormModalState(); +} + +class _StaffUserFormModalState extends State { + static const double _modalWidth = 930; + + final _formKey = GlobalKey(); + final _nomController = TextEditingController(); + final _prenomController = TextEditingController(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + final _telephoneController = TextEditingController(); + final _passwordToggleFocusNode = + FocusNode(skipTraversal: true, canRequestFocus: false); + + bool _isSubmitting = false; + bool _obscurePassword = true; + bool _isLoadingRelais = true; + bool _dirty = false; + List _relais = []; + String? _selectedRelaisId; + String? _currentUserId; + String? _currentUserRole; + + String _baselineNom = ''; + String _baselinePrenom = ''; + String _baselineEmail = ''; + String _baselinePhone = ''; + String? _baselineRelaisId; + + bool get _isEditMode => widget.initialUser != null; + bool get _isSuperAdminTarget => + (widget.initialUser?.role ?? '').toLowerCase() == 'super_admin'; + bool get _isSelfTarget => + _isEditMode && + _currentUserId != null && + widget.initialUser!.id == _currentUserId; + bool get _canDeleteTarget { + if (!_isEditMode || widget.readOnly) return false; + if (_isSelfTarget || _isSuperAdminTarget) return false; + return canDeleteGestionnaire(_currentUserRole); + } + + bool get _isLockedAdminIdentity => + _isEditMode && widget.adminMode && _isSuperAdminTarget; + + bool get _fieldsEnabled => !widget.readOnly && !_isSubmitting; + + String get _targetRoleKey { + if (widget.initialUser != null) { + return (widget.initialUser!.role).toLowerCase(); + } + return widget.adminMode ? 'administrateur' : 'gestionnaire'; + } + + String get _targetRoleLabel { + switch (_targetRoleKey) { + case 'super_admin': + return 'Super administrateur'; + case 'administrateur': + return 'Administrateur'; + case 'gestionnaire': + return 'Gestionnaire'; + default: + return 'Utilisateur'; + } + } + + IconData get _targetRoleIcon { + switch (_targetRoleKey) { + case 'super_admin': + return Icons.verified_user_outlined; + case 'administrateur': + return Icons.admin_panel_settings_outlined; + case 'gestionnaire': + return Icons.assignment_ind_outlined; + default: + return Icons.person_outline; + } + } + + @override + void initState() { + super.initState(); + final user = widget.initialUser; + if (user != null) { + _nomController.text = user.nom ?? ''; + _prenomController.text = user.prenom ?? ''; + _emailController.text = user.email; + _telephoneController.text = formatPhoneForDisplay(user.telephone ?? ''); + _passwordController.clear(); + final initialRelaisId = user.relaisId?.trim(); + _selectedRelaisId = + (initialRelaisId == null || initialRelaisId.isEmpty) + ? null + : initialRelaisId; + _captureBaseline(); + } + for (final c in [ + _nomController, + _prenomController, + _emailController, + _passwordController, + _telephoneController, + ]) { + c.addListener(_onFieldChanged); + } + if (widget.withRelais) { + _loadRelais(); + } else { + _isLoadingRelais = false; + } + _loadCurrentUserId(); + } + + void _captureBaseline() { + _baselineNom = formatPersonNameCase(_nomController.text); + _baselinePrenom = formatPersonNameCase(_prenomController.text); + _baselineEmail = normalizeEmailText(_emailController.text); + _baselinePhone = normalizePhone(_telephoneController.text); + _baselineRelaisId = _selectedRelaisId; + } + + void _onFieldChanged() { + if (widget.readOnly || !_isEditMode) return; + final dirty = _computeDirty(); + if (dirty != _dirty) setState(() => _dirty = dirty); + } + + bool _computeDirty() { + if (!_isEditMode) return true; + final nom = formatPersonNameCase(_nomController.text); + final prenom = formatPersonNameCase(_prenomController.text); + final email = normalizeEmailText(_emailController.text); + final phone = normalizePhone(_telephoneController.text); + final passwordProvided = _passwordController.text.trim().isNotEmpty; + return nom != _baselineNom || + prenom != _baselinePrenom || + email != _baselineEmail || + phone != _baselinePhone || + passwordProvided || + _selectedRelaisId != _baselineRelaisId; + } + + Future _loadCurrentUserId() async { + final cached = await AuthService.getCurrentUser(); + if (!mounted) return; + if (cached != null) { + setState(() { + _currentUserId = cached.id; + _currentUserRole = cached.role; + }); + return; + } + final refreshed = await AuthService.refreshCurrentUser(); + if (!mounted || refreshed == null) return; + setState(() { + _currentUserId = refreshed.id; + _currentUserRole = refreshed.role; + }); + } + + @override + void dispose() { + for (final c in [ + _nomController, + _prenomController, + _emailController, + _passwordController, + _telephoneController, + ]) { + c.removeListener(_onFieldChanged); + c.dispose(); + } + _passwordToggleFocusNode.dispose(); + super.dispose(); + } + + List _fallbackRelaisFromUser() { + final id = _selectedRelaisId?.trim(); + if (id == null || id.isEmpty) return const []; + final nom = (widget.initialUser?.relaisNom ?? '').trim(); + return [ + RelaisModel( + id: id, + nom: nom.isNotEmpty ? nom : 'Relais actuel', + adresse: '', + actif: true, + ), + ]; + } + + Future _loadRelais() async { + try { + final list = await RelaisService.getRelais(); + if (!mounted) return; + final uniqueById = {}; + for (final relais in list) { + uniqueById[relais.id] = relais; + } + + final filtered = uniqueById.values.where((r) => r.actif).toList(); + if (_selectedRelaisId != null && + !filtered.any((r) => r.id == _selectedRelaisId)) { + final selected = uniqueById[_selectedRelaisId!]; + if (selected != null) { + filtered.add(selected); + } else { + filtered.addAll(_fallbackRelaisFromUser()); + } + } + + setState(() { + _relais = filtered; + _isLoadingRelais = false; + }); + } catch (_) { + if (!mounted) return; + setState(() { + _relais = _fallbackRelaisFromUser(); + _isLoadingRelais = false; + }); + } + } + + String? _required(String? value, String field) { + if (value == null || value.trim().isEmpty) { + return '$field est requis'; + } + return null; + } + + String? _validatePassword(String? value) { + if (_isEditMode && (value == null || value.trim().isEmpty)) { + return null; + } + final base = _required(value, 'Mot de passe'); + if (base != null) return base; + if (value!.trim().length < 6) return 'Minimum 6 caractères'; + return null; + } + + Future _submit() async { + if (widget.readOnly) return; + if (_isSubmitting) return; + if (_isEditMode && !_dirty) return; + if (!_formKey.currentState!.validate()) return; + + setState(() => _isSubmitting = true); + + try { + final normalizedNom = formatPersonNameCase(_nomController.text); + final normalizedPrenom = formatPersonNameCase(_prenomController.text); + final normalizedPhone = normalizePhone(_telephoneController.text); + final passwordProvided = _passwordController.text.trim().isNotEmpty; + + if (_isEditMode) { + if (widget.adminMode) { + final lockedNom = formatPersonNameCase(widget.initialUser!.nom ?? ''); + final lockedPrenom = + formatPersonNameCase(widget.initialUser!.prenom ?? ''); + await UserService.updateAdministrateur( + adminId: widget.initialUser!.id, + nom: _isLockedAdminIdentity ? lockedNom : normalizedNom, + prenom: _isLockedAdminIdentity ? lockedPrenom : normalizedPrenom, + email: normalizeEmailText(_emailController.text), + telephone: normalizedPhone.isEmpty + ? normalizePhone(widget.initialUser!.telephone ?? '') + : normalizedPhone, + password: passwordProvided ? _passwordController.text : null, + ); + } else { + final currentUser = widget.initialUser!; + final initialNom = formatPersonNameCase(currentUser.nom ?? ''); + final initialPrenom = formatPersonNameCase(currentUser.prenom ?? ''); + final initialEmail = normalizeEmailText(currentUser.email); + final initialPhone = normalizePhone(currentUser.telephone ?? ''); + + final onlyRelaisChanged = normalizedNom == initialNom && + normalizedPrenom == initialPrenom && + normalizeEmailText(_emailController.text) == initialEmail && + normalizedPhone == initialPhone && + !passwordProvided; + + if (onlyRelaisChanged) { + await UserService.updateGestionnaireRelais( + gestionnaireId: currentUser.id, + relaisId: _selectedRelaisId, + ); + } else { + await UserService.updateGestionnaire( + gestionnaireId: currentUser.id, + nom: normalizedNom, + prenom: normalizedPrenom, + email: normalizeEmailText(_emailController.text), + telephone: + normalizedPhone.isEmpty ? initialPhone : normalizedPhone, + relaisId: _selectedRelaisId, + password: passwordProvided ? _passwordController.text : null, + ); + } + } + } else { + if (widget.adminMode) { + await UserService.createAdministrateur( + nom: normalizedNom, + prenom: normalizedPrenom, + email: normalizeEmailText(_emailController.text), + password: _passwordController.text, + telephone: normalizePhone(_telephoneController.text), + ); + } else { + await UserService.createGestionnaire( + nom: normalizedNom, + prenom: normalizedPrenom, + email: normalizeEmailText(_emailController.text), + password: _passwordController.text, + telephone: normalizePhone(_telephoneController.text), + relaisId: _selectedRelaisId, + ); + } + } + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + _isEditMode + ? (widget.adminMode + ? 'Administrateur modifié avec succès.' + : 'Gestionnaire modifié avec succès.') + : (widget.adminMode + ? 'Administrateur créé avec succès.' + : 'Gestionnaire créé avec succès.'), + ), + ), + ); + Navigator.of(context).pop(true); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e.toString().replaceFirst('Exception: ', '')), + backgroundColor: Colors.red.shade700, + ), + ); + } finally { + if (mounted) { + setState(() => _isSubmitting = false); + } + } + } + + Future _delete() async { + if (widget.readOnly) return; + if (!_canDeleteTarget) return; + if (!_isEditMode || _isSubmitting) return; + + final name = widget.initialUser!.fullName.isEmpty + ? widget.initialUser!.email + : widget.initialUser!.fullName; + final confirmed = await showSuppressionConfirmDialog( + context, + title: widget.adminMode + ? 'Supprimer l\'administrateur' + : 'Supprimer le gestionnaire', + people: [ + widget.adminMode + ? SuppressionPersonLine.administrateur(name) + : SuppressionPersonLine.gestionnaire(name), + ], + footnotes: const [ + 'Le compte sera définitivement supprimé.', + ], + ); + + if (!confirmed) return; + + setState(() => _isSubmitting = true); + try { + await UserService.deleteUser(widget.initialUser!.id); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + widget.adminMode + ? 'Administrateur supprimé.' + : 'Gestionnaire supprimé.', + ), + ), + ); + Navigator.of(context).pop(true); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e.toString().replaceFirst('Exception: ', '')), + backgroundColor: Colors.red.shade700, + ), + ); + setState(() => _isSubmitting = false); + } + } + + String _headerTitle() { + if (!_isEditMode) { + return widget.adminMode + ? 'Créer un administrateur' + : 'Créer un gestionnaire'; + } + final prenom = (_prenomController.text.trim().isNotEmpty + ? _prenomController.text + : (widget.initialUser?.prenom ?? '')) + .trim(); + final nom = (_nomController.text.trim().isNotEmpty + ? _nomController.text + : (widget.initialUser?.nom ?? '')) + .trim(); + final full = '$prenom $nom'.trim(); + if (full.isNotEmpty) return full; + return widget.initialUser?.email ?? _targetRoleLabel; + } + + Widget _buildFooter() { + if (widget.readOnly) { + return Row( + children: [ + TextButton( + onPressed: + _isSubmitting ? null : () => Navigator.of(context).pop(false), + child: const Text('Fermer'), + ), + ], + ); + } + + if (!_isEditMode) { + return Row( + children: [ + TextButton( + onPressed: + _isSubmitting ? null : () => Navigator.of(context).pop(false), + child: const Text('Annuler'), + ), + const Spacer(), + ElevatedButton( + style: ValidationModalTheme.primaryElevatedStyle, + onPressed: _isSubmitting ? null : _submit, + child: _isSubmitting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text('Créer'), + ), + ], + ); + } + + return Row( + children: [ + if (_canDeleteTarget) + OutlinedButton( + onPressed: _isSubmitting ? null : _delete, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red.shade700, + ), + child: const Text('Supprimer'), + ), + if (_canDeleteTarget) const SizedBox(width: 8), + TextButton( + onPressed: + _isSubmitting ? null : () => Navigator.of(context).pop(false), + child: const Text('Fermer'), + ), + const Spacer(), + ElevatedButton( + style: ValidationModalTheme.primaryElevatedStyle, + onPressed: !_dirty || _isSubmitting ? null : _submit, + child: _isSubmitting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text(_dirty ? 'Sauvegarder' : 'Aucune modification'), + ), + ], + ); + } + + Widget _namedField({ + required String label, + required TextEditingController controller, + required String requiredLabel, + bool enabled = true, + }) { + return ValidationLabeledField( + label: label, + field: SizedBox( + height: ValidationFormMetrics.fieldHeight, + child: TextFormField( + controller: controller, + enabled: enabled, + textCapitalization: TextCapitalization.words, + textAlignVertical: TextAlignVertical.center, + inputFormatters: const [PersonNameInputFormatter()], + style: ValidationFormMetrics.fieldTextStyle, + decoration: ValidationFieldDecoration.input(), + validator: (!enabled || widget.readOnly) + ? null + : (v) => _required(v, requiredLabel), + ), + ), + ); + } + + Widget _passwordField() { + return ValidationLabeledField( + label: _isEditMode ? 'Nouveau mot de passe' : 'Mot de passe', + field: SizedBox( + height: ValidationFormMetrics.fieldHeight, + child: TextFormField( + controller: _passwordController, + enabled: _fieldsEnabled, + obscureText: _obscurePassword, + enableSuggestions: false, + autocorrect: false, + autofillHints: _isEditMode + ? const [] + : const [AutofillHints.newPassword], + textAlignVertical: TextAlignVertical.center, + style: ValidationFormMetrics.fieldTextStyle, + decoration: ValidationFieldDecoration.input().copyWith( + suffixIcon: widget.readOnly + ? null + : ExcludeFocus( + child: IconButton( + focusNode: _passwordToggleFocusNode, + onPressed: !_fieldsEnabled + ? null + : () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + icon: Icon( + _obscurePassword + ? Icons.visibility_off + : Icons.visibility, + ), + ), + ), + errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11), + errorMaxLines: 2, + ), + validator: widget.readOnly ? null : _validatePassword, + ), + ), + ); + } + + Widget _relaisField() { + final selectedValue = _selectedRelaisId != null && + _relais.any((relais) => relais.id == _selectedRelaisId) + ? _selectedRelaisId + : null; + + return ValidationLabeledField( + label: 'Relais principal', + field: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + height: ValidationFormMetrics.fieldHeight, + child: DropdownButtonFormField( + isExpanded: true, + value: selectedValue, + decoration: ValidationFieldDecoration.input(), + style: ValidationFormMetrics.fieldTextStyle, + items: [ + const DropdownMenuItem( + value: null, + child: Text('Aucun relais'), + ), + ..._relais.map( + (relais) => DropdownMenuItem( + value: relais.id, + child: Text(relais.nom), + ), + ), + ], + onChanged: (_isLoadingRelais || !_fieldsEnabled) + ? null + : (value) { + setState(() { + _selectedRelaisId = value; + if (_isEditMode) { + _dirty = _computeDirty(); + } + }); + }, + ), + ), + if (_isLoadingRelais) ...[ + const SizedBox(height: 8), + const LinearProgressIndicator(minHeight: 2), + ], + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final nameEnabled = + _fieldsEnabled && !_isLockedAdminIdentity; + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: _modalWidth), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(18, 16, 4, 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2, right: 10), + child: Icon( + _targetRoleIcon, + size: 22, + color: ValidationModalTheme.primaryActionBackground, + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _headerTitle(), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + if (_isEditMode) ...[ + const SizedBox(height: 4), + Text( + _targetRoleLabel, + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade700, + ), + ), + ], + ], + ), + ), + IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 40, + minHeight: 40, + ), + icon: const Icon(Icons.close), + onPressed: _isSubmitting + ? null + : () => Navigator.of(context).pop(false), + tooltip: 'Fermer', + ), + ], + ), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _namedField( + label: 'Prénom', + controller: _prenomController, + requiredLabel: 'Prénom', + enabled: nameEnabled, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _namedField( + label: 'Nom', + controller: _nomController, + requiredLabel: 'Nom', + enabled: nameEnabled, + ), + ), + ], + ), + SizedBox(height: ValidationFormMetrics.rowGapBelow), + ValidationLabeledField( + label: 'Email', + field: IgnorePointer( + ignoring: !_fieldsEnabled, + child: ValidationEmailField( + controller: _emailController, + hintText: 'ex. nom@domaine.fr', + ), + ), + ), + SizedBox(height: ValidationFormMetrics.rowGapBelow), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _passwordField()), + const SizedBox(width: 12), + Expanded( + child: ValidationLabeledField( + label: 'Téléphone', + field: IgnorePointer( + ignoring: !_fieldsEnabled, + child: ValidationPhoneField( + controller: _telephoneController, + hintText: '06 12 34 56 78', + allowEmpty: _isEditMode, + ), + ), + ), + ), + ], + ), + if (widget.withRelais) ...[ + SizedBox(height: ValidationFormMetrics.rowGapBelow), + _relaisField(), + ], + ], + ), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 16, 14), + child: _buildFooter(), + ), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/lib/widgets/dashboard/user_management_panel.dart b/frontend/lib/widgets/dashboard/user_management_panel.dart index 800435c..9a87d93 100644 --- a/frontend/lib/widgets/dashboard/user_management_panel.dart +++ b/frontend/lib/widgets/dashboard/user_management_panel.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart'; +import 'package:p_tits_pas/widgets/dashboard/staff_user_form_modal.dart'; import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart'; import 'package:p_tits_pas/widgets/dashboard/am_dossier_create_modal.dart'; import 'package:p_tits_pas/widgets/dashboard/assistante_maternelle_management_widget.dart'; @@ -385,7 +385,7 @@ class _UserManagementPanelState extends State { context: context, barrierDismissible: false, builder: (dialogContext) { - return const AdminUserFormDialog(); + return const StaffUserFormModal(); }, ); @@ -403,7 +403,7 @@ class _UserManagementPanelState extends State { context: context, barrierDismissible: false, builder: (dialogContext) { - return const AdminUserFormDialog( + return const StaffUserFormModal( adminMode: true, withRelais: false, ); From 71b1897678df1e69d4c3c2649d707fea935590eb Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Mon, 14 Sep 2026 17:45:43 +0200 Subject: [PATCH 85/85] docs: rationalisation post-0.1.0 (bilan, purge tmp, archives). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bilan 29 + index versions 05 ; suivi tickets pointeur Gitea - Purge docs/tmp et archive/temporaires livrés - Archive SuperNounou, liste tickets figée, backlog Phase 2, notes 14/92 - Suppression stubs PROCEDURE/22 ; INDEX et roadmap alignés Co-authored-by: Cursor --- docs/00_INDEX.md | 123 ++++----- docs/04_ROADMAP-GENERALE.md | 30 +-- docs/05_VERSIONS-ET-MILESTONES.md | 56 ++++ docs/22_DOCUMENTS-LEGAUX.md | 8 - docs/23_SUIVI-TICKETS.md | 12 + docs/24_DECISIONS-PROJET.md | 27 +- docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md | 2 +- docs/29_BILAN-VERSION-0.1.0.md | 197 ++++++++++++++ docs/EVOLUTIONS_CDC.md | 12 +- docs/PROCEDURE-API-GITEA.md | 8 - docs/archive/README.md | 27 +- .../obsolete}/14_NOTE-BACKEND-CONFIG-SETUP.md | 0 .../obsolete}/23_LISTE-TICKETS.md | 0 .../obsolete}/25_PHASE-2-BACKLOG.md | 0 .../92_NOTE-BACKEND-GESTIONNAIRES.md | 0 docs/archive/obsolete/README.md | 16 +- ...rNounou_Cahier_Des_Charges_Complet_V1.1.md | 0 .../obsolete}/SuperNounou_SSS-001.md | 0 .../131-fiche-parent-co-parent-header.md | 127 --------- .../temporaires/POINT_TICKETS_FRONT_API.txt | 36 --- docs/archive/temporaires/README.md | 15 +- .../TEMP_112-back-reprise-alignement-front.md | 244 ------------------ .../TEMP_131-back-fiche-am-enfants.md | 132 ---------- .../TEMP_131-back-fiche-parent-co-parent.md | 124 --------- .../temporaires/TEMP_FRONT_alignement_AM.md | 46 ---- docs/tmp/112-back-reprise-alignement-front.md | 244 ------------------ docs/tmp/129-contrat-api-staff-parent.md | 89 ------- docs/tmp/135-contrat-api-ajout-co-parent.md | 73 ------ .../135-mini-spec-front-edition-dossier.md | 83 ------ docs/tmp/152-mini-spec-front-est-multiple.md | 65 ----- docs/tmp/153-contrat-api-liste-dossiers.md | 77 ------ docs/tmp/153-mini-spec-front-dossiers.md | 167 ------------ docs/tmp/154-matrice-suppression.md | 35 --- docs/tmp/154-mini-spec-front-suppression.md | 169 ------------ docs/tmp/155-mini-spec-rename-admin-prefix.md | 54 ---- ...55-suite-move-admin-panels-to-dashboard.md | 203 --------------- docs/tmp/156-contrat-api-staff-am.md | 75 ------ ...64-staff-modal-uniformisation-mini-spec.md | 14 - 38 files changed, 379 insertions(+), 2211 deletions(-) create mode 100644 docs/05_VERSIONS-ET-MILESTONES.md delete mode 100644 docs/22_DOCUMENTS-LEGAUX.md create mode 100644 docs/23_SUIVI-TICKETS.md create mode 100644 docs/29_BILAN-VERSION-0.1.0.md delete mode 100644 docs/PROCEDURE-API-GITEA.md rename docs/{ => archive/obsolete}/14_NOTE-BACKEND-CONFIG-SETUP.md (100%) rename docs/{ => archive/obsolete}/23_LISTE-TICKETS.md (100%) rename docs/{ => archive/obsolete}/25_PHASE-2-BACKLOG.md (100%) rename docs/{ => archive/obsolete}/92_NOTE-BACKEND-GESTIONNAIRES.md (100%) rename docs/{ => archive/obsolete}/SuperNounou_Cahier_Des_Charges_Complet_V1.1.md (100%) rename docs/{ => archive/obsolete}/SuperNounou_SSS-001.md (100%) delete mode 100644 docs/archive/temporaires/131-fiche-parent-co-parent-header.md delete mode 100644 docs/archive/temporaires/POINT_TICKETS_FRONT_API.txt delete mode 100644 docs/archive/temporaires/TEMP_112-back-reprise-alignement-front.md delete mode 100644 docs/archive/temporaires/TEMP_131-back-fiche-am-enfants.md delete mode 100644 docs/archive/temporaires/TEMP_131-back-fiche-parent-co-parent.md delete mode 100644 docs/archive/temporaires/TEMP_FRONT_alignement_AM.md delete mode 100644 docs/tmp/112-back-reprise-alignement-front.md delete mode 100644 docs/tmp/129-contrat-api-staff-parent.md delete mode 100644 docs/tmp/135-contrat-api-ajout-co-parent.md delete mode 100644 docs/tmp/135-mini-spec-front-edition-dossier.md delete mode 100644 docs/tmp/152-mini-spec-front-est-multiple.md delete mode 100644 docs/tmp/153-contrat-api-liste-dossiers.md delete mode 100644 docs/tmp/153-mini-spec-front-dossiers.md delete mode 100644 docs/tmp/154-matrice-suppression.md delete mode 100644 docs/tmp/154-mini-spec-front-suppression.md delete mode 100644 docs/tmp/155-mini-spec-rename-admin-prefix.md delete mode 100644 docs/tmp/155-suite-move-admin-panels-to-dashboard.md delete mode 100644 docs/tmp/156-contrat-api-staff-am.md delete mode 100644 docs/tmp/164-staff-modal-uniformisation-mini-spec.md diff --git a/docs/00_INDEX.md b/docs/00_INDEX.md index 7e5b9f6..6d96158 100644 --- a/docs/00_INDEX.md +++ b/docs/00_INDEX.md @@ -1,94 +1,77 @@ -# 📚 Index de la Documentation - PtitsPas App +# Index de la documentation — P'titsPas -Bienvenue dans la documentation complète de l'application PtitsPas. +Index de navigation du dépôt. Dernière révision : **septembre 2026** (clôture doc 0.1.0). -Ce fichier sert d'index pour naviguer dans toute la documentation du projet. +## Produit & versions -## 📖 Table des matières +| Doc | Contenu | +|-----|---------| +| [01 — Cahier des charges](./01_CAHIER-DES-CHARGES.md) | CDC actuel (V1.3) — amendement via **#117** | +| [EVOLUTIONS_CDC.md](./EVOLUTIONS_CDC.md) | Écarts CDC → app (intrant amendement) | +| [05 — Versions & milestones](./05_VERSIONS-ET-MILESTONES.md) | Semver Gitea + bilans | +| [29 — Bilan version 0.1.0](./29_BILAN-VERSION-0.1.0.md) | Tickets livrés 0.1.0 + thèmes | +| [04 — Roadmap générale](./04_ROADMAP-GENERALE.md) | Vision phases long terme | +| [28 — Évolution famille / responsables](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) | Modèle dossier / foyer | -### 📋 Cahier des Charges -- [**01 - Cahier des Charges**](./01_CAHIER-DES-CHARGES.md) - Cahier des charges complet du projet P'titsPas (V1.3 - 24/11/2025) +## Architecture & infra -### Architecture & Infrastructure -- [**02 - Architecture**](./02_ARCHITECTURE.md) - Vue d'ensemble de l'architecture mono-repo et multi-conteneurs -- [**03 - Déploiement**](./03_DEPLOYMENT.md) - Guide complet de déploiement et configuration CI/CD +| Doc | Contenu | +|-----|---------| +| [02 — Architecture](./02_ARCHITECTURE.md) | Mono-repo, conteneurs | +| [03 — Déploiement](./03_DEPLOYMENT.md) | Deploy / CI-CD | +| [10 — Database](./10_DATABASE.md) | Schéma BDD | +| [11 — API](./11_API.md) | Endpoints REST | +| [21 — Configuration système](./21_CONFIGURATION-SYSTEME.md) | Config on-premise | +| [99 — Règles de codage](./99_REGLES-CODAGE.md) | Conventions | -### Planification -- [**04 - Roadmap Générale**](./04_ROADMAP-GENERALE.md) - Roadmap complète du projet (Phases 1 à 5+) +## Workflows & métier -### Développement -- [**10 - Database Schema**](./10_DATABASE.md) - Schéma de la base de données et modèles -- [**11 - API Documentation**](./11_API.md) - Documentation complète des endpoints REST -- [**14 - Note backend config setup**](./14_NOTE-BACKEND-CONFIG-SETUP.md) - Setup configuration -- [**92 - Note backend gestionnaires**](./92_NOTE-BACKEND-GESTIONNAIRES.md) - Gestionnaires -- [**99 - Règles de codage**](./99_REGLES-CODAGE.md) - Conventions de code +| Doc | Contenu | +|-----|---------| +| [20 — Workflow création de compte](./20_WORKFLOW-CREATION-COMPTE.md) | Inscription / validation | +| [juridique/](./juridique/README.md) | CGU / CGC / privacy + [22 technique](./juridique/22_DOCUMENTS-LEGAUX.md) | +| [CHARTE_GRAPHIQUE.md](./CHARTE_GRAPHIQUE.md) | Charte UI | -### Workflows Fonctionnels -- [**20 - Workflow Création de Compte**](./20_WORKFLOW-CREATION-COMPTE.md) - Workflow complet de création et validation des comptes utilisateurs -- [**21 - Configuration Système**](./21_CONFIGURATION-SYSTEME.md) - Configuration on-premise dynamique -- [**22 - Documents Légaux**](./juridique/22_DOCUMENTS-LEGAUX.md) - Gestion CGU/Privacy avec versioning +## Projet & outillage -### Juridique (sources & technique) -- [**Dossier juridique**](./juridique/README.md) - Index : CGU/CGC en Markdown, - export PDF, lien vers la doc technique n°22 +| Doc | Contenu | +|-----|---------| +| [23 — Suivi tickets](./23_SUIVI-TICKETS.md) | Pointeur Gitea (plus de liste figée) | +| [24 — Décisions projet](./24_DECISIONS-PROJET.md) | ADR / décisions | +| [26 — API Gitea](./26_GITEA-API.md) | Issues, PR, milestones | +| [27 — Briefing frontend](./27_BRIEFING-FRONTEND.md) | Accès Git, priorités | -### Projet & suivi (Gitea / tickets) -- [**23 - Liste des Tickets**](./23_LISTE-TICKETS.md) - 61 tickets Phase 1 détaillés -- [**24 - Décisions Projet**](./24_DECISIONS-PROJET.md) - Décisions architecturales et fonctionnelles -- [**25 - Backlog Phase 2**](./25_PHASE-2-BACKLOG.md) - Fonctionnalités techniques reportées -- [**28 - Évolution famille et responsables**](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) - Modèle dossier/famille, recompositions, v1.0.0 vs post-1.0.0 -- [**26 - API Gitea**](./26_GITEA-API.md) - Procédure d'utilisation de l'API Gitea (issues, PR, branches, labels) -- [**27 - Briefing frontend**](./27_BRIEFING-FRONTEND.md) - Accès Git, priorités, scripts Gitea (token) +## Audit -### Archive & convention de nommage -- [**Dossier archive**](./archive/README.md) - Fichiers **sans** `NN_` déplacés - (temporaires, obsolètes) ; règles de rangement et suppression -- Pointeur : [PROCEDURE-API-GITEA.md](./PROCEDURE-API-GITEA.md) → voir **26** +| Doc | Contenu | +|-----|---------| +| [90 — Audit YNOV](./90_AUDIT.md) | Analyse code étudiant | -### Exceptions de nommage (racine `docs/`) -Fichiers **sans préfixe numérique** encore à la racine par **héritage** ou -références outils (`.cursorrules`, etc.) — **à renommer** en `NN_` quand -possible : -- `CHARTE_GRAPHIQUE.md` -- [`EVOLUTIONS_CDC.md`](./EVOLUTIONS_CDC.md) — écarts CDC / app ; voir aussi [**28 - Évolution famille**](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) -- `SuperNounou_Cahier_Des_Charges_Complet_V1.1.md` -- `SuperNounou_SSS-001.md` +## Archive -### Administration (À créer) -- [**30 - Guide d'administration**](./30_ADMIN.md) - Gestion des utilisateurs, accès PgAdmin, logs -- [**31 - Troubleshooting**](./31_TROUBLESHOOTING.md) - Résolution des problèmes courants +| Emplacement | Usage | +|-------------|--------| +| [archive/](./archive/README.md) | Obsolete / temporaires | +| [archive/obsolete/](./archive/obsolete/) | CDC SuperNounou, ancienne liste tickets, backlog Phase 2 figé, notes ponctuelles | -### Frontend (À créer) -- [**40 - Frontend Flutter**](./40_FRONTEND.md) - Structure de l'application mobile/web +## Données de test -### Audit & Analyse -- [**90 - Audit du projet YNOV**](./90_AUDIT.md) - Analyse complète du code étudiant et fonctionnalités +| Doc | Contenu | +|-----|---------| +| [test-data/](./test-data/README.md) | Jeux utilisateurs test | -## 🚀 Quick Start +## Quick start ```bash -# Cloner le projet -git clone ssh://gitea-jmartin/jmartin/app.git ptitspas-app - -# Lancer l'environnement de développement +git clone … ptitspas-app cd ptitspas-app docker compose up -d - -# Accéder aux services -Frontend: https://app.ptits-pas.fr -API: https://app.ptits-pas.fr/api -PgAdmin: https://app.ptits-pas.fr/pgadmin +# Front https://app.ptits-pas.fr — API /api — PgAdmin /pgadmin ``` -## 🔗 Liens utiles +## Liens -- **Gitea** : https://git.ptits-pas.fr -- **Production** : https://app.ptits-pas.fr -- **Mail** : https://mail.ptits-pas.fr - -## 📝 Maintenance - -Cette documentation est maintenue par Julien Martin (julien.martin@ptits-pas.fr). - -Dernière mise à jour : Juin 2026 +- Gitea : https://git.ptits-pas.fr/jmartin/petitspas +- Prod : https://app.ptits-pas.fr +Mainteneur : Julien Martin (julien.martin@ptits-pas.fr). diff --git a/docs/04_ROADMAP-GENERALE.md b/docs/04_ROADMAP-GENERALE.md index 35c587d..2a00c50 100644 --- a/docs/04_ROADMAP-GENERALE.md +++ b/docs/04_ROADMAP-GENERALE.md @@ -44,22 +44,21 @@ Les **Phases 2, 3, 4+** sont des **ébauches indicatives** qui seront affinées - ✅ Logging & Monitoring - ✅ Tests & Documentation -### Versions incrémentales +### Versions incrémentales (semver / Gitea) -| Version | Objectif | Tickets | Estimation | -|---------|----------|---------|------------| -| **0.1.0** | MVP Fonctionnel | ~21 | ~45h | -| **0.2.0** | Sécurité & RGPD | ~10 | ~35h | -| **0.3.0** | Interfaces Complètes | ~17 | ~52h | -| **0.4.0** | Tests & Documentation | ~6 | ~24h | -| **0.5.0** | Monitoring & Optimisations | ~7 | ~17h | -| **1.0.0** | 🎉 **Release Phase 1** | **61** | **~173h** | +La table historique « ~21 tickets / 0.1.0 » est **obsolète**. +État réel des milestones, bilans et tag : **[05_VERSIONS-ET-MILESTONES.md](./05_VERSIONS-ET-MILESTONES.md)**. -### Livrable +| Version | Statut (sept. 2026) | +|---------|---------------------| +| **0.1.0** | **Terminée** — [bilan](./29_BILAN-VERSION-0.1.0.md) (48 tickets fermés) | +| **0.2.0+** | Ouvertes — voir Gitea + doc 05 | -Application installable avec création et validation de comptes utilisateurs. +### Livrable Phase 1 (visée) -**Référence** : [23_LISTE-TICKETS.md](./23_LISTE-TICKETS.md) +Application installable avec création et validation de comptes utilisateurs, puis enrichissements dashboard / dossiers (0.1.0 livré). + +**Tickets** : Gitea — pointeur [23_SUIVI-TICKETS.md](./23_SUIVI-TICKETS.md). --- @@ -216,7 +215,7 @@ Suivi quotidien des enfants + Fonctionnalités complémentaires. Application mature, optimisée et riche en fonctionnalités. -**Référence** : [25_PHASE-2-BACKLOG.md](./25_PHASE-2-BACKLOG.md) (anciennes fonctionnalités techniques) +**Référence** : [archive/obsolete/25_PHASE-2-BACKLOG.md](./archive/obsolete/25_PHASE-2-BACKLOG.md) (figé) + [05_VERSIONS-ET-MILESTONES.md](./05_VERSIONS-ET-MILESTONES.md) --- @@ -317,9 +316,10 @@ Exemples : - [00_INDEX.md](./00_INDEX.md) - Index général de la documentation - [01_CAHIER-DES-CHARGES.md](./01_CAHIER-DES-CHARGES.md) - Cahier des charges v1.3 - [20_WORKFLOW-CREATION-COMPTE.md](./20_WORKFLOW-CREATION-COMPTE.md) - Workflow création de comptes -- [23_LISTE-TICKETS.md](./23_LISTE-TICKETS.md) - Liste des 61 tickets Phase 1 +- [23_SUIVI-TICKETS.md](./23_SUIVI-TICKETS.md) / [29_BILAN-VERSION-0.1.0.md](./29_BILAN-VERSION-0.1.0.md) — suivi Gitea + bilan 0.1.0 - [24_DECISIONS-PROJET.md](./24_DECISIONS-PROJET.md) - Décisions architecturales -- [25_PHASE-2-BACKLOG.md](./25_PHASE-2-BACKLOG.md) - Anciennes fonctionnalités techniques +- [05_VERSIONS-ET-MILESTONES.md](./05_VERSIONS-ET-MILESTONES.md) — milestones Gitea +- [archive/obsolete/25_PHASE-2-BACKLOG.md](./archive/obsolete/25_PHASE-2-BACKLOG.md) — backlog Phase 2 figé --- diff --git a/docs/05_VERSIONS-ET-MILESTONES.md b/docs/05_VERSIONS-ET-MILESTONES.md new file mode 100644 index 0000000..1855284 --- /dev/null +++ b/docs/05_VERSIONS-ET-MILESTONES.md @@ -0,0 +1,56 @@ +# Versions & milestones — P'titsPas + +**Source de vérité tickets** : Gitea [`jmartin/petitspas`](https://git.ptits-pas.fr/jmartin/petitspas) +**Bilans de version** : documents `29_BILAN-…` (et suivants) + +Ce fichier remplace, pour le **semver / milestones**, les anciennes tables figées de la roadmap Phase 1. + +--- + +## État des milestones + +| Milestone | Rôle | Statut | +|-----------|------|--------| +| **0.1.0** | MVP opérable (auth, inscription, dashboard dossiers/fiches, suppressions, cleanups) | **Terminée** — [bilan](./29_BILAN-VERSION-0.1.0.md) | +| **0.2.0** | Suite produit (ex. recherche / échanges — sans contrat) | Ouverte | +| **0.3.0** | Contrat + planning | Ouverte | +| **0.4.0** | Carnet de liaison | Ouverte | +| **0.9.0** | Hors périmètre cleanup 0.1.0 (doublons, upload, tech auth/photos, UX erreurs…) | Ouverte | +| **1.0.0** | Release majeure Phase 1 (critères PO) | Réserve | +| **Backlog transverse** | Doc étendue, CI/tests, RGPD avancé, monitoring — hors semver dédié | Ouverte | + +Liens Gitea : [milestones](https://git.ptits-pas.fr/jmartin/petitspas/milestones). + +--- + +## Bilans + +| Version | Document | +|---------|----------| +| 0.1.0 | [29_BILAN-VERSION-0.1.0.md](./29_BILAN-VERSION-0.1.0.md) | + +--- + +## Relation avec la roadmap phases + +La vision long terme (Phases 2–5 : mise en relation, contrats, carnet…) reste dans [04_ROADMAP-GENERALE.md](./04_ROADMAP-GENERALE.md). +Les **jalons livrables** se gèrent ici + dans Gitea. + +Ancien backlog « Phase 2 » technique ([archive](./archive/obsolete/25_PHASE-2-BACKLOG.md)) : à croiser avec les milestones ci-dessus ; ne plus maintenir en double. + +--- + +## Suivi des tickets + +- **Création / état** : Gitea uniquement. +- **Mémoire d’une version livrée** : bilan `29_…` (pas de re-copie exhaustive dans un fichier tickets). +- Ancienne liste figée Phase 1 : [archive/obsolete/23_LISTE-TICKETS.md](./archive/obsolete/23_LISTE-TICKETS.md). +- Pointeur court : [23_SUIVI-TICKETS.md](./23_SUIVI-TICKETS.md). + +--- + +## Tag Git + +| Tag | Condition | +|-----|-----------| +| `v0.1.0` | Milestone 0.1.0 fermée + bilan mergé sur `master` | diff --git a/docs/22_DOCUMENTS-LEGAUX.md b/docs/22_DOCUMENTS-LEGAUX.md deleted file mode 100644 index c213372..0000000 --- a/docs/22_DOCUMENTS-LEGAUX.md +++ /dev/null @@ -1,8 +0,0 @@ -# Fichier déplacé - -La documentation **Documents légaux** a été déplacée vers : - -**[juridique/22_DOCUMENTS-LEGAUX.md](./juridique/22_DOCUMENTS-LEGAUX.md)** - -Voir aussi le dossier **[juridique/](./juridique/)** pour les sources **CGU** -et **CGC** en Markdown. diff --git a/docs/23_SUIVI-TICKETS.md b/docs/23_SUIVI-TICKETS.md new file mode 100644 index 0000000..6ad743b --- /dev/null +++ b/docs/23_SUIVI-TICKETS.md @@ -0,0 +1,12 @@ +# Suivi des tickets — P'titsPas + +**Source de vérité** : [Gitea — issues](https://git.ptits-pas.fr/jmartin/petitspas/issues) +**Milestones / versions** : [05_VERSIONS-ET-MILESTONES.md](./05_VERSIONS-ET-MILESTONES.md) +**API Gitea** : [26_GITEA-API.md](./26_GITEA-API.md) + +Ne plus maintenir de catalogue exhaustif des tickets dans le dépôt : l’état (ouvert / fermé / milestone) change dans Gitea. + +Pour une **version livrée**, lire le bilan correspondant (ex. [29_BILAN-VERSION-0.1.0.md](./29_BILAN-VERSION-0.1.0.md)). + +Archive historique (liste Phase 1 figée, avril 2026) : +[archive/obsolete/23_LISTE-TICKETS.md](./archive/obsolete/23_LISTE-TICKETS.md). diff --git a/docs/24_DECISIONS-PROJET.md b/docs/24_DECISIONS-PROJET.md index cf38376..32d16ae 100644 --- a/docs/24_DECISIONS-PROJET.md +++ b/docs/24_DECISIONS-PROJET.md @@ -423,31 +423,30 @@ ptitspas-app/ - Maintenance (tout au même endroit) - Versioning (Git) -**Structure** : +**Structure** (sept. 2026) : ``` docs/ ├── 00_INDEX.md ├── 01_CAHIER-DES-CHARGES.md ├── 02_ARCHITECTURE.md ├── 03_DEPLOYMENT.md +├── 04_ROADMAP-GENERALE.md +├── 05_VERSIONS-ET-MILESTONES.md ├── 10_DATABASE.md ├── 11_API.md ├── 20_WORKFLOW-CREATION-COMPTE.md ├── 21_CONFIGURATION-SYSTEME.md -├── 22_DOCUMENTS-LEGAUX.md # pointeur → juridique/ -├── 27_BRIEFING-FRONTEND.md -├── PROCEDURE-API-GITEA.md # pointeur → 26_GITEA-API.md -├── juridique/ -│ ├── README.md -│ ├── cgu.md -│ ├── cgc.md -│ └── 22_DOCUMENTS-LEGAUX.md -├── archive/ -│ ├── README.md -│ ├── temporaires/ -│ └── obsolete/ -├── 23_LISTE-TICKETS.md +├── 23_SUIVI-TICKETS.md ├── 24_DECISIONS-PROJET.md (ce document) +├── 26_GITEA-API.md +├── 27_BRIEFING-FRONTEND.md +├── 28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md +├── 29_BILAN-VERSION-0.1.0.md +├── 99_REGLES-CODAGE.md +├── EVOLUTIONS_CDC.md +├── CHARTE_GRAPHIQUE.md +├── juridique/ # CGU + 22_DOCUMENTS-LEGAUX.md +├── archive/ # obsolete / temporaires ├── 90_AUDIT.md └── test-data/ ``` diff --git a/docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md b/docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md index b8cbfbe..170bc93 100644 --- a/docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md +++ b/docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md @@ -3,7 +3,7 @@ **Version** : 1.1 **Date** : 16 juin 2026 **Statut** : Réflexions produit / architecture — complément au [CDC](./01_CAHIER-DES-CHARGES.md) -**Documents liés** : [EVOLUTIONS_CDC.md](./EVOLUTIONS_CDC.md), [24_DECISIONS-PROJET.md](./24_DECISIONS-PROJET.md), [23_LISTE-TICKETS.md](./23_LISTE-TICKETS.md) +**Documents liés** : [EVOLUTIONS_CDC.md](./EVOLUTIONS_CDC.md), [24_DECISIONS-PROJET.md](./24_DECISIONS-PROJET.md), [23_SUIVI-TICKETS.md](./23_SUIVI-TICKETS.md), [29_BILAN-VERSION-0.1.0.md](./29_BILAN-VERSION-0.1.0.md) --- diff --git a/docs/29_BILAN-VERSION-0.1.0.md b/docs/29_BILAN-VERSION-0.1.0.md new file mode 100644 index 0000000..2d831e6 --- /dev/null +++ b/docs/29_BILAN-VERSION-0.1.0.md @@ -0,0 +1,197 @@ +# Bilan — Version 0.1.0 + +**Statut** : terminée +**Milestone Gitea** : [0.1.0](https://git.ptits-pas.fr/jmartin/petitspas/milestone/10) +**Dépôt** : `jmartin/petitspas` +**Tag prévu** : `v0.1.0` (sur `master` après merge de cette doc) + +Ce document est la **mémoire produit** de la version 0.1.0 : ce qui a été livré, ticket par ticket, et ce qui a été reporté. + +--- + +## 1. Périmètre produit livré + +La 0.1.0 couvre le **MVP opérable** pour une collectivité : + +- Authentification, création / oubli de mot de passe, e-mails associés +- Inscription parent & AM, validation / refus gestionnaire, reprise après refus +- Dashboard staff (admin + gestionnaire) : listes, fiches, rattachements +- Onglet **Dossiers** + wizards création / édition (famille & AM) +- Suppressions métier (droits, confirms, cascades API) +- Cleanups structurels (préfixe `Admin*`, panels dashboard, modale staff, retrait `est_multiple`) + +**Hors 0.1.0** (reporté) : doublons avancés, famille N responsables, statut enfant gardé/sans garde, combobox RPE AM, chantier CDC (#117), tickets tech/observabilité — voir §4 et [05_VERSIONS-ET-MILESTONES.md](./05_VERSIONS-ET-MILESTONES.md). + +--- + +## 2. Thèmes livrés + +### 2.1 Auth, mot de passe, e-mails + +| # | Titre | Livré | +|---|-------|--------| +| 24 | API Création mot de passe | Endpoints token → création MDP post-validation | +| 28 | Templates Email — Validation | Mails validation avec lien MDP | +| 30 | Connexion — Vérification statut | Blocage comptes pending / suspendus à la connexion | +| 43 | Écran Création Mot de Passe | UI lien e-mail création MDP | +| 47 | Écran Changement MDP Obligatoire | Première connexion staff | +| 50 | Affichage dynamique CGU | CGU/Privacy versionnées à l’inscription | +| 118 | Page création mot de passe (Front + API) | Alignement front/API du flux lien e-mail | +| 123 | Durcissement token création MDP | TTL, usage unique, contrôles API | +| 127 | Mot de passe oublié | Demande → e-mail → réinitialisation (flux distinct de #24/#43) | + +### 2.2 Inscription, numéros de dossier, reprise + +| # | Titre | Livré | +|---|-------|--------| +| 104 | Numéro de dossier — frontend | Affichage listes / mails / modales ; format AAAA-… | +| 112 | Reprise après refus — frontend | Lien e-mail `/reprise` + reprise par n° dossier | +| 120 | Inscription AM — photo & UX | Chaîne photo / API / UX alignée parents | +| 144 | Consentement photo enfant | Persistance du consentement à l’inscription | + +### 2.3 Dashboard — fiches, listes, rattachements + +| # | Titre | Livré | +|---|-------|--------| +| 115 | Rattachement enfants — backend | Attach/detach parent↔enfant et AM↔enfant | +| 116 | Rattachement enfants — frontend | UI fiches parent / AM | +| 130 | UserService — APIs métier | Branchement parents / AM / enfants côté front | +| 131 | Édition fiche parent + AM | Modales édition dashboard | +| 132 | Création enfant (onglet Enfants) | Création + rattachement foyer | +| 136 | API enfants — droits & liste | Droits gestionnaire + enrichissement liste | +| 137 | Onglet Enfants — liste globale | Panneau liste dashboard | +| 138 | Fiche enfant + liste dans parent | Fiche enfant ; enfants dans fiche parent | +| 140 | Epic fiche parent / affiliation | Livraison regroupée dashboard admin/gestionnaire | +| 142 | Clic carte → modale | Ouverture fiche depuis les listes | +| 145 | Lien co-parent cliquable | Navigation fiche parent → co-parent | +| 146 | Modale sélection enfant | UX rattacher enfant (AM + parent) | +| 147 | Modale sélection AM | UX rattacher AM depuis fiche enfant | +| 148 | Capacité max AM | Désactivation rattachement si capacité atteinte | +| 149 | Case libre AM → rattacher | Clic emplacement vide pour rattacher | +| 151 | GET /relais pour gestionnaire | Combo relais dans modale staff | +| 157 | Enfant sans responsable | Détachement dernier parent + alerte liste | +| 158 | Affiliation foyer (pivot + co-parent) | Attach/detach cohérents sur le foyer | + +### 2.4 Dossiers staff (création, édition, liste) + +| # | Titre | Livré | +|---|-------|--------| +| 129 | Création dossier parent | Wizard + API staff famille | +| 135 | Édition dossier + 2ᵉ parent | Mode edit wizards + `POST …/co-parent` | +| 153 | Onglet Dossiers | Liste unifiée + à valider (sans création dans l’onglet) | +| 156 | Création dossier AM | Wizard + API staff AM | + +### 2.5 Suppressions & droits staff + +| # | Titre | Livré | +|---|-------|--------| +| 133 | Suppression parent + AM (UI) | Première vague UI (complétée par #160) | +| 134 | Droits « Ajouter gestionnaire » | Visibilité / API selon rôle | +| 143 | Bug supprimer sa propre fiche | Masquage / interdiction auto-suppression | +| 154 | Epic suppressions | Cadrage règles métier suppressions | +| 159 | Suppressions métier — backend | Cascades, garde-fous, droits API | +| 160 | Suppressions dashboard — frontend | Poubelles + dialogues de confirmation | +| 161 | Admin création staff 403 | Admin peut créer gestionnaire et administrateur | + +### 2.6 Cleanups structure & UX + +| # | Titre | Livré | +|---|-------|--------| +| 25 | API Liste comptes en attente | Historique ; couvert par flux dossiers / validation | +| 26 | API Validation / Refus | Historique ; couvert par flux dossiers / validation | +| 152 | Retrait `est_multiple` | Suppression full stack (BDD, API, front, docs) | +| 155 | Rename préfixe `Admin*` | Widgets partagés sans préfixe Admin (option C) | +| 162 | Panels → `widgets/dashboard/` | Suite #155 — panels staff sous dashboard | +| 164 | Modale staff uniformisée | `StaffUserFormModal` (shell 930, champs contrôlés) | + +--- + +## 3. Inventaire exhaustif (48 tickets fermés, milestone 0.1.0) + +| # | Titre | +|---|-------| +| 24 | [Backend] API Création mot de passe | +| 25 | [Backend] API Liste comptes en attente | +| 26 | [Backend] API Validation/Refus comptes | +| 28 | [Backend] Templates Email - Validation | +| 30 | [Backend] Connexion - Vérification statut | +| 43 | [Frontend] Écran Création Mot de Passe | +| 47 | [Frontend] Écran Changement MDP Obligatoire | +| 50 | [Frontend] Affichage dynamique CGU lors inscription | +| 104 | Numéro de dossier – frontend | +| 112 | Reprise après refus – frontend | +| 115 | [Backend] Rattachement enfants — parent et AM | +| 116 | [Frontend] Rattachement enfants — parent et AM | +| 118 | Page création mot de passe (lien email) – Front + API | +| 120 | [Full-stack] Inscription AM — photo, API et UX alignés sur les parents | +| 123 | [Tech] Durcissement token création MDP | +| 127 | [Full-stack] Mot de passe oublié — flux complet | +| 129 | Création dossier parent (wizard + API staff) | +| 130 | [Frontend] UserService — brancher APIs parents, AM et enfants | +| 131 | [Frontend] Édition fiche parent + AM | +| 132 | Création enfant depuis l’onglet Enfants | +| 133 | [Frontend] Suppression compte parent + AM | +| 134 | Droits bouton « Ajouter gestionnaire » | +| 135 | Mode édition dossier (+ ajout 2ᵉ parent) | +| 136 | [Backend] API enfants — droits gestionnaire + enrichissement liste | +| 137 | [Frontend] Onglet Enfants — liste globale | +| 138 | [Frontend] Fiche enfant + liste enfants dans fiche parent | +| 140 | Dashboard admin — fiche parent, enfants et affiliation | +| 142 | Clic sur carte → ouvrir la modale | +| 143 | Bug — Gestionnaire Supprimer sur sa propre fiche | +| 144 | Bug — Consentement photo enfant non sauvegardé | +| 145 | Lien co-parent cliquable | +| 146 | UX — modale sélection d'enfant | +| 147 | UX — modale sélection d'AM | +| 148 | Bug — capacité max AM | +| 149 | Fiche AM — clic case libre pour rattacher | +| 151 | Bug — GET /relais gestionnaire | +| 152 | Cleanup — supprimer `est_multiple` | +| 153 | Onglet permanent « Dossiers » | +| 154 | Epic — suppressions utilisateurs / dossiers / enfants / AM | +| 155 | Cleanup — renommer préfixe Admin* | +| 156 | Création dossier AM (wizard + API staff) | +| 157 | Enfant sans responsable | +| 158 | Affiliation enfant au foyer (pivot + co-parent) | +| 159 | Backend suppressions métier (#154) | +| 160 | Frontend suppressions dashboard (#154) | +| 161 | Bug — Admin création gestionnaire / administrateur | +| 162 | Cleanup — panels vers `widgets/dashboard/` | +| 164 | Uniformisation modale staff + champs contrôlés | + +Issues : https://git.ptits-pas.fr/jmartin/petitspas/issues?q=&type=all&state=closed&labels=&milestone=10&assignee=0 + +--- + +## 4. Reporté hors 0.1.0 + +| # | Titre | Destination typique | +|---|-------|---------------------| +| 113 / 114 | Doublons inscription / alerte gestionnaire | 0.9.0 | +| 117 | Évolution du cahier des charges | Doc (amendement CDC post-0.1.0) | +| 121–122, 124–125 | Tech auth / photos / DB | 0.9.0 | +| 126 | Upload documents légaux 500 | 0.9.0 | +| 128 | Audit / traçabilité modifications | 0.9.0 | +| 139 | Famille complexe N responsables | Post-0.1.0 / epic | +| 141 | Statut enfant gardé / sans garde | Post-0.1.0 | +| 150 | Combobox rattachement RPE (AM) | 0.2.0 | + +Voir aussi [05_VERSIONS-ET-MILESTONES.md](./05_VERSIONS-ET-MILESTONES.md). + +--- + +## 5. Suite documentaire + +1. **Amendement CDC** — ticket **#117** : intégrer les écarts réellement livrés (dossiers staff, onglet Enfants, suppressions, retrait naissance multiple, etc.) à partir de ce bilan, [EVOLUTIONS_CDC.md](./EVOLUTIONS_CDC.md) et [28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md). +2. **Tag** `v0.1.0` sur `master` lorsque milestone fermée + ce bilan mergé. +3. Enchaîner les milestones **0.2.0+** selon [05_VERSIONS-ET-MILESTONES.md](./05_VERSIONS-ET-MILESTONES.md). + +--- + +## 6. Références code (points d’entrée) + +- Modale staff : `frontend/lib/widgets/dashboard/staff_user_form_modal.dart` +- Fiches : `parent_edit_modal.dart`, `am_edit_modal.dart`, `child_detail_modal.dart` +- Wizards dossiers : `parent_dossier_wizard.dart`, `am_dossier_wizard.dart` +- Règles suppressions : tickets #154 / #159 / #160 +- Cleanup `est_multiple` : #152 diff --git a/docs/EVOLUTIONS_CDC.md b/docs/EVOLUTIONS_CDC.md index 98b57b6..75b7b42 100644 --- a/docs/EVOLUTIONS_CDC.md +++ b/docs/EVOLUTIONS_CDC.md @@ -2,7 +2,9 @@ Ce document liste les modifications à apporter au cahier des charges original pour le rendre conforme à l'application développée. -> **Document complémentaire (juin 2026)** — réflexions sur le **modèle famille / numéro de dossier**, familles recomposées, tuteurs et responsables légaux : voir **[28 - Évolution famille et responsables](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md)**. +> **Intrant pour #117** (amendement CDC post-0.1.0). Compléter avec le [bilan 0.1.0](./29_BILAN-VERSION-0.1.0.md) et **[28 - Évolution famille et responsables](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md)**. + +> **Obsolète depuis #152** : ne plus proposer de champ « naissance multiple / `est_multiple` » — retiré de l’app (BDD, API, front). ## 1. Gestion des Enfants @@ -11,7 +13,6 @@ Ce document liste les modifications à apporter au cahier des charges original p #### Situation actuelle dans le CDC : - Mentionne uniquement la collecte d'informations sur l'enfant - Ne précise pas la possibilité d'ajouter plusieurs enfants -- Ne mentionne pas la gestion des naissances multiples - Ne mentionne pas la gestion des enfants à naître #### Modifications proposées : @@ -22,17 +23,18 @@ Ajouter le paragraphe suivant après la description de la collecte d'information Les parents peuvent ajouter autant d'enfants que nécessaire. Pour chaque enfant, les informations suivantes sont collectées : - Prénom - Date de naissance (ou date prévue pour les enfants à naître) +- Genre - Photo (optionnelle) - Consentement pour l'utilisation de la photo -- Indication si l'enfant fait partie d'une naissance multiple (jumeaux, triplés, etc.) Les parents peuvent : - Ajouter un nouvel enfant à tout moment - Supprimer un enfant ajouté - Modifier les informations d'un enfant existant - Indiquer si l'enfant est à naître -- Indiquer si l'enfant fait partie d'une naissance multiple - Donner ou retirer leur consentement pour l'utilisation de la photo de l'enfant + +Note : le concept de « naissance multiple » / jumeaux n'est pas géré par un champ dédié (retiré en 0.1.0, #152). ``` ### Modifications à apporter dans la section "Workflow de création de compte" @@ -50,9 +52,9 @@ Remplacer l'étape 3 par : - Pour chaque enfant : * Saisie du prénom * Saisie de la date de naissance (ou date prévue) + * Genre * Option d'ajout d'une photo * Option de consentement photo - * Indication si naissance multiple * Indication si enfant à naître - Possibilité de modifier ou supprimer un enfant ``` diff --git a/docs/PROCEDURE-API-GITEA.md b/docs/PROCEDURE-API-GITEA.md deleted file mode 100644 index fcae5a0..0000000 --- a/docs/PROCEDURE-API-GITEA.md +++ /dev/null @@ -1,8 +0,0 @@ -# Fichier déplacé / fusionné - -La procédure **API Gitea** est désormais documentée sous : - -**[26_GITEA-API.md](./26_GITEA-API.md)** - -L’ancienne copie `PROCEDURE-API-GITEA.md` est archivée dans -`docs/archive/obsolete/` (doublon). diff --git a/docs/archive/README.md b/docs/archive/README.md index 794b038..674fdbc 100644 --- a/docs/archive/README.md +++ b/docs/archive/README.md @@ -1,30 +1,19 @@ # Archive documentation · P'titsPas -Ce dossier regroupe les fichiers **sans préfixe numérique** à la racine de -`docs/` qui ne sont plus des **références actives**, ou qui sont des -**brouillons / temporaires**. +Fichiers **hors références actives** : brouillons livrés, CDC historiques, listes figées. ## Règle de nommage (racine `docs/`) -- Les documents **normatifs** à la racine portent un préfixe **`NN_`** - (deux chiffres), ex. `23_LISTE-TICKETS.md`. -- **Exceptions** (héritage ou outillage) listées dans - [**00_INDEX.md**](../00_INDEX.md#exceptions-de-nommage) : charte, CDC - historique, évolutions — **cible** : les renommer progressivement en `NN_` - et mettre à jour `.cursorrules` / liens. +- Documents **normatifs** : préfixe **`NN_`**. +- Exceptions héritage listées dans [00_INDEX.md](../00_INDEX.md) (`CHARTE_GRAPHIQUE.md`, `EVOLUTIONS_CDC.md`). -## Sous-dossiers ici +## Sous-dossiers | Dossier | Usage | |---------|--------| -| [**temporaires/**](./temporaires/) | Notes jetables, exports de travail. - **Supprimables** quand la tâche associée est close. | -| [**obsolete/**](./obsolete/) | Ancienne doc **remplacée** ou **doublon** - (conservée un temps pour historique). **Supprimer** après bascule confirmée - si plus aucune référence. | +| [**temporaires/**](./temporaires/) | Brouillons jetables. **Vider** dès livraison. | +| [**obsolete/**](./obsolete/) | Doc remplacée (CDC SuperNounou, ancienne liste tickets, notes ponctuelles, backlog Phase 2 figé). | -## Hors `docs/` racine +## Politique `tmp/` -Les dossiers thématiques (**`juridique/`**, **`test-data/`**, etc.) peuvent -contenir des fichiers sans `NN_` : la règle `NN_` s’applique surtout aux -fichiers **directement** sous `docs/`. +Le dossier `docs/tmp/` **n’est plus utilisé**. Les mini-specs de tickets livrés sont purgés ; la mémoire produit = bilans de version (`29_…`) + tickets Gitea. diff --git a/docs/14_NOTE-BACKEND-CONFIG-SETUP.md b/docs/archive/obsolete/14_NOTE-BACKEND-CONFIG-SETUP.md similarity index 100% rename from docs/14_NOTE-BACKEND-CONFIG-SETUP.md rename to docs/archive/obsolete/14_NOTE-BACKEND-CONFIG-SETUP.md diff --git a/docs/23_LISTE-TICKETS.md b/docs/archive/obsolete/23_LISTE-TICKETS.md similarity index 100% rename from docs/23_LISTE-TICKETS.md rename to docs/archive/obsolete/23_LISTE-TICKETS.md diff --git a/docs/25_PHASE-2-BACKLOG.md b/docs/archive/obsolete/25_PHASE-2-BACKLOG.md similarity index 100% rename from docs/25_PHASE-2-BACKLOG.md rename to docs/archive/obsolete/25_PHASE-2-BACKLOG.md diff --git a/docs/92_NOTE-BACKEND-GESTIONNAIRES.md b/docs/archive/obsolete/92_NOTE-BACKEND-GESTIONNAIRES.md similarity index 100% rename from docs/92_NOTE-BACKEND-GESTIONNAIRES.md rename to docs/archive/obsolete/92_NOTE-BACKEND-GESTIONNAIRES.md diff --git a/docs/archive/obsolete/README.md b/docs/archive/obsolete/README.md index ade8bb1..707373b 100644 --- a/docs/archive/obsolete/README.md +++ b/docs/archive/obsolete/README.md @@ -4,11 +4,13 @@ Ancienne documentation **déplacée** depuis `docs/` : | Fichier | Motif | |---------|--------| -| `PROCEDURE-API-GITEA.md` | Doublon fonctionnel de - [**26_GITEA-API.md**](../../26_GITEA-API.md). | -| `ARCHITECTURE_TECHNIQUE.md` | Non référencé ; la vue d’ensemble est dans - [**02_ARCHITECTURE.md**](../../02_ARCHITECTURE.md). | -| `STATUS-APPLICATION.md` | Instantané daté ; non tenu comme doc vivante. | +| `PROCEDURE-API-GITEA.md` | Doublon de [26_GITEA-API.md](../../26_GITEA-API.md) | +| `ARCHITECTURE_TECHNIQUE.md` | Remplacé par [02_ARCHITECTURE.md](../../02_ARCHITECTURE.md) | +| `STATUS-APPLICATION.md` | Instantané daté | +| `23_LISTE-TICKETS.md` | Liste Phase 1 figée (avr. 2026) — suivi = Gitea + bilans | +| `25_PHASE-2-BACKLOG.md` | Backlog technique figé — voir [05_VERSIONS…](../../05_VERSIONS-ET-MILESTONES.md) | +| `SuperNounou_*` | CDC / SSS historiques | +| `14_NOTE-BACKEND-CONFIG-SETUP.md` | Note ticket ponctuelle | +| `92_NOTE-BACKEND-GESTIONNAIRES.md` | Note ticket ponctuelle | -Après vérification qu’aucun lien externe ne pointe encore vers ces chemins, on -peut **supprimer** ce sous-dossier ou ne garder que des pointeurs minimalistes. +Mémoire produit des versions livrées : [29_BILAN-VERSION-0.1.0.md](../../29_BILAN-VERSION-0.1.0.md). diff --git a/docs/SuperNounou_Cahier_Des_Charges_Complet_V1.1.md b/docs/archive/obsolete/SuperNounou_Cahier_Des_Charges_Complet_V1.1.md similarity index 100% rename from docs/SuperNounou_Cahier_Des_Charges_Complet_V1.1.md rename to docs/archive/obsolete/SuperNounou_Cahier_Des_Charges_Complet_V1.1.md diff --git a/docs/SuperNounou_SSS-001.md b/docs/archive/obsolete/SuperNounou_SSS-001.md similarity index 100% rename from docs/SuperNounou_SSS-001.md rename to docs/archive/obsolete/SuperNounou_SSS-001.md diff --git a/docs/archive/temporaires/131-fiche-parent-co-parent-header.md b/docs/archive/temporaires/131-fiche-parent-co-parent-header.md deleted file mode 100644 index 6f8a3c8..0000000 --- a/docs/archive/temporaires/131-fiche-parent-co-parent-header.md +++ /dev/null @@ -1,127 +0,0 @@ -# #131 — En-tête fiche parent : co-parent (note front → back) - -**Ticket :** #131 (fiche parent dashboard, doc `28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1) -**Date :** 2026-06-01 -**Statut front :** livré (en-tête dynamique) -**Modif backend demandée :** **aucune fonctionnelle** — ce document fixe le contrat attendu ; le back valide `co_parent` et masque les champs sensibles. - ---- - -## 1. Comportement UI (front) - -Dans la modale **fiche parent** (`AdminParentEditModal`) : - -| Zone | Contenu | -|------|---------| -| **Titre** | `prenom` + `nom` du parent affiché (plus le libellé fixe « Fiche parent ») | -| **Sous-titre** | `Co-parent : {prenom} {nom}` — affiché **uniquement** si un co-parent est connu | - -Le titre se met à jour en direct pendant l’édition des champs nom/prénom. -Le sous-titre provient du co-parent **chargé depuis l’API** (pas saisi à la main dans la modale). - ---- - -## 2. Endpoints consommés - -| Méthode | Route | Usage front | -|---------|-------|-------------| -| `GET` | `/api/v1/parents` | Liste parents (onglet Parents) | -| `GET` | `/api/v1/parents/:userId` | Rechargement fiche après rattachement/détachement enfant | -| `PATCH` | `/api/v1/parents/:userId/fiche` | Sauvegarde identité + statut (inchangé) | - -Rôles : `super_admin`, `gestionnaire`, `administrateur` (selon route). - ---- - -## 3. Contrat JSON attendu pour `co_parent` - -Le front parse `ParentModel.fromJson` avec la clé **`co_parent`** (snake_case), objet utilisateur imbriqué. - -### Champs minimum utilisés pour le sous-titre - -| Clé JSON | Usage | -|----------|--------| -| `co_parent` | Objet ou absent/`null` | -| `co_parent.id` | Identifiant (futur lien cliquable éventuel) | -| `co_parent.prenom` | Affichage | -| `co_parent.nom` | Affichage | - -Affichage front : `'{prenom} {nom}'.trim()` → libellé `Co-parent : …`. - -### Exemple de fragment de réponse (`GET /parents/:id`) - -```json -{ - "user_id": "33333333-3333-3333-3333-333333333333", - "numero_dossier": "2026-000042", - "user": { - "id": "33333333-3333-3333-3333-333333333333", - "email": "parent1@example.com", - "prenom": "Paul", - "nom": "PARENT", - "statut": "actif", - "telephone": "0601020304" - }, - "co_parent": { - "id": "44444444-4444-4444-4444-444444444444", - "email": "coparent1@example.com", - "prenom": "Clara", - "nom": "COPARENT", - "role": "parent", - "statut": "actif" - }, - "parentChildren": [] -} -``` - -> **Note :** le front lit `user` (pas `utilisateur`). La doc `11_API.md` § Parents mentionne encore `utilisateur` / `id_co_parent` seul — le contrat **effectif** côté Nest/TypeORM est l’entité `Parents` sérialisée (`user`, `co_parent`, `parentChildren`, …). - ---- - -## 4. État backend - -### Relations (déjà en place) - -- `findAll()` et `findOne(user_id)` chargent **`co_parent`** ; -- FK : `parents.id_co_parent` → `utilisateurs.id` ; -- inscription couple : les deux sens renseignés en principe (`auth.service.ts`). - -### Livraison back (#131) - -- `mapParentForApi` / `sanitizeUserForApi` : réponses `GET/PATCH/POST/DELETE` parents **sans** `password`, `token_creation_mdp`, `password_reset_*` sur `user` et `co_parent`. - -**Checklist validation :** - -- [x] `GET /parents/:id` renvoie `co_parent` peuplé quand `id_co_parent` est non null -- [x] `GET /parents` (liste) inclut `co_parent` -- [x] `prenom` / `nom` du co-parent présents -- [x] Pas de fuite `password` / tokens sur `user` ni `co_parent` - ---- - -## 5. Points d’attention (hors périmètre immédiat) - -| Sujet | Détail | -|-------|--------| -| **Lien inverse** | Si B est co-parent de A (`A.id_co_parent = B`) mais `B.id_co_parent` est `null`, le sous-titre **ne s’affichera pas** sur la fiche de B. Pas de résolution inverse côté front. | -| **Familles > 2 adultes** | Sous-titre = co-parent direct (`id_co_parent`) uniquement. | -| **Trou AM ↔ enfants en garde** | Pas de lien AM–enfant aujourd’hui (à documenter / traiter plus tard). | - ---- - -## 6. Fichiers back concernés - -| Fichier | Rôle | -|---------|------| -| `backend/src/routes/parents/parents.service.ts` | `findOne`, `findAll` + relations | -| `backend/src/routes/parents/parents.controller.ts` | `mapParentForApi` sur les réponses | -| `backend/src/routes/parents/parents.mapper.ts` | Sérialisation API | -| `backend/src/common/utils/sanitize-user-for-api.ts` | Masquage secrets | -| `backend/src/entities/parents.entity.ts` | relation `co_parent` | - ---- - -## 7. Références - -- `docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1 -- Ticket Gitea **#131** diff --git a/docs/archive/temporaires/POINT_TICKETS_FRONT_API.txt b/docs/archive/temporaires/POINT_TICKETS_FRONT_API.txt deleted file mode 100644 index fac0b2d..0000000 --- a/docs/archive/temporaires/POINT_TICKETS_FRONT_API.txt +++ /dev/null @@ -1,36 +0,0 @@ -# Archivé docs/archive/temporaires/ — export jetable, supprimer si inutile. -Point tickets frontend (API Gitea) - 27/01/2026 -================================================ - -Issues avec label "frontend" : 20 (ouvertes: 12, fermees: 8) - -Num | Etat | Titre -----+--------+-------------------------------------------------------- - 35 | open | [Frontend] Écran Création Gestionnaire - 36 | closed | [Frontend] Inscription Parent - Étape 1 (Parent 1) - 37 | closed | [Frontend] Inscription Parent - Étape 2 (Parent 2) - 38 | closed | [Frontend] Inscription Parent - Étape 3 (Enfants) - 39 | closed | [Frontend] Inscription Parent - Étapes 4-6 (Finalisatio - 40 | closed | [Frontend] Inscription AM - Panneau 1 (Identité) - 41 | closed | [Frontend] Inscription AM - Panneau 2 (Infos pro) - 42 | closed | [Frontend] Inscription AM - Finalisation - 43 | open | [Frontend] Écran Création Mot de Passe - 44 | closed | [Frontend] Dashboard Gestionnaire - Structure - 45 | open | [Frontend] Dashboard Gestionnaire - Liste Parents - 46 | open | [Frontend] Dashboard Gestionnaire - Liste AM - 47 | open | [Frontend] Écran Changement MDP Obligatoire - 48 | open | [Frontend] Gestion Erreurs & Messages - 49 | open | [Frontend] Écran Gestion Documents Légaux (Admin) - 50 | open | [Frontend] Affichage dynamique CGU lors inscription - 51 | open | [Frontend] Écran Logs Admin (optionnel v1.1) - 54 | open | [Tests] Tests E2E Frontend - 82 | closed | [Frontend] Adapter �cran Login pour mobile - 83 | closed | [Frontend] Adapter �cran Choix Inscription pour mobile - -Suivi doc 23_LISTE-TICKETS (Gitea #73,78,79,81,82,83): - #73 closed labels=[] - #78 closed labels=[] - #79 closed labels=[] - #81 closed labels=[] - #82 closed (écran Login mobile) - #83 closed labels=['frontend', 'p3', 'phase-1', 'ux'] diff --git a/docs/archive/temporaires/README.md b/docs/archive/temporaires/README.md index bde6fa3..ecc5dc2 100644 --- a/docs/archive/temporaires/README.md +++ b/docs/archive/temporaires/README.md @@ -1,10 +1,11 @@ # Temporaires -Fichiers **non numérotés** de travail (brouillons, listes de tickets exportées, -alignements UI en cours, etc.). +Dossier **vide** après clôture 0.1.0 (purge sept. 2026). -- Préfixe conseillé pour les nouveaux fichiers jetables : **`TEMP_`** ou - **`WIP_`** dans ce dossier. -- **Suppression** : dès que la fonctionnalité est livrée ou le sujet clos, - supprimer le fichier (ou le déplacer vers `obsolete/` si une trace utile - reste nécessaire). +Si un brouillon de travail est nécessaire un temps : + +- le placer ici avec préfixe `TEMP_` / `WIP_` ; +- le **supprimer** dès livraison (ne pas laisser pourrir) ; +- pour une trace utile durable → bilan de version ou archive `obsolete/`. + +Ne plus utiliser `docs/tmp/`. diff --git a/docs/archive/temporaires/TEMP_112-back-reprise-alignement-front.md b/docs/archive/temporaires/TEMP_112-back-reprise-alignement-front.md deleted file mode 100644 index 65eff55..0000000 --- a/docs/archive/temporaires/TEMP_112-back-reprise-alignement-front.md +++ /dev/null @@ -1,244 +0,0 @@ -# #112 — Alignement front après évolution back (reprise dossier complet) - -**Branche déployée :** `feature/112-reprise-apres-refus-front` -**Commit back :** `d70577b1` — `feat(#112): reprise après refus — dossier complet GET/PATCH` -**Date :** 2026-06-16 - -Ce document décrit le **contrat API réel** après extension du back, et ce que le front doit encore brancher pour exploiter le dossier complet (au-delà de l’identité seule). - ---- - -## 1. Endpoints (inchangés côté URL) - -| Méthode | Route | Auth | -|---------|-------|------| -| `GET` | `/api/v1/auth/reprise-dossier?token={uuid}` | Public | -| `PATCH` | `/api/v1/auth/reprise-resoumettre` | Public | -| `POST` | `/api/v1/auth/reprise-identify` | Public (inchangé) | - -> **Note :** le ticket #111 parlait de `PUT` ; l’implémentation reste en **`PATCH`** (comme avant). - ---- - -## 2. `GET /auth/reprise-dossier` — réponse enrichie - -### Champs communs (toujours présents) - -Identiques à avant : `id`, `email`, `prenom`, `nom`, `telephone`, `adresse`, `ville`, `code_postal`, `numero_dossier`, `role`, `photo_url`, `genre`, `situation_familiale`. - -### Rôle `parent` (+ champs #119) - -Alignés sur `DossierFamilleCompletDto` : - -```json -{ - "parents": [ - { - "user_id": "uuid", - "email": "…", - "prenom": "…", - "nom": "…", - "telephone": "…", - "adresse": "…", - "ville": "…", - "code_postal": "…", - "statut": "refuse", - "co_parent_id": "uuid-parent-entity" - } - ], - "enfants": [ - { - "id": "uuid-enfant", - "first_name": "Emma", - "last_name": "MARTIN", - "genre": "F", - "status": "actif", - "birth_date": "2023-02-15T00:00:00.000Z", - "due_date": null, - "photo_url": "/uploads/photos/…", - "consent_photo": true, - "est_multiple": false - } - ], - "texte_motivation": "Nous recherchons…" -} -``` - -**Mapping front suggéré :** - -| JSON back | Modèle / wizard parent | -|-----------|-------------------------| -| `parents[]` | `UserRegistrationData.parent1` + `parent2` (matcher par `email` ou ordre : titulaire = `id` du GET racine) | -| `enfants[].first_name` / `last_name` | `ChildData.firstName` / `lastName` | -| `enfants[].birth_date` | `ChildData.birthDate` (ISO → `DateTime`) | -| `enfants[].due_date` | `ChildData.dueDate` (enfant `a_naitre`) | -| `enfants[].status` | `actif` = né, `a_naitre` = à naître | -| `enfants[].photo_url` | `ApiConfig.absoluteMediaUrl()` + conserver pour reprise sans re-upload | -| `enfants[].id` | **Obligatoire** pour le PATCH (update par id) | -| `enfants[].est_multiple` | `grossesse_multiple` si utilisé | -| `texte_motivation` | étape présentation / motivation | - -Si `numero_dossier` absent : pas de `parents[]` / `enfants[]` / `texte_motivation` (identité seule). - -### Rôle `assistante_maternelle` - -Champs racine + fiche pro (structure **aplatie**, pas de sous-objet `user`) : - -```json -{ - "consentement_photo": true, - "date_naissance": "1985-03-12T00:00:00.000Z", - "lieu_naissance_ville": "Paris", - "lieu_naissance_pays": "France", - "numero_agrement": "AGR-2024-12345", - "nir": "123456789012345", - "date_agrement": "2024-06-01T00:00:00.000Z", - "nb_max_enfants": 4, - "place_disponible": 2, - "biographie": "…" -} -``` - -**Mapping `AmRegistrationData` :** - -| JSON back | Champ front | -|-----------|-------------| -| `nb_max_enfants` | `capaciteAccueil` | -| `place_disponible` | `placesDisponibles` | -| `numero_agrement` | `numeroAgrement` | -| `biographie` | `biographie` / présentation | -| `photo_url` | déjà géré via `RepriseSession.photoUrl` | - ---- - -## 3. `PATCH /auth/reprise-resoumettre` — body étendu - -### Commun - -```json -{ "token": "uuid-reprise" } -``` - -### Parent — champs à envoyer depuis le wizard - -| Champ PATCH | Source wizard | Notes | -|-------------|---------------|-------| -| `prenom`, `nom`, `telephone`, `adresse`, `ville`, `code_postal` | Parent 1 (titulaire token) | Champs racine | -| `co_parent_prenom`, `co_parent_nom`, `co_parent_telephone` | Parent 2 | | -| `co_parent_meme_adresse`, `co_parent_adresse`, `co_parent_code_postal`, `co_parent_ville` | Parent 2 adresse | | -| `texte_motivation` **ou** `presentation_dossier` | Étape motivation | Les deux alias acceptés | -| `enfants[]` | Liste enfants | Voir ci-dessous | - -**Structure `enfants[]` (miroir inscription + `id` obligatoire) :** - -```json -{ - "id": "uuid-enfant-existant", - "prenom": "Emma", - "nom": "MARTIN", - "date_naissance": "2023-02-15", - "date_previsionnelle_naissance": null, - "genre": "F", - "photo_base64": "data:image/jpeg;base64,…", - "photo_filename": "emma.jpg", - "grossesse_multiple": false -} -``` - -- **v1 back :** update par `id` uniquement — pas de création/suppression d’enfant. -- Si `id` inconnu pour ce dossier → **400** `Enfant inconnu pour ce dossier : {id}`. -- Sans nouvelle photo : ne pas envoyer `photo_base64` (l’existant est conservé). - -### AM — champs à envoyer - -| Champ PATCH | Source | -|-------------|--------| -| Identité + `photo_url` ou `photo_base64` + `photo_filename` | Étapes 1–2 | -| `consentement_photo`, `date_naissance`, `lieu_naissance_ville`, `lieu_naissance_pays` | Identité | -| `numero_agrement`, `nir`, `date_agrement` | Pro | -| `capacite_accueil`, `places_disponibles` | Pro | -| `biographie` | Présentation | - -Validation NIR identique à l’inscription si `nir` fourni. - -### Réponse succès (nouveau format) - -```json -{ - "message": "Dossier resoumis avec succès. Il est de nouveau en attente de validation.", - "statut": "en_attente", - "user_id": "uuid", - "numero_dossier": "2026-000021" -} -``` - -Code HTTP : **200** (pas de corps `Users` brut comme l’ancien back). - -### Effet métier - -- **Parent :** tous les users `role=parent` avec le même `numero_dossier` passent en `en_attente` ; `token_reprise` invalidé sur **tous** (symétrique refus #110). -- **AM :** un seul user. - -### E-mail accusé resoumission (parent) - -Après `PATCH` réussi, un e-mail est envoyé à **chaque parent** du dossier (`sendResoumissionPendingEmail`) : -- confirmation de resoumission ; -- rappel du **numéro de dossier** ; -- mention « en attente de validation ». - -Échec SMTP : logué, **ne bloque pas** la resoumission (même règle que l'inscription initiale). - ---- - -## 4. Fichiers front à modifier (checklist) - -### Modèles - -- [ ] `lib/models/reprise_dossier.dart` — parser `parents[]`, `enfants[]`, `texte_motivation`, champs AM -- [ ] Réutiliser ou mapper vers `DossierFamilleEnfant` / structures existantes (#119 admin) si possible - -### Session / préremplissage - -- [ ] `lib/services/reprise_session.dart` - - `applyToParent` : remplir parent1/parent2 depuis `parents[]`, enfants, motivation - - `applyToAm` : remplir tous les champs AM - -### API - -- [ ] `lib/services/auth_service.dart` — `resoumettreReprise()` : accepter body complet (parent + AM), pas seulement identité -- [ ] Étendre `UserRegistrationData` / `AmRegistrationData` helpers `toReprisePatchBody()` si utile - -### Écrans fin de parcours - -- [ ] `parent_register_step5_screen.dart` — PATCH avec co-parent, enfants, motivation -- [ ] `am_register_step4_screen.dart` — PATCH avec fiche AM complète - -### Hors scope back (inchangé) - -RIB / IBAN / attestation CAF (étape 5 wizard parent) : **non persistés** — rien à envoyer en reprise. - -### Non implémenté front (ticket #112 initial) - -- [ ] Modale login « J’ai un numéro de dossier » → `POST /auth/reprise-identify` (back prêt, front absent) - ---- - -## 5. Tests manuels suggérés - -1. Refuser un dossier parent complet (≥1 enfant + co-parent + motivation). -2. Ouvrir le lien mail `/reprise?token=…`. -3. Vérifier dans DevTools que le GET contient `enfants[]` et `texte_motivation`. -4. Après branchement front : wizard prérempli sur toutes les étapes. -5. Resoumettre → statut `en_attente` pour les deux parents ; dossier visible file validation admin (#119). - ---- - -## 6. Références code back - -``` -backend/src/routes/auth/dto/reprise-dossier.dto.ts -backend/src/routes/auth/dto/resoumettre-reprise.dto.ts -backend/src/routes/auth/dto/enfant-reprise.dto.ts -backend/src/routes/auth/auth.service.ts → getRepriseDossier, resoumettreReprise -backend/src/routes/parents/dto/dossier-famille-complet.dto.ts -``` diff --git a/docs/archive/temporaires/TEMP_131-back-fiche-am-enfants.md b/docs/archive/temporaires/TEMP_131-back-fiche-am-enfants.md deleted file mode 100644 index 870ef51..0000000 --- a/docs/archive/temporaires/TEMP_131-back-fiche-am-enfants.md +++ /dev/null @@ -1,132 +0,0 @@ -# #131 — Fiche AM éditable + affiliation enfants (note front → back) - -**Ticket :** #131 (partie AM, doc `28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1) -**Date :** 2026-06-01 -**Statut front :** modale livrée (2 onglets) — **API affiliation AM↔enfant à implémenter** - ---- - -## 1. Comportement UI (front) - -Modale `AdminAmEditModal` — même shell que la fiche parent (~930 px) : - -| Onglet | Contenu | -|--------|---------| -| **Identité & professionnel** | `IdentityBlock` éditable + grille pro (agrément, ville résidence, capacité, places, NIR/agrément date en lecture seule, biographie, switch disponible) + gélule statut | -| **Enfants accueillis** | Liste cartes enfants (réutilise `AdminChildrenAffiliationPanel` / `AdminEnfantUserCard`) + rattacher / détacher | - -En-tête : prénom nom · sous-titre `Zone · Agrément · Dossier`. - ---- - -## 2. Endpoints consommés - -### Déjà existants (partiels) - -| Méthode | Route | Usage | -|---------|-------|-------| -| `GET` | `/api/v1/assistantes-maternelles` | Liste AM | -| `GET` | `/api/v1/assistantes-maternelles/:userId` | Détail (403 possible pour `administrateur` → fallback liste) | -| `PATCH` | `/api/v1/users/:userId` | Identité + statut (admin / super_admin uniquement) | -| `PATCH` | `/api/v1/assistantes-maternelles/:userId` | Champs pro (gestionnaire / super_admin) | - -### À créer (recommandé — miroir parent #131 / #115) - -| Méthode | Route | Rôle | -|---------|-------|------| -| `PATCH` | `/api/v1/assistantes-maternelles/:userId/fiche` | Mise à jour unifiée identité + pro + statut (`super_admin`, `gestionnaire`, `administrateur`) | -| `POST` | `/api/v1/assistantes-maternelles/:userId/enfants/:enfantId` | Rattacher un enfant | -| `DELETE` | `/api/v1/assistantes-maternelles/:userId/enfants/:enfantId` | Détacher un enfant | -| `GET` | `/api/v1/assistantes-maternelles/:userId` | Inclure `amChildren[]` (relation enfant) | - -Le front appelle déjà ces routes ; en l’absence de `PATCH …/fiche`, il tente un fallback `PATCH users` + `PATCH assistantes-maternelles` (échoue selon le rôle connecté). - ---- - -## 3. Modèle de données affiliation AM ↔ enfant - -**À définir côté BDD** (pas de table dédiée aujourd’hui, contrairement à `enfants_parents`) : - -Proposition alignée parent : - -```sql --- Piste : enfants_assistantes_maternelles -CREATE TABLE enfants_assistantes_maternelles ( - id_am UUID NOT NULL REFERENCES utilisateurs(id) ON DELETE CASCADE, - id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE, - PRIMARY KEY (id_am, id_enfant) -); -``` - -Réponse API attendue sur `GET /assistantes-maternelles/:id` : - -```json -{ - "user_id": "uuid-am", - "user": { "id": "…", "prenom": "Claire", "nom": "MARTIN", "statut": "actif" }, - "approval_number": "AGR-2024-12345", - "residence_city": "Bezons", - "max_children": 4, - "places_available": 2, - "available": true, - "amChildren": [ - { - "child": { - "id": "uuid-enfant", - "first_name": "Emma", - "last_name": "MARTIN", - "status": "actif", - "birth_date": "2023-02-15" - } - } - ] -} -``` - -Le front parse `amChildren` / `am_children` / `assistanteChildren` (même logique que `parentChildren`). - ---- - -## 4. Body `PATCH …/fiche` suggéré - -```json -{ - "nom": "MARTIN", - "prenom": "Claire", - "email": "claire@example.com", - "telephone": "0612345678", - "adresse": "5 place Bellecour", - "ville": "Lyon", - "code_postal": "69002", - "statut": "actif", - "approval_number": "AGR-2024-12345", - "residence_city": "Lyon", - "max_children": 4, - "places_available": 2, - "biography": "…", - "available": true -} -``` - -NIR et date d’agrément : lecture seule dans la modale (modification hors périmètre admin v1). - ---- - -## 5. Fichiers front concernés - -| Fichier | Rôle | -|---------|------| -| `frontend/lib/widgets/admin/common/admin_am_edit_modal.dart` | Modale 2 onglets | -| `frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart` | Liste enfants partagée parent/AM | -| `frontend/lib/widgets/admin/common/admin_status_capsule.dart` | Gélule statut partagée | -| `frontend/lib/models/assistante_maternelle_model.dart` | Parse champs pro + `amChildren` | -| `frontend/lib/services/user_service.dart` | `getAssistanteMaternelle`, `updateAmFiche`, `attachEnfantToAm`, `detachEnfantFromAm` | -| `frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart` | Ouverture modale au clic Modifier | - ---- - -## 6. Références - -- Fiche parent : `PATCH /parents/:id/fiche`, `POST|DELETE /parents/:id/enfants/:enfantId` -- Ticket Gitea **#131**, **#115** -- `docs/archive/temporaires/TEMP_131-back-fiche-parent-co-parent.md` diff --git a/docs/archive/temporaires/TEMP_131-back-fiche-parent-co-parent.md b/docs/archive/temporaires/TEMP_131-back-fiche-parent-co-parent.md deleted file mode 100644 index 43ac761..0000000 --- a/docs/archive/temporaires/TEMP_131-back-fiche-parent-co-parent.md +++ /dev/null @@ -1,124 +0,0 @@ -# #131 — En-tête fiche parent : co-parent (note front → back) - -**Ticket :** #131 (fiche parent dashboard, doc `28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1) -**Date :** 2026-06-01 -**Statut front :** livré (en-tête dynamique) -**Modif backend demandée :** **aucune** — ce document fixe le contrat attendu et invite à valider que l’existant le couvre. - ---- - -## 1. Comportement UI (front) - -Dans la modale **fiche parent** (`AdminParentEditModal`) : - -| Zone | Contenu | -|------|---------| -| **Titre** | `prenom` + `nom` du parent affiché (plus le libellé fixe « Fiche parent ») | -| **Sous-titre** | `Co-parent : {prenom} {nom}` — affiché **uniquement** si un co-parent est connu | - -Le titre se met à jour en direct pendant l’édition des champs nom/prénom. -Le sous-titre provient du co-parent **chargé depuis l’API** (pas saisi à la main dans la modale). - ---- - -## 2. Endpoints consommés - -| Méthode | Route | Usage front | -|---------|-------|-------------| -| `GET` | `/api/v1/parents` | Liste parents (onglet Parents) | -| `GET` | `/api/v1/parents/:userId` | Rechargement fiche après rattachement/détachement enfant | -| `PATCH` | `/api/v1/parents/:userId/fiche` | Sauvegarde identité + statut (inchangé) | - -Rôles : `super_admin`, `gestionnaire`, `administrateur` (selon route). - ---- - -## 3. Contrat JSON attendu pour `co_parent` - -Le front parse `ParentModel.fromJson` avec la clé **`co_parent`** (snake_case), objet utilisateur imbriqué. - -### Champs minimum utilisés pour le sous-titre - -| Clé JSON | Usage | -|----------|--------| -| `co_parent` | Objet ou absent/`null` | -| `co_parent.id` | Identifiant (futur lien cliquable éventuel) | -| `co_parent.prenom` | Affichage | -| `co_parent.nom` | Affichage | - -Affichage front : `'{prenom} {nom}'.trim()` → libellé `Co-parent : …`. - -### Exemple de fragment de réponse (`GET /parents/:id`) - -```json -{ - "user_id": "33333333-3333-3333-3333-333333333333", - "numero_dossier": "2026-000042", - "user": { - "id": "33333333-3333-3333-3333-333333333333", - "email": "parent1@example.com", - "prenom": "Paul", - "nom": "PARENT", - "statut": "actif", - "telephone": "0601020304" - }, - "co_parent": { - "id": "44444444-4444-4444-4444-444444444444", - "email": "coparent1@example.com", - "prenom": "Clara", - "nom": "COPARENT", - "role": "parent", - "statut": "actif" - }, - "parentChildren": [] -} -``` - -> **Note :** le front lit `user` (pas `utilisateur`). La doc `11_API.md` § Parents mentionne encore `utilisateur` / `id_co_parent` seul — le contrat **effectif** côté Nest/TypeORM est l’entité `Parents` sérialisée (`user`, `co_parent`, `parentChildren`, …). - ---- - -## 4. État backend (à valider, pas à refaire) - -D’après le code actuel (`parents.service.ts`) : - -- `findAll()` et `findOne(user_id)` chargent déjà la relation **`co_parent`** ; -- la FK métier est `parents.id_co_parent` → `utilisateurs.id` ; -- à l’inscription couple, les deux sens sont en principe renseignés (`auth.service.ts`). - -**Checklist validation back :** - -- [ ] `GET /parents/:id` renvoie bien `co_parent` peuplé quand `id_co_parent` est non null -- [ ] `GET /parents` (liste) inclut aussi `co_parent` (sous-titre disponible dès l’ouverture sans re-fetch) -- [ ] Les champs `prenom` / `nom` du co-parent sont présents dans la réponse JSON - -Si ces trois points passent en recette, **aucun changement backend n’est nécessaire** pour cette fonctionnalité. - ---- - -## 5. Points d’attention (hors périmètre immédiat) - -| Sujet | Détail | -|-------|--------| -| **Lien inverse** | Si le parent B est le co-parent de A (`A.id_co_parent = B`) mais que `B.id_co_parent` est `null`, le sous-titre **ne s’affichera pas** sur la fiche de B. Le front ne fait pas de résolution inverse. À traiter côté back **seulement si** des données legacy ont un lien à sens unique. | -| **Familles > 2 adultes** | Le sous-titre n’affiche que le co-parent direct (`id_co_parent`). Les autres responsables liés uniquement via `enfants_parents` ne sont pas listés ici (cf. doc 28 §6). | -| **Données sensibles** | Vérifier que la sérialisation de `co_parent` n’expose pas `password` / tokens (même remarque que pour `user`). | - ---- - -## 6. Fichiers front concernés - -| Fichier | Rôle | -|---------|------| -| `frontend/lib/models/parent_model.dart` | Parse `co_parent` → `AppUser? coParent` | -| `frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart` | Titre + sous-titre | -| `frontend/lib/services/user_service.dart` | `getParents()` / `getParent()` | - ---- - -## 7. Références - -- `docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1 -- `backend/src/routes/parents/parents.service.ts` — `findOne`, `findAll` -- `backend/src/entities/parents.entity.ts` — relation `co_parent` -- Ticket Gitea **#131** diff --git a/docs/archive/temporaires/TEMP_FRONT_alignement_AM.md b/docs/archive/temporaires/TEMP_FRONT_alignement_AM.md deleted file mode 100644 index 21f2eed..0000000 --- a/docs/archive/temporaires/TEMP_FRONT_alignement_AM.md +++ /dev/null @@ -1,46 +0,0 @@ -# TEMP — Alignement front / API (inscription AM & validation gestionnaire) - -> **Archivé** (`docs/archive/temporaires/`) — **fichier temporaire** ; à -> **supprimer** une fois le front livré ou le sujet clos (voir -> `docs/archive/temporaires/README.md`). - -Ce document décrit les changements **côté API** et ce que **Flutter** doit faire pour rester aligné. Aucune modification front n’a été faite dans le chantier backend associé. - -## 1. `POST /auth/register/am` — lieu de naissance obligatoire - -- **`lieu_naissance_ville`** et **`lieu_naissance_pays`** sont **obligatoires** (non vides après trim, min. **2 caractères** chacun, max 100). -- Réponses **400** si manquants ou invalides (messages class-validator). -- **Action front** : champs obligatoires dans le parcours AM (étapes identité / naissance), validation UI avant envoi ; afficher les erreurs renvoyées par l’API. - -## 2. Réponse `GET /dossiers/:numeroDossier` (type `am`) - -Sous `dossier.user`, l’API peut inclure : - -| Clé JSON | Description | -|----------|-------------| -| `date_naissance` | Date (si renseignée à l’inscription) | -| `lieu_naissance_ville` | Ville de naissance | -| `lieu_naissance_pays` | Pays de naissance | -| `consentement_photo` | Booléen (exposé dans `dossier.user`) | - -À la **racine** de `dossier` (objet AM), champs déjà renvoyés par le backend : `disponible`, `annees_experience`, `specialite`, `nb_max_enfants`, `place_disponible`, etc. - -**Action front** : - -- Étendre **`AppUser.fromJson` / `toJson`** (`lib/models/user.dart`) pour mapper `date_naissance`, `lieu_naissance_ville`, `lieu_naissance_pays`, `consentement_photo`. -- Étendre **`DossierAM.fromJson`** (`lib/models/dossier_unifie.dart`) pour parser `disponible`, `annees_experience`, `specialite` à la racine du dossier (noms snake_case comme dans la réponse JSON Nest). - -## 3. `ValidationAmWizard` (admin) - -Afficher pour cohérence avec le formulaire d’inscription : - -- **Informations personnelles** : date de naissance, ville / pays de naissance, consentement photo (Oui/Non). -- **Informations professionnelles** : disponibilité, années d’expérience, spécialité (afficher « – » si `null`). - -## 4. `place_disponible` à l’inscription - -- Le backend initialise **`place_disponible`** sur la fiche AM à la **même valeur** que **`capacite_accueil`** à la création. Le wizard peut donc afficher une valeur cohérente avec la capacité sans champ séparé côté public. - ---- - -*Dernière mise à jour : alignement backend branche `feature/120-inscription-am-photo-backend`.* diff --git a/docs/tmp/112-back-reprise-alignement-front.md b/docs/tmp/112-back-reprise-alignement-front.md deleted file mode 100644 index 65eff55..0000000 --- a/docs/tmp/112-back-reprise-alignement-front.md +++ /dev/null @@ -1,244 +0,0 @@ -# #112 — Alignement front après évolution back (reprise dossier complet) - -**Branche déployée :** `feature/112-reprise-apres-refus-front` -**Commit back :** `d70577b1` — `feat(#112): reprise après refus — dossier complet GET/PATCH` -**Date :** 2026-06-16 - -Ce document décrit le **contrat API réel** après extension du back, et ce que le front doit encore brancher pour exploiter le dossier complet (au-delà de l’identité seule). - ---- - -## 1. Endpoints (inchangés côté URL) - -| Méthode | Route | Auth | -|---------|-------|------| -| `GET` | `/api/v1/auth/reprise-dossier?token={uuid}` | Public | -| `PATCH` | `/api/v1/auth/reprise-resoumettre` | Public | -| `POST` | `/api/v1/auth/reprise-identify` | Public (inchangé) | - -> **Note :** le ticket #111 parlait de `PUT` ; l’implémentation reste en **`PATCH`** (comme avant). - ---- - -## 2. `GET /auth/reprise-dossier` — réponse enrichie - -### Champs communs (toujours présents) - -Identiques à avant : `id`, `email`, `prenom`, `nom`, `telephone`, `adresse`, `ville`, `code_postal`, `numero_dossier`, `role`, `photo_url`, `genre`, `situation_familiale`. - -### Rôle `parent` (+ champs #119) - -Alignés sur `DossierFamilleCompletDto` : - -```json -{ - "parents": [ - { - "user_id": "uuid", - "email": "…", - "prenom": "…", - "nom": "…", - "telephone": "…", - "adresse": "…", - "ville": "…", - "code_postal": "…", - "statut": "refuse", - "co_parent_id": "uuid-parent-entity" - } - ], - "enfants": [ - { - "id": "uuid-enfant", - "first_name": "Emma", - "last_name": "MARTIN", - "genre": "F", - "status": "actif", - "birth_date": "2023-02-15T00:00:00.000Z", - "due_date": null, - "photo_url": "/uploads/photos/…", - "consent_photo": true, - "est_multiple": false - } - ], - "texte_motivation": "Nous recherchons…" -} -``` - -**Mapping front suggéré :** - -| JSON back | Modèle / wizard parent | -|-----------|-------------------------| -| `parents[]` | `UserRegistrationData.parent1` + `parent2` (matcher par `email` ou ordre : titulaire = `id` du GET racine) | -| `enfants[].first_name` / `last_name` | `ChildData.firstName` / `lastName` | -| `enfants[].birth_date` | `ChildData.birthDate` (ISO → `DateTime`) | -| `enfants[].due_date` | `ChildData.dueDate` (enfant `a_naitre`) | -| `enfants[].status` | `actif` = né, `a_naitre` = à naître | -| `enfants[].photo_url` | `ApiConfig.absoluteMediaUrl()` + conserver pour reprise sans re-upload | -| `enfants[].id` | **Obligatoire** pour le PATCH (update par id) | -| `enfants[].est_multiple` | `grossesse_multiple` si utilisé | -| `texte_motivation` | étape présentation / motivation | - -Si `numero_dossier` absent : pas de `parents[]` / `enfants[]` / `texte_motivation` (identité seule). - -### Rôle `assistante_maternelle` - -Champs racine + fiche pro (structure **aplatie**, pas de sous-objet `user`) : - -```json -{ - "consentement_photo": true, - "date_naissance": "1985-03-12T00:00:00.000Z", - "lieu_naissance_ville": "Paris", - "lieu_naissance_pays": "France", - "numero_agrement": "AGR-2024-12345", - "nir": "123456789012345", - "date_agrement": "2024-06-01T00:00:00.000Z", - "nb_max_enfants": 4, - "place_disponible": 2, - "biographie": "…" -} -``` - -**Mapping `AmRegistrationData` :** - -| JSON back | Champ front | -|-----------|-------------| -| `nb_max_enfants` | `capaciteAccueil` | -| `place_disponible` | `placesDisponibles` | -| `numero_agrement` | `numeroAgrement` | -| `biographie` | `biographie` / présentation | -| `photo_url` | déjà géré via `RepriseSession.photoUrl` | - ---- - -## 3. `PATCH /auth/reprise-resoumettre` — body étendu - -### Commun - -```json -{ "token": "uuid-reprise" } -``` - -### Parent — champs à envoyer depuis le wizard - -| Champ PATCH | Source wizard | Notes | -|-------------|---------------|-------| -| `prenom`, `nom`, `telephone`, `adresse`, `ville`, `code_postal` | Parent 1 (titulaire token) | Champs racine | -| `co_parent_prenom`, `co_parent_nom`, `co_parent_telephone` | Parent 2 | | -| `co_parent_meme_adresse`, `co_parent_adresse`, `co_parent_code_postal`, `co_parent_ville` | Parent 2 adresse | | -| `texte_motivation` **ou** `presentation_dossier` | Étape motivation | Les deux alias acceptés | -| `enfants[]` | Liste enfants | Voir ci-dessous | - -**Structure `enfants[]` (miroir inscription + `id` obligatoire) :** - -```json -{ - "id": "uuid-enfant-existant", - "prenom": "Emma", - "nom": "MARTIN", - "date_naissance": "2023-02-15", - "date_previsionnelle_naissance": null, - "genre": "F", - "photo_base64": "data:image/jpeg;base64,…", - "photo_filename": "emma.jpg", - "grossesse_multiple": false -} -``` - -- **v1 back :** update par `id` uniquement — pas de création/suppression d’enfant. -- Si `id` inconnu pour ce dossier → **400** `Enfant inconnu pour ce dossier : {id}`. -- Sans nouvelle photo : ne pas envoyer `photo_base64` (l’existant est conservé). - -### AM — champs à envoyer - -| Champ PATCH | Source | -|-------------|--------| -| Identité + `photo_url` ou `photo_base64` + `photo_filename` | Étapes 1–2 | -| `consentement_photo`, `date_naissance`, `lieu_naissance_ville`, `lieu_naissance_pays` | Identité | -| `numero_agrement`, `nir`, `date_agrement` | Pro | -| `capacite_accueil`, `places_disponibles` | Pro | -| `biographie` | Présentation | - -Validation NIR identique à l’inscription si `nir` fourni. - -### Réponse succès (nouveau format) - -```json -{ - "message": "Dossier resoumis avec succès. Il est de nouveau en attente de validation.", - "statut": "en_attente", - "user_id": "uuid", - "numero_dossier": "2026-000021" -} -``` - -Code HTTP : **200** (pas de corps `Users` brut comme l’ancien back). - -### Effet métier - -- **Parent :** tous les users `role=parent` avec le même `numero_dossier` passent en `en_attente` ; `token_reprise` invalidé sur **tous** (symétrique refus #110). -- **AM :** un seul user. - -### E-mail accusé resoumission (parent) - -Après `PATCH` réussi, un e-mail est envoyé à **chaque parent** du dossier (`sendResoumissionPendingEmail`) : -- confirmation de resoumission ; -- rappel du **numéro de dossier** ; -- mention « en attente de validation ». - -Échec SMTP : logué, **ne bloque pas** la resoumission (même règle que l'inscription initiale). - ---- - -## 4. Fichiers front à modifier (checklist) - -### Modèles - -- [ ] `lib/models/reprise_dossier.dart` — parser `parents[]`, `enfants[]`, `texte_motivation`, champs AM -- [ ] Réutiliser ou mapper vers `DossierFamilleEnfant` / structures existantes (#119 admin) si possible - -### Session / préremplissage - -- [ ] `lib/services/reprise_session.dart` - - `applyToParent` : remplir parent1/parent2 depuis `parents[]`, enfants, motivation - - `applyToAm` : remplir tous les champs AM - -### API - -- [ ] `lib/services/auth_service.dart` — `resoumettreReprise()` : accepter body complet (parent + AM), pas seulement identité -- [ ] Étendre `UserRegistrationData` / `AmRegistrationData` helpers `toReprisePatchBody()` si utile - -### Écrans fin de parcours - -- [ ] `parent_register_step5_screen.dart` — PATCH avec co-parent, enfants, motivation -- [ ] `am_register_step4_screen.dart` — PATCH avec fiche AM complète - -### Hors scope back (inchangé) - -RIB / IBAN / attestation CAF (étape 5 wizard parent) : **non persistés** — rien à envoyer en reprise. - -### Non implémenté front (ticket #112 initial) - -- [ ] Modale login « J’ai un numéro de dossier » → `POST /auth/reprise-identify` (back prêt, front absent) - ---- - -## 5. Tests manuels suggérés - -1. Refuser un dossier parent complet (≥1 enfant + co-parent + motivation). -2. Ouvrir le lien mail `/reprise?token=…`. -3. Vérifier dans DevTools que le GET contient `enfants[]` et `texte_motivation`. -4. Après branchement front : wizard prérempli sur toutes les étapes. -5. Resoumettre → statut `en_attente` pour les deux parents ; dossier visible file validation admin (#119). - ---- - -## 6. Références code back - -``` -backend/src/routes/auth/dto/reprise-dossier.dto.ts -backend/src/routes/auth/dto/resoumettre-reprise.dto.ts -backend/src/routes/auth/dto/enfant-reprise.dto.ts -backend/src/routes/auth/auth.service.ts → getRepriseDossier, resoumettreReprise -backend/src/routes/parents/dto/dossier-famille-complet.dto.ts -``` diff --git a/docs/tmp/129-contrat-api-staff-parent.md b/docs/tmp/129-contrat-api-staff-parent.md deleted file mode 100644 index b07e681..0000000 --- a/docs/tmp/129-contrat-api-staff-parent.md +++ /dev/null @@ -1,89 +0,0 @@ -# Mini-spec API — POST /parents/dossier (#129) - -Contrat pour le **plan front** (wizard création dossier famille staff). - -Miroir de **#156** (`POST /assistantes-maternelles/dossier`). - -## Endpoint - -| | | -|--|--| -| **Méthode** | `POST` | -| **URL** | `{base}/parents/dossier` | -| **Auth** | Bearer JWT | -| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` | -| **Content-Type** | `application/json` | - -Ne **pas** appeler `POST /auth/register/parent` depuis le dashboard. - -## Body (JSON) - -Aligné `RegisterParentCompletDto`, **sans** CGU/privacy obligatoires (acceptées serveur). - -### Parent 1 (obligatoire) - -| Champ | Type | Obligatoire | Notes | -|-------|------|-------------|--------| -| `email` | string | oui | unique | -| `prenom` | string | oui | | -| `nom` | string | oui | | -| `telephone` | string | oui | `0X…` ou `+33…` | -| `adresse` | string | non | | -| `code_postal` | string | non | | -| `ville` | string | non | | - -### Co-parent (optionnel) - -`co_parent_email`, `co_parent_prenom`, `co_parent_nom`, `co_parent_telephone`, -`co_parent_meme_adresse`, `co_parent_adresse`, `co_parent_code_postal`, `co_parent_ville`. - -Si co-parent fourni : e-mail distinct ; mêmes règles téléphone / adresse que register. - -### Enfants (≥ 1) - -| Champ | Type | Notes | -|-------|------|--------| -| `enfants` | `EnfantInscriptionDto[]` | `prenom`, `nom`, `date_naissance` / `date_previsionnelle_naissance`, `genre`, `photo_base64`, `photo_filename`, etc. | - -### Présentation - -| Champ | Type | Obligatoire | -|-------|------|-------------| -| `presentation_dossier` | string | non (max 2000) | - -## Réponses - -### 201 Created - -```json -{ - "message": "Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.", - "numero_dossier": "2026-000043", - "parent_user_id": "uuid-pivot", - "co_parent_user_id": "uuid-ou-null", - "statut": "actif", - "enfant_ids": ["uuid", "..."] -} -``` - -Effets serveur : user(s) parent **actif**, fiches `parents`, enfants + foyer, n° dossier, -**e-mail création MDP** pour chaque compte sans MDP (pas d’accusé « en attente »). - -### Erreurs - -| Code | Cas | -|------|-----| -| 400 | Validation DTO / métier (enfants vides, dates, etc.) | -| 401 | Token manquant / invalide | -| 403 | Rôle non staff | -| 409 | Conflit e-mail (pivot et/ou co-parent) | - -## Front - -- `UserService.createParentDossier(body)` → cet endpoint -- Wizard create basé sur `ValidationFamilyWizard` -- Ne pas envoyer `acceptation_cgu` / `acceptation_privacy` (optionnels) - -## Branche - -`feature/129-creation-dossier-parent` diff --git a/docs/tmp/135-contrat-api-ajout-co-parent.md b/docs/tmp/135-contrat-api-ajout-co-parent.md deleted file mode 100644 index dd47421..0000000 --- a/docs/tmp/135-contrat-api-ajout-co-parent.md +++ /dev/null @@ -1,73 +0,0 @@ -# Mini-spec API — POST /parents/:id/co-parent (#135) - -Contrat back pour l’ajout d’un **2ᵉ parent** sur un foyer mono-parent (staff). - -## Endpoint - -| | | -|--|--| -| **Méthode** | `POST` | -| **URL** | `{base}/api/v1/parents/{parentUserId}/co-parent` | -| **Auth** | Bearer JWT | -| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` | -| **Succès** | **201** | - -`parentUserId` = UUID du **parent pivot** (déjà dans le dossier). - -Ne **pas** appeler `POST /auth/register/parent` ni `POST /parents/dossier`. - ---- - -## Body (JSON) - -| Champ | Type | Obligatoire | Notes | -|-------|------|-------------|--------| -| `email` | string | oui | unique | -| `prenom` | string | oui | | -| `nom` | string | oui | | -| `telephone` | string | oui | `0X…` ou `+33…` | -| `meme_adresse` | bool | non | défaut **true** → copie adresse du pivot | -| `adresse` | string | si `meme_adresse=false` | | -| `code_postal` | string | si `meme_adresse=false` | | -| `ville` | string | si `meme_adresse=false` | | - ---- - -## Comportement 201 - -- User co-parent **actif** + token création MDP -- Fiche `parents` + liens pivot ↔ co-parent + même `numero_dossier` -- Enfants du foyer rattachés au co-parent -- E-mail **création MDP** (pas mail « en attente ») - -```json -{ - "message": "Co-parent ajouté au foyer. Un e-mail de création de mot de passe a été envoyé.", - "numero_dossier": "2026-000043", - "parent_user_id": "uuid-pivot", - "co_parent_user_id": "uuid-co", - "statut": "actif" -} -``` - -## Erreurs - -| Code | Cas | -|------|-----| -| 400 | Déjà un co-parent / 2 responsables / validation adresse | -| 401 | Token invalide | -| 403 | Rôle non staff | -| 404 | Pivot introuvable | -| 409 | Email déjà pris | - -## Réemploi édition identité - -| Endpoint | Usage | -|----------|--------| -| `GET /dossiers/:numero` | Préremplir wizard edit | -| `PATCH /parents/:id/fiche` | Sauver identité pivot / co-parent existant | -| `PATCH /assistantes-maternelles/:id/fiche` | Édition AM | - -## Branche - -`feature/135-edition-dossier` diff --git a/docs/tmp/135-mini-spec-front-edition-dossier.md b/docs/tmp/135-mini-spec-front-edition-dossier.md deleted file mode 100644 index f20113b..0000000 --- a/docs/tmp/135-mini-spec-front-edition-dossier.md +++ /dev/null @@ -1,83 +0,0 @@ -# Mini-spec front — Mode édition dossier + ajout 2ᵉ parent (#135) - -Branche : `feature/135-edition-dossier` -Ticket : **#135** (full-stack) - -Prérequis : **#153** (liste Dossiers) livré. - ---- - -## Objectif - -1. Clic sur un dossier (liste #153) → ouvrir le wizard en mode **`edit`** -2. Foyer **mono-parent** : page co-parent → **switch** ajouter un 2ᵉ parent -3. Sauvegarder les champs via APIs existantes + nouvel endpoint co-parent - ---- - -## Modes wizard - -| Mode | Famille | AM | -|------|---------|-----| -| `review` | déjà | déjà | -| `create` | déjà (#129) | déjà (#156) | -| **`edit`** | **à faire** | **à faire** | - -Factories : `ParentDossierWizard.edit(...)` / `AmDossierWizard.edit(...)` -Préremplir via `UserService.getDossierByNumero(numero)`. - ---- - -## APIs - -| Action | Endpoint | -|--------|----------| -| Charger | `GET /dossiers/:numero` | -| Sauver parent | `PATCH /parents/:id/fiche` | -| Sauver AM | `PATCH /assistantes-maternelles/:id/fiche` | -| **Ajouter co-parent** | **`POST /parents/:pivotUserId/co-parent`** — voir `docs/tmp/135-contrat-api-ajout-co-parent.md` | - -Body co-parent : - -```json -{ - "email": "thomas@…", - "prenom": "Thomas", - "nom": "MARTIN", - "telephone": "0678456789", - "meme_adresse": true -} -``` - -`UserService.addCoParent(pivotUserId, body)` → cet endpoint. - ---- - -## UX - -- Depuis `DossiersManagementWidget` / carte liste : clic → edit (plus seulement review pending) -- Pending : garder validation (review) ; dossiers actifs → edit -- Mono-parent : switch « Ajouter un co-parent » (comme create) → au save, `POST …/co-parent` si nouveau -- Déjà 2 parents : éditer les deux fiches ; pas de 3ᵉ -- Pas de bouton créer dans l’onglet Dossiers - ---- - -## Hors scope - -- Famille N responsables (#139) -- Suppressions (#154) -- Création dossier initial (#129 / #156) - ---- - -## Critères d’acceptation - -- [ ] Clic dossier actif → wizard edit prérempli -- [ ] PATCH fiche enregistre les modifs -- [ ] Mono-parent + switch → co-parent créé (actif + mail MDP) -- [ ] review / create inchangés - -## Branche - -`feature/135-edition-dossier` diff --git a/docs/tmp/152-mini-spec-front-est-multiple.md b/docs/tmp/152-mini-spec-front-est-multiple.md deleted file mode 100644 index 0576312..0000000 --- a/docs/tmp/152-mini-spec-front-est-multiple.md +++ /dev/null @@ -1,65 +0,0 @@ -# Mini-spec — Suppression complète grossesse multiple / `est_multiple` - -**Ticket** : **#152** — https://git.ptits-pas.fr/jmartin/petitspas/issues/152 -**Branche** : `feature/152-remove-est-multiple` (depuis `develop`) -**Périmètre** : **full stack** — BDD + back + front + scripts + docs. **Aucun fantôme.** - ---- - -## Décision - -On **supprime tout**. Pas de DTO « ignorés », pas de compat payload. - -`forbidNonWhitelisted: true` ⇒ back et front **partent ensemble** (même feature / même déploiement). - ---- - -## Alias retirés - -`est_multiple` · `is_multiple` · `grossesse_multiple` · `multipleBirth` · `estMultiple` · `isMultiple` · `jumeau_multiple` - ---- - -## Back / BDD (fait sur la branche) - -- [x] Migration `database/migrations/2026_drop_enfants_est_multiple.sql` -- [x] `BDD.sql`, seeds, CSV test -- [x] Entity `Children` sans colonne -- [x] DTO create/inscription/réponse/dossier famille **sans** le champ -- [x] Services auth / enfants / parents : plus de mapping -- [x] Prisma legacy `isMultiple` retiré - -## Front (fait sur la branche) - -- [x] Modèles admin / dossier / inscription -- [x] Payloads inscription + reprise -- [x] Modale enfant + wizard dossier famille -- [x] Step3 inscription parent - -## Scripts / docs - -- [x] `tests/scripts/register-parent-*.mjs` -- [x] `docs/10_DATABASE.md`, `docs/99_REGLES-CODAGE.md` -- Docs tmp/archive #112 : mentions historiques OK (archive) - ---- - -## Déploiement - -1. Appliquer la migration SQL sur la BDD vivante -2. Deploy back **et** front de cette branche -3. Smoke : création enfant staff, inscription parent, reprise, wizard famille - -## Vérif - -```bash -rg -n 'est_multiple|is_multiple|grossesse_multiple|multipleBirth|estMultiple|isMultiple|jumeau_multiple' \ - backend/src frontend/lib database tests/scripts docs/10_DATABASE.md docs/99_REGLES-CODAGE.md -``` -→ **0** hit (hors ce fichier mini-spec / archives). - -## Hors scope - -- Métier futur « fratrie / jumeaux » → nouveau ticket -- #155 rename Admin* -- Ticket modales staff diff --git a/docs/tmp/153-contrat-api-liste-dossiers.md b/docs/tmp/153-contrat-api-liste-dossiers.md deleted file mode 100644 index b427a7f..0000000 --- a/docs/tmp/153-contrat-api-liste-dossiers.md +++ /dev/null @@ -1,77 +0,0 @@ -# Mini-spec API — GET /dossiers (#153) - -Contrat pour le **plan front** (onglet permanent Dossiers). - -## Endpoint - -| | | -|--|--| -| **Méthode** | `GET` | -| **URL** | `{base}/api/v1/dossiers` | -| **Auth** | Bearer JWT | -| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` | -| **Query** | `q` (optionnel) — recherche n° / libellé / email | - -Complète `GET /dossiers/:numeroDossier` (#119) déjà existant. - ---- - -## Réponse 200 - -Tableau de lignes (1 entrée = 1 `numero_dossier`) : - -```json -[ - { - "type": "famille", - "numero_dossier": "2026-000043", - "libelle": "Claire MARTIN & Thomas MARTIN", - "emails": ["claire@test.fr", "thomas@test.fr"], - "user_ids": ["uuid-pivot", "uuid-co"], - "statut": "actif", - "a_valider": false, - "date_reference": "2026-01-12T10:00:00.000Z" - }, - { - "type": "assistante_maternelle", - "numero_dossier": "2026-000042", - "libelle": "Marie DUPONT", - "emails": ["marie@test.fr"], - "user_ids": ["uuid-am"], - "statut": "en_attente", - "a_valider": true, - "date_reference": "2026-02-01T08:00:00.000Z" - } -] -``` - -### Champs - -| Champ | Notes | -|-------|--------| -| `type` | `famille` \| `assistante_maternelle` | -| `numero_dossier` | Clé d’unité | -| `libelle` | Noms formatés (foyer : `A & B`) | -| `emails` / `user_ids` | Membres du foyer ou AM | -| `statut` | Agrégé : `en_attente` si au moins un user pending | -| `a_valider` | `true` si pending → section haute UI | -| `date_reference` | `MIN(cree_le)` des users | - -**Tri** : `a_valider` d’abord, puis `numero_dossier` décroissant. - -**Famille** : dédupliquée par `numero_dossier` (pivot + co-parent = 1 ligne). - ---- - -## Front - -- `UserService.getDossiers({ q? })` → cet endpoint -- Section haute : filtrer `a_valider == true` **ou** continuer pending APIs existantes -- Section basse : liste complète (ou hors pending selon règle UX) -- Clic → `GET /dossiers/:numero` (détail) / validation review - -Composition client `getParents`+`getAM` **plus nécessaire** si cet endpoint est déployé. - -## Branche - -`feature/153-onglet-dossiers` diff --git a/docs/tmp/153-mini-spec-front-dossiers.md b/docs/tmp/153-mini-spec-front-dossiers.md deleted file mode 100644 index 9e4f8f6..0000000 --- a/docs/tmp/153-mini-spec-front-dossiers.md +++ /dev/null @@ -1,167 +0,0 @@ -# Mini-spec front — Onglet permanent « Dossiers » (#153) - -Branche Git (front + back) : `feature/153-onglet-dossiers` -Ticket Gitea : **#153** (ticket normal, plus epic) - -> Suite prévue : **#135** = au clic, mode **édition** wizard + ajout 2ᵉ parent. -> **#153** = onglet + listes + navigation / validation pending. **Pas** de création, **pas** d’édition complète. - ---- - -## Contexte / objectif - -Remplacer l’onglet conditionnel **« À valider »** (apparaît/disparaît selon pending) par un onglet **permanent « Dossiers »** dans le dashboard admin/gestionnaire. - -Quand on ouvre **Dossiers** : - -1. **En haut** — section **Dossiers à valider** (AM + familles pending) -2. **En dessous** — liste de **tous les dossiers** (familles **et** AM), 1 ligne = 1 `numero_dossier` -3. Différenciation visuelle famille vs AM : **couleur + icône** -4. **Barre de recherche** (n° dossier, nom, email…) - -**Pas** de bouton « Créer un dossier » ici (création via **+ Parents** #129 / **+ Asmat** #156). - ---- - -## UX cible - -### Onglets dashboard (`UserManagementPanel`) - -| Avant (#107) | Après (#153) | -|--------------|--------------| -| « À valider » **conditionnel** si pending | **« Dossiers » toujours visible** (admin + gestionnaire) | -| Contenu = seulement pending | Pending **en haut** + liste complète **en bas** | - -Ordre suggéré des onglets : - -`Dossiers` | `Parents` | `Enfants` | `Assistantes maternelles` | `Gestionnaires` | (`Administrateurs`) - -### Section haute — À valider - -- Réutiliser / adapter `PendingValidationWidget` (ou extraire la liste dans un sous-widget). -- Sources déjà branchées : - - `UserService.getPendingUsers(role: 'assistante_maternelle')` - - `UserService.getPendingFamilies()` -- Clic ligne pending → **`ValidationDossierModal`** / wizards `.review` (inchangé). -- Si section vide : ne pas afficher de gros vide ; masquer la section ou message court « Aucun dossier en attente ». - -### Section basse — Tous les dossiers - -1 ligne = **1 dossier** (`numero_dossier`), type : - -| Type | Libellé UI | Couleur (suggestion) | -|------|------------|----------------------| -| `famille` | Famille / Parents | teinte existante parents (ex. violet / rose dashboard) | -| `assistante_maternelle` | AM | teinte existante AM (ex. teal / bleu) | - -Colonnes / infos utiles (cartes style `AdminUserCard` ou lignes type pending) : - -- n° dossier -- type (pastille couleur + icône) -- libellé (noms parents ou AM) -- email(s) principal(aux) -- statut user / dossier si dispo (`actif`, `en_attente`, …) -- date utile si dispo - -**Déduplication** : un foyer (pivot + co-parent) = **une** ligne famille (même `numero_dossier`). Idem AM. - -### Recherche - -- La search bar du panel (aujourd’hui désactivée / hint « pas de recherche » sur À valider) doit **filtrer la liste unifiée** (et idéalement aussi le pending affiché). -- Critères **minimum** : `numero_dossier`, nom, prénom, email. -- Harmoniser le hint : `Rechercher un dossier (n°, nom, email)…` - -### État vide liste complète - -Aide optionnelle : *« Pour créer un dossier → onglet Parents (+ Parents) ou Assistantes maternelles (+ Asmat) »*. - -### Clic sur un dossier de la liste complète (#153) - -| Cas | Comportement #153 | -|-----|-------------------| -| Pending | Ouvrir validation (review) — déjà en place | -| Dossier **actif** / non pending | Ouvrir consultation via `GET /dossiers/:numeroDossier` (`UserService.getDossierByNumero`) en **lecture / review** si possible **sans** save édition | - -**Ne pas** implémenter le mode `edit` ni le switch 2ᵉ parent → **#135**. - -Si l’ouverture « review » d’un dossier actif est trop lourde pour ce ticket : clic peut temporairement no-op / snackbar *« Édition dossier : prochainement (#135) »* — **à éviter** si `getDossierByNumero` + wizard review marche déjà pour les deux types. - ---- - -## Données / APIs (front) - -### Déjà disponibles (préférer composer côté front pour #153) - -| Besoin | API / service | -|--------|----------------| -| Pending AM | `getPendingUsers(role: assistante_maternelle)` | -| Pending familles | `getPendingFamilies()` | -| Parents (avec `numero_dossier`) | `getParents()` | -| AM (avec `numero_dossier`) | `getAssistantesMaternelles()` | -| Détail unifié | `getDossierByNumero(numero)` → `GET /dossiers/:numeroDossier` | - -**Pas d’endpoint `GET /dossiers` liste** aujourd’hui. Pour #153 : - -- Construire la liste unifiée **côté client** à partir de `getParents()` + `getAssistantesMaternelles()` (group by `numero_dossier`). -- Exclure ou marquer les pending déjà dans la section haute (éviter doublons visuels, ou les laisser dans les deux avec badge « à valider » — **préférence** : pending **uniquement** en haut ; liste basse = tous **hors** pending **ou** tous avec badge ; choisir une règle claire et documenter dans le PR). - -**Règle recommandée** : -- Haut = pending only -- Bas = **tous** les dossiers ayant un `numero_dossier` (y compris pending) **OU** bas = non-pending only -→ **Recommandation produit** : bas = **tous** (vision complète), pending aussi en haut pour action rapide. Si doublon gênant : bas = non-pending only. - -### Si le back ajoute plus tard `GET /dossiers` - -Brancher `UserService.getDossiers()` — hors scope bloquant #153 front si composition client OK. - ---- - -## Fichiers front probables - -| Fichier | Rôle | -|---------|------| -| `frontend/lib/widgets/admin/user_management_panel.dart` | Onglet permanent **Dossiers** ; retirer logique conditionnelle À valider ; search sur cet onglet | -| `frontend/lib/widgets/admin/pending_validation_widget.dart` | Réemploi section haute (ou refactor léger) | -| **Nouveau** `…/dossiers_management_widget.dart` (nom libre) | Shell onglet : pending + liste unifiée + refresh | -| **Nouveau** modèle léger `DossierListItem` (type, numero, libelle, emails, statut…) | Mapping parents/AM → ligne | -| `user_service.dart` / `api_config.dart` | Seulement si helper `getDossiersUnified()` côté client (pas forcément nouvel endpoint) | -| `validation_dossier_modal.dart` | Réemploi ouverture pending / détail | - -Réutiliser look & feel cartes / hover « Ouvrir » de `_PendingValidationRow` / `AdminUserCard`. - ---- - -## Hors scope (#153) - -- Bouton créer dossier -- Mode `edit` wizard + ajout 2ᵉ parent → **#135** -- Suppressions → **#154** -- Famille N responsables → **#139** -- Changer les onglets Parents / AM / Enfants (restent) - ---- - -## Critères d’acceptation front - -- [ ] Onglet **Dossiers** toujours visible (même 0 pending) -- [ ] Plus d’onglet conditionnel **« À valider »** -- [ ] Section haute pending si non vide ; validation au clic OK -- [ ] Liste unifiée familles + AM en dessous ; 1 ligne / `numero_dossier` -- [ ] Couleur + icône différencient famille / AM -- [ ] Recherche filtre (n° + nom + email minimum) -- [ ] **Aucun** bouton créer dans cet onglet -- [ ] Pas de régression validation pending (valider / refuser) - ---- - -## Back (info — Cursor back séparé si besoin) - -- Liste unifiée : **pas bloquante** si composition front -- Optionnel : `GET /api/v1/dossiers` (liste) pour perf / pagination plus tard -- `GET /dossiers/:numero` déjà là (#119) - ---- - -## Branche - -`feature/153-onglet-dossiers` (depuis `develop`) diff --git a/docs/tmp/154-matrice-suppression.md b/docs/tmp/154-matrice-suppression.md deleted file mode 100644 index fc1da2f..0000000 --- a/docs/tmp/154-matrice-suppression.md +++ /dev/null @@ -1,35 +0,0 @@ -# Matrice suppression — #154 / back **#159** / front **#160** - -**Statut** : cadrage PO validé (sept. 2026) -**Milestone** : 0.1.0 -**Email** : pas d’email de suppression (cas rare) - -## Droits - -| Cible | Qui peut supprimer | -|-------|-------------------| -| Dossier / parent / enfant / AM | `GESTIONNAIRE`, `ADMINISTRATEUR`, `SUPER_ADMIN` | -| Gestionnaire (user) | `ADMINISTRATEUR`, `SUPER_ADMIN` | -| Administrateur (user) | Autre admin OK ; **self interdit** ; **dernier admin** = `SUPER_ADMIN` only ; `SUPER_ADMIN` non supprimable | - -## Matrice métier - -| Point d’entrée | Action | Effet | -|----------------|--------|--------| -| Dossiers | Delete dossier **famille** | Tous **parents** + tous **enfants** ; clore placements AM des enfants | -| Dossiers / AM | Delete dossier **AM** ou compte AM | **Compte AM + dossier AM** ; enfants **conservés** ; placements **clos** | -| Parents | Co-parent (autre parent reste) | Compte parent seul ; dossier + enfants restent | -| Parents | Dernier parent | Parent + **enfants** rattachés | -| Enfants | Pas dernier | Enfant seul (retiré du dossier) | -| Enfants | Dernier + `deleteDossier=true` | Cascade dossier famille (parents + enfants) | -| Enfants | Dernier + `deleteDossier=false` | Enfant seul ; dossier peut apparaître **`sans_enfant`** | -| Pending / validé | — | **Mêmes règles** (pas de différenciation) | - -## Warning - -- `sans_enfant` sur liste `GET /dossiers` (dossier famille sans enfant lié). -- Miroir de `sans_responsable` (#157) côté enfants. - -## Hors scope - -Soft-delete RGPD, audit (#128), famille N (#139), restriction admin-only métier (plus tard). diff --git a/docs/tmp/154-mini-spec-front-suppression.md b/docs/tmp/154-mini-spec-front-suppression.md deleted file mode 100644 index 271b26d..0000000 --- a/docs/tmp/154-mini-spec-front-suppression.md +++ /dev/null @@ -1,169 +0,0 @@ -# Mini-spec front — Suppressions dashboard - -**Ticket front** : **#160** — https://git.ptits-pas.fr/jmartin/petitspas/issues/160 -**Ticket back** : **#159** — https://git.ptits-pas.fr/jmartin/petitspas/issues/159 -**Epic** : #154 (complète #133) -**Branche back** : `feature/159-suppressions-backend` -**Doc matrice** : [154-matrice-suppression.md](./154-matrice-suppression.md) - -Travail **en parallèle** : ce contrat est la source de vérité UI ↔ API. - ---- - -## UX commune - -Sur chaque ligne / carte des listes : - -- Icône **poubelle** en bout de ligne -- Clic → **dialog de confirmation** (texte d’impact) → DELETE → **refresh** liste -- Pending = **mêmes** règles que validés -- **Pas** d’email - -| Liste | Poubelle visible si | -|-------|---------------------| -| Dossiers, Parents, Enfants, AM | gestionnaire **ou** admin | -| Gestionnaires | **admin** only | -| Administrateurs | admin+ ; **pas** sur sa propre ligne ; dernier admin : UI warning + réservé super_admin | - ---- - -## Contrat API - -Base : auth Bearer. Erreurs : `400` / `403` / `404` / `409` avec `message` FR. - -### 1. `DELETE /dossiers/:numeroDossier` - -- **Famille** → supprime tous parents + enfants du n° ; clos placements AM des enfants. -- **AM** → compte AM + dossier AM ; enfants gardés ; placements clos. - -**Réponse 200** (exemple) : - -```json -{ - "type": "famille", - "numero_dossier": "2026-000043", - "deleted_user_ids": ["…"], - "deleted_enfant_ids": ["…"], - "message": "Dossier famille supprimé." -} -``` - -ou - -```json -{ - "type": "assistante_maternelle", - "numero_dossier": "2026-000015", - "deleted_user_ids": ["…"], - "deleted_enfant_ids": [], - "message": "Dossier assistante maternelle supprimé." -} -``` - -**Dialog UI** : lister libellé + n° + « X parent(s), Y enfant(s) » (ou « compte AM, enfants conservés »). - ---- - -### 2. `DELETE /users/:id` - -Comportement selon le **rôle** de la cible : - -| Cible | Effet | -|-------|--------| -| Parent **co-parent** | Delete ce user seul | -| Parent **dernier** du dossier | Delete user + enfants du foyer | -| AM | Delete user AM + dossier AM ; enfants conservés ; placements clos | -| Gestionnaire | Admin only ; self → 403 | -| Administrateur | Self → 403 ; dernier admin → super_admin only sinon 403 ; super_admin → 403 | - -**Réponse 200** : - -```json -{ - "deleted_user_ids": ["…"], - "deleted_enfant_ids": ["…"], - "message": "…" -} -``` - -**Dialogs** : - -- Co-parent : « Ce parent sera retiré / supprimé du dossier {n°}. Les enfants restent avec le co-parent. » -- Dernier parent : « Dernier parent du dossier {n°}. Les enfants rattachés seront aussi supprimés. » -- AM : « Le compte et le dossier AM seront supprimés. Les enfants accueillis ne seront pas supprimés. » - -Optionnel (si exposé) : `GET /users/:id/suppression-impact` — sinon calculer depuis données déjà en liste / détail dossier. - ---- - -### 3. `DELETE /enfants/:id?deleteDossier=true|false` - -- Pas dernier enfant → delete enfant (`deleteDossier` ignoré ou false). -- Dernier enfant + `deleteDossier=false` → delete enfant ; dossier famille peut passer `sans_enfant`. -- Dernier enfant + `deleteDossier=true` → cascade dossier famille (parents + enfants). - -**Réponse 200** : - -```json -{ - "deleted_enfant_ids": ["…"], - "deleted_user_ids": ["…"], - "dossier_supprime": false, - "message": "…" -} -``` - -**Dialog** : - -- Standard : « L’enfant sera supprimé du dossier de {famille} ({n°}). » -- Dernier : proposer **deux actions** : - 1. Supprimer l’enfant seulement (`deleteDossier=false`) - 2. Supprimer aussi le dossier / parents (`deleteDossier=true`) - -Pour savoir si dernier : compter enfants du `numero_dossier` (détail dossier ou champ impact API). - ---- - -### 4. `GET /dossiers` — flag `sans_enfant` - -Chaque item famille peut exposer : - -```json -"sans_enfant": true -``` - -- `true` si dossier **famille** sans enfant lié. -- AM : `false` ou omis. - -**UI** : badge / warning vigilance (comme `sans_responsable` / alertes AM). - ---- - -## UserService (Flutter) — signatures cibles - -```dart -Future deleteDossier(String numeroDossier); -Future> deleteUser(String userId); -Future> deleteEnfant(String enfantId, {bool deleteDossier = false}); -``` - -(Adapter le parsing au JSON réel une fois le back mergé ; en parallèle, stubber sur ce contrat.) - ---- - -## Fichiers front probables - -- Cartes listes : `admin_user_card.dart`, `admin_enfant_user_card.dart`, cartes dossiers -- Listes : `dossiers_management_widget.dart`, `parent_managmant_widget.dart`, `enfant_management_widget.dart`, `assistante_maternelle_management_widget.dart`, `gestionnaire_management_widget.dart`, `admin_management_widget.dart` -- `user_service.dart` - ---- - -## Critères front (#160) - -- [ ] Poubelle selon droits -- [ ] Confirmations avec impact -- [ ] Refresh après succès -- [ ] Dernier enfant : choix dossier oui/non -- [ ] Warning `sans_enfant` -- [ ] Self-admin / dernier admin gérés côté UI (masquer ou message 403) diff --git a/docs/tmp/155-mini-spec-rename-admin-prefix.md b/docs/tmp/155-mini-spec-rename-admin-prefix.md deleted file mode 100644 index afe3c4f..0000000 --- a/docs/tmp/155-mini-spec-rename-admin-prefix.md +++ /dev/null @@ -1,54 +0,0 @@ -# Mini-spec — Rename préfixe `Admin*` dashboard partagé (#155) - -**Ticket** : **#155** -**Branche** : `feature/155-rename-admin-prefix-dashboard` (depuis `develop`) -**Décision naming** : **option C** — dossier neutre `widgets/dashboard/` + noms **sans** préfixe `Admin`. - ---- - -## Principe - -Les widgets partagés **admin + gestionnaire** ne doivent plus s’appeler `Admin*`. -On garde `Admin*` seulement là où c’est vraiment le rôle administrateur. - -## Gardé `Admin*` (hors rename) - -| Élément | Raison | -|---------|--------| -| `AdminManagementWidget` | Onglet **Administrateurs** | -| `screens/administrateurs/*` | `AdminDashboardScreen`, `AdminCreateDialog`, `AdminUserFormDialog` | -| `EnfantAdminModel` | Modèle API (pas un widget) — hors scope ticket | - -## Renames faits - -| Avant | Après | -|-------|--------| -| `widgets/admin/common/admin_child_detail_modal.dart` → `AdminChildDetailModal` | `widgets/dashboard/child_detail_modal.dart` → `ChildDetailModal` | -| `admin_am_edit_modal` → `AdminAmEditModal` | `am_edit_modal` → `AmEditModal` | -| `admin_parent_edit_modal` → `AdminParentEditModal` | `parent_edit_modal` → `ParentEditModal` | -| `admin_user_card` → `AdminUserCard` | `user_card` → `UserCard` | -| `admin_enfant_user_card` → `AdminEnfantUserCard` | `enfant_user_card` → `EnfantUserCard` | -| `admin_am_photo_frame` → `AdminAmPhotoFrame` | `am_photo_frame` → `AmPhotoFrame` | -| `admin_am_children_capacity_grid` | `am_children_capacity_grid` → `AmChildrenCapacityGrid` | -| `admin_children_affiliation_panel` | `children_affiliation_panel` → `ChildrenAffiliationPanel` | -| `admin_select_*` / `AdminSelect*` / `AdminFamilleFoyer` | `select_*` / `Select*` / `FamilleFoyer` | -| `admin_status_capsule` | `status_capsule` → `StatusCapsule` | -| `admin_list_state` → `AdminListState` | `user_list_state` → `UserListState` | -| `admin_detail_modal` → `AdminDetailModal` / `AdminDetailField` | `detail_modal` → `DetailModal` / `DetailField` | -| `dashboard_admin.dart` | `user_management_sub_bar.dart` (`DashboardUserManagementSubBar` inchangé) | - -Dossier `widgets/admin/` conserve encore les panels métier (`user_management_panel`, wizards, etc.) + `AdminManagementWidget`. - -**Phase 2** (même ticket #155) : déplacer ces panels → `widgets/dashboard/` — voir [155-suite-move-admin-panels-to-dashboard.md](./155-suite-move-admin-panels-to-dashboard.md). - -## Hors scope - -- Refonte UX modales (ticket dédié) -- Rename API / back -- Déplacer tout `widgets/admin/` → `widgets/dashboard/` (panels) — possible follow-up - -## Critères - -- [x] Plus de préfixe `Admin` sur les composants **partagés** listés -- [ ] Build Flutter / recette dashboard admin + gestionnaire OK -- [x] Pas de changement comportemental (rename mécanique) diff --git a/docs/tmp/155-suite-move-admin-panels-to-dashboard.md b/docs/tmp/155-suite-move-admin-panels-to-dashboard.md deleted file mode 100644 index e5a86db..0000000 --- a/docs/tmp/155-suite-move-admin-panels-to-dashboard.md +++ /dev/null @@ -1,203 +0,0 @@ -# Mini-spec — Déplacer les panels `widgets/admin/` → `widgets/dashboard/` - -**Ticket** : **#155** (phase 2 — même ticket que le rename `Admin*`) -**Phase 1** : widgets `Admin*` → `widgets/dashboard/` (déjà sur `feature/155-rename-admin-prefix-dashboard`) -**Branche** : poursuivre / rebaser `feature/155-rename-admin-prefix-dashboard` (ou nouvelle branche depuis `develop` après merge phase 1) -**Nature** : rename / move mécanique — **zéro** changement UX / métier - ---- - -## Contexte - -Après la phase 1 (#155), la situation est **hybride** : - -| Emplacement | Contenu | -|-------------|---------| -| `widgets/dashboard/` | Composants partagés sans préfixe `Admin*` (modales, cartes, selects, sub-bar…) | -| `widgets/admin/` | **Panels** du dashboard staff (listes, wizards, validation, shell `UserManagementPanel`…) + `AdminManagementWidget` | - -Le dossier `admin/` laisse encore croire « réservé administrateur », alors que **gestionnaire** consomme les mêmes panels (`GestionnaireDashboardScreen` → `UserManagementPanel`). - -Ce ticket **termine l’option C** au niveau dossier : tout le dashboard staff vit sous `widgets/dashboard/`, sauf ce qui est **vraiment** rôle admin. - ---- - -## Objectif - -``` -frontend/lib/widgets/admin/ - ↓ git mv + update imports -frontend/lib/widgets/dashboard/… -``` - -Critère : un nouveau dev ne doit plus ouvrir `widgets/admin/` pour du code partagé admin+gestionnaire. - ---- - -## Cible d’arborescence (proposée) - -``` -widgets/dashboard/ - ├── (déjà là #155) child_detail_modal.dart, am_edit_modal.dart, user_card.dart, … - ├── user_management_panel.dart ← shell onglets - ├── user_management_sub_bar.dart ← déjà déplacé #155 - ├── dossiers_management_widget.dart - ├── dossier_list_card.dart - ├── parent_management_widget.dart ← corriger le typo managmant au passage ? - ├── enfant_management_widget.dart - ├── assistante_maternelle_management_widget.dart - ├── gestionnaire_management_widget.dart - ├── pending_validation_widget.dart - ├── parent_dossier_create_modal.dart - ├── parent_dossier_wizard.dart - ├── am_dossier_create_modal.dart - ├── am_dossier_wizard.dart - ├── validation_*.dart ← family/am wizards, refus, theme, confirm - ├── parametres_panel.dart ← utilisé par écran admin (OK dans dashboard) - ├── relais_management_panel.dart - ├── common/ ← sous-dossier optionnel - │ ├── suppression_confirm_dialog.dart - │ ├── user_list.dart - │ └── validation_detail_section.dart - └── … - -widgets/admin/ ← mince, rôle admin seulement - └── admin_management_widget.dart ← onglet Administrateurs -``` - -### Variante B (plus stricte) - -`AdminManagementWidget` + éventuels helpers purement admin → -`screens/administrateurs/widgets/` -et **suppression** du dossier `widgets/admin/`. - -**Reco** : **variante A** (garder `widgets/admin/` minimal avec seulement `AdminManagementWidget`) — moins de churn screens, clair. - ---- - -## Inventaire à déplacer (état actuel) - -### Racine `widgets/admin/` → `widgets/dashboard/` - -| Fichier actuel | Notes | -|----------------|--------| -| `user_management_panel.dart` | Shell partagé admin + gestionnaire | -| `dossiers_management_widget.dart` | | -| `dossier_list_card.dart` | | -| `parent_managmant_widget.dart` | Typo historique `managmant` — **option** : renommer → `parent_management_widget.dart` dans le même ticket ou ticket typo séparé | -| `enfant_management_widget.dart` | | -| `assistante_maternelle_management_widget.dart` | | -| `gestionnaire_management_widget.dart` | | -| `pending_validation_widget.dart` | | -| `parent_dossier_create_modal.dart` | | -| `parent_dossier_wizard.dart` | | -| `am_dossier_create_modal.dart` | | -| `am_dossier_wizard.dart` | | -| `validation_am_wizard.dart` | | -| `validation_family_wizard.dart` | | -| `validation_dossier_modal.dart` | | -| `validation_modal_theme.dart` | | -| `validation_refus_form.dart` | | -| `validation_valider_confirm_dialog.dart` | | -| `parametres_panel.dart` | Écran admin seulement, mais pas préfixé Admin — OK dashboard | -| `relais_management_panel.dart` | | - -### `widgets/admin/common/` → `widgets/dashboard/common/` (ou plat) - -| Fichier | Notes | -|---------|--------| -| `suppression_confirm_dialog.dart` | Partagé (y compris `screens/administrateurs/creation/*`) | -| `user_list.dart` | | -| `validation_detail_section.dart` | | - -### **Ne pas** déplacer - -| Fichier | Destination | -|---------|-------------| -| `admin_management_widget.dart` | Reste `widgets/admin/` (ou variante B → screens) | - -### Déjà fait (#155) — ne pas retraiter - -Tout ce qui est déjà sous `widgets/dashboard/` (`child_detail_modal`, `am_edit_modal`, `user_card`, `select_*`, `user_management_sub_bar`, …). - ---- - -## Consommateurs d’imports (à mettre à jour) - -### Screens -- `screens/administrateurs/admin_dashboardScreen.dart` — `UserManagementPanel`, `ParametresPanel` -- `screens/gestionnaire/gestionnaire_dashboard_screen.dart` — `UserManagementPanel` -- `screens/administrateurs/creation/admin_create.dart` — `suppression_confirm_dialog` -- `screens/administrateurs/creation/gestionnaires_create.dart` — idem - -### Widgets déjà en `dashboard/` -- `am_edit_modal`, `child_detail_modal`, `parent_edit_modal`, `select_*` — imports vers `widgets/admin/common/*` ou panels - -### Divers -- `widgets/common/identity_block.dart` (si import admin) -- Tous les fichiers **déplacés** entre eux (imports relatifs / package) - -### Hors scope rename classes -Sauf décision explicite sur le typo `parent_managmant_widget` → pas de rename de **classes** métier dans ce ticket (seulement chemins de fichiers + imports). -`AdminManagementWidget` **conserve** son nom. - ---- - -## Plan d’exécution - -1. Partir de `feature/155-rename-admin-prefix-dashboard` (phase 1) **ou** `develop` si phase 1 déjà mergée -2. `git mv` fichiers selon inventaire -3. Remplacer globalement - `package:p_tits_pas/widgets/admin/` → `package:p_tits_pas/widgets/dashboard/` - **sauf** `…/widgets/admin/admin_management_widget.dart` -4. Corriger imports relatifs cassés -5. Grep de contrôle (ci-dessous) -6. Build Flutter web (Docker) + smoke dashboard admin **et** gestionnaire -7. Merge → squash master si flux habituel - ---- - -## Vérifs - -```bash -# Plus de panels partagés sous admin (seul AdminManagement attendu) -find frontend/lib/widgets/admin -name '*.dart' - -# Plus d’imports panels vers l’ancien chemin (sauf AdminManagement) -rg -n "widgets/admin/(user_management|dossiers_|parent_|enfant_|assistante|gestionnaire|pending|validation_|parametres|relais|am_dossier|parent_dossier|dossier_list|common/)" frontend/lib - -# Screens OK -rg -n "widgets/admin/" frontend/lib/screens -``` - -Attendu screens : **0** hit vers panels ; éventuellement plus aucun hit `widgets/admin/` sauf si import explicite `AdminManagementWidget` depuis `user_management_panel` (chemin `widgets/admin/admin_management_widget.dart`). - ---- - -## Hors scope - -- Refonte UX des modales / panels (ticket dédié annoncé) -- Rename `EnfantAdminModel` -- Rename `screens/administrateurs/` -- Rename `AdminUserFormDialog` / `AdminCreateDialog` -- Changement API / back -- #152 (`est_multiple`) — autre branche - ---- - -## Critères d’acceptation - -- [ ] Inventaire déplacé selon tableau -- [ ] `widgets/admin/` ne contient plus que `admin_management_widget.dart` (variante A) -- [ ] Imports screens + widgets à jour -- [ ] Build Flutter OK -- [ ] Recette : dashboard **administrateur** et **gestionnaire** (listes, ouverture fiches, validation, création dossier) sans régression -- [ ] Aucun changement comportemental volontaire - ---- - -## Risques / notes - -- **Conflits de merge** si d’autres features touchent les panels → faire ce ticket quand la surface dashboard est calme (fin 0.1.0 OK) -- Typo `parent_managmant_widget` : soit inclus (bonus), soit ticket cleanup 1-ligne séparé -- Docs d’archive citant `widgets/admin/…` : pas obligatoire de mettre à jour ; `docs/27_BRIEFING-FRONTEND.md` oui si encore listé diff --git a/docs/tmp/156-contrat-api-staff-am.md b/docs/tmp/156-contrat-api-staff-am.md deleted file mode 100644 index 312e148..0000000 --- a/docs/tmp/156-contrat-api-staff-am.md +++ /dev/null @@ -1,75 +0,0 @@ -# Mini-spec API — POST /assistantes-maternelles/dossier (#156) - -Contrat pour le **plan front** (wizard création AM staff). - -## Endpoint - -| | | -|--|--| -| **Méthode** | `POST` | -| **URL** | `{base}/assistantes-maternelles/dossier` | -| **Auth** | Bearer JWT | -| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` | -| **Content-Type** | `application/json` | - -Ne **pas** appeler `POST /auth/register/am` depuis le dashboard. - -## Body (JSON) - -Aligné inscription AM publique, **sans** CGU/privacy obligatoires (acceptées serveur). - -| Champ | Type | Obligatoire | Notes | -|-------|------|-------------|--------| -| `email` | string | oui | unique | -| `prenom` | string | oui | | -| `nom` | string | oui | | -| `telephone` | string | oui | `0X…` ou `+33…` | -| `adresse` | string | non | | -| `code_postal` | string | non | | -| `ville` | string | non | | -| `photo_base64` | string | non | data-URL `data:image/…;base64,…` | -| `photo_filename` | string | non | hint nom fichier | -| `consentement_photo` | bool | oui | | -| `date_naissance` | date ISO | non | `YYYY-MM-DD` | -| `lieu_naissance_ville` | string | oui | | -| `lieu_naissance_pays` | string | oui | | -| `nir` | string | oui | 15 car. (Corse 2A/2B OK) | -| `numero_agrement` | string | oui | unique | -| `date_agrement` | date ISO | non | | -| `capacite_accueil` | int | oui | 1–10 | -| `places_disponibles` | int | oui | 0–10, ≤ capacité | -| `biographie` | string | non | max 2000 | - -## Réponses - -### 201 Created - -```json -{ - "message": "Dossier AM créé et validé. Un e-mail de création de mot de passe a été envoyé.", - "user_id": "uuid", - "statut": "actif", - "numero_dossier": "2026-000042" -} -``` - -Effets serveur : user AM **actif**, fiche `assistantes_maternelles`, n° dossier, **e-mail création MDP** (pas d’accusé « en attente »). - -### Erreurs - -| Code | Cas | -|------|-----| -| 400 | Validation / NIR / places > capacité | -| 403 | Rôle non staff | -| 409 | Email, NIR ou agrément déjà pris | -| 401 | Token manquant / invalide | - -## Front - -- `UserService.createAmDossier(body)` → cet endpoint -- Après 201 : refresh liste AM ; snackbar OK -- Wizard create : ne pas envoyer `acceptation_cgu` / `acceptation_privacy` (optionnels) - -## Branche - -`feature/156-creation-dossier-am` diff --git a/docs/tmp/164-staff-modal-uniformisation-mini-spec.md b/docs/tmp/164-staff-modal-uniformisation-mini-spec.md deleted file mode 100644 index f035ca8..0000000 --- a/docs/tmp/164-staff-modal-uniformisation-mini-spec.md +++ /dev/null @@ -1,14 +0,0 @@ -# Mini-spec — Uniformisation modale staff (Gestionnaire / Administrateur) - -**Ticket** : **#164** — https://git.ptits-pas.fr/jmartin/petitspas/issues/164 -**Branche** : `feature/164-staff-modal-uniformisation` (depuis `develop`) -**Périmètre** : **front only** — pas d’API / BDD -**Milestone** : **0.1.0** - -Voir le corps du ticket #164 pour la spec complète. - -## Livré - -- `frontend/lib/widgets/dashboard/staff_user_form_modal.dart` → `StaffUserFormModal` -- Ancien `AdminUserFormDialog` / `gestionnaires_create.dart` retiré -- Imports : `user_management_panel`, `gestionnaire_management_widget`, `admin_management_widget`