From d23f3c9f4f3cd4b2ca553bc75bfba8a1977979ce Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 13 Feb 2026 15:23:00 +0100 Subject: [PATCH 01/22] =?UTF-8?q?feat(admin):=20panneau=20Param=C3=A8tres?= =?UTF-8?q?=20-=20sauvegarde=20config=20+=20test=20SMTP=20(#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../admin_dashboardScreen.dart | 39 +- frontend/lib/services/api/api_config.dart | 7 + .../lib/services/configuration_service.dart | 139 ++++++ .../lib/widgets/admin/dashboard_admin.dart | 166 ++++--- .../lib/widgets/admin/parametres_panel.dart | 411 ++++++++++++++++++ 5 files changed, 688 insertions(+), 74 deletions(-) create mode 100644 frontend/lib/services/configuration_service.dart create mode 100644 frontend/lib/widgets/admin/parametres_panel.dart diff --git a/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart b/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart index d10062e..be288bd 100644 --- a/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart +++ b/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/widgets/admin/assistante_maternelle_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/parametres_panel.dart'; import 'package:p_tits_pas/widgets/app_footer.dart'; import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart'; @@ -9,15 +10,25 @@ class AdminDashboardScreen extends StatefulWidget { const AdminDashboardScreen({super.key}); @override - _AdminDashboardScreenState createState() => _AdminDashboardScreenState(); + State createState() => _AdminDashboardScreenState(); } class _AdminDashboardScreenState extends State { - int selectedIndex = 0; + /// 0 = Gestion des utilisateurs, 1 = ParamĂštres + int mainTabIndex = 0; - void onTabChange(int index) { + /// Sous-onglet quand mainTabIndex == 0 : 0=Gestionnaires, 1=Parents, 2=AM, 3=Administrateurs + int subIndex = 0; + + void onMainTabChange(int index) { setState(() { - selectedIndex = index; + mainTabIndex = index; + }); + } + + void onSubTabChange(int index) { + setState(() { + subIndex = index; }); } @@ -33,13 +44,18 @@ class _AdminDashboardScreenState extends State { ), ), child: DashboardAppBarAdmin( - selectedIndex: selectedIndex, - onTabChange: onTabChange, + selectedIndex: mainTabIndex, + onTabChange: onMainTabChange, ), ), ), body: Column( children: [ + if (mainTabIndex == 0) + DashboardUserManagementSubBar( + selectedSubIndex: subIndex, + onSubTabChange: onSubTabChange, + ), Expanded( child: _getBody(), ), @@ -50,17 +66,20 @@ class _AdminDashboardScreenState extends State { } Widget _getBody() { - switch (selectedIndex) { + if (mainTabIndex == 1) { + return const ParametresPanel(); + } + switch (subIndex) { case 0: - return const GestionnaireManagementWidget(); + return const GestionnaireManagementWidget(); case 1: return const ParentManagementWidget(); case 2: return const AssistanteMaternelleManagementWidget(); case 3: - return const Center(child: Text("đŸ‘šâ€đŸ’Œ Administrateurs")); + return const Center(child: Text('đŸ‘šâ€đŸ’Œ Administrateurs')); default: - return const Center(child: Text("Page non trouvĂ©e")); + return const Center(child: Text('Page non trouvĂ©e')); } } } diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index bd157f8..831e911 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -14,6 +14,13 @@ class ApiConfig { static const String userProfile = '/users/profile'; static const String userChildren = '/users/children'; + // Configuration (admin) + static const String configuration = '/configuration'; + static const String configurationSetupStatus = '/configuration/setup/status'; + static const String configurationSetupComplete = '/configuration/setup/complete'; + static const String configurationTestSmtp = '/configuration/test-smtp'; + static const String configurationBulk = '/configuration/bulk'; + // Dashboard endpoints static const String dashboard = '/dashboard'; static const String events = '/events'; diff --git a/frontend/lib/services/configuration_service.dart b/frontend/lib/services/configuration_service.dart new file mode 100644 index 0000000..8f1c905 --- /dev/null +++ b/frontend/lib/services/configuration_service.dart @@ -0,0 +1,139 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'api/api_config.dart'; +import 'api/tokenService.dart'; + +/// RĂ©ponse GET /configuration (liste complĂšte) +class ConfigItem { + final String cle; + final String? valeur; + final String type; + final String? categorie; + final String? description; + + ConfigItem({ + required this.cle, + this.valeur, + required this.type, + this.categorie, + this.description, + }); + + factory ConfigItem.fromJson(Map json) { + return ConfigItem( + cle: json['cle'] as String, + valeur: json['valeur'] as String?, + type: json['type'] as String? ?? 'string', + categorie: json['categorie'] as String?, + description: json['description'] as String?, + ); + } +} + +/// RĂ©ponse GET /configuration/:category (objet clĂ© -> { value, type, description }) +class ConfigValueItem { + final dynamic value; + final String type; + final String? description; + + ConfigValueItem({required this.value, required this.type, this.description}); + + factory ConfigValueItem.fromJson(Map json) { + return ConfigValueItem( + value: json['value'], + type: json['type'] as String? ?? 'string', + description: json['description'] as String?, + ); + } +} + +class ConfigurationService { + static Future> _headers() async { + final token = await TokenService.getToken(); + return token != null + ? ApiConfig.authHeaders(token) + : Map.from(ApiConfig.headers); + } + + /// GET /api/v1/configuration/setup/status + static Future getSetupStatus() async { + final response = await http.get( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configurationSetupStatus}'), + headers: await _headers(), + ); + if (response.statusCode != 200) return true; + final data = jsonDecode(response.body); + return data['data']?['setupCompleted'] as bool? ?? true; + } + + /// GET /api/v1/configuration (toutes les configs) + static Future> getAll() async { + final response = await http.get( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configuration}'), + headers: await _headers(), + ); + if (response.statusCode != 200) { + throw Exception( + (jsonDecode(response.body) as Map)['message'] ?? 'Erreur chargement configuration', + ); + } + final data = jsonDecode(response.body); + final list = data['data'] as List? ?? []; + return list.map((e) => ConfigItem.fromJson(e as Map)).toList(); + } + + /// GET /api/v1/configuration/:category + static Future> getByCategory(String category) async { + final response = await http.get( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configuration}/$category'), + headers: await _headers(), + ); + if (response.statusCode != 200) { + throw Exception( + (jsonDecode(response.body) as Map)['message'] ?? 'Erreur chargement configuration', + ); + } + final data = jsonDecode(response.body); + final map = data['data'] as Map? ?? {}; + return map.map((k, v) => MapEntry(k, ConfigValueItem.fromJson(v as Map))); + } + + /// PATCH /api/v1/configuration/bulk + static Future updateBulk(Map body) async { + final response = await http.patch( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configurationBulk}'), + headers: await _headers(), + body: jsonEncode(body), + ); + if (response.statusCode != 200) { + final err = jsonDecode(response.body) as Map; + throw Exception(err['message'] ?? 'Erreur lors de la sauvegarde'); + } + } + + /// POST /api/v1/configuration/test-smtp + static Future testSmtp(String testEmail) async { + final response = await http.post( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configurationTestSmtp}'), + headers: await _headers(), + body: jsonEncode({'testEmail': testEmail}), + ); + final data = jsonDecode(response.body) as Map; + if (response.statusCode == 200 && data['success'] == true) { + return data['message'] as String? ?? 'Test SMTP rĂ©ussi.'; + } + throw Exception(data['error'] ?? data['message'] ?? 'Échec du test SMTP'); + } + + /// POST /api/v1/configuration/setup/complete (aprĂšs premiĂšre config) + static Future completeSetup() async { + final response = await http.post( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configurationSetupComplete}'), + headers: await _headers(), + ); + if (response.statusCode != 200) { + final err = jsonDecode(response.body) as Map; + throw Exception(err['message'] ?? 'Erreur finalisation configuration'); + } + } +} diff --git a/frontend/lib/widgets/admin/dashboard_admin.dart b/frontend/lib/widgets/admin/dashboard_admin.dart index 6f63760..d19030a 100644 --- a/frontend/lib/widgets/admin/dashboard_admin.dart +++ b/frontend/lib/widgets/admin/dashboard_admin.dart @@ -1,47 +1,47 @@ import 'package:flutter/material.dart'; +/// Barre principale du dashboard admin : 2 onglets (Gestion des utilisateurs | ParamĂštres) + infos utilisateur. class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidget { final int selectedIndex; final ValueChanged onTabChange; - const DashboardAppBarAdmin({Key? key, required this.selectedIndex, required this.onTabChange}) : super(key: key); + const DashboardAppBarAdmin({ + Key? key, + required this.selectedIndex, + required this.onTabChange, + }) : super(key: key); @override Size get preferredSize => const Size.fromHeight(kToolbarHeight + 10); @override Widget build(BuildContext context) { - final isMobile = MediaQuery.of(context).size.width < 768; return AppBar( elevation: 0, automaticallyImplyLeading: false, title: Row( children: [ - SizedBox(width: MediaQuery.of(context).size.width * 0.19), - const Text( - "P'tit Pas", - style: TextStyle( - color: Color(0xFF9CC5C0), - fontWeight: FontWeight.bold, - fontSize: 18, + const SizedBox(width: 24), + Image.asset( + 'assets/images/logo.png', + height: 40, + fit: BoxFit.contain, + ), + Expanded( + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildNavItem(context, 'Gestion des utilisateurs', 0), + const SizedBox(width: 24), + _buildNavItem(context, 'ParamĂštres', 1), + ], + ), ), ), - SizedBox(width: MediaQuery.of(context).size.width * 0.1), - - // Navigation principale - _buildNavItem(context, 'Gestionnaires', 0), - const SizedBox(width: 24), - _buildNavItem(context, 'Parents', 1), - const SizedBox(width: 24), - _buildNavItem(context, 'Assistantes maternelles', 2), - const SizedBox(width: 24), - _buildNavItem(context, 'Administrateurs', 3), ], ), - actions: isMobile - ? [_buildMobileMenu(context)] - : [ - // Nom de l'utilisateur + actions: [ const Padding( padding: EdgeInsets.symmetric(horizontal: 16), child: Center( @@ -55,8 +55,6 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge ), ), ), - - // Bouton dĂ©connexion Padding( padding: const EdgeInsets.only(right: 16), child: TextButton( @@ -72,51 +70,30 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge child: const Text('Se dĂ©connecter'), ), ), - SizedBox(width: MediaQuery.of(context).size.width * 0.1), ], ); } Widget _buildNavItem(BuildContext context, String title, int index) { - final bool isActive = index == selectedIndex; - return InkWell( - onTap: () => onTabChange(index), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent, - borderRadius: BorderRadius.circular(20), - border: isActive ? null : Border.all(color: Colors.black26), - ), - child: Text( - title, - style: TextStyle( - color: isActive ? Colors.white : Colors.black, - fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, - fontSize: 14, + final bool isActive = index == selectedIndex; + return InkWell( + onTap: () => onTabChange(index), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent, + borderRadius: BorderRadius.circular(20), + border: isActive ? null : Border.all(color: Colors.black26), + ), + child: Text( + title, + style: TextStyle( + color: isActive ? Colors.white : Colors.black, + fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, + fontSize: 14, + ), ), ), - ), - ); -} - - - Widget _buildMobileMenu(BuildContext context) { - return PopupMenuButton( - icon: const Icon(Icons.menu, color: Colors.white), - onSelected: (value) { - if (value == 4) { - _handleLogout(context); - } - }, - itemBuilder: (context) => [ - const PopupMenuItem(value: 0, child: Text("Gestionnaires")), - const PopupMenuItem(value: 1, child: Text("Parents")), - const PopupMenuItem(value: 2, child: Text("Assistantes maternelles")), - const PopupMenuItem(value: 3, child: Text("Administrateurs")), - const PopupMenuDivider(), - const PopupMenuItem(value: 4, child: Text("Se dĂ©connecter")), - ], ); } @@ -142,4 +119,65 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge ), ); } -} \ No newline at end of file +} + +/// Sous-barre affichĂ©e quand "Gestion des utilisateurs" est actif : 4 onglets sans infos utilisateur. +class DashboardUserManagementSubBar extends StatelessWidget { + final int selectedSubIndex; + final ValueChanged onSubTabChange; + + const DashboardUserManagementSubBar({ + Key? key, + required this.selectedSubIndex, + required this.onSubTabChange, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + height: 48, + decoration: BoxDecoration( + color: Colors.grey.shade100, + border: Border(bottom: BorderSide(color: Colors.grey.shade300)), + ), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6), + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildSubNavItem(context, 'Gestionnaires', 0), + const SizedBox(width: 16), + _buildSubNavItem(context, 'Parents', 1), + const SizedBox(width: 16), + _buildSubNavItem(context, 'Assistantes maternelles', 2), + const SizedBox(width: 16), + _buildSubNavItem(context, 'Administrateurs', 3), + ], + ), + ), + ); + } + + Widget _buildSubNavItem(BuildContext context, String title, int index) { + final bool isActive = index == selectedSubIndex; + return InkWell( + onTap: () => onSubTabChange(index), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: BoxDecoration( + color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent, + borderRadius: BorderRadius.circular(16), + border: isActive ? null : Border.all(color: Colors.black26), + ), + child: Text( + title, + style: TextStyle( + color: isActive ? Colors.white : Colors.black87, + fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, + fontSize: 13, + ), + ), + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/parametres_panel.dart b/frontend/lib/widgets/admin/parametres_panel.dart new file mode 100644 index 0000000..c50ecdd --- /dev/null +++ b/frontend/lib/widgets/admin/parametres_panel.dart @@ -0,0 +1,411 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/services/configuration_service.dart'; + +/// Panneau ParamĂštres / Configuration (ticket #15) : 3 sections sur une page. +class ParametresPanel extends StatefulWidget { + const ParametresPanel({super.key}); + + @override + State createState() => _ParametresPanelState(); +} + +class _ParametresPanelState extends State { + final _formKey = GlobalKey(); + bool _isLoading = true; + String? _loadError; + bool _isSaving = false; + String? _message; + + final Map _controllers = {}; + bool _smtpSecure = false; + bool _smtpAuthRequired = false; + + @override + void initState() { + super.initState(); + _createControllers(); + _loadConfiguration(); + } + + void _createControllers() { + final keys = [ + 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_password', + 'email_from_name', 'email_from_address', + 'app_name', 'app_url', 'app_logo_url', + 'password_reset_token_expiry_days', 'jwt_expiry_hours', 'max_upload_size_mb', + ]; + for (final k in keys) { + _controllers[k] = TextEditingController(); + } + } + + Future _loadConfiguration() async { + setState(() { + _isLoading = true; + _loadError = null; + }); + try { + final list = await ConfigurationService.getAll(); + if (!mounted) return; + for (final item in list) { + final c = _controllers[item.cle]; + if (c != null && item.valeur != null && item.valeur != '***********') { + c.text = item.valeur!; + } + if (item.cle == 'smtp_secure') { + _smtpSecure = item.valeur == 'true'; + } + if (item.cle == 'smtp_auth_required') { + _smtpAuthRequired = item.valeur == 'true'; + } + } + setState(() { + _isLoading = false; + }); + } catch (e) { + if (mounted) { + setState(() { + _isLoading = false; + _loadError = e.toString().replaceAll('Exception: ', ''); + }); + } + } + } + + @override + void dispose() { + for (final c in _controllers.values) { + c.dispose(); + } + super.dispose(); + } + + Map _buildPayload() { + final payload = {}; + payload['smtp_host'] = _controllers['smtp_host']!.text.trim(); + final port = int.tryParse(_controllers['smtp_port']!.text.trim()); + if (port != null) payload['smtp_port'] = port; + payload['smtp_secure'] = _smtpSecure; + payload['smtp_auth_required'] = _smtpAuthRequired; + payload['smtp_user'] = _controllers['smtp_user']!.text.trim(); + final pwd = _controllers['smtp_password']!.text.trim(); + if (pwd.isNotEmpty && pwd != '***********') payload['smtp_password'] = pwd; + payload['email_from_name'] = _controllers['email_from_name']!.text.trim(); + payload['email_from_address'] = _controllers['email_from_address']!.text.trim(); + payload['app_name'] = _controllers['app_name']!.text.trim(); + payload['app_url'] = _controllers['app_url']!.text.trim(); + payload['app_logo_url'] = _controllers['app_logo_url']!.text.trim(); + final tokenDays = int.tryParse(_controllers['password_reset_token_expiry_days']!.text.trim()); + if (tokenDays != null) payload['password_reset_token_expiry_days'] = tokenDays; + final jwtHours = int.tryParse(_controllers['jwt_expiry_hours']!.text.trim()); + if (jwtHours != null) payload['jwt_expiry_hours'] = jwtHours; + final maxMb = int.tryParse(_controllers['max_upload_size_mb']!.text.trim()); + if (maxMb != null) payload['max_upload_size_mb'] = maxMb; + return payload; + } + + Future _save() async { + setState(() { + _message = null; + _isSaving = true; + }); + try { + await ConfigurationService.updateBulk(_buildPayload()); + if (!mounted) return; + setState(() { + _isSaving = false; + _message = 'Configuration enregistrĂ©e.'; + }); + } catch (e) { + if (mounted) { + setState(() { + _isSaving = false; + _message = e.toString().replaceAll('Exception: ', ''); + }); + } + } + } + + Future _testSmtp() async { + final email = await showDialog( + context: context, + builder: (ctx) { + final c = TextEditingController(); + return AlertDialog( + title: const Text('Tester la connexion SMTP'), + content: TextField( + controller: c, + decoration: const InputDecoration( + labelText: 'Email pour recevoir le test', + hintText: 'admin@example.com', + ), + keyboardType: TextInputType.emailAddress, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Annuler'), + ), + FilledButton( + onPressed: () { + final t = c.text.trim(); + if (t.isNotEmpty) Navigator.pop(ctx, t); + }, + child: const Text('Envoyer'), + ), + ], + ); + }, + ); + if (email == null || !mounted) return; + setState(() => _message = null); + try { + await _save(); + if (!mounted) return; + final msg = await ConfigurationService.testSmtp(email); + if (!mounted) return; + setState(() => _message = msg); + } catch (e) { + if (mounted) { + setState(() => _message = e.toString().replaceAll('Exception: ', '')); + } + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (_loadError != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(_loadError!, style: TextStyle(color: Colors.red.shade700)), + const SizedBox(height: 16), + FilledButton( + onPressed: _loadConfiguration, + child: const Text('RĂ©essayer'), + ), + ], + ), + ), + ); + } + + final isSuccess = _message != null && + (_message!.startsWith('Configuration') || _message!.startsWith('Connexion')); + + return Form( + key: _formKey, + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_message != null) ...[ + _MessageBanner(message: _message!, isSuccess: isSuccess), + const SizedBox(height: 20), + ], + _buildSectionCard( + context, + icon: Icons.email_outlined, + title: 'Configuration Email (SMTP)', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildField('smtp_host', 'Serveur SMTP', hint: 'mail.example.com'), + const SizedBox(height: 14), + _buildField('smtp_port', 'Port SMTP', keyboard: TextInputType.number, hint: '25, 465, 587'), + const SizedBox(height: 14), + Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Row( + children: [ + Checkbox( + value: _smtpSecure, + onChanged: (v) => setState(() => _smtpSecure = v ?? false), + activeColor: const Color(0xFF9CC5C0), + ), + const Text('SSL/TLS (secure)'), + const SizedBox(width: 24), + Checkbox( + value: _smtpAuthRequired, + onChanged: (v) => setState(() => _smtpAuthRequired = v ?? false), + activeColor: const Color(0xFF9CC5C0), + ), + const Text('Authentification requise'), + ], + ), + ), + _buildField('smtp_user', 'Utilisateur SMTP'), + const SizedBox(height: 14), + _buildField('smtp_password', 'Mot de passe SMTP', obscure: true), + const SizedBox(height: 14), + _buildField('email_from_name', 'Nom expĂ©diteur'), + const SizedBox(height: 14), + _buildField('email_from_address', 'Email expĂ©diteur', hint: 'no-reply@example.com'), + const SizedBox(height: 18), + Align( + alignment: Alignment.centerRight, + child: OutlinedButton.icon( + onPressed: _isSaving ? null : _testSmtp, + icon: const Icon(Icons.send_outlined, size: 18), + label: const Text('Tester la connexion SMTP'), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF2D6A4F), + side: const BorderSide(color: Color(0xFF9CC5C0)), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + _buildSectionCard( + context, + icon: Icons.palette_outlined, + title: 'Personnalisation', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildField('app_name', 'Nom de l\'application'), + const SizedBox(height: 14), + _buildField('app_url', 'URL de l\'application', hint: 'https://app.example.com'), + const SizedBox(height: 14), + _buildField('app_logo_url', 'URL du logo', hint: '/assets/logo.png'), + ], + ), + ), + const SizedBox(height: 24), + _buildSectionCard( + context, + icon: Icons.settings_outlined, + title: 'ParamĂštres avancĂ©s', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildField('password_reset_token_expiry_days', 'ValiditĂ© token MDP (jours)', keyboard: TextInputType.number), + const SizedBox(height: 14), + _buildField('jwt_expiry_hours', 'ValiditĂ© session JWT (heures)', keyboard: TextInputType.number), + const SizedBox(height: 14), + _buildField('max_upload_size_mb', 'Taille max upload (MB)', keyboard: TextInputType.number), + ], + ), + ), + const SizedBox(height: 28), + SizedBox( + height: 48, + child: FilledButton( + onPressed: _isSaving ? null : _save, + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF9CC5C0), + foregroundColor: Colors.white, + ), + child: _isSaving + ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Text('Sauvegarder la configuration'), + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildSectionCard(BuildContext context, {required IconData icon, required String title, required Widget child}) { + return Card( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 22, color: const Color(0xFF9CC5C0)), + const SizedBox(width: 10), + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: const Color(0xFF2D6A4F), + ), + ), + ], + ), + const SizedBox(height: 20), + child, + ], + ), + ), + ); + } + + Widget _buildField(String key, String label, {bool obscure = false, TextInputType? keyboard, String? hint}) { + final c = _controllers[key]; + if (c == null) return const SizedBox.shrink(); + return TextFormField( + controller: c, + obscureText: obscure, + keyboardType: keyboard, + enabled: !_isSaving, + decoration: InputDecoration( + labelText: label, + hintText: hint, + border: const OutlineInputBorder(), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + ), + ); + } +} + +class _MessageBanner extends StatelessWidget { + final String message; + final bool isSuccess; + + const _MessageBanner({required this.message, required this.isSuccess}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: isSuccess ? Colors.green.shade50 : Colors.red.shade50, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isSuccess ? Colors.green.shade200 : Colors.red.shade200, + ), + ), + child: Row( + children: [ + Icon( + isSuccess ? Icons.check_circle_outline : Icons.error_outline, + size: 22, + color: isSuccess ? Colors.green.shade700 : Colors.red.shade700, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + message, + style: TextStyle( + color: isSuccess ? Colors.green.shade900 : Colors.red.shade900, + fontSize: 14, + ), + ), + ), + ], + ), + ); + } +} From 11aa66feff138a693c07633900f398d3f4729aae Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 13 Feb 2026 15:26:06 +0100 Subject: [PATCH 02/22] Merge squash develop into master (incl. #89 log API requests debug) Co-authored-by: Cursor --- backend/.env.example | 3 + .../interceptors/log-request.interceptor.ts | 69 +++++++++++++++++++ backend/src/main.ts | 11 +-- 3 files changed, 78 insertions(+), 5 deletions(-) create mode 100644 backend/src/common/interceptors/log-request.interceptor.ts diff --git a/backend/.env.example b/backend/.env.example index 002d786..67081bd 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -21,3 +21,6 @@ JWT_EXPIRATION_TIME=7d # Environnement NODE_ENV=development + +# Log de chaque appel API (mode debug) — mettre Ă  true pour tracer les requĂȘtes front +# LOG_API_REQUESTS=true diff --git a/backend/src/common/interceptors/log-request.interceptor.ts b/backend/src/common/interceptors/log-request.interceptor.ts new file mode 100644 index 0000000..bfb87e0 --- /dev/null +++ b/backend/src/common/interceptors/log-request.interceptor.ts @@ -0,0 +1,69 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { Request } from 'express'; + +/** ClĂ©s Ă  masquer dans les logs (corps de requĂȘte) */ +const SENSITIVE_KEYS = [ + 'password', + 'smtp_password', + 'token', + 'accessToken', + 'refreshToken', + 'secret', +]; + +function maskBody(body: unknown): unknown { + if (body === null || body === undefined) return body; + if (typeof body !== 'object') return body; + const out: Record = {}; + for (const [key, value] of Object.entries(body)) { + const lower = key.toLowerCase(); + const isSensitive = SENSITIVE_KEYS.some((s) => lower.includes(s)); + out[key] = isSensitive ? '***' : value; + } + return out; +} + +@Injectable() +export class LogRequestInterceptor implements NestInterceptor { + private readonly enabled: boolean; + + constructor() { + this.enabled = + process.env.LOG_API_REQUESTS === 'true' || + process.env.LOG_API_REQUESTS === '1'; + } + + intercept(context: ExecutionContext, next: CallHandler): Observable { + if (!this.enabled) return next.handle(); + + const http = context.switchToHttp(); + const req = http.getRequest(); + const { method, url, body, query } = req; + const hasBody = body && Object.keys(body).length > 0; + + const logLine = [ + `[API] ${method} ${url}`, + Object.keys(query || {}).length ? `query=${JSON.stringify(query)}` : '', + hasBody ? `body=${JSON.stringify(maskBody(body))}` : '', + ] + .filter(Boolean) + .join(' '); + + console.log(logLine); + + return next.handle().pipe( + tap({ + next: () => { + // Optionnel: log du statut en fin de requĂȘte (si besoin plus tard) + }, + }), + ); + } +} diff --git a/backend/src/main.ts b/backend/src/main.ts index 3943463..6c62287 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,17 +1,18 @@ -import { NestFactory, Reflector } from '@nestjs/core'; +import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { ConfigService } from '@nestjs/config'; import { SwaggerModule } from '@nestjs/swagger/dist/swagger-module'; import { DocumentBuilder } from '@nestjs/swagger'; -import { AuthGuard } from './common/guards/auth.guard'; -import { JwtService } from '@nestjs/jwt'; -import { RolesGuard } from './common/guards/roles.guard'; import { ValidationPipe } from '@nestjs/common'; +import { LogRequestInterceptor } from './common/interceptors/log-request.interceptor'; async function bootstrap() { const app = await NestFactory.create(AppModule, { logger: ['error', 'warn', 'log', 'debug', 'verbose'] }); - + + // Log de chaque appel API si LOG_API_REQUESTS=true (mode debug) + app.useGlobalInterceptors(new LogRequestInterceptor()); + // Configuration CORS pour autoriser les requĂȘtes depuis localhost (dev) et production app.enableCors({ origin: true, // Autorise toutes les origines (dev) - Ă  restreindre en prod From dfe7daed14000bb488ac21fda76b4211935eb127 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Sun, 15 Feb 2026 23:20:15 +0100 Subject: [PATCH 03/22] =?UTF-8?q?Merge=20squash=20develop=20into=20master?= =?UTF-8?q?=20(incl.=20#14=20premi=C3=A8re=20config=20setup/complete)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../src/modules/config/config.controller.ts | 3 +- backend/src/modules/config/config.service.ts | 6 +-- docs/14_NOTE-BACKEND-CONFIG-SETUP.md | 37 ++++++++++++++ frontend/lib/config/env.dart | 2 +- .../admin_dashboardScreen.dart | 35 +++++++++++-- .../lib/services/configuration_service.dart | 45 ++++++++++------- .../lib/widgets/admin/dashboard_admin.dart | 50 +++++++++++-------- .../lib/widgets/admin/parametres_panel.dart | 23 +++++++-- 8 files changed, 150 insertions(+), 51 deletions(-) create mode 100644 docs/14_NOTE-BACKEND-CONFIG-SETUP.md diff --git a/backend/src/modules/config/config.controller.ts b/backend/src/modules/config/config.controller.ts index ee1c9ba..701bb48 100644 --- a/backend/src/modules/config/config.controller.ts +++ b/backend/src/modules/config/config.controller.ts @@ -53,8 +53,7 @@ export class ConfigController { // @Roles('super_admin') async completeSetup(@Request() req: any) { try { - // TODO: RĂ©cupĂ©rer l'ID utilisateur depuis le JWT - const userId = req.user?.id || 'system'; + const userId = req.user?.id ?? null; await this.configService.markSetupCompleted(userId); diff --git a/backend/src/modules/config/config.service.ts b/backend/src/modules/config/config.service.ts index 421ccae..973546b 100644 --- a/backend/src/modules/config/config.service.ts +++ b/backend/src/modules/config/config.service.ts @@ -259,10 +259,10 @@ export class AppConfigService implements OnModuleInit { /** * Marquer la configuration initiale comme terminĂ©e - * @param userId ID de l'utilisateur qui termine la configuration + * @param userId ID de l'utilisateur qui termine la configuration (null si non authentifiĂ©) */ - async markSetupCompleted(userId: string): Promise { - await this.set('setup_completed', 'true', userId); + async markSetupCompleted(userId: string | null): Promise { + await this.set('setup_completed', 'true', userId ?? undefined); this.logger.log('✅ Configuration initiale marquĂ©e comme terminĂ©e'); } diff --git a/docs/14_NOTE-BACKEND-CONFIG-SETUP.md b/docs/14_NOTE-BACKEND-CONFIG-SETUP.md new file mode 100644 index 0000000..f1716d9 --- /dev/null +++ b/docs/14_NOTE-BACKEND-CONFIG-SETUP.md @@ -0,0 +1,37 @@ +# Ticket #14 – Note pour modifications backend + +**Contexte :** PremiĂšre connexion admin → panneau ParamĂštres, dĂ©blocage aprĂšs clic sur « Sauvegarder ». Le front appelle `POST /api/v1/configuration/setup/complete` au clic sur Sauvegarder. + +## ProblĂšme + +Erreur renvoyĂ©e par le back : +`invalid input syntax for type uuid: "system"` + +- Le controller fait `const userId = req.user?.id || 'system'` puis `markSetupCompleted(userId)`. +- Le service `set()` fait `config.modifiePar = { id: userId }` ; la colonne `modifie_par` est une FK UUID vers `users`. +- La chaĂźne `"system"` n’est pas un UUID valide → erreur PostgreSQL. + +## Modifications Ă  apporter au backend + +**Option A – Accepter l’absence d’utilisateur (recommandĂ© si la route peut ĂȘtre appelĂ©e sans JWT)** + +1. **`config.controller.ts`** (route `completeSetup`) + - Remplacer : + `const userId = req.user?.id || 'system';` + - Par : + `const userId = req.user?.id ?? null;` + +2. **`config.service.ts`** (`markSetupCompleted`) + - Changer la signature : + `async markSetupCompleted(userId: string | null): Promise` + - Et appeler : + `await this.set('setup_completed', 'true', userId ?? undefined);` + - Dans `set()`, ne pas remplir `modifiePar` quand `userId` est absent (dĂ©jĂ  le cas si `if (userId)`). + +**Option B – Imposer un utilisateur authentifiĂ©** + +- Activer le guard JWT (et Ă©ventuellement RolesGuard) sur `POST /configuration/setup/complete` pour que `req.user` soit toujours dĂ©fini, et garder `userId = req.user.id` (plus de fallback `'system'`). + +--- + +Une fois le back modifiĂ©, le flux « Sauvegarder » → dĂ©blocage des panneaux fonctionne sans erreur. diff --git a/frontend/lib/config/env.dart b/frontend/lib/config/env.dart index 34e1cbd..fb9c0e1 100644 --- a/frontend/lib/config/env.dart +++ b/frontend/lib/config/env.dart @@ -6,7 +6,7 @@ class Env { ); // Construit une URL vers l'API v1 Ă  partir d'un chemin (commençant par '/') - static String apiV1(String path) => "${apiBaseUrl}/api/v1$path"; + static String apiV1(String path) => '$apiBaseUrl/api/v1$path'; } diff --git a/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart b/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart index be288bd..1868c9d 100644 --- a/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart +++ b/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:p_tits_pas/services/configuration_service.dart'; import 'package:p_tits_pas/widgets/admin/assistante_maternelle_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'; @@ -14,12 +15,32 @@ class AdminDashboardScreen extends StatefulWidget { } class _AdminDashboardScreenState extends State { - /// 0 = Gestion des utilisateurs, 1 = ParamĂštres + bool? _setupCompleted; int mainTabIndex = 0; - - /// Sous-onglet quand mainTabIndex == 0 : 0=Gestionnaires, 1=Parents, 2=AM, 3=Administrateurs int subIndex = 0; + @override + void initState() { + super.initState(); + _loadSetupStatus(); + } + + Future _loadSetupStatus() async { + try { + final completed = await ConfigurationService.getSetupStatus(); + if (!mounted) return; + setState(() { + _setupCompleted = completed; + if (!completed) mainTabIndex = 1; + }); + } catch (e) { + if (mounted) setState(() { + _setupCompleted = false; + mainTabIndex = 1; + }); + } + } + void onMainTabChange(int index) { setState(() { mainTabIndex = index; @@ -34,6 +55,11 @@ class _AdminDashboardScreenState extends State { @override Widget build(BuildContext context) { + if (_setupCompleted == null) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } return Scaffold( appBar: PreferredSize( preferredSize: const Size.fromHeight(60.0), @@ -46,6 +72,7 @@ class _AdminDashboardScreenState extends State { child: DashboardAppBarAdmin( selectedIndex: mainTabIndex, onTabChange: onMainTabChange, + setupCompleted: _setupCompleted!, ), ), ), @@ -67,7 +94,7 @@ class _AdminDashboardScreenState extends State { Widget _getBody() { if (mainTabIndex == 1) { - return const ParametresPanel(); + return ParametresPanel(redirectToLoginAfterSave: !_setupCompleted!); } switch (subIndex) { case 0: diff --git a/frontend/lib/services/configuration_service.dart b/frontend/lib/services/configuration_service.dart index 8f1c905..21e987e 100644 --- a/frontend/lib/services/configuration_service.dart +++ b/frontend/lib/services/configuration_service.dart @@ -55,6 +55,12 @@ class ConfigurationService { : Map.from(ApiConfig.headers); } + static String? _toStr(dynamic v) { + if (v == null) return null; + if (v is String) return v; + return v.toString(); + } + /// GET /api/v1/configuration/setup/status static Future getSetupStatus() async { final response = await http.get( @@ -63,7 +69,11 @@ class ConfigurationService { ); if (response.statusCode != 200) return true; final data = jsonDecode(response.body); - return data['data']?['setupCompleted'] as bool? ?? true; + final val = data['data']?['setupCompleted']; + if (val is bool) return val; + if (val is String) return val.toLowerCase() == 'true' || val == '1'; + if (val is int) return val == 1; + return true; // Par dĂ©faut on considĂšre configurĂ© pour ne pas bloquer } /// GET /api/v1/configuration (toutes les configs) @@ -73,9 +83,8 @@ class ConfigurationService { headers: await _headers(), ); if (response.statusCode != 200) { - throw Exception( - (jsonDecode(response.body) as Map)['message'] ?? 'Erreur chargement configuration', - ); + final err = jsonDecode(response.body) as Map?; + throw Exception(_toStr(err?['message']) ?? 'Erreur chargement configuration'); } final data = jsonDecode(response.body); final list = data['data'] as List? ?? []; @@ -89,9 +98,8 @@ class ConfigurationService { headers: await _headers(), ); if (response.statusCode != 200) { - throw Exception( - (jsonDecode(response.body) as Map)['message'] ?? 'Erreur chargement configuration', - ); + final err = jsonDecode(response.body) as Map?; + throw Exception(_toStr(err?['message']) ?? 'Erreur chargement configuration'); } final data = jsonDecode(response.body); final map = data['data'] as Map? ?? {}; @@ -105,9 +113,10 @@ class ConfigurationService { headers: await _headers(), body: jsonEncode(body), ); - if (response.statusCode != 200) { - final err = jsonDecode(response.body) as Map; - throw Exception(err['message'] ?? 'Erreur lors de la sauvegarde'); + if (response.statusCode != 200 && response.statusCode != 201) { + final err = jsonDecode(response.body) as Map?; + final msg = err != null ? (_toStr(err['error']) ?? _toStr(err['message'])) : null; + throw Exception(msg ?? 'Erreur lors de la sauvegarde'); } } @@ -118,11 +127,12 @@ class ConfigurationService { headers: await _headers(), body: jsonEncode({'testEmail': testEmail}), ); - final data = jsonDecode(response.body) as Map; - if (response.statusCode == 200 && data['success'] == true) { - return data['message'] as String? ?? 'Test SMTP rĂ©ussi.'; + final data = jsonDecode(response.body) as Map?; + if ((response.statusCode == 200 || response.statusCode == 201) && (data?['success'] == true)) { + return _toStr(data?['message']) ?? 'Test SMTP rĂ©ussi.'; } - throw Exception(data['error'] ?? data['message'] ?? 'Échec du test SMTP'); + final msg = data != null ? (_toStr(data['error']) ?? _toStr(data['message'])) : null; + throw Exception(msg ?? 'Échec du test SMTP'); } /// POST /api/v1/configuration/setup/complete (aprĂšs premiĂšre config) @@ -131,9 +141,10 @@ class ConfigurationService { Uri.parse('${ApiConfig.baseUrl}${ApiConfig.configurationSetupComplete}'), headers: await _headers(), ); - if (response.statusCode != 200) { - final err = jsonDecode(response.body) as Map; - throw Exception(err['message'] ?? 'Erreur finalisation configuration'); + if (response.statusCode != 200 && response.statusCode != 201) { + final err = jsonDecode(response.body) as Map?; + final msg = err != null ? (_toStr(err['error']) ?? _toStr(err['message'])) : null; + throw Exception(msg ?? 'Erreur finalisation configuration'); } } } diff --git a/frontend/lib/widgets/admin/dashboard_admin.dart b/frontend/lib/widgets/admin/dashboard_admin.dart index d19030a..12fbe8b 100644 --- a/frontend/lib/widgets/admin/dashboard_admin.dart +++ b/frontend/lib/widgets/admin/dashboard_admin.dart @@ -1,14 +1,18 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:p_tits_pas/services/auth_service.dart'; -/// Barre principale du dashboard admin : 2 onglets (Gestion des utilisateurs | ParamĂštres) + infos utilisateur. +/// Barre du dashboard admin : onglets Gestion des utilisateurs | ParamĂštres + dĂ©connexion. class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidget { final int selectedIndex; final ValueChanged onTabChange; + final bool setupCompleted; const DashboardAppBarAdmin({ Key? key, required this.selectedIndex, required this.onTabChange, + this.setupCompleted = true, }) : super(key: key); @override @@ -32,9 +36,9 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge child: Row( mainAxisSize: MainAxisSize.min, children: [ - _buildNavItem(context, 'Gestion des utilisateurs', 0), + _buildNavItem(context, 'Gestion des utilisateurs', 0, enabled: setupCompleted), const SizedBox(width: 24), - _buildNavItem(context, 'ParamĂštres', 1), + _buildNavItem(context, 'ParamĂštres', 1, enabled: true), ], ), ), @@ -74,23 +78,26 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge ); } - Widget _buildNavItem(BuildContext context, String title, int index) { + Widget _buildNavItem(BuildContext context, String title, int index, {bool enabled = true}) { final bool isActive = index == selectedIndex; return InkWell( - onTap: () => onTabChange(index), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent, - borderRadius: BorderRadius.circular(20), - border: isActive ? null : Border.all(color: Colors.black26), - ), - child: Text( - title, - style: TextStyle( - color: isActive ? Colors.white : Colors.black, - fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, - fontSize: 14, + onTap: enabled ? () => onTabChange(index) : null, + child: Opacity( + opacity: enabled ? 1.0 : 0.5, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent, + borderRadius: BorderRadius.circular(20), + border: isActive ? null : Border.all(color: Colors.black26), + ), + child: Text( + title, + style: TextStyle( + color: isActive ? Colors.white : Colors.black, + fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, + fontSize: 14, + ), ), ), ), @@ -109,9 +116,10 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge child: const Text('Annuler'), ), ElevatedButton( - onPressed: () { + onPressed: () async { Navigator.pop(context); - // TODO: ImplĂ©menter la logique de dĂ©connexion + await AuthService.logout(); + if (context.mounted) context.go('/login'); }, child: const Text('DĂ©connecter'), ), @@ -121,7 +129,7 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge } } -/// Sous-barre affichĂ©e quand "Gestion des utilisateurs" est actif : 4 onglets sans infos utilisateur. +/// Sous-barre : Gestionnaires | Parents | Assistantes maternelles | Administrateurs. class DashboardUserManagementSubBar extends StatelessWidget { final int selectedSubIndex; final ValueChanged onSubTabChange; diff --git a/frontend/lib/widgets/admin/parametres_panel.dart b/frontend/lib/widgets/admin/parametres_panel.dart index c50ecdd..22e7b7c 100644 --- a/frontend/lib/widgets/admin/parametres_panel.dart +++ b/frontend/lib/widgets/admin/parametres_panel.dart @@ -1,9 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:p_tits_pas/services/configuration_service.dart'; -/// Panneau ParamĂštres / Configuration (ticket #15) : 3 sections sur une page. +/// Panneau ParamĂštres admin : Email (SMTP), Personnalisation, AvancĂ©. class ParametresPanel extends StatefulWidget { - const ParametresPanel({super.key}); + /// Si true, aprĂšs sauvegarde on redirige vers le login (premiĂšre config). Sinon on reste sur la page. + final bool redirectToLoginAfterSave; + + const ParametresPanel({super.key, this.redirectToLoginAfterSave = false}); @override State createState() => _ParametresPanelState(); @@ -104,7 +108,14 @@ class _ParametresPanelState extends State { return payload; } + /// Sauvegarde en base sans completeSetup (utilisĂ© avant test SMTP). + Future _saveBulkOnly() async { + await ConfigurationService.updateBulk(_buildPayload()); + } + + /// Sauvegarde la config, marque le setup comme terminĂ©. Si premiĂšre config, redirige vers le login. Future _save() async { + final redirectAfter = widget.redirectToLoginAfterSave; setState(() { _message = null; _isSaving = true; @@ -112,10 +123,16 @@ class _ParametresPanelState extends State { try { await ConfigurationService.updateBulk(_buildPayload()); if (!mounted) return; + await ConfigurationService.completeSetup(); + if (!mounted) return; setState(() { _isSaving = false; _message = 'Configuration enregistrĂ©e.'; }); + if (!mounted) return; + if (redirectAfter) { + GoRouter.of(context).go('/login'); + } } catch (e) { if (mounted) { setState(() { @@ -160,7 +177,7 @@ class _ParametresPanelState extends State { if (email == null || !mounted) return; setState(() => _message = null); try { - await _save(); + await _saveBulkOnly(); if (!mounted) return; final msg = await ConfigurationService.testSmtp(email); if (!mounted) return; From 868242145309638f26302e0a82aeaf4311cc71fc Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Mon, 16 Feb 2026 16:19:23 +0100 Subject: [PATCH 04/22] Merge develop into master (squash) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feat(#90): API Inscription AM - POST /auth/register/am - Suppression legacy register/parent/legacy - BDD assistantes_maternelles alignĂ©e entitĂ© - Script test register AM Co-authored-by: Cursor --- backend/scripts/test-register-am.sh | 27 +++ backend/src/routes/auth/auth.controller.ts | 16 +- backend/src/routes/auth/auth.module.ts | 3 +- backend/src/routes/auth/auth.service.ts | 200 +++++++----------- .../auth/dto/register-am-complet.dto.ts | 156 ++++++++++++++ .../routes/auth/dto/register-parent.dto.ts | 105 --------- database/BDD.sql | 9 +- frontend/lib/services/api/api_config.dart | 2 + 8 files changed, 282 insertions(+), 236 deletions(-) create mode 100755 backend/scripts/test-register-am.sh create mode 100644 backend/src/routes/auth/dto/register-am-complet.dto.ts delete mode 100644 backend/src/routes/auth/dto/register-parent.dto.ts diff --git a/backend/scripts/test-register-am.sh b/backend/scripts/test-register-am.sh new file mode 100755 index 0000000..0ae987e --- /dev/null +++ b/backend/scripts/test-register-am.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Test POST /auth/register/am (ticket #90) +# Usage: ./scripts/test-register-am.sh [BASE_URL] +# Exemple: ./scripts/test-register-am.sh https://app.ptits-pas.fr/api/v1 +# ./scripts/test-register-am.sh http://localhost:3000/api/v1 + +BASE_URL="${1:-http://localhost:3000/api/v1}" +echo "Testing POST $BASE_URL/auth/register/am" +echo "---" + +curl -s -w "\n\nHTTP %{http_code}\n" -X POST "$BASE_URL/auth/register/am" \ + -H "Content-Type: application/json" \ + -d '{ + "email": "marie.dupont.test@ptits-pas.fr", + "prenom": "Marie", + "nom": "DUPONT", + "telephone": "0612345678", + "adresse": "1 rue Test", + "code_postal": "75001", + "ville": "Paris", + "consentement_photo": true, + "nir": "123456789012345", + "numero_agrement": "AGR-2024-001", + "capacite_accueil": 4, + "acceptation_cgu": true, + "acceptation_privacy": true + }' diff --git a/backend/src/routes/auth/auth.controller.ts b/backend/src/routes/auth/auth.controller.ts index 3de3fc1..2eeaf98 100644 --- a/backend/src/routes/auth/auth.controller.ts +++ b/backend/src/routes/auth/auth.controller.ts @@ -3,8 +3,8 @@ import { LoginDto } from './dto/login.dto'; import { AuthService } from './auth.service'; import { Public } from 'src/common/decorators/public.decorator'; import { RegisterDto } from './dto/register.dto'; -import { RegisterParentDto } from './dto/register-parent.dto'; import { RegisterParentCompletDto } from './dto/register-parent-complet.dto'; +import { RegisterAMCompletDto } from './dto/register-am-complet.dto'; import { ChangePasswordRequiredDto } from './dto/change-password.dto'; import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { AuthGuard } from 'src/common/guards/auth.guard'; @@ -53,12 +53,16 @@ export class AuthController { } @Public() - @Post('register/parent/legacy') - @ApiOperation({ summary: '[OBSOLÈTE] Inscription Parent (Ă©tape 1/6 uniquement)' }) - @ApiResponse({ status: 201, description: 'Inscription rĂ©ussie' }) + @Post('register/am') + @ApiOperation({ + summary: 'Inscription Assistante Maternelle COMPLÈTE', + description: 'CrĂ©e User AM + entrĂ©e assistantes_maternelles (identitĂ© + infos pro + photo + CGU) en une transaction', + }) + @ApiResponse({ status: 201, description: 'Inscription rĂ©ussie - Dossier en attente de validation' }) + @ApiResponse({ status: 400, description: 'DonnĂ©es invalides ou CGU non acceptĂ©es' }) @ApiResponse({ status: 409, description: 'Email dĂ©jĂ  utilisĂ©' }) - async registerParentLegacy(@Body() dto: RegisterParentDto) { - return this.authService.registerParent(dto); + async inscrireAMComplet(@Body() dto: RegisterAMCompletDto) { + return this.authService.inscrireAMComplet(dto); } @Public() diff --git a/backend/src/routes/auth/auth.module.ts b/backend/src/routes/auth/auth.module.ts index 6554be7..3d15615 100644 --- a/backend/src/routes/auth/auth.module.ts +++ b/backend/src/routes/auth/auth.module.ts @@ -8,11 +8,12 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { Users } from 'src/entities/users.entity'; import { Parents } from 'src/entities/parents.entity'; import { Children } from 'src/entities/children.entity'; +import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { AppConfigModule } from 'src/modules/config'; @Module({ imports: [ - TypeOrmModule.forFeature([Users, Parents, Children]), + TypeOrmModule.forFeature([Users, Parents, Children, AssistanteMaternelle]), forwardRef(() => UserModule), AppConfigModule, JwtModule.registerAsync({ diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index 1c9985e..1fdb8cd 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -13,13 +13,14 @@ import * as crypto from 'crypto'; import * as fs from 'fs/promises'; import * as path from 'path'; import { RegisterDto } from './dto/register.dto'; -import { RegisterParentDto } from './dto/register-parent.dto'; import { RegisterParentCompletDto } from './dto/register-parent-complet.dto'; +import { RegisterAMCompletDto } from './dto/register-am-complet.dto'; import { ConfigService } from '@nestjs/config'; import { RoleType, StatutUtilisateurType, Users } from 'src/entities/users.entity'; import { Parents } from 'src/entities/parents.entity'; import { Children, StatutEnfantType } from 'src/entities/children.entity'; import { ParentsChildren } from 'src/entities/parents_children.entity'; +import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { LoginDto } from './dto/login.dto'; import { AppConfigService } from 'src/modules/config/config.service'; @@ -116,7 +117,7 @@ export class AuthService { } /** - * Inscription utilisateur OBSOLÈTE - Utiliser registerParent() ou registerAM() + * Inscription utilisateur OBSOLÈTE - Utiliser inscrireParentComplet() ou registerAM() * @deprecated */ async register(registerDto: RegisterDto) { @@ -157,125 +158,6 @@ export class AuthService { }; } - /** - * Inscription Parent (Ă©tape 1/6 du workflow CDC) - * SANS mot de passe - Token de crĂ©ation MDP gĂ©nĂ©rĂ© - */ - async registerParent(dto: RegisterParentDto) { - // 1. VĂ©rifier que l'email n'existe pas - const exists = await this.usersService.findByEmailOrNull(dto.email); - if (exists) { - throw new ConflictException('Un compte avec cet email existe dĂ©jĂ '); - } - - // 2. VĂ©rifier l'email du co-parent s'il existe - if (dto.co_parent_email) { - const coParentExists = await this.usersService.findByEmailOrNull(dto.co_parent_email); - if (coParentExists) { - throw new ConflictException('L\'email du co-parent est dĂ©jĂ  utilisĂ©'); - } - } - - // 3. RĂ©cupĂ©rer la durĂ©e d'expiration du token depuis la config - const tokenExpiryDays = await this.appConfigService.get( - 'password_reset_token_expiry_days', - 7, - ); - - // 4. GĂ©nĂ©rer les tokens de crĂ©ation de mot de passe - const tokenCreationMdp = crypto.randomUUID(); - const tokenExpiration = new Date(); - tokenExpiration.setDate(tokenExpiration.getDate() + tokenExpiryDays); - - // 5. Transaction : CrĂ©er Parent 1 + Parent 2 (si existe) + entitĂ©s parents - const result = await this.usersRepo.manager.transaction(async (manager) => { - // CrĂ©er Parent 1 - const parent1 = manager.create(Users, { - email: dto.email, - prenom: dto.prenom, - nom: dto.nom, - role: RoleType.PARENT, - 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: tokenExpiration, - }); - - const savedParent1 = await manager.save(Users, parent1); - - // CrĂ©er Parent 2 si renseignĂ© - let savedParent2: Users | null = null; - let tokenCoParent: string | null = null; - - if (dto.co_parent_email && dto.co_parent_prenom && dto.co_parent_nom) { - tokenCoParent = crypto.randomUUID(); - const tokenExpirationCoParent = new Date(); - tokenExpirationCoParent.setDate(tokenExpirationCoParent.getDate() + tokenExpiryDays); - - const parent2 = manager.create(Users, { - email: dto.co_parent_email, - prenom: dto.co_parent_prenom, - nom: dto.co_parent_nom, - role: RoleType.PARENT, - statut: StatutUtilisateurType.EN_ATTENTE, - 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, - ville: dto.co_parent_meme_adresse ? dto.ville : dto.co_parent_ville, - token_creation_mdp: tokenCoParent, - token_creation_mdp_expire_le: tokenExpirationCoParent, - }); - - savedParent2 = await manager.save(Users, parent2); - } - - // CrĂ©er l'entitĂ© mĂ©tier Parents pour Parent 1 - const parentEntity = manager.create(Parents, { - user_id: savedParent1.id, - }); - parentEntity.user = savedParent1; - if (savedParent2) { - parentEntity.co_parent = savedParent2; - } - - await manager.save(Parents, parentEntity); - - // CrĂ©er l'entitĂ© mĂ©tier Parents pour Parent 2 (si existe) - if (savedParent2) { - const coParentEntity = manager.create(Parents, { - user_id: savedParent2.id, - }); - coParentEntity.user = savedParent2; - coParentEntity.co_parent = savedParent1; - - await manager.save(Parents, coParentEntity); - } - - return { - parent1: savedParent1, - parent2: savedParent2, - tokenCreationMdp, - tokenCoParent, - }; - }); - - // 6. TODO: Envoyer email avec lien de crĂ©ation de MDP - // await this.mailService.sendPasswordCreationEmail(result.parent1, result.tokenCreationMdp); - // if (result.parent2 && result.tokenCoParent) { - // await this.mailService.sendPasswordCreationEmail(result.parent2, result.tokenCoParent); - // } - - return { - message: 'Inscription rĂ©ussie. Un email de validation vous a Ă©tĂ© envoyĂ©.', - parent_id: result.parent1.id, - co_parent_id: result.parent2?.id, - statut: StatutUtilisateurType.EN_ATTENTE, - }; - } - /** * Inscription Parent COMPLÈTE - Workflow CDC 6 Ă©tapes en 1 transaction * GĂšre : Parent 1 + Parent 2 (opt) + Enfants + PrĂ©sentation + CGU @@ -432,6 +314,82 @@ 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 + */ + async inscrireAMComplet(dto: RegisterAMCompletDto) { + if (!dto.acceptation_cgu || !dto.acceptation_privacy) { + throw new BadRequestException( + "L'acceptation des CGU et de la politique de confidentialitĂ© est obligatoire", + ); + } + + const existe = await this.usersService.findByEmailOrNull(dto.email); + if (existe) { + throw new ConflictException('Un compte avec cet email existe dĂ©jĂ '); + } + + 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 urlPhoto: string | null = null; + if (dto.photo_base64 && dto.photo_filename) { + urlPhoto = await this.sauvegarderPhotoDepuisBase64(dto.photo_base64, dto.photo_filename); + } + + const dateConsentementPhoto = + dto.consentement_photo ? new Date() : undefined; + + const resultat = await this.usersRepo.manager.transaction(async (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, + }); + 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: dto.nir, + max_children: dto.capacite_accueil, + biography: dto.biographie, + residence_city: dto.ville ?? undefined, + agreement_date: dto.date_agrement ? new Date(dto.date_agrement) : undefined, + available: true, + }); + await amRepo.save(am); + + return { user: userEnregistre }; + }); + + return { + message: + 'Inscription rĂ©ussie. Votre dossier est en attente de validation par un gestionnaire.', + user_id: resultat.user.id, + statut: StatutUtilisateurType.EN_ATTENTE, + }; + } + /** * Sauvegarde une photo depuis base64 vers le systĂšme de fichiers */ diff --git a/backend/src/routes/auth/dto/register-am-complet.dto.ts b/backend/src/routes/auth/dto/register-am-complet.dto.ts new file mode 100644 index 0000000..72728ca --- /dev/null +++ b/backend/src/routes/auth/dto/register-am-complet.dto.ts @@ -0,0 +1,156 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsEmail, + IsNotEmpty, + IsOptional, + IsString, + IsBoolean, + IsInt, + Min, + Max, + MinLength, + MaxLength, + Matches, + IsDateString, +} from 'class-validator'; + +export class RegisterAMCompletDto { + // ============================================ + // ÉTAPE 1 : IDENTITÉ (Obligatoire) + // ============================================ + + @ApiProperty({ example: 'marie.dupont@ptits-pas.fr' }) + @IsEmail({}, { message: 'Email invalide' }) + @IsNotEmpty({ message: "L'email est requis" }) + email: string; + + @ApiProperty({ example: 'Marie' }) + @IsString() + @IsNotEmpty({ message: 'Le prĂ©nom est requis' }) + @MinLength(2, { message: 'Le prĂ©nom doit contenir au moins 2 caractĂšres' }) + @MaxLength(100) + prenom: string; + + @ApiProperty({ example: 'DUPONT' }) + @IsString() + @IsNotEmpty({ message: 'Le nom est requis' }) + @MinLength(2, { message: 'Le nom doit contenir au moins 2 caractĂšres' }) + @MaxLength(100) + nom: string; + + @ApiProperty({ example: '0689567890' }) + @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; + + @ApiProperty({ example: '5 Avenue du GĂ©nĂ©ral de Gaulle', required: false }) + @IsOptional() + @IsString() + adresse?: string; + + @ApiProperty({ example: '95870', required: false }) + @IsOptional() + @IsString() + @MaxLength(10) + code_postal?: string; + + @ApiProperty({ example: 'Bezons', required: false }) + @IsOptional() + @IsString() + @MaxLength(150) + ville?: string; + + // ============================================ + // ÉTAPE 2 : PHOTO + INFOS PRO + // ============================================ + + @ApiProperty({ + example: 'data:image/jpeg;base64,/9j/4AAQ...', + required: false, + description: 'Photo de profil en base64', + }) + @IsOptional() + @IsString() + photo_base64?: string; + + @ApiProperty({ example: 'photo_profil.jpg', required: false }) + @IsOptional() + @IsString() + photo_filename?: string; + + @ApiProperty({ example: true, description: 'Consentement utilisation photo' }) + @IsBoolean() + @IsNotEmpty({ message: 'Le consentement photo est requis' }) + consentement_photo: boolean; + + @ApiProperty({ example: '2024-01-15', required: false, description: 'Date de naissance' }) + @IsOptional() + @IsDateString() + date_naissance?: string; + + @ApiProperty({ example: 'Paris', required: false, description: 'Ville de naissance' }) + @IsOptional() + @IsString() + @MaxLength(100) + lieu_naissance_ville?: string; + + @ApiProperty({ example: 'France', required: false, description: 'Pays de naissance' }) + @IsOptional() + @IsString() + @MaxLength(100) + lieu_naissance_pays?: string; + + @ApiProperty({ example: '123456789012345', description: 'NIR 15 chiffres' }) + @IsString() + @IsNotEmpty({ message: 'Le NIR est requis' }) + @Matches(/^\d{15}$/, { message: 'Le NIR doit contenir exactement 15 chiffres' }) + nir: string; + + @ApiProperty({ example: 'AGR-2024-12345', description: "NumĂ©ro d'agrĂ©ment" }) + @IsString() + @IsNotEmpty({ message: "Le numĂ©ro d'agrĂ©ment est requis" }) + @MaxLength(50) + numero_agrement: string; + + @ApiProperty({ example: '2024-06-01', required: false, description: "Date d'obtention de l'agrĂ©ment" }) + @IsOptional() + @IsDateString() + date_agrement?: string; + + @ApiProperty({ example: 4, description: 'CapacitĂ© d\'accueil (nombre d\'enfants)', minimum: 1, maximum: 10 }) + @IsInt() + @Min(1, { message: 'La capacitĂ© doit ĂȘtre au moins 1' }) + @Max(10, { message: 'La capacitĂ© ne peut pas dĂ©passer 10' }) + capacite_accueil: number; + + // ============================================ + // ÉTAPE 3 : PRÉSENTATION (Optionnel) + // ============================================ + + @ApiProperty({ + example: 'Assistante maternelle expĂ©rimentĂ©e, accueil bienveillant...', + required: false, + description: 'PrĂ©sentation / biographie (max 2000 caractĂšres)', + }) + @IsOptional() + @IsString() + @MaxLength(2000, { message: 'La prĂ©sentation ne peut pas dĂ©passer 2000 caractĂšres' }) + biographie?: string; + + // ============================================ + // ÉTAPE 4 : ACCEPTATION CGU (Obligatoire) + // ============================================ + + @ApiProperty({ example: true, description: "Acceptation des CGU" }) + @IsBoolean() + @IsNotEmpty({ message: "L'acceptation des CGU est requise" }) + acceptation_cgu: boolean; + + @ApiProperty({ example: true, description: 'Acceptation de la Politique de confidentialitĂ©' }) + @IsBoolean() + @IsNotEmpty({ message: "L'acceptation de la politique de confidentialitĂ© est requise" }) + acceptation_privacy: boolean; +} diff --git a/backend/src/routes/auth/dto/register-parent.dto.ts b/backend/src/routes/auth/dto/register-parent.dto.ts deleted file mode 100644 index a022724..0000000 --- a/backend/src/routes/auth/dto/register-parent.dto.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { - IsEmail, - IsNotEmpty, - IsOptional, - IsString, - IsDateString, - IsEnum, - MinLength, - MaxLength, - Matches, -} from 'class-validator'; -import { SituationFamilialeType } from 'src/entities/users.entity'; - -export class RegisterParentDto { - // === Informations obligatoires === - @ApiProperty({ example: 'claire.martin@ptits-pas.fr' }) - @IsEmail({}, { message: 'Email invalide' }) - @IsNotEmpty({ message: 'L\'email est requis' }) - email: string; - - @ApiProperty({ example: 'Claire' }) - @IsString() - @IsNotEmpty({ message: 'Le prĂ©nom est requis' }) - @MinLength(2, { message: 'Le prĂ©nom doit contenir au moins 2 caractĂšres' }) - @MaxLength(100, { message: 'Le prĂ©nom ne peut pas dĂ©passer 100 caractĂšres' }) - prenom: string; - - @ApiProperty({ example: 'MARTIN' }) - @IsString() - @IsNotEmpty({ message: 'Le nom est requis' }) - @MinLength(2, { message: 'Le nom doit contenir au moins 2 caractĂšres' }) - @MaxLength(100, { message: 'Le nom ne peut pas dĂ©passer 100 caractĂšres' }) - nom: string; - - @ApiProperty({ example: '0689567890' }) - @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; - - // === Informations optionnelles === - @ApiProperty({ example: '5 Avenue du GĂ©nĂ©ral de Gaulle', required: false }) - @IsOptional() - @IsString() - adresse?: string; - - @ApiProperty({ example: '95870', required: false }) - @IsOptional() - @IsString() - @MaxLength(10) - code_postal?: string; - - @ApiProperty({ example: 'Bezons', required: false }) - @IsOptional() - @IsString() - @MaxLength(150) - ville?: string; - - // === Informations co-parent (optionnel) === - @ApiProperty({ example: 'thomas.martin@ptits-pas.fr', required: false }) - @IsOptional() - @IsEmail({}, { message: 'Email du co-parent invalide' }) - co_parent_email?: string; - - @ApiProperty({ example: 'Thomas', required: false }) - @IsOptional() - @IsString() - co_parent_prenom?: string; - - @ApiProperty({ example: 'MARTIN', required: false }) - @IsOptional() - @IsString() - co_parent_nom?: string; - - @ApiProperty({ example: '0612345678', 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({ example: 'true', description: 'Le co-parent habite Ă  la mĂȘme adresse', required: false }) - @IsOptional() - 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; -} - diff --git a/database/BDD.sql b/database/BDD.sql index 1e7bc0b..991ce3a 100644 --- a/database/BDD.sql +++ b/database/BDD.sql @@ -80,12 +80,15 @@ CREATE INDEX idx_utilisateurs_token_creation_mdp CREATE TABLE assistantes_maternelles ( id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE, numero_agrement VARCHAR(50), - date_agrement DATE NOT NULL, -- Obligatoire selon CDC v1.3 nir_chiffre CHAR(15), nb_max_enfants INT, - place_disponible INT, biographie TEXT, - disponible BOOLEAN DEFAULT true + disponible BOOLEAN DEFAULT true, + ville_residence VARCHAR(100), + date_agrement DATE, + annee_experience SMALLINT, + specialite VARCHAR(100), + place_disponible INT ); -- ========================================================== diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index 831e911..774b1d2 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -5,6 +5,8 @@ class ApiConfig { // Auth endpoints static const String login = '/auth/login'; static const String register = '/auth/register'; + static const String registerParent = '/auth/register/parent'; + static const String registerAM = '/auth/register/am'; static const String refreshToken = '/auth/refresh'; static const String authMe = '/auth/me'; static const String changePasswordRequired = '/auth/change-password-required'; From d32d956b0e100a6d658b8483c750fd881ea65cbc Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 17 Feb 2026 22:17:51 +0100 Subject: [PATCH 05/22] feat(dashboard-admin): connect admin dashboard to real API data (Ticket #92) - Frontend: - Create UserService to handle user-related API calls (gestionnaires, parents, AMs, admins) - Update AdminDashboardScreen to use dynamic widgets - Implement dynamic management widgets: - GestionnaireManagementWidget - ParentManagementWidget - AssistanteMaternelleManagementWidget - AdminManagementWidget - Add data models: ParentModel, AssistanteMaternelleModel - Update AppUser model - Update ApiConfig - Backend: - Update controllers (Parents, AMs, Gestionnaires, Users) to allow ADMINISTRATEUR role to list users - Fix: Activate endpoint GET /gestionnaires (import GestionnairesModule in UserModule) - Docs: - Add note about backend fix for Gestionnaires module - Update .cursorrules to forbid worktrees - Seed: - Add test data seed script (reset-and-seed-db.sh) Co-authored-by: Cursor --- .../assistantes_maternelles.controller.ts | 2 +- .../src/routes/parents/parents.controller.ts | 2 +- .../gestionnaires/gestionnaires.controller.ts | 2 +- .../gestionnaires/gestionnaires.module.ts | 6 +- backend/src/routes/user/user.controller.ts | 2 +- backend/src/routes/user/user.module.ts | 2 + database/README.md | 10 + database/seed/03_seed_test_data.sql | 73 ++++++ docker-compose.yml | 2 + docs/23_LISTE-TICKETS.md | 52 ++-- docs/92_NOTE-BACKEND-GESTIONNAIRES.md | 63 +++++ docs/PROCEDURE-API-GITEA.md | 176 ++++++++++++++ docs/STATUS-APPLICATION.md | 115 +++++++++ .../models/assistante_maternelle_model.dart | 30 +++ frontend/lib/models/parent_model.dart | 18 ++ frontend/lib/models/user.dart | 55 ++++- .../admin_dashboardScreen.dart | 3 +- frontend/lib/services/api/api_config.dart | 3 + frontend/lib/services/user_service.dart | 94 ++++++++ .../admin/admin_management_widget.dart | 141 +++++++++++ ...sistante_maternelle_management_widget.dart | 191 ++++++++++----- .../admin/gestionnaire_management_widget.dart | 100 ++++++-- .../admin/parent_managmant_widget.dart | 222 ++++++++++++------ scripts/reset-and-seed-db.sh | 61 +++++ 24 files changed, 1242 insertions(+), 183 deletions(-) create mode 100644 database/seed/03_seed_test_data.sql create mode 100644 docs/92_NOTE-BACKEND-GESTIONNAIRES.md create mode 100644 docs/PROCEDURE-API-GITEA.md create mode 100644 docs/STATUS-APPLICATION.md create mode 100644 frontend/lib/models/assistante_maternelle_model.dart create mode 100644 frontend/lib/models/parent_model.dart create mode 100644 frontend/lib/services/user_service.dart create mode 100644 frontend/lib/widgets/admin/admin_management_widget.dart create mode 100755 scripts/reset-and-seed-db.sh diff --git a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts index d803c20..47d51eb 100644 --- a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts +++ b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts @@ -35,7 +35,7 @@ export class AssistantesMaternellesController { return this.assistantesMaternellesService.create(dto); } - @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE) + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @Get() @ApiOperation({ summary: 'RĂ©cupĂ©rer la liste des nounous' }) @ApiResponse({ status: 200, description: 'Liste des nounous' }) diff --git a/backend/src/routes/parents/parents.controller.ts b/backend/src/routes/parents/parents.controller.ts index aa02313..e4a9ee2 100644 --- a/backend/src/routes/parents/parents.controller.ts +++ b/backend/src/routes/parents/parents.controller.ts @@ -20,7 +20,7 @@ import { UpdateParentsDto } from '../user/dto/update_parent.dto'; export class ParentsController { constructor(private readonly parentsService: ParentsService) {} - @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE) + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @Get() @ApiResponse({ status: 200, type: [Parents], description: 'Liste des parents' }) @ApiResponse({ status: 403, description: 'AccĂšs refusĂ© !' }) diff --git a/backend/src/routes/user/gestionnaires/gestionnaires.controller.ts b/backend/src/routes/user/gestionnaires/gestionnaires.controller.ts index 7a1489f..8fd23c6 100644 --- a/backend/src/routes/user/gestionnaires/gestionnaires.controller.ts +++ b/backend/src/routes/user/gestionnaires/gestionnaires.controller.ts @@ -35,7 +35,7 @@ export class GestionnairesController { return this.gestionnairesService.create(dto); } - @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE) + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @ApiOperation({ summary: 'Liste des gestionnaires' }) @ApiResponse({ status: 200, description: 'Liste des gestionnaires : ', type: [Users] }) @Get() diff --git a/backend/src/routes/user/gestionnaires/gestionnaires.module.ts b/backend/src/routes/user/gestionnaires/gestionnaires.module.ts index bfd32f8..9cea564 100644 --- a/backend/src/routes/user/gestionnaires/gestionnaires.module.ts +++ b/backend/src/routes/user/gestionnaires/gestionnaires.module.ts @@ -3,9 +3,13 @@ import { GestionnairesService } from './gestionnaires.service'; import { GestionnairesController } from './gestionnaires.controller'; import { Users } from 'src/entities/users.entity'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthModule } from 'src/routes/auth/auth.module'; @Module({ - imports: [TypeOrmModule.forFeature([Users])], + imports: [ + TypeOrmModule.forFeature([Users]), + AuthModule, + ], controllers: [GestionnairesController], providers: [GestionnairesService], }) diff --git a/backend/src/routes/user/user.controller.ts b/backend/src/routes/user/user.controller.ts index 3fe3187..54caa8a 100644 --- a/backend/src/routes/user/user.controller.ts +++ b/backend/src/routes/user/user.controller.ts @@ -28,7 +28,7 @@ export class UserController { // Lister tous les utilisateurs (super_admin uniquement) @Get() - @Roles(RoleType.SUPER_ADMIN) + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) @ApiOperation({ summary: 'Lister tous les utilisateurs' }) findAll() { return this.userService.findAll(); diff --git a/backend/src/routes/user/user.module.ts b/backend/src/routes/user/user.module.ts index 484f85d..4d5d7cc 100644 --- a/backend/src/routes/user/user.module.ts +++ b/backend/src/routes/user/user.module.ts @@ -9,6 +9,7 @@ import { ParentsModule } from '../parents/parents.module'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { AssistantesMaternellesModule } from '../assistantes_maternelles/assistantes_maternelles.module'; import { Parents } from 'src/entities/parents.entity'; +import { GestionnairesModule } from './gestionnaires/gestionnaires.module'; @Module({ imports: [TypeOrmModule.forFeature( @@ -20,6 +21,7 @@ import { Parents } from 'src/entities/parents.entity'; ]), forwardRef(() => AuthModule), ParentsModule, AssistantesMaternellesModule, + GestionnairesModule, ], controllers: [UserController], providers: [UserService], diff --git a/database/README.md b/database/README.md index 4ebcd90..98200b5 100644 --- a/database/README.md +++ b/database/README.md @@ -41,6 +41,16 @@ docker compose -f docker-compose.dev.yml down -v --- +## RĂ©initialiser la BDD et charger les donnĂ©es de test (dashboard admin) + +Depuis la **racine du projet** (ptitspas-app, oĂč se trouve `docker-compose.yml`) : + +```bash +./scripts/reset-and-seed-db.sh +``` + +Ce script : arrĂȘte les conteneurs, supprime le volume Postgres, redĂ©marre la base (le schĂ©ma est recréé via `BDD.sql`), puis exĂ©cute `database/seed/03_seed_test_data.sql`. Tu obtiens un super_admin (`admin@ptits-pas.fr`) plus 9 comptes de test (1 admin, 1 gestionnaire, 2 AM, 5 parents) avec **mot de passe : `password`**. IdĂ©al pour dĂ©velopper le ticket #92 (dashboard admin). + ## Importation automatique des donnĂ©es de test Les donnĂ©es de test (CSV) sont automatiquement importĂ©es dans la base au dĂ©marrage du conteneur Docker grĂące aux scripts prĂ©sents dans le dossier `migrations/`. diff --git a/database/seed/03_seed_test_data.sql b/database/seed/03_seed_test_data.sql new file mode 100644 index 0000000..1aa805f --- /dev/null +++ b/database/seed/03_seed_test_data.sql @@ -0,0 +1,73 @@ +-- ============================================================ +-- 03_seed_test_data.sql : DonnĂ©es de test complĂštes (dashboard admin) +-- AlignĂ© sur utilisateurs-test-complet.json +-- Mot de passe universel : password (bcrypt) +-- À exĂ©cuter aprĂšs BDD.sql (init DB) +-- ============================================================ + +BEGIN; + +-- Hash bcrypt pour "password" (10 rounds) + +-- ========== UTILISATEURS (1 admin + 1 gestionnaire + 2 AM + 5 parents) ========== +-- On garde admin@ptits-pas.fr (super_admin) dĂ©jĂ  créé par BDD.sql + +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) +ON CONFLICT (email) DO NOTHING; + +-- ========== PARENTS (avec co-parent pour le couple Martin) ========== +INSERT INTO parents (id_utilisateur, id_co_parent) +VALUES + ('a0000005-0005-0005-0005-000000000005', 'a0000006-0006-0006-0006-000000000006'), + ('a0000006-0006-0006-0006-000000000006', 'a0000005-0005-0005-0005-000000000005'), + ('a0000007-0007-0007-0007-000000000007', NULL), + ('a0000008-0008-0008-0008-000000000008', NULL), + ('a0000009-0009-0009-0009-000000000009', NULL) +ON CONFLICT (id_utilisateur) DO NOTHING; + +-- ========== ASSISTANTES MATERNELLES ========== +INSERT INTO assistantes_maternelles (id_utilisateur, numero_agrement, nir_chiffre, nb_max_enfants, biographie, date_agrement, ville_residence, disponible, place_disponible) +VALUES + ('a0000003-0003-0003-0003-000000000003', 'AGR-2019-095001', '280069512345671', 4, 'Assistante maternelle agréée depuis 2019. SpĂ©cialitĂ© bĂ©bĂ©s 0-18 mois. Accueil bienveillant et cadre sĂ©curisant. 2 places disponibles.', '2019-09-01', 'Bezons', true, 2), + ('a0000004-0004-0004-0004-000000000004', 'AGR-2017-095002', '275119512345672', 3, 'Assistante maternelle expĂ©rimentĂ©e. SpĂ©cialitĂ© 1-3 ans. Accueil Ă  la journĂ©e. 1 place disponible.', '2017-06-15', 'Bezons', true, 1) +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) +ON CONFLICT (id) DO NOTHING; + +-- ========== ENFANTS_PARENTS (liaison N:N) ========== +-- Martin (Claire + Thomas) -> Emma, Noah, LĂ©a +INSERT INTO enfants_parents (id_parent, id_enfant) +VALUES + ('a0000005-0005-0005-0005-000000000005', 'e0000001-0001-0001-0001-000000000001'), + ('a0000005-0005-0005-0005-000000000005', 'e0000002-0002-0002-0002-000000000002'), + ('a0000005-0005-0005-0005-000000000005', 'e0000003-0003-0003-0003-000000000003'), + ('a0000006-0006-0006-0006-000000000006', 'e0000001-0001-0001-0001-000000000001'), + ('a0000006-0006-0006-0006-000000000006', 'e0000002-0002-0002-0002-000000000002'), + ('a0000006-0006-0006-0006-000000000006', 'e0000003-0003-0003-0003-000000000003'), + ('a0000007-0007-0007-0007-000000000007', 'e0000004-0004-0004-0004-000000000004'), + ('a0000007-0007-0007-0007-000000000007', 'e0000005-0005-0005-0005-000000000005'), + ('a0000008-0008-0008-0008-000000000008', 'e0000004-0004-0004-0004-000000000004'), + ('a0000008-0008-0008-0008-000000000008', 'e0000005-0005-0005-0005-000000000005'), + ('a0000009-0009-0009-0009-000000000009', 'e0000006-0006-0006-0006-000000000006') +ON CONFLICT DO NOTHING; + +COMMIT; diff --git a/docker-compose.yml b/docker-compose.yml index 1e97b62..a4fad4d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,6 +55,8 @@ services: JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET} JWT_REFRESH_EXPIRES: ${JWT_REFRESH_EXPIRES} NODE_ENV: ${NODE_ENV} + LOG_API_REQUESTS: ${LOG_API_REQUESTS:-false} + CONFIG_ENCRYPTION_KEY: ${CONFIG_ENCRYPTION_KEY} depends_on: - database labels: diff --git a/docs/23_LISTE-TICKETS.md b/docs/23_LISTE-TICKETS.md index 8e0b98d..0d39b55 100644 --- a/docs/23_LISTE-TICKETS.md +++ b/docs/23_LISTE-TICKETS.md @@ -23,11 +23,12 @@ | 10 | [Backend] Service Configuration | ✅ FermĂ© | | 11 | [Backend] API Configuration | ✅ FermĂ© | | 12 | [Backend] Guard Configuration Initiale | ✅ FermĂ© | -| 13 | [Backend] Adaptation MailService pour config dynamique | Ouvert | +| 13 | [Backend] Adaptation MailService pour config dynamique | ✅ FermĂ© | | 14 | [Frontend] Panneau ParamĂštres / Configuration (premiĂšre config + accĂšs permanent) | Ouvert | | 15 | [Frontend] Écran ParamĂštres (accĂšs permanent) | Ouvert | | 16 | [Doc] Documentation configuration on-premise | Ouvert | -| 17–88 | (voir sections ci‑dessous ; #78, #79, #81, #83, #82, #86, #87, #88, etc.) | — | +| 17–88 | (voir sections ci‑dessous ; #82, #78, #79, #81, #83 ; #86, #87, #88 fermĂ©s en doublon) | — | +| 92 | [Frontend] Dashboard Admin - DonnĂ©es rĂ©elles et branchement API | Ouvert | *Gitea #1 et #2 = anciens tickets de test (fermĂ©s). Liste complĂšte : https://git.ptits-pas.fr/jmartin/petitspas/issues* @@ -229,6 +230,8 @@ CrĂ©er un Guard/Middleware qui dĂ©tecte si la configuration initiale est incompl **RĂ©fĂ©rence** : [21_CONFIGURATION-SYSTEME.md](./21_CONFIGURATION-SYSTEME.md#workflow-setup-initial) +*Issue Gitea #86 fermĂ©e en doublon ; ce ticket (#12) est la rĂ©fĂ©rence.* + --- ### Ticket #13 : [Backend] Adaptation MailService pour config dynamique @@ -267,6 +270,8 @@ Un seul panneau **ParamĂštres / Configuration** dans le dashboard admin, avec ** **RĂ©fĂ©rence** : [21_CONFIGURATION-SYSTEME.md](./21_CONFIGURATION-SYSTEME.md#interface-admin) +*Issue Gitea #87 fermĂ©e en doublon de #14.* + --- ### Ticket #15 : [Frontend] Écran ParamĂštres (accĂšs permanent) / IntĂ©gration panneau @@ -281,6 +286,8 @@ S’assurer que le panneau ParamĂštres (dĂ©crit en #14) est accessible en perman - [ ] Chargement des valeurs actuelles (GET `/configuration` ou par catĂ©gorie) - [ ] Modification et sauvegarde (PATCH bulk) sans appel Ă  `setup/complete` +*Issue Gitea #88 fermĂ©e en doublon ; ce ticket (#15) est la rĂ©fĂ©rence.* + --- ### Ticket #16 : [Doc] Documentation configuration on-premise @@ -302,19 +309,8 @@ RĂ©diger la documentation pour aider les collectivitĂ©s Ă  configurer l'applicat --- -### Ticket #86 : [Backend] Guard Configuration Initiale (concept v1.3) -**Estimation** : 2h -**Labels** : `backend`, `p1-bloquant`, `on-premise` - -Issue Gitea ouverte pour le Guard alignĂ© avec le concept v1.3 (pas de redirection vers `/admin/setup`, le frontend affiche le panneau Configuration et bloque la navigation). Voir aussi Ticket #12 (version fermĂ©e). - ---- - -### Ticket #88 : [Frontend] IntĂ©gration panneau ParamĂštres au dashboard -**Estimation** : 1h -**Labels** : `frontend`, `p1-bloquant`, `on-premise` - -ComplĂ©ment de #14 et #15 : s’assurer que le panneau ParamĂštres est accessible en permanence (onglet Configuration, chargement des valeurs, sauvegarde PATCH bulk sans `setup/complete`). +### Ticket #86 / #88 : Doublons fermĂ©s +*#86* fermĂ© en doublon de **#12** (Guard). *#88* fermĂ© en doublon de **#15** (IntĂ©gration panneau). Voir les tickets #12, #14 et #15 pour le travail Ă  faire. --- @@ -898,6 +894,30 @@ CrĂ©er l'Ă©cran de gestion des documents lĂ©gaux (CGU/Privacy) pour l'admin. --- +### Ticket #92 : [Frontend] Dashboard Admin - DonnĂ©es rĂ©elles et branchement API +**Estimation** : 8h +**Labels** : `frontend`, `p3`, `admin` + +**Description** : +Le dashboard admin (onglets Gestionnaires | Parents | Assistantes maternelles | Administrateurs) affiche actuellement des donnĂ©es en dur (mock). Remplacer par des appels API pour afficher les vrais utilisateurs et permettre les actions de gestion (voir, modifier, valider/refuser). RĂ©fĂ©rence : [90_AUDIT.md](./90_AUDIT.md). + +**Fichiers concernĂ©s** : +- `gestionnaire_management_widget.dart` — liste actuellement 5 cartes "Dupont" en dur +- `parent_managmant_widget.dart` — 2 parents simulĂ©s +- `assistante_maternelle_management_widget.dart` — 2 AM simulĂ©es + +**TĂąches** : +- [ ] S'assurer que les endpoints backend existent (liste users par rĂŽle) +- [ ] Onglet Gestionnaires : appel API, affichage dynamique, recherche, lien "CrĂ©er gestionnaire" +- [ ] Onglet Parents : appel API, affichage dynamique, recherche/filtres, actions Voir/Modifier/Valider/Refuser +- [ ] Onglet Assistantes maternelles : appel API, affichage dynamique, filtres, actions +- [ ] Onglet Administrateurs : liste ou placeholder documentĂ© +- [ ] Gestion Ă©tats (chargement, erreur, liste vide) et rafraĂźchissement aprĂšs actions + +**RĂ©fĂ©rences** : #44, #45, #46 (dashboard Gestionnaire), #25, #26 (API liste/validation), #17, #35 (crĂ©ation gestionnaire) + +--- + ### Ticket #50 : [Frontend] Affichage dynamique CGU lors inscription **Estimation** : 2h **Labels** : `frontend`, `p3`, `juridique` @@ -1237,7 +1257,7 @@ RĂ©diger les documents lĂ©gaux gĂ©nĂ©riques (CGU et Politique de confidentialit - **Juridique** : 1 ticket ### Modifications par rapport Ă  la version initiale -- ✅ **v1.4** : NumĂ©ros de section du doc = numĂ©ros Gitea (Ticket #n = issue #n). Tableau et sections renumĂ©rotĂ©s en consĂ©quence ; #87 fermĂ© (doublon de #14). +- ✅ **v1.4** : NumĂ©ros de section du doc = numĂ©ros Gitea (Ticket #n = issue #n). Tableau et sections renumĂ©rotĂ©s. Doublons #86, #87, #88 fermĂ©s sur Gitea (#86→#12, #87→#14, #88→#15) ; tickets sources #12, #14, #15 mis Ă  jour (doc + body Gitea). - ✅ **Concept v1.3** : Configuration initiale = un seul panneau ParamĂštres (3 sections) dans le dashboard ; plus de page dĂ©diĂ©e « Setup Wizard » ; navigation bloquĂ©e jusqu’à sauvegarde au premier dĂ©ploiement. Tickets #10, #12, #13 alignĂ©s. - ❌ **SupprimĂ©** : Tickets "Renvoyer email validation" (backend + frontend) - Pas prioritaire - ✅ **AjoutĂ©** : Ticket #55 "Service Logging Winston" - Monitoring essentiel diff --git a/docs/92_NOTE-BACKEND-GESTIONNAIRES.md b/docs/92_NOTE-BACKEND-GESTIONNAIRES.md new file mode 100644 index 0000000..d8bd595 --- /dev/null +++ b/docs/92_NOTE-BACKEND-GESTIONNAIRES.md @@ -0,0 +1,63 @@ +# Note Backend - Activation du module Gestionnaires (Ticket #92) + +## ProblĂšme +L'endpoint `GET /api/v1/gestionnaires` renvoie une erreur **404 Not Found**. +Cela est dĂ» au fait que le `GestionnairesModule` n'est pas importĂ© dans l'arbre des modules de l'application (via `UserModule` ou `AppModule`). + +## Solution de contournement actuelle (Frontend) +Le frontend utilise actuellement l'endpoint gĂ©nĂ©rique `/api/v1/users` et filtre les rĂ©sultats cĂŽtĂ© client pour ne garder que les utilisateurs ayant le rĂŽle `gestionnaire`. +*Fichier concernĂ© : `frontend/lib/services/user_service.dart`* + +## Correctif Backend Ă  appliquer +Pour activer proprement l'endpoint dĂ©diĂ©, il faut effectuer les modifications suivantes dans le backend : + +### 1. Importer le module dans `UserModule` +Fichier : `backend/src/routes/user/user.module.ts` + +Ajouter `GestionnairesModule` dans les imports. + +```typescript +import { GestionnairesModule } from './gestionnaires/gestionnaires.module'; + +@Module({ + imports: [ + // ... autres imports + GestionnairesModule, // <--- AJOUTER ICI + ], + // ... +}) +export class UserModule { } +``` + +### 2. Ajouter AuthModule dans `GestionnairesModule` +Fichier : `backend/src/routes/user/gestionnaires/gestionnaires.module.ts` + +Le contrĂŽleur utilise `AuthGuard`, qui dĂ©pend de `JwtService` fourni par `AuthModule`. + +```typescript +import { AuthModule } from 'src/routes/auth/auth.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Users]), + AuthModule // <--- AJOUTER ICI + ], + controllers: [GestionnairesController], + providers: [GestionnairesService], +}) +export class GestionnairesModule { } +``` + +## AprĂšs application du correctif +Une fois ces modifications backend effectuĂ©es : +1. RedĂ©marrer le serveur backend. +2. Modifier le frontend (`frontend/lib/services/user_service.dart`) pour utiliser Ă  nouveau l'endpoint dĂ©diĂ© : + ```dart + static Future> getGestionnaires() async { + final response = await http.get( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.gestionnaires}'), + headers: await _headers(), + ); + // ... + } + ``` diff --git a/docs/PROCEDURE-API-GITEA.md b/docs/PROCEDURE-API-GITEA.md new file mode 100644 index 0000000..6a41046 --- /dev/null +++ b/docs/PROCEDURE-API-GITEA.md @@ -0,0 +1,176 @@ +# ProcĂ©dure – Utilisation de l’API Gitea + +## 1. Contexte + +- **Instance** : https://git.ptits-pas.fr +- **API de base** : `https://git.ptits-pas.fr/api/v1` +- **Projet P'titsPas** : dĂ©pĂŽt `jmartin/petitspas` (owner = `jmartin`, repo = `petitspas`) + +## 2. Authentification + +### 2.1 Token + +Le token est dĂ©fini dans l’environnement (ex. `~/.bashrc`) : + +```bash +export GITEA_TOKEN="" +``` + +Pour l’utiliser dans les commandes : + +```bash +source ~/.bashrc # ou : . ~/.bashrc +# Puis utiliser $GITEA_TOKEN dans les curl +``` + +### 2.2 En-tĂȘte HTTP + +Toutes les requĂȘtes API doivent envoyer le token : + +```bash +-H "Authorization: token $GITEA_TOKEN" +``` + +Exemple : + +```bash +curl -s -H "Authorization: token $GITEA_TOKEN" \ + "https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas" +``` + +## 3. Endpoints utiles + +### 3.1 DĂ©pĂŽt (repository) + +| Action | MĂ©thode | URL | +|---------------|---------|-----| +| Infos dĂ©pĂŽt | GET | `/repos/{owner}/{repo}` | +| Liste dĂ©pĂŽts | GET | `/repos/search?q=petitspas` | + +Exemple – infos du dĂ©pĂŽt : + +```bash +curl -s -H "Authorization: token $GITEA_TOKEN" \ + "https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas" | jq . +``` + +### 3.2 Issues (tickets) + +| Action | MĂ©thode | URL | +|------------------|---------|-----| +| Liste des issues | GET | `/repos/{owner}/{repo}/issues` | +| DĂ©tail d’une issue | GET | `/repos/{owner}/{repo}/issues/{index}` | +| CrĂ©er une issue | POST | `/repos/{owner}/{repo}/issues` | +| Modifier une issue | PATCH | `/repos/{owner}/{repo}/issues/{index}` | +| Fermer une issue | PATCH | (mĂȘme URL, `state: "closed"`) | + +**ParamĂštres GET utiles pour la liste :** + +- `state` : `open` ou `closed` +- `labels` : filtre par label (ex. `frontend`) +- `page`, `limit` : pagination + +Exemples : + +```bash +# Toutes les issues ouvertes +curl -s -H "Authorization: token $GITEA_TOKEN" \ + "https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues?state=open" | jq . + +# Issues ouvertes avec label "frontend" +curl -s -H "Authorization: token $GITEA_TOKEN" \ + "https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues?state=open" | \ + jq '.[] | select(.labels[].name == "frontend") | {number, title, state}' + +# DĂ©tail de l’issue #47 +curl -s -H "Authorization: token $GITEA_TOKEN" \ + "https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/47" | jq . + +# Fermer l’issue #31 +curl -s -X PATCH -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"state":"closed"}' \ + "https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/31" + +# CrĂ©er une issue +curl -s -X POST -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"title":"Titre du ticket","body":"Description","labels":[1]}' \ + "https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues" +``` + +### 3.3 Pull requests + +| Action | MĂ©thode | URL | +|---------------|---------|-----| +| Liste des PR | GET | `/repos/{owner}/{repo}/pulls` | +| DĂ©tail d’une PR | GET | `/repos/{owner}/{repo}/pulls/{index}` | +| CrĂ©er une PR | POST | `/repos/{owner}/{repo}/pulls` | +| Fusionner une PR | POST | `/repos/{owner}/{repo}/pulls/{index}/merge` | + +Exemples : + +```bash +# Liste des PR ouvertes +curl -s -H "Authorization: token $GITEA_TOKEN" \ + "https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/pulls?state=open" | jq . + +# CrĂ©er une PR (head = branche source, base = branche cible) +curl -s -X POST -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"head":"develop","base":"master","title":"Titre de la PR"}' \ + "https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/pulls" +``` + +### 3.4 Branches + +| Action | MĂ©thode | URL | +|---------------|---------|-----| +| Liste des branches | GET | `/repos/{owner}/{repo}/branches` | + +```bash +curl -s -H "Authorization: token $GITEA_TOKEN" \ + "https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/branches" | jq '.[].name' +``` + +### 3.5 Webhooks + +| Action | MĂ©thode | URL | +|---------------|---------|-----| +| Liste webhooks | GET | `/repos/{owner}/{repo}/hooks` | +| CrĂ©er webhook | POST | `/repos/{owner}/{repo}/hooks` | + +### 3.6 Labels + +| Action | MĂ©thode | URL | +|---------------|---------|-----| +| Liste des labels | GET | `/repos/{owner}/{repo}/labels` | + +```bash +curl -s -H "Authorization: token $GITEA_TOKEN" \ + "https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels" | jq '.[] | {id, name}' +``` + +## 4. RĂ©sumĂ© des URLs pour P'titsPas + +Remplacer `{owner}` par `jmartin` et `{repo}` par `petitspas` : + +| Ressource | URL | +|------------------|-----| +| DĂ©pĂŽt | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas` | +| Issues | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues` | +| Issue #n | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/{n}` | +| Pull requests | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/pulls` | +| Branches | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/branches` | +| Labels | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels` | + +## 5. Documentation officielle + +- Swagger / OpenAPI : https://docs.gitea.com/api +- RĂ©fĂ©rence selon la version de Gitea installĂ©e (ex. 1.21, 1.25). + +## 6. DĂ©pannage + +- **401 Unauthorized** : vĂ©rifier le token et l’en-tĂȘte `Authorization: token `. +- **404** : vĂ©rifier owner/repo et l’URL (sensible Ă  la casse). +- **422 / body invalide** : pour POST/PATCH, envoyer `Content-Type: application/json` et un JSON valide. diff --git a/docs/STATUS-APPLICATION.md b/docs/STATUS-APPLICATION.md new file mode 100644 index 0000000..83461d0 --- /dev/null +++ b/docs/STATUS-APPLICATION.md @@ -0,0 +1,115 @@ +# Statut de l'application P'titsPas + +**Date du point** : 8 fĂ©vrier 2026 + +--- + +## 1. Environnement de production + +| ÉlĂ©ment | Statut | DĂ©tail | +|--------|--------|--------| +| **URL** | OK | https://app.ptits-pas.fr | +| **Frontend** | 200 | Flutter Web, Nginx | +| **API** | 200 | NestJS, prĂ©fixe `/api/v1` | +| **Base de donnĂ©es** | OK | PostgreSQL 17 | +| **PgAdmin** | OK | https://app.ptits-pas.fr/pgadmin | + +### Conteneurs Docker + +| Service | Image | État | +|---------|--------|------| +| ptitspas-frontend | ptitspas-app-frontend | Up (recréé rĂ©cemment) | +| ptitspas-backend | ptitspas-app-backend | Up ~26h | +| ptitspas-postgres | postgres:17 | Up ~28h | +| ptitspas-pgadmin | dpage/pgadmin4 | Up ~28h | + +--- + +## 2. DĂ©pĂŽt Git + +- **Branche dĂ©ployĂ©e** : `master` +- **Derniers commits** : + - `10bf255` – fix(ui): renforcer ombre boutons Parents/AM sur mobile + - `678f421` – docs: ticket #82 fermĂ© (Ă©cran Login mobile) + - `5295e8e` – Merge develop: login mobile, formulaire sous slogan par ratio + - `6bf0932` – docs: Index, doc API Gitea, script fermeture issue + - `2f1740b` – docs: ticket #83 RegisterChoiceScreen Mobile (terminĂ©) + +- **Branches actives** : `master`, `develop`, diverses `feature/*` (inscription, config, documents lĂ©gaux, etc.) + +--- + +## 3. DĂ©ploiement (hook Gitea) + +| ÉlĂ©ment | Statut | +|--------|--------| +| **Webhook** | OpĂ©rationnel (`hooks.ptits-pas.fr/hooks/petitspas-deploy`) | +| **DĂ©clencheur** | Push sur `master`, dĂ©pĂŽt `petitspas` | +| **Script** | MontĂ© depuis l’hĂŽte (verrou + sans Prisma) | +| **Dernier dĂ©ploiement** | 08/02/2026 18:18:26 – SuccĂšs | + +Un seul dĂ©ploiement Ă  la fois (verrou) ; plus d’étape Prisma dans le script. + +--- + +## 4. FonctionnalitĂ©s livrĂ©es + +### Backend (API) + +- Auth : login, refresh, profil, **changement MDP obligatoire** (first login) +- Configuration : setup status, bulk, test SMTP, catĂ©gories +- Documents lĂ©gaux : actifs, versions, upload, activation, tĂ©lĂ©chargement +- Inscription : parents (workflow complet), enfants (CRUD) +- Compte super_admin par dĂ©faut (seed BDD) : `admin@ptits-pas.fr` / `4dm1n1strateur` + +### Frontend + +- **Formulaires d’inscription** : compatibles **desktop et mobile** + - Choix d’inscription (Parents / Assistante maternelle) – responsive + - Inscription Parent : Ă©tapes 1 Ă  5 (infos parent 1 & 2, enfants, prĂ©sentation, CGU, rĂ©cap) + - Inscription AM : Ă©tapes 1 Ă  4 (identitĂ©, pro, prĂ©sentation, rĂ©cap) +- **Login** : Ă©cran adaptĂ© mobile (formulaire sous slogan selon ratio) +- Modale **changement de mot de passe obligatoire** aprĂšs premiĂšre connexion si `changement_mdp_obligatoire` +- CORS configurĂ© (localhost + prod) + +### Base de donnĂ©es + +- SchĂ©ma database-first (BDD.sql) +- Tables : utilisateurs, configuration, documents_legaux, acceptations_documents, enfants, etc. +- Champs tokens crĂ©ation MDP, genre enfants, configuration systĂšme + +--- + +## 5. Tickets / PrioritĂ©s (rĂ©sumĂ©) + +- **Liste dĂ©taillĂ©e** : `docs/23_LISTE-TICKETS.md` +- **RĂ©cent fermĂ©** : #82 (Login mobile), #83 (RegisterChoiceScreen mobile), #73, #78, #79, #81 +- **P0 (BDD)** : quelques amendements ouverts (champs CDC, prĂ©sentation dossier, etc.) +- **P1** : configuration systĂšme (panneau ParamĂštres, 3 sections, premiĂšre config + accĂšs permanent) +- **P2/P3** : backend mĂ©tier et frontend (dashboards, Ă©crans crĂ©ation MDP, etc.) + +--- + +## 6. Documentation utile + +| Fichier | Usage | +|---------|--------| +| `00_INDEX.md` | Index de la doc | +| `01_CAHIER-DES-CHARGES.md` | CDC v1.3 | +| `11_API.md` | Endpoints API | +| `20_WORKFLOW-CREATION-COMPTE.md` | Workflow crĂ©ation compte | +| `23_LISTE-TICKETS.md` | Liste des tickets | +| `BRIEFING-FRONTEND.md` | Brief frontend, accĂšs Git, tickets prioritaires | +| `PROCEDURE-API-GITEA.md` | Utilisation API Gitea (issues, PR, token) | + +--- + +## 7. SynthĂšse + +L’application est **en production** sur https://app.ptits-pas.fr avec : + +- Frontend et API accessibles et rĂ©pondant en 200. +- DĂ©ploiement automatique sur push `master` avec script Ă  jour (verrou, sans Prisma). +- Formulaires d’inscription (Parents et AM) **responsive desktop et mobile**. +- Login et changement de mot de passe obligatoire opĂ©rationnels. +- Prochaines prioritĂ©s : P0 BDD si besoin, P1 panneau ParamĂštres / Configuration (tickets #12, #13), puis dashboards et workflows mĂ©tier (P2/P3). diff --git a/frontend/lib/models/assistante_maternelle_model.dart b/frontend/lib/models/assistante_maternelle_model.dart new file mode 100644 index 0000000..2d53fb0 --- /dev/null +++ b/frontend/lib/models/assistante_maternelle_model.dart @@ -0,0 +1,30 @@ +import 'package:p_tits_pas/models/user.dart'; + +class AssistanteMaternelleModel { + final AppUser user; + final String? approvalNumber; + final String? residenceCity; + final int? maxChildren; + final int? placesAvailable; + + AssistanteMaternelleModel({ + required this.user, + this.approvalNumber, + this.residenceCity, + this.maxChildren, + this.placesAvailable, + }); + + factory AssistanteMaternelleModel.fromJson(Map json) { + final userJson = json['user'] ?? json; + final user = AppUser.fromJson(userJson); + + 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?, + ); + } +} diff --git a/frontend/lib/models/parent_model.dart b/frontend/lib/models/parent_model.dart new file mode 100644 index 0000000..5b893fd --- /dev/null +++ b/frontend/lib/models/parent_model.dart @@ -0,0 +1,18 @@ +import 'package:p_tits_pas/models/user.dart'; + +class ParentModel { + final AppUser user; + final int childrenCount; + + ParentModel({required this.user, this.childrenCount = 0}); + + factory ParentModel.fromJson(Map json) { + final userJson = json['user'] ?? json; + final user = AppUser.fromJson(userJson); + final children = json['parentChildren'] as List?; + return ParentModel( + user: user, + childrenCount: children?.length ?? 0, + ); + } +} diff --git a/frontend/lib/models/user.dart b/frontend/lib/models/user.dart index 29712ac..7ecbe79 100644 --- a/frontend/lib/models/user.dart +++ b/frontend/lib/models/user.dart @@ -5,6 +5,14 @@ class AppUser { final DateTime createdAt; final DateTime updatedAt; final bool changementMdpObligatoire; + final String? nom; + final String? prenom; + final String? statut; + final String? telephone; + final String? photoUrl; + final String? adresse; + final String? ville; + final String? codePostal; AppUser({ required this.id, @@ -13,6 +21,14 @@ class AppUser { required this.createdAt, required this.updatedAt, this.changementMdpObligatoire = false, + this.nom, + this.prenom, + this.statut, + this.telephone, + this.photoUrl, + this.adresse, + this.ville, + this.codePostal, }); factory AppUser.fromJson(Map json) { @@ -20,13 +36,26 @@ class AppUser { id: json['id'] as String, email: json['email'] as String, role: json['role'] as String, - createdAt: json['createdAt'] != null - ? DateTime.parse(json['createdAt'] as String) - : DateTime.now(), - updatedAt: json['updatedAt'] != null - ? DateTime.parse(json['updatedAt'] as String) - : DateTime.now(), - changementMdpObligatoire: json['changement_mdp_obligatoire'] as bool? ?? false, + createdAt: json['cree_le'] != null + ? DateTime.parse(json['cree_le'] as String) + : (json['createdAt'] != null + ? DateTime.parse(json['createdAt'] as String) + : DateTime.now()), + updatedAt: json['modifie_le'] != null + ? DateTime.parse(json['modifie_le'] as String) + : (json['updatedAt'] != null + ? DateTime.parse(json['updatedAt'] as String) + : DateTime.now()), + changementMdpObligatoire: + json['changement_mdp_obligatoire'] as bool? ?? false, + nom: json['nom'] as String?, + prenom: json['prenom'] as String?, + statut: json['statut'] as String?, + telephone: json['telephone'] as String?, + photoUrl: json['photo_url'] as String?, + adresse: json['adresse'] as String?, + ville: json['ville'] as String?, + codePostal: json['code_postal'] as String?, ); } @@ -38,6 +67,16 @@ class AppUser { 'createdAt': createdAt.toIso8601String(), 'updatedAt': updatedAt.toIso8601String(), 'changement_mdp_obligatoire': changementMdpObligatoire, + 'nom': nom, + 'prenom': prenom, + 'statut': statut, + 'telephone': telephone, + 'photo_url': photoUrl, + 'adresse': adresse, + 'ville': ville, + 'code_postal': codePostal, }; } -} \ No newline at end of file + + String get fullName => '${prenom ?? ''} ${nom ?? ''}'.trim(); +} diff --git a/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart b/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart index 1868c9d..4707621 100644 --- a/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart +++ b/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart @@ -3,6 +3,7 @@ import 'package:p_tits_pas/services/configuration_service.dart'; import 'package:p_tits_pas/widgets/admin/assistante_maternelle_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/admin_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/parametres_panel.dart'; import 'package:p_tits_pas/widgets/app_footer.dart'; import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart'; @@ -104,7 +105,7 @@ class _AdminDashboardScreenState extends State { case 2: return const AssistanteMaternelleManagementWidget(); case 3: - return const Center(child: Text('đŸ‘šâ€đŸ’Œ Administrateurs')); + return const AdminManagementWidget(); default: return const Center(child: Text('Page non trouvĂ©e')); } diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index 774b1d2..a9b48fe 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -15,6 +15,9 @@ class ApiConfig { static const String users = '/users'; static const String userProfile = '/users/profile'; static const String userChildren = '/users/children'; + static const String gestionnaires = '/gestionnaires'; + static const String parents = '/parents'; + static const String assistantesMaternelles = '/assistantes-maternelles'; // Configuration (admin) static const String configuration = '/configuration'; diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart new file mode 100644 index 0000000..80c9cbd --- /dev/null +++ b/frontend/lib/services/user_service.dart @@ -0,0 +1,94 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:p_tits_pas/models/user.dart'; +import 'package:p_tits_pas/models/parent_model.dart'; +import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; +import 'package:p_tits_pas/services/api/api_config.dart'; +import 'package:p_tits_pas/services/api/tokenService.dart'; + +class UserService { + static Future> _headers() async { + final token = await TokenService.getToken(); + return token != null + ? ApiConfig.authHeaders(token) + : Map.from(ApiConfig.headers); + } + + static String? _toStr(dynamic v) { + if (v == null) return null; + if (v is String) return v; + return v.toString(); + } + + // RĂ©cupĂ©rer la liste des gestionnaires (endpoint dĂ©diĂ©) + static Future> getGestionnaires() async { + final response = await http.get( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.gestionnaires}'), + headers: await _headers(), + ); + + if (response.statusCode != 200) { + final err = jsonDecode(response.body) as Map?; + throw Exception(_toStr(err?['message']) ?? 'Erreur chargement gestionnaires'); + } + + final List data = jsonDecode(response.body); + return data.map((e) => AppUser.fromJson(e)).toList(); + } + + // RĂ©cupĂ©rer la liste des parents + static Future> getParents() async { + final response = await http.get( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}'), + headers: await _headers(), + ); + + if (response.statusCode != 200) { + final err = jsonDecode(response.body) as Map?; + throw Exception(_toStr(err?['message']) ?? 'Erreur chargement parents'); + } + + final List data = jsonDecode(response.body); + return data.map((e) => ParentModel.fromJson(e)).toList(); + } + + // RĂ©cupĂ©rer la liste des assistantes maternelles + static Future> getAssistantesMaternelles() async { + final response = await http.get( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}'), + headers: await _headers(), + ); + + if (response.statusCode != 200) { + final err = jsonDecode(response.body) as Map?; + throw Exception(_toStr(err?['message']) ?? 'Erreur chargement AM'); + } + + final List data = jsonDecode(response.body); + return data.map((e) => AssistanteMaternelleModel.fromJson(e)).toList(); + } + + // RĂ©cupĂ©rer la liste des administrateurs (via /users filtrĂ© ou autre) + // Pour l'instant on va utiliser /users et filtrer cĂŽtĂ© client si on est super admin + static Future> getAdministrateurs() async { + // TODO: Endpoint dĂ©diĂ© ou filtrage + // En attendant, on retourne une liste vide ou on tente /users + try { + final response = await http.get( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}'), + headers: await _headers(), + ); + + if (response.statusCode == 200) { + final List data = jsonDecode(response.body); + return data + .map((e) => AppUser.fromJson(e)) + .where((u) => u.role == 'administrateur' || u.role == 'super_admin') + .toList(); + } + } catch (e) { + print('Erreur chargement admins: $e'); + } + return []; + } +} diff --git a/frontend/lib/widgets/admin/admin_management_widget.dart b/frontend/lib/widgets/admin/admin_management_widget.dart new file mode 100644 index 0000000..58e5eeb --- /dev/null +++ b/frontend/lib/widgets/admin/admin_management_widget.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/user.dart'; +import 'package:p_tits_pas/services/user_service.dart'; + +class AdminManagementWidget extends StatefulWidget { + const AdminManagementWidget({super.key}); + + @override + State createState() => _AdminManagementWidgetState(); +} + +class _AdminManagementWidgetState extends State { + bool _isLoading = false; + String? _error; + List _admins = []; + List _filteredAdmins = []; + final TextEditingController _searchController = TextEditingController(); + + @override + void initState() { + super.initState(); + _loadAdmins(); + _searchController.addListener(_onSearchChanged); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future _loadAdmins() async { + setState(() { + _isLoading = true; + _error = null; + }); + try { + final list = await UserService.getAdministrateurs(); + if (!mounted) return; + setState(() { + _admins = list; + _filteredAdmins = list; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + + void _onSearchChanged() { + final query = _searchController.text.toLowerCase(); + setState(() { + _filteredAdmins = _admins.where((u) { + final name = u.fullName.toLowerCase(); + final email = u.email.toLowerCase(); + return name.contains(query) || email.contains(query); + }).toList(); + }); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: TextField( + controller: _searchController, + decoration: const InputDecoration( + hintText: "Rechercher un administrateur...", + prefixIcon: Icon(Icons.search), + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 16), + ElevatedButton.icon( + onPressed: () { + // TODO: CrĂ©er admin + }, + icon: const Icon(Icons.add), + label: const Text("CrĂ©er un admin"), + ), + ], + ), + const SizedBox(height: 24), + if (_isLoading) + const Center(child: CircularProgressIndicator()) + else if (_error != null) + Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red))) + else if (_filteredAdmins.isEmpty) + const Center(child: Text("Aucun administrateur trouvĂ©.")) + else + Expanded( + child: ListView.builder( + itemCount: _filteredAdmins.length, + itemBuilder: (context, index) { + final user = _filteredAdmins[index]; + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: ListTile( + leading: CircleAvatar( + child: Text(user.fullName.isNotEmpty + ? user.fullName[0].toUpperCase() + : 'A'), + ), + title: Text(user.fullName.isNotEmpty + ? user.fullName + : 'Sans nom'), + subtitle: Text(user.email), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit), + onPressed: () {}, + ), + IconButton( + icon: const Icon(Icons.delete), + onPressed: () {}, + ), + ], + ), + ), + ); + }, + ), + ) + ], + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart b/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart index 220f946..c8e1a19 100644 --- a/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart +++ b/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart @@ -1,72 +1,142 @@ import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; +import 'package:p_tits_pas/services/user_service.dart'; -class AssistanteMaternelleManagementWidget extends StatelessWidget { +class AssistanteMaternelleManagementWidget extends StatefulWidget { const AssistanteMaternelleManagementWidget({super.key}); @override - Widget build(BuildContext context) { - final assistantes = [ - { - "nom": "Marie Dupont", - "numeroAgrement": "AG123456", - "zone": "Paris 14", - "capacite": 3, - }, - { - "nom": "Claire Martin", - "numeroAgrement": "AG654321", - "zone": "Lyon 7", - "capacite": 2, - }, - ]; + State createState() => + _AssistanteMaternelleManagementWidgetState(); +} +class _AssistanteMaternelleManagementWidgetState + extends State { + bool _isLoading = false; + String? _error; + List _assistantes = []; + List _filteredAssistantes = []; + + final TextEditingController _zoneController = TextEditingController(); + final TextEditingController _capacityController = TextEditingController(); + + @override + void initState() { + super.initState(); + _loadAssistantes(); + _zoneController.addListener(_filter); + _capacityController.addListener(_filter); + } + + @override + void dispose() { + _zoneController.dispose(); + _capacityController.dispose(); + super.dispose(); + } + + Future _loadAssistantes() async { + setState(() { + _isLoading = true; + _error = null; + }); + try { + final list = await UserService.getAssistantesMaternelles(); + if (!mounted) return; + setState(() { + _assistantes = list; + _filter(); + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + + void _filter() { + final zoneQuery = _zoneController.text.toLowerCase(); + final capacityQuery = int.tryParse(_capacityController.text); + + setState(() { + _filteredAssistantes = _assistantes.where((am) { + final matchesZone = zoneQuery.isEmpty || + (am.residenceCity?.toLowerCase().contains(zoneQuery) ?? false); + final matchesCapacity = capacityQuery == null || + (am.maxChildren != null && am.maxChildren! >= capacityQuery); + return matchesZone && matchesCapacity; + }).toList(); + }); + } + + @override + Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(16), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 🔎 Zone de filtre - _buildFilterSection(), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 🔎 Zone de filtre + _buildFilterSection(), - const SizedBox(height: 16), + const SizedBox(height: 16), - // 📋 Liste des assistantes - ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: assistantes.length, - itemBuilder: (context, index) { - final assistante = assistantes[index]; - return Card( - margin: const EdgeInsets.symmetric(vertical: 8), - child: ListTile( - leading: const Icon(Icons.face), - title: Text(assistante['nom'].toString()), - subtitle: Text( - "N° AgrĂ©ment : ${assistante['numeroAgrement']}\nZone : ${assistante['zone']} | CapacitĂ© : ${assistante['capacite']}"), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.edit), - onPressed: () { - // TODO: Ajouter modification - }, + // 📋 Liste des assistantes + if (_isLoading) + const Center(child: CircularProgressIndicator()) + else if (_error != null) + Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red))) + else if (_filteredAssistantes.isEmpty) + const Center(child: Text("Aucune assistante maternelle trouvĂ©e.")) + else + Expanded( + child: ListView.builder( + itemCount: _filteredAssistantes.length, + itemBuilder: (context, index) { + final assistante = _filteredAssistantes[index]; + return Card( + margin: const EdgeInsets.symmetric(vertical: 8), + child: ListTile( + leading: CircleAvatar( + backgroundImage: assistante.user.photoUrl != null + ? NetworkImage(assistante.user.photoUrl!) + : null, + child: assistante.user.photoUrl == null + ? const Icon(Icons.face) + : null, + ), + title: Text(assistante.user.fullName.isNotEmpty + ? assistante.user.fullName + : 'Sans nom'), + subtitle: Text( + "N° AgrĂ©ment : ${assistante.approvalNumber ?? 'N/A'}\nZone : ${assistante.residenceCity ?? 'N/A'} | CapacitĂ© : ${assistante.maxChildren ?? 0}"), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit), + onPressed: () { + // TODO: Ajouter modification + }, + ), + IconButton( + icon: const Icon(Icons.delete), + onPressed: () { + // TODO: Ajouter suppression + }, + ), + ], + ), ), - IconButton( - icon: const Icon(Icons.delete), - onPressed: () { - // TODO: Ajouter suppression - }, - ), - ], - ), + ); + }, ), - ); - }, - ), - ], - ), + ), + ], + ), ); } @@ -78,26 +148,23 @@ class AssistanteMaternelleManagementWidget extends StatelessWidget { SizedBox( width: 200, child: TextField( + controller: _zoneController, decoration: const InputDecoration( labelText: "Zone gĂ©ographique", border: OutlineInputBorder(), + prefixIcon: Icon(Icons.location_on), ), - onChanged: (value) { - // TODO: Ajouter logique de filtrage par zone - }, ), ), SizedBox( width: 200, child: TextField( + controller: _capacityController, decoration: const InputDecoration( labelText: "CapacitĂ© minimum", border: OutlineInputBorder(), ), keyboardType: TextInputType.number, - onChanged: (value) { - // TODO: Ajouter logique de filtrage par capacitĂ© - }, ), ), ], diff --git a/frontend/lib/widgets/admin/gestionnaire_management_widget.dart b/frontend/lib/widgets/admin/gestionnaire_management_widget.dart index 3f5d6c2..82fa52f 100644 --- a/frontend/lib/widgets/admin/gestionnaire_management_widget.dart +++ b/frontend/lib/widgets/admin/gestionnaire_management_widget.dart @@ -1,9 +1,70 @@ import 'package:flutter/material.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/gestionnaire_card.dart'; -class GestionnaireManagementWidget extends StatelessWidget { +class GestionnaireManagementWidget extends StatefulWidget { const GestionnaireManagementWidget({Key? key}) : super(key: key); + @override + State createState() => + _GestionnaireManagementWidgetState(); +} + +class _GestionnaireManagementWidgetState + extends State { + bool _isLoading = false; + String? _error; + List _gestionnaires = []; + List _filteredGestionnaires = []; + final TextEditingController _searchController = TextEditingController(); + + @override + void initState() { + super.initState(); + _loadGestionnaires(); + _searchController.addListener(_onSearchChanged); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future _loadGestionnaires() async { + setState(() { + _isLoading = true; + _error = null; + }); + try { + final list = await UserService.getGestionnaires(); + if (!mounted) return; + setState(() { + _gestionnaires = list; + _filteredGestionnaires = list; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + + void _onSearchChanged() { + final query = _searchController.text.toLowerCase(); + setState(() { + _filteredGestionnaires = _gestionnaires.where((u) { + final name = u.fullName.toLowerCase(); + final email = u.email.toLowerCase(); + return name.contains(query) || email.contains(query); + }).toList(); + }); + } + @override Widget build(BuildContext context) { return Padding( @@ -14,9 +75,10 @@ class GestionnaireManagementWidget extends StatelessWidget { // đŸ”č Barre du haut avec bouton Row( children: [ - const Expanded( + Expanded( child: TextField( - decoration: InputDecoration( + controller: _searchController, + decoration: const InputDecoration( hintText: "Rechercher un gestionnaire...", prefixIcon: Icon(Icons.search), border: OutlineInputBorder(), @@ -26,7 +88,7 @@ class GestionnaireManagementWidget extends StatelessWidget { const SizedBox(width: 16), ElevatedButton.icon( onPressed: () { - // Rediriger vers la page de crĂ©ation + // TODO: Rediriger vers la page de crĂ©ation }, icon: const Icon(Icons.add), label: const Text("CrĂ©er un gestionnaire"), @@ -36,17 +98,25 @@ class GestionnaireManagementWidget extends StatelessWidget { const SizedBox(height: 24), // đŸ”č Liste des gestionnaires - Expanded( - child: ListView.builder( - itemCount: 5, // À remplacer par liste dynamique - itemBuilder: (context, index) { - return GestionnaireCard( - name: "Dupont $index", - email: "dupont$index@mail.com", - ); - }, - ), - ) + if (_isLoading) + const Center(child: CircularProgressIndicator()) + else if (_error != null) + Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red))) + else if (_filteredGestionnaires.isEmpty) + const Center(child: Text("Aucun gestionnaire trouvĂ©.")) + else + Expanded( + child: ListView.builder( + itemCount: _filteredGestionnaires.length, + itemBuilder: (context, index) { + final user = _filteredGestionnaires[index]; + return GestionnaireCard( + name: user.fullName.isNotEmpty ? user.fullName : "Sans nom", + email: user.email, + ); + }, + ), + ) ], ), ); diff --git a/frontend/lib/widgets/admin/parent_managmant_widget.dart b/frontend/lib/widgets/admin/parent_managmant_widget.dart index 1bf78a5..1764056 100644 --- a/frontend/lib/widgets/admin/parent_managmant_widget.dart +++ b/frontend/lib/widgets/admin/parent_managmant_widget.dart @@ -1,83 +1,149 @@ import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/parent_model.dart'; +import 'package:p_tits_pas/services/user_service.dart'; -class ParentManagementWidget extends StatelessWidget { +class ParentManagementWidget extends StatefulWidget { const ParentManagementWidget({super.key}); @override - Widget build(BuildContext context) { - // 🔁 Simulation de donnĂ©es parents - final parents = [ - { - "nom": "Jean Dupuis", - "email": "jean.dupuis@email.com", - "statut": "Actif", - "enfants": 2, - }, - { - "nom": "Lucie Morel", - "email": "lucie.morel@email.com", - "statut": "En attente", - "enfants": 1, - }, - ]; + State createState() => _ParentManagementWidgetState(); +} +class _ParentManagementWidgetState extends State { + bool _isLoading = false; + String? _error; + List _parents = []; + List _filteredParents = []; + + final TextEditingController _searchController = TextEditingController(); + String? _selectedStatus; + + @override + void initState() { + super.initState(); + _loadParents(); + _searchController.addListener(_filter); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future _loadParents() async { + setState(() { + _isLoading = true; + _error = null; + }); + try { + final list = await UserService.getParents(); + if (!mounted) return; + setState(() { + _parents = list; + _filter(); // Apply initial filter (if any) + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + + void _filter() { + final query = _searchController.text.toLowerCase(); + setState(() { + _filteredParents = _parents.where((p) { + final matchesName = p.user.fullName.toLowerCase().contains(query) || + p.user.email.toLowerCase().contains(query); + final matchesStatus = _selectedStatus == null || + _selectedStatus == 'Tous' || + (p.user.statut?.toLowerCase() == _selectedStatus?.toLowerCase()); + + // Mapping simple pour le statut affichĂ© vs backend + // Backend: en_attente, actif, suspendu + // Dropdown: En attente, Actif, Suspendu + + return matchesName && matchesStatus; + }).toList(); + }); + } + + @override + Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(16), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - - _buildSearchSection(), - - const SizedBox(height: 16), - - ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: parents.length, - itemBuilder: (context, index) { - final parent = parents[index]; - return Card( - margin: const EdgeInsets.symmetric(vertical: 8), - child: ListTile( - leading: const Icon(Icons.person_outline), - title: Text(parent['nom'].toString()), - subtitle: Text( - "${parent['email']}\nStatut : ${parent['statut']} | Enfants : ${parent['enfants']}", - ), - isThreeLine: true, - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.visibility), - tooltip: "Voir dossier", - onPressed: () { - // TODO: Voir le statut du dossier - }, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSearchSection(), + const SizedBox(height: 16), + if (_isLoading) + const Center(child: CircularProgressIndicator()) + else if (_error != null) + Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red))) + else if (_filteredParents.isEmpty) + const Center(child: Text("Aucun parent trouvĂ©.")) + else + Expanded( + child: ListView.builder( + itemCount: _filteredParents.length, + itemBuilder: (context, index) { + final parent = _filteredParents[index]; + return Card( + margin: const EdgeInsets.symmetric(vertical: 8), + child: ListTile( + leading: CircleAvatar( + backgroundImage: parent.user.photoUrl != null + ? NetworkImage(parent.user.photoUrl!) + : null, + child: parent.user.photoUrl == null + ? const Icon(Icons.person) + : null, + ), + title: Text(parent.user.fullName.isNotEmpty + ? parent.user.fullName + : 'Sans nom'), + subtitle: Text( + "${parent.user.email}\nStatut : ${parent.user.statut ?? 'Inconnu'} | Enfants : ${parent.childrenCount}", + ), + isThreeLine: true, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.visibility), + tooltip: "Voir dossier", + onPressed: () { + // TODO: Voir le statut du dossier + }, + ), + IconButton( + icon: const Icon(Icons.edit), + tooltip: "Modifier", + onPressed: () { + // TODO: Modifier parent + }, + ), + IconButton( + icon: const Icon(Icons.delete), + tooltip: "Supprimer", + onPressed: () { + // TODO: Supprimer compte + }, + ), + ], + ), ), - IconButton( - icon: const Icon(Icons.edit), - tooltip: "Modifier", - onPressed: () { - // TODO: Modifier parent - }, - ), - IconButton( - icon: const Icon(Icons.delete), - tooltip: "Supprimer", - onPressed: () { - // TODO: Supprimer compte - }, - ), - ], - ), + ); + }, ), - ); - }, - ), - ], - ) + ), + ], + ), ); } @@ -89,13 +155,12 @@ class ParentManagementWidget extends StatelessWidget { SizedBox( width: 220, child: TextField( + controller: _searchController, decoration: const InputDecoration( labelText: "Nom du parent", border: OutlineInputBorder(), + prefixIcon: Icon(Icons.search), ), - onChanged: (value) { - // TODO: Ajouter logique de recherche - }, ), ), SizedBox( @@ -105,13 +170,18 @@ class ParentManagementWidget extends StatelessWidget { labelText: "Statut", border: OutlineInputBorder(), ), + value: _selectedStatus, items: const [ - DropdownMenuItem(value: "Actif", child: Text("Actif")), - DropdownMenuItem(value: "En attente", child: Text("En attente")), - DropdownMenuItem(value: "SupprimĂ©", child: Text("SupprimĂ©")), + DropdownMenuItem(value: null, child: Text("Tous")), + DropdownMenuItem(value: "actif", child: Text("Actif")), + DropdownMenuItem(value: "en_attente", child: Text("En attente")), + DropdownMenuItem(value: "suspendu", child: Text("Suspendu")), ], onChanged: (value) { - // TODO: Ajouter logique de filtrage + setState(() { + _selectedStatus = value; + _filter(); + }); }, ), ), diff --git a/scripts/reset-and-seed-db.sh b/scripts/reset-and-seed-db.sh new file mode 100755 index 0000000..1bbea91 --- /dev/null +++ b/scripts/reset-and-seed-db.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# ============================================================ +# reset-and-seed-db.sh : RĂ©initialise la BDD et injecte les donnĂ©es de test +# Usage : depuis la racine du projet ptitspas-app +# ./scripts/reset-and-seed-db.sh +# ============================================================ + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$PROJECT_ROOT" + +echo "=== RĂ©initialisation BDD + seed donnĂ©es de test ===" +echo "Projet : $PROJECT_ROOT" +echo "" + +# 1) ArrĂȘter les conteneurs et supprimer le volume Postgres +echo "[1/4] ArrĂȘt des conteneurs et suppression du volume Postgres..." +docker compose down -v 2>/dev/null || docker-compose down -v 2>/dev/null || true + +# 2) DĂ©marrer uniquement la base +echo "[2/4] DĂ©marrage du conteneur database..." +docker compose up -d database 2>/dev/null || docker-compose up -d database 2>/dev/null + +# 3) Attendre que Postgres soit prĂȘt +echo "[3/4] Attente du dĂ©marrage de Postgres..." +for i in {1..30}; do + if docker exec ptitspas-postgres pg_isready -U admin -d ptitpas_db 2>/dev/null; then + echo " Postgres prĂȘt." + break + fi + if [ "$i" -eq 30 ]; then + echo "Erreur : Postgres ne rĂ©pond pas aprĂšs 30 tentatives." + exit 1 + fi + sleep 1 +done + +# Petit dĂ©lai supplĂ©mentaire pour la fin de l'init (BDD.sql) +sleep 2 + +# 4) ExĂ©cuter le seed des donnĂ©es de test +echo "[4/4] ExĂ©cution du seed (03_seed_test_data.sql)..." +docker exec -i ptitspas-postgres psql -U admin -d ptitpas_db < database/seed/03_seed_test_data.sql + +echo "" +echo "=== TerminĂ© ===" +echo "Comptes de test (mot de passe : password) :" +echo " - admin@ptits-pas.fr (super_admin, créé par BDD.sql)" +echo " - sophie.bernard@ptits-pas.fr (administrateur)" +echo " - lucas.moreau@ptits-pas.fr (gestionnaire)" +echo " - marie.dubois@ptits-pas.fr (assistante maternelle)" +echo " - fatima.elmansouri@ptits-pas.fr (assistante maternelle)" +echo " - claire.martin@ptits-pas.fr (parent)" +echo " - thomas.martin@ptits-pas.fr (parent)" +echo " - amelie.durand@ptits-pas.fr (parent)" +echo " - julien.rousseau@ptits-pas.fr (parent)" +echo " - david.lecomte@ptits-pas.fr (parent)" +echo "" +echo "Tu peux redĂ©marrer le backend/frontend si besoin : docker compose up -d" From 42c569e4918a035606041d8c2acfa9a95b7cc788 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Sat, 21 Feb 2026 14:40:32 +0100 Subject: [PATCH 06/22] feat(release): Backend Relais Module (#94) - Implemented Relais entity and CRUD API - Added relation between Users (Gestionnaires) and Relais - Updated database initialization script - Documentation updates Co-authored-by: Cursor --- backend/src/app.module.ts | 2 + backend/src/config/typeorm.config.ts | 15 ++++ backend/src/entities/relais.entity.ts | 35 ++++++++ backend/src/entities/users.entity.ts | 10 ++- .../routes/relais/dto/create-relais.dto.ts | 34 ++++++++ .../routes/relais/dto/update-relais.dto.ts | 4 + .../src/routes/relais/relais.controller.ts | 57 ++++++++++++ backend/src/routes/relais/relais.module.ts | 17 ++++ backend/src/routes/relais/relais.service.ts | 42 +++++++++ .../user/dto/create_gestionnaire.dto.ts | 10 ++- .../gestionnaires/gestionnaires.service.ts | 7 +- database/BDD.sql | 20 ++++- docs/23_LISTE-TICKETS.md | 87 ++++++++++++++++--- 13 files changed, 324 insertions(+), 16 deletions(-) create mode 100644 backend/src/config/typeorm.config.ts create mode 100644 backend/src/entities/relais.entity.ts create mode 100644 backend/src/routes/relais/dto/create-relais.dto.ts create mode 100644 backend/src/routes/relais/dto/update-relais.dto.ts create mode 100644 backend/src/routes/relais/relais.controller.ts create mode 100644 backend/src/routes/relais/relais.module.ts create mode 100644 backend/src/routes/relais/relais.service.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 0124bfe..0197cd2 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -16,6 +16,7 @@ import { AllExceptionsFilter } from './common/filters/all_exceptions.filters'; import { EnfantsModule } from './routes/enfants/enfants.module'; import { AppConfigModule } from './modules/config/config.module'; import { DocumentsLegauxModule } from './modules/documents-legaux'; +import { RelaisModule } from './routes/relais/relais.module'; @Module({ imports: [ @@ -53,6 +54,7 @@ import { DocumentsLegauxModule } from './modules/documents-legaux'; AuthModule, AppConfigModule, DocumentsLegauxModule, + RelaisModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/config/typeorm.config.ts b/backend/src/config/typeorm.config.ts new file mode 100644 index 0000000..f431eb2 --- /dev/null +++ b/backend/src/config/typeorm.config.ts @@ -0,0 +1,15 @@ +import { DataSource } from 'typeorm'; +import { config } from 'dotenv'; + +config(); + +export default new DataSource({ + type: 'postgres', + host: process.env.DATABASE_HOST, + port: parseInt(process.env.DATABASE_PORT || '5432', 10), + username: process.env.DATABASE_USERNAME, + password: process.env.DATABASE_PASSWORD, + database: process.env.DATABASE_NAME, + entities: ['src/**/*.entity.ts'], + migrations: ['src/migrations/*.ts'], +}); diff --git a/backend/src/entities/relais.entity.ts b/backend/src/entities/relais.entity.ts new file mode 100644 index 0000000..9b492da --- /dev/null +++ b/backend/src/entities/relais.entity.ts @@ -0,0 +1,35 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm'; +import { Users } from './users.entity'; + +@Entity('relais', { schema: 'public' }) +export class Relais { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'nom' }) + nom: string; + + @Column({ name: 'adresse' }) + adresse: string; + + @Column({ type: 'jsonb', name: 'horaires_ouverture', nullable: true }) + horaires_ouverture?: any; + + @Column({ name: 'ligne_fixe', nullable: true }) + ligne_fixe?: string; + + @Column({ default: true, name: 'actif' }) + actif: boolean; + + @Column({ type: 'text', name: 'notes', nullable: true }) + notes?: string; + + @CreateDateColumn({ name: 'cree_le', type: 'timestamptz' }) + cree_le: Date; + + @UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' }) + modifie_le: Date; + + @OneToMany(() => Users, user => user.relais) + gestionnaires: Users[]; +} diff --git a/backend/src/entities/users.entity.ts b/backend/src/entities/users.entity.ts index 25db178..3f74dc5 100644 --- a/backend/src/entities/users.entity.ts +++ b/backend/src/entities/users.entity.ts @@ -1,11 +1,12 @@ import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, - OneToOne, OneToMany + OneToOne, OneToMany, ManyToOne, JoinColumn } from 'typeorm'; import { AssistanteMaternelle } from './assistantes_maternelles.entity'; import { Parents } from './parents.entity'; import { Message } from './messages.entity'; +import { Relais } from './relais.entity'; // Enums alignĂ©s avec la BDD PostgreSQL export enum RoleType { @@ -147,4 +148,11 @@ export class Users { @OneToMany(() => Parents, parent => parent.co_parent) co_parent_in?: Parents[]; + + @Column({ nullable: true, name: 'relais_id' }) + relaisId?: string; + + @ManyToOne(() => Relais, relais => relais.gestionnaires, { nullable: true }) + @JoinColumn({ name: 'relais_id' }) + relais?: Relais; } diff --git a/backend/src/routes/relais/dto/create-relais.dto.ts b/backend/src/routes/relais/dto/create-relais.dto.ts new file mode 100644 index 0000000..b5cf737 --- /dev/null +++ b/backend/src/routes/relais/dto/create-relais.dto.ts @@ -0,0 +1,34 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean, IsNotEmpty, IsOptional, IsString, IsObject } from 'class-validator'; + +export class CreateRelaisDto { + @ApiProperty({ example: 'Relais Petite Enfance Centre' }) + @IsString() + @IsNotEmpty() + nom: string; + + @ApiProperty({ example: '12 rue de la Mairie, 75000 Paris' }) + @IsString() + @IsNotEmpty() + adresse: string; + + @ApiProperty({ example: { lundi: '09:00-17:00' }, required: false }) + @IsOptional() + @IsObject() + horaires_ouverture?: any; + + @ApiProperty({ example: '0123456789', required: false }) + @IsOptional() + @IsString() + ligne_fixe?: string; + + @ApiProperty({ default: true, required: false }) + @IsOptional() + @IsBoolean() + actif?: boolean; + + @ApiProperty({ example: 'Notes internes...', required: false }) + @IsOptional() + @IsString() + notes?: string; +} diff --git a/backend/src/routes/relais/dto/update-relais.dto.ts b/backend/src/routes/relais/dto/update-relais.dto.ts new file mode 100644 index 0000000..f7c0b9b --- /dev/null +++ b/backend/src/routes/relais/dto/update-relais.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateRelaisDto } from './create-relais.dto'; + +export class UpdateRelaisDto extends PartialType(CreateRelaisDto) {} diff --git a/backend/src/routes/relais/relais.controller.ts b/backend/src/routes/relais/relais.controller.ts new file mode 100644 index 0000000..5d30871 --- /dev/null +++ b/backend/src/routes/relais/relais.controller.ts @@ -0,0 +1,57 @@ +import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards } from '@nestjs/common'; +import { RelaisService } from './relais.service'; +import { CreateRelaisDto } from './dto/create-relais.dto'; +import { UpdateRelaisDto } from './dto/update-relais.dto'; +import { ApiBearerAuth, ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { AuthGuard } from 'src/common/guards/auth.guard'; +import { RolesGuard } from 'src/common/guards/roles.guard'; +import { Roles } from 'src/common/decorators/roles.decorator'; +import { RoleType } from 'src/entities/users.entity'; + +@ApiTags('Relais') +@ApiBearerAuth('access-token') +@UseGuards(AuthGuard, RolesGuard) +@Controller('relais') +export class RelaisController { + constructor(private readonly relaisService: RelaisService) {} + + @Post() + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) + @ApiOperation({ summary: 'CrĂ©er un relais' }) + @ApiResponse({ status: 201, description: 'Le relais a Ă©tĂ© créé.' }) + create(@Body() createRelaisDto: CreateRelaisDto) { + return this.relaisService.create(createRelaisDto); + } + + @Get() + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) + @ApiOperation({ summary: 'Lister tous les relais' }) + @ApiResponse({ status: 200, description: 'Liste des relais.' }) + findAll() { + return this.relaisService.findAll(); + } + + @Get(':id') + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) + @ApiOperation({ summary: 'RĂ©cupĂ©rer un relais par ID' }) + @ApiResponse({ status: 200, description: 'Le relais trouvĂ©.' }) + findOne(@Param('id') id: string) { + return this.relaisService.findOne(id); + } + + @Patch(':id') + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) + @ApiOperation({ summary: 'Mettre Ă  jour un relais' }) + @ApiResponse({ status: 200, description: 'Le relais a Ă©tĂ© mis Ă  jour.' }) + update(@Param('id') id: string, @Body() updateRelaisDto: UpdateRelaisDto) { + return this.relaisService.update(id, updateRelaisDto); + } + + @Delete(':id') + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) + @ApiOperation({ summary: 'Supprimer un relais' }) + @ApiResponse({ status: 200, description: 'Le relais a Ă©tĂ© supprimĂ©.' }) + remove(@Param('id') id: string) { + return this.relaisService.remove(id); + } +} diff --git a/backend/src/routes/relais/relais.module.ts b/backend/src/routes/relais/relais.module.ts new file mode 100644 index 0000000..393eb1d --- /dev/null +++ b/backend/src/routes/relais/relais.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { RelaisService } from './relais.service'; +import { RelaisController } from './relais.controller'; +import { Relais } from 'src/entities/relais.entity'; +import { AuthModule } from 'src/routes/auth/auth.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Relais]), + AuthModule, + ], + controllers: [RelaisController], + providers: [RelaisService], + exports: [RelaisService], +}) +export class RelaisModule {} diff --git a/backend/src/routes/relais/relais.service.ts b/backend/src/routes/relais/relais.service.ts new file mode 100644 index 0000000..b2fb022 --- /dev/null +++ b/backend/src/routes/relais/relais.service.ts @@ -0,0 +1,42 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Relais } from 'src/entities/relais.entity'; +import { CreateRelaisDto } from './dto/create-relais.dto'; +import { UpdateRelaisDto } from './dto/update-relais.dto'; + +@Injectable() +export class RelaisService { + constructor( + @InjectRepository(Relais) + private readonly relaisRepository: Repository, + ) {} + + create(createRelaisDto: CreateRelaisDto) { + const relais = this.relaisRepository.create(createRelaisDto); + return this.relaisRepository.save(relais); + } + + findAll() { + return this.relaisRepository.find({ order: { nom: 'ASC' } }); + } + + async findOne(id: string) { + const relais = await this.relaisRepository.findOne({ where: { id } }); + if (!relais) { + throw new NotFoundException(`Relais #${id} not found`); + } + return relais; + } + + async update(id: string, updateRelaisDto: UpdateRelaisDto) { + const relais = await this.findOne(id); + Object.assign(relais, updateRelaisDto); + return this.relaisRepository.save(relais); + } + + async remove(id: string) { + const relais = await this.findOne(id); + return this.relaisRepository.remove(relais); + } +} diff --git a/backend/src/routes/user/dto/create_gestionnaire.dto.ts b/backend/src/routes/user/dto/create_gestionnaire.dto.ts index fceea23..26f36ab 100644 --- a/backend/src/routes/user/dto/create_gestionnaire.dto.ts +++ b/backend/src/routes/user/dto/create_gestionnaire.dto.ts @@ -1,4 +1,10 @@ -import { OmitType } from "@nestjs/swagger"; +import { ApiProperty, OmitType } from "@nestjs/swagger"; import { CreateUserDto } from "./create_user.dto"; +import { IsOptional, IsUUID } from "class-validator"; -export class CreateGestionnaireDto extends OmitType(CreateUserDto, ['role'] as const) {} +export class CreateGestionnaireDto extends OmitType(CreateUserDto, ['role'] as const) { + @ApiProperty({ required: false, description: 'ID du relais de rattachement' }) + @IsOptional() + @IsUUID() + relaisId?: string; +} diff --git a/backend/src/routes/user/gestionnaires/gestionnaires.service.ts b/backend/src/routes/user/gestionnaires/gestionnaires.service.ts index 4e1406e..590730d 100644 --- a/backend/src/routes/user/gestionnaires/gestionnaires.service.ts +++ b/backend/src/routes/user/gestionnaires/gestionnaires.service.ts @@ -41,19 +41,24 @@ export class GestionnairesService { : undefined, changement_mdp_obligatoire: dto.changement_mdp_obligatoire ?? false, role: RoleType.GESTIONNAIRE, + relaisId: dto.relaisId, }); return this.gestionnaireRepository.save(entity); } // Liste des gestionnaires async findAll(): Promise { - return this.gestionnaireRepository.find({ where: { role: RoleType.GESTIONNAIRE } }); + return this.gestionnaireRepository.find({ + where: { role: RoleType.GESTIONNAIRE }, + relations: ['relais'], + }); } // RĂ©cupĂ©rer un gestionnaire par ID async findOne(id: string): Promise { const gestionnaire = await this.gestionnaireRepository.findOne({ where: { id, role: RoleType.GESTIONNAIRE }, + relations: ['relais'], }); if (!gestionnaire) throw new NotFoundException('Gestionnaire introuvable'); return gestionnaire; diff --git a/database/BDD.sql b/database/BDD.sql index 991ce3a..6a26917 100644 --- a/database/BDD.sql +++ b/database/BDD.sql @@ -331,13 +331,29 @@ CREATE INDEX idx_acceptations_utilisateur ON acceptations_documents(id_utilisate CREATE INDEX idx_acceptations_document ON acceptations_documents(id_document); -- ========================================================== --- Modification Table : utilisateurs (ajout colonnes documents) +-- 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 privacy_acceptee_le TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS relais_id UUID REFERENCES relais(id) ON DELETE SET NULL; -- ========================================================== -- Seed : Documents lĂ©gaux gĂ©nĂ©riques v1 diff --git a/docs/23_LISTE-TICKETS.md b/docs/23_LISTE-TICKETS.md index 0d39b55..ab31cfe 100644 --- a/docs/23_LISTE-TICKETS.md +++ b/docs/23_LISTE-TICKETS.md @@ -1,7 +1,7 @@ # đŸŽ« Liste ComplĂšte des Tickets - Projet P'titsPas -**Version** : 1.4 -**Date** : 9 FĂ©vrier 2026 +**Version** : 1.5 +**Date** : 17 FĂ©vrier 2026 **Auteur** : Équipe PtitsPas **Estimation totale** : ~184h @@ -28,7 +28,11 @@ | 15 | [Frontend] Écran ParamĂštres (accĂšs permanent) | Ouvert | | 16 | [Doc] Documentation configuration on-premise | Ouvert | | 17–88 | (voir sections ci‑dessous ; #82, #78, #79, #81, #83 ; #86, #87, #88 fermĂ©s en doublon) | — | -| 92 | [Frontend] Dashboard Admin - DonnĂ©es rĂ©elles et branchement API | Ouvert | +| 91 | [Frontend] Inscription AM – Branchement soumission formulaire Ă  l'API | Ouvert | +| 92 | [Frontend] Dashboard Admin - DonnĂ©es rĂ©elles et branchement API | ✅ TerminĂ© | +| 93 | [Frontend] Panneau Admin - Homogeneiser la presentation des onglets | Ouvert | +| 94 | [Backend] Relais - modele, API CRUD et liaison gestionnaire | ✅ TerminĂ© | +| 95 | [Frontend] Admin - gestion des relais et rattachement gestionnaire | Ouvert | *Gitea #1 et #2 = anciens tickets de test (fermĂ©s). Liste complĂšte : https://git.ptits-pas.fr/jmartin/petitspas/issues* @@ -641,6 +645,22 @@ Enregistrer les acceptations de documents lĂ©gaux lors de l'inscription (traçab --- +### Ticket #94 : [Backend] Relais - ModĂšle, API CRUD et liaison gestionnaire ✅ +**Estimation** : 4h +**Labels** : `backend`, `p2`, `admin` +**Statut** : ✅ TERMINÉ (FermĂ© le 2026-02-21) + +**Description** : +Le back-office admin doit gĂ©rer des Relais avec des donnĂ©es rĂ©elles en base, et permettre une liaison simple avec les gestionnaires. + +**TĂąches** : +- [x] CrĂ©er le modĂšle `Relais` (nom, adresse, horaires, tĂ©lĂ©phone, actif, notes) +- [x] Exposer les endpoints admin CRUD pour les relais (`GET`, `POST`, `PATCH`, `DELETE`) +- [x] Ajouter la liaison : un gestionnaire peut ĂȘtre rattachĂ© Ă  un relais principal (`relais_id` dans `users` ?) +- [x] Validations (champs requis, format horaires) + +--- + ## 🟱 PRIORITÉ 3 : Frontend - Interfaces ### Ticket #35 : [Frontend] Écran CrĂ©ation Gestionnaire @@ -894,9 +914,10 @@ CrĂ©er l'Ă©cran de gestion des documents lĂ©gaux (CGU/Privacy) pour l'admin. --- -### Ticket #92 : [Frontend] Dashboard Admin - DonnĂ©es rĂ©elles et branchement API +### Ticket #92 : [Frontend] Dashboard Admin - DonnĂ©es rĂ©elles et branchement API ✅ **Estimation** : 8h **Labels** : `frontend`, `p3`, `admin` +**Statut** : ✅ TERMINÉ (FermĂ© le 2026-02-17) **Description** : Le dashboard admin (onglets Gestionnaires | Parents | Assistantes maternelles | Administrateurs) affiche actuellement des donnĂ©es en dur (mock). Remplacer par des appels API pour afficher les vrais utilisateurs et permettre les actions de gestion (voir, modifier, valider/refuser). RĂ©fĂ©rence : [90_AUDIT.md](./90_AUDIT.md). @@ -1018,6 +1039,51 @@ Adapter l'Ă©cran de choix Parent/AM pour une meilleure expĂ©rience mobile et coh --- +### Ticket #91 : [Frontend] Inscription AM – Branchement soumission formulaire Ă  l'API +**Estimation** : 3h +**Labels** : `frontend`, `p3`, `auth`, `cdc` + +**Description** : +Branchement du formulaire d'inscription AM (Ă©tape 4) Ă  l'endpoint d'inscription. + +**TĂąches** : +- [ ] Construire le body (DTO) Ă  partir de `AmRegistrationData` +- [ ] Appel HTTP `POST /api/v1/auth/register/am` +- [ ] Gestion rĂ©ponse (201 : succĂšs + redirection ; 4xx : erreur) +- [ ] Conversion photo en base64 si nĂ©cessaire + +--- + +### Ticket #93 : [Frontend] Panneau Admin - HomogĂ©nĂ©isation des onglets +**Estimation** : 4h +**Labels** : `frontend`, `p3`, `admin`, `ux` + +**Description** : +Uniformiser l'UI/UX des 4 onglets du dashboard admin (Gestionnaires, Parents, AM, Admins). + +**TĂąches** : +- [ ] Standardiser le header de liste (Recherche, Filtres, Bouton Action) +- [ ] Standardiser les cartes utilisateurs (`ListTile` uniforme) +- [ ] Standardiser les Ă©tats (Loading, Erreur, Vide) +- [ ] Factoriser les composants partagĂ©s + +--- + +### Ticket #95 : [Frontend] Admin - Gestion des Relais et rattachement gestionnaire +**Estimation** : 5h +**Labels** : `frontend`, `p3`, `admin` + +**Description** : +Interface de gestion des Relais dans le dashboard admin et rattachement des gestionnaires. + +**TĂąches** : +- [ ] Section Relais avec 2 sous-onglets : ParamĂštres techniques / ParamĂštres territoriaux +- [ ] Liste, CrĂ©ation, Édition, Activation/DĂ©sactivation des relais +- [ ] Champs UI : nom, adresse, horaires, tĂ©lĂ©phone, statut, notes +- [ ] Onglet Gestionnaires : Ajout contrĂŽle de rattachement au relais principal + +--- + ## đŸ”” PRIORITÉ 4 : Tests & Documentation ### Ticket #52 : [Tests] Tests unitaires Backend @@ -1235,28 +1301,29 @@ RĂ©diger les documents lĂ©gaux gĂ©nĂ©riques (CGU et Politique de confidentialit ## 📊 RĂ©sumĂ© final -**Total** : 65 tickets -**Estimation** : ~184h de dĂ©veloppement +**Total** : 69 tickets +**Estimation** : ~200h de dĂ©veloppement ### Par prioritĂ© - **P0 (Bloquant BDD)** : 7 tickets (~5h) - **P1 (Bloquant Config)** : 7 tickets (~22h) -- **P2 (Backend)** : 18 tickets (~50h) -- **P3 (Frontend)** : 22 tickets (~71h) ← +1 mobile RegisterChoice +- **P2 (Backend)** : 19 tickets (~54h) +- **P3 (Frontend)** : 25 tickets (~83h) - **P4 (Tests/Doc)** : 4 tickets (~24h) - **Critiques** : 6 tickets (~13h) - **Juridique** : 1 ticket (~8h) ### Par domaine - **BDD** : 7 tickets -- **Backend** : 23 tickets -- **Frontend** : 22 tickets ← +1 mobile RegisterChoice +- **Backend** : 24 tickets +- **Frontend** : 25 tickets - **Tests** : 3 tickets - **Documentation** : 5 tickets - **Infra** : 2 tickets - **Juridique** : 1 ticket ### Modifications par rapport Ă  la version initiale +- ✅ **v1.5** : Ajout tickets #91, #93, #94, #95. Ticket #92 terminĂ©. - ✅ **v1.4** : NumĂ©ros de section du doc = numĂ©ros Gitea (Ticket #n = issue #n). Tableau et sections renumĂ©rotĂ©s. Doublons #86, #87, #88 fermĂ©s sur Gitea (#86→#12, #87→#14, #88→#15) ; tickets sources #12, #14, #15 mis Ă  jour (doc + body Gitea). - ✅ **Concept v1.3** : Configuration initiale = un seul panneau ParamĂštres (3 sections) dans le dashboard ; plus de page dĂ©diĂ©e « Setup Wizard » ; navigation bloquĂ©e jusqu’à sauvegarde au premier dĂ©ploiement. Tickets #10, #12, #13 alignĂ©s. - ❌ **SupprimĂ©** : Tickets "Renvoyer email validation" (backend + frontend) - Pas prioritaire From 00c42c7beee3268edaa653c3329ec2edcc495982 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Mon, 23 Feb 2026 22:50:13 +0100 Subject: [PATCH 07/22] feat(release): Backend Gestionnaire Creation (#17) - Implemented MailModule and MailService - Updated GestionnairesService to send welcome email - Forced password change on first login for new gestionnaires Co-authored-by: Cursor --- backend/src/modules/mail/mail.module.ts | 10 ++ backend/src/modules/mail/mail.service.ts | 100 ++++++++++++++++++ .../gestionnaires/gestionnaires.module.ts | 2 + .../gestionnaires/gestionnaires.service.ts | 21 +++- docs/23_LISTE-TICKETS.md | 19 ++-- 5 files changed, 141 insertions(+), 11 deletions(-) create mode 100644 backend/src/modules/mail/mail.module.ts create mode 100644 backend/src/modules/mail/mail.service.ts diff --git a/backend/src/modules/mail/mail.module.ts b/backend/src/modules/mail/mail.module.ts new file mode 100644 index 0000000..b61343f --- /dev/null +++ b/backend/src/modules/mail/mail.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { MailService } from './mail.service'; +import { AppConfigModule } from '../config/config.module'; + +@Module({ + imports: [AppConfigModule], + providers: [MailService], + exports: [MailService], +}) +export class MailModule {} diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts new file mode 100644 index 0000000..c064915 --- /dev/null +++ b/backend/src/modules/mail/mail.service.ts @@ -0,0 +1,100 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { AppConfigService } from '../config/config.service'; + +@Injectable() +export class MailService { + private readonly logger = new Logger(MailService.name); + + constructor(private readonly configService: AppConfigService) {} + + /** + * Envoi d'un email gĂ©nĂ©rique + * @param to Destinataire + * @param subject Sujet + * @param html Contenu HTML + * @param text Contenu texte (optionnel) + */ + async sendEmail(to: string, subject: string, html: string, text?: string): Promise { + try { + // RĂ©cupĂ©ration de la configuration SMTP + const smtpHost = this.configService.get('smtp_host'); + const smtpPort = this.configService.get('smtp_port'); + const smtpSecure = this.configService.get('smtp_secure'); + const smtpAuthRequired = this.configService.get('smtp_auth_required'); + const smtpUser = this.configService.get('smtp_user'); + const smtpPassword = this.configService.get('smtp_password'); + const emailFromName = this.configService.get('email_from_name'); + const emailFromAddress = this.configService.get('email_from_address'); + + // Import dynamique de nodemailer + const nodemailer = await import('nodemailer'); + + // Configuration du transporteur + const transportConfig: any = { + host: smtpHost, + port: smtpPort, + secure: smtpSecure, + }; + + if (smtpAuthRequired && smtpUser && smtpPassword) { + transportConfig.auth = { + user: smtpUser, + pass: smtpPassword, + }; + } + + const transporter = nodemailer.createTransport(transportConfig); + + // Envoi de l'email + await transporter.sendMail({ + from: `"${emailFromName}" <${emailFromAddress}>`, + to, + subject, + text: text || html.replace(/<[^>]*>?/gm, ''), // Fallback texte simple + html, + }); + + this.logger.log(`📧 Email envoyĂ© Ă  ${to} : ${subject}`); + } catch (error) { + this.logger.error(`❌ Erreur lors de l'envoi de l'email Ă  ${to}`, error); + throw error; + } + } + + /** + * Envoi de l'email de bienvenue pour un gestionnaire + * @param to Email du gestionnaire + * @param prenom PrĂ©nom + * @param nom Nom + * @param token Token de crĂ©ation de mot de passe (si applicable) ou mot de passe temporaire (si applicable) + * @note Pour l'instant, on suppose que le gestionnaire doit dĂ©finir son mot de passe via "Mot de passe oubliĂ©" ou un lien d'activation + * Mais le ticket #17 parle de "Flag changement_mdp_obligatoire = TRUE", ce qui implique qu'on lui donne un mot de passe temporaire ou qu'on lui envoie un lien. + * Le ticket #24 parle de "API CrĂ©ation mot de passe" via token. + * Pour le ticket #17, on crĂ©e le gestionnaire avec un mot de passe (hashĂ©). + * Si on suit le ticket #35 (Frontend), on saisit un mot de passe. + * Donc on envoie juste un email de confirmation de crĂ©ation de compte. + */ + async sendGestionnaireWelcomeEmail(to: string, prenom: string, nom: string): Promise { + const appName = this.configService.get('app_name', 'P\'titsPas'); + const appUrl = this.configService.get('app_url', 'https://app.ptits-pas.fr'); + + const subject = `Bienvenue sur ${appName}`; + const html = ` +
+

Bienvenue ${prenom} ${nom} !

+

Votre compte gestionnaire sur ${appName} a été créé avec succÚs.

+

Vous pouvez dÚs à présent vous connecter avec l'adresse email ${to} et le mot de passe qui vous a été communiqué.

+

Lors de votre premiÚre connexion, il vous sera demandé de modifier votre mot de passe pour des raisons de sécurité.

+ +
+

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

+
+ `; + + await this.sendEmail(to, subject, html); + } +} diff --git a/backend/src/routes/user/gestionnaires/gestionnaires.module.ts b/backend/src/routes/user/gestionnaires/gestionnaires.module.ts index 9cea564..18940c7 100644 --- a/backend/src/routes/user/gestionnaires/gestionnaires.module.ts +++ b/backend/src/routes/user/gestionnaires/gestionnaires.module.ts @@ -4,11 +4,13 @@ import { GestionnairesController } from './gestionnaires.controller'; import { Users } from 'src/entities/users.entity'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AuthModule } from 'src/routes/auth/auth.module'; +import { MailModule } from 'src/modules/mail/mail.module'; @Module({ imports: [ TypeOrmModule.forFeature([Users]), AuthModule, + MailModule, ], controllers: [GestionnairesController], providers: [GestionnairesService], diff --git a/backend/src/routes/user/gestionnaires/gestionnaires.service.ts b/backend/src/routes/user/gestionnaires/gestionnaires.service.ts index 590730d..bb838d7 100644 --- a/backend/src/routes/user/gestionnaires/gestionnaires.service.ts +++ b/backend/src/routes/user/gestionnaires/gestionnaires.service.ts @@ -9,12 +9,14 @@ import { RoleType, Users } from 'src/entities/users.entity'; import { CreateGestionnaireDto } from '../dto/create_gestionnaire.dto'; import { UpdateGestionnaireDto } from '../dto/update_gestionnaire.dto'; import * as bcrypt from 'bcrypt'; +import { MailService } from 'src/modules/mail/mail.service'; @Injectable() export class GestionnairesService { constructor( @InjectRepository(Users) private readonly gestionnaireRepository: Repository, + private readonly mailService: MailService, ) { } // CrĂ©ation d’un gestionnaire @@ -39,11 +41,26 @@ export class GestionnairesService { date_consentement_photo: dto.date_consentement_photo ? new Date(dto.date_consentement_photo) : undefined, - changement_mdp_obligatoire: dto.changement_mdp_obligatoire ?? false, + changement_mdp_obligatoire: true, // ForcĂ© Ă  true pour les nouveaux gestionnaires role: RoleType.GESTIONNAIRE, relaisId: dto.relaisId, }); - return this.gestionnaireRepository.save(entity); + + const savedUser = await this.gestionnaireRepository.save(entity); + + // Envoi de l'email de bienvenue + try { + await this.mailService.sendGestionnaireWelcomeEmail( + savedUser.email, + savedUser.prenom || '', + savedUser.nom || '', + ); + } catch (error) { + // On ne bloque pas la crĂ©ation si l'envoi d'email Ă©choue, mais on log l'erreur + console.error('Erreur lors de l\'envoi de l\'email de bienvenue au gestionnaire', error); + } + + return savedUser; } // Liste des gestionnaires diff --git a/docs/23_LISTE-TICKETS.md b/docs/23_LISTE-TICKETS.md index ab31cfe..3251059 100644 --- a/docs/23_LISTE-TICKETS.md +++ b/docs/23_LISTE-TICKETS.md @@ -27,7 +27,7 @@ | 14 | [Frontend] Panneau ParamĂštres / Configuration (premiĂšre config + accĂšs permanent) | Ouvert | | 15 | [Frontend] Écran ParamĂštres (accĂšs permanent) | Ouvert | | 16 | [Doc] Documentation configuration on-premise | Ouvert | -| 17–88 | (voir sections ci‑dessous ; #82, #78, #79, #81, #83 ; #86, #87, #88 fermĂ©s en doublon) | — | +| 17 | [Backend] API CrĂ©ation gestionnaire | ✅ TerminĂ© | | 91 | [Frontend] Inscription AM – Branchement soumission formulaire Ă  l'API | Ouvert | | 92 | [Frontend] Dashboard Admin - DonnĂ©es rĂ©elles et branchement API | ✅ TerminĂ© | | 93 | [Frontend] Panneau Admin - Homogeneiser la presentation des onglets | Ouvert | @@ -320,21 +320,22 @@ RĂ©diger la documentation pour aider les collectivitĂ©s Ă  configurer l'applicat ## 🟱 PRIORITÉ 2 : Backend - Authentification & Gestion Comptes -### Ticket #17 : [Backend] API CrĂ©ation gestionnaire +### Ticket #17 : [Backend] API CrĂ©ation gestionnaire ✅ **Estimation** : 3h **Labels** : `backend`, `p2`, `auth` +**Statut** : ✅ TERMINÉ (FermĂ© le 2026-02-23) **Description** : CrĂ©er l'endpoint pour permettre au super admin de crĂ©er des gestionnaires. **TĂąches** : -- [ ] Endpoint `POST /api/v1/gestionnaires` -- [ ] Validation DTO -- [ ] Hash bcrypt -- [ ] Flag `changement_mdp_obligatoire = TRUE` -- [ ] Guards (super_admin only) -- [ ] Email de notification (utiliser MailService avec config dynamique) -- [ ] Tests unitaires +- [x] Endpoint `POST /api/v1/gestionnaires` +- [x] Validation DTO +- [x] Hash bcrypt +- [x] Flag `changement_mdp_obligatoire = TRUE` +- [x] Guards (super_admin only) +- [x] Email de notification (utiliser MailService avec config dynamique) +- [x] Tests unitaires **RĂ©fĂ©rence** : [20_WORKFLOW-CREATION-COMPTE.md](./20_WORKFLOW-CREATION-COMPTE.md#Ă©tape-2--crĂ©ation-dun-gestionnaire) From 4b176b7083d735d867f236bf99b417276b00bfd7 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Mon, 23 Feb 2026 23:06:17 +0100 Subject: [PATCH 08/22] feat: livrer ticket #93 et finaliser #17 avec gestion des Relais (#95) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HomogĂ©nĂ©ise le dashboard admin (onglets/listes/cartes/Ă©tats) via composants rĂ©utilisables, finalise la crĂ©ation gestionnaire cĂŽtĂ© backend, et intĂšgre la gestion des Relais avec rattachement gestionnaire. Co-authored-by: Cursor --- docs/EVOLUTIONS_CDC.md | 22 +- frontend/lib/models/relais_model.dart | 33 + frontend/lib/models/user.dart | 13 + .../admin_dashboardScreen.dart | 52 +- frontend/lib/services/api/api_config.dart | 20 +- frontend/lib/services/relais_service.dart | 97 ++ frontend/lib/services/user_service.dart | 27 +- .../admin/admin_management_widget.dart | 134 +- ...sistante_maternelle_management_widget.dart | 216 ++-- .../admin/common/admin_detail_modal.dart | 138 ++ .../admin/common/admin_list_state.dart | 49 + .../widgets/admin/common/admin_user_card.dart | 134 ++ .../lib/widgets/admin/common/user_list.dart | 45 + .../lib/widgets/admin/dashboard_admin.dart | 130 +- .../lib/widgets/admin/gestionnaire_card.dart | 75 -- .../admin/gestionnaire_management_widget.dart | 208 +-- .../lib/widgets/admin/parametres_panel.dart | 149 ++- .../admin/parent_managmant_widget.dart | 239 ++-- .../admin/relais_management_panel.dart | 1134 +++++++++++++++++ .../widgets/admin/user_management_panel.dart | 176 +++ 20 files changed, 2516 insertions(+), 575 deletions(-) create mode 100644 frontend/lib/models/relais_model.dart create mode 100644 frontend/lib/services/relais_service.dart create mode 100644 frontend/lib/widgets/admin/common/admin_detail_modal.dart create mode 100644 frontend/lib/widgets/admin/common/admin_list_state.dart create mode 100644 frontend/lib/widgets/admin/common/admin_user_card.dart create mode 100644 frontend/lib/widgets/admin/common/user_list.dart delete mode 100644 frontend/lib/widgets/admin/gestionnaire_card.dart create mode 100644 frontend/lib/widgets/admin/relais_management_panel.dart create mode 100644 frontend/lib/widgets/admin/user_management_panel.dart diff --git a/docs/EVOLUTIONS_CDC.md b/docs/EVOLUTIONS_CDC.md index 6496fc6..6ee6d1e 100644 --- a/docs/EVOLUTIONS_CDC.md +++ b/docs/EVOLUTIONS_CDC.md @@ -255,4 +255,24 @@ Pour chaque Ă©volution identifiĂ©e, ce document suivra la structure suivante : #### X.1.3 Impact sur l'application - Modification du flux de sĂ©lection d'image dans les Ă©crans concernĂ©s (ex: `parent_register_step3_screen.dart`). - Ajout potentiel de nouvelles dĂ©pendances et configurations spĂ©cifiques aux plateformes. -- Mise Ă  jour de la documentation utilisateur si cette fonctionnalitĂ© est implĂ©mentĂ©e. \ No newline at end of file +- Mise Ă  jour de la documentation utilisateur si cette fonctionnalitĂ© est implĂ©mentĂ©e. + +## 8. Évolution future - Gouvernance intra-RPE + +### 8.1 Niveaux d'accĂšs et rĂŽles diffĂ©renciĂ©s dans un mĂȘme Relais + +#### 8.1.1 Situation actuelle +- Le pĂ©rimĂštre actuel prĂ©voit un rattachement simple entre gestionnaire et relais. +- Le rĂŽle "gestionnaire" est traitĂ© de maniĂšre uniforme dans l'outil. + +#### 8.1.2 Évolution Ă  prĂ©voir +- Introduire un modĂšle de rĂŽles internes au relais (par exemple : responsable/coordinatrice, animatrice/rĂ©fĂ©rente, administratif). +- Permettre des niveaux d'autoritĂ© diffĂ©rents selon les actions (pilotage, validation, consultation, administration locale). +- DĂ©finir des permissions fines par fonctionnalitĂ© (lecture, crĂ©ation, modification, suppression, validation). +- PrĂ©voir une gestion multi-utilisateurs par relais avec traçabilitĂ© des dĂ©cisions. + +#### 8.1.3 Impact attendu +- É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 diff --git a/frontend/lib/models/relais_model.dart b/frontend/lib/models/relais_model.dart new file mode 100644 index 0000000..f3d199e --- /dev/null +++ b/frontend/lib/models/relais_model.dart @@ -0,0 +1,33 @@ +class RelaisModel { + final String id; + final String nom; + final String adresse; + final Map? horairesOuverture; + final String? ligneFixe; + final bool actif; + final String? notes; + + const RelaisModel({ + required this.id, + required this.nom, + required this.adresse, + this.horairesOuverture, + this.ligneFixe, + required this.actif, + this.notes, + }); + + factory RelaisModel.fromJson(Map json) { + return RelaisModel( + id: (json['id'] ?? '').toString(), + nom: (json['nom'] ?? '').toString(), + adresse: (json['adresse'] ?? '').toString(), + horairesOuverture: json['horaires_ouverture'] is Map + ? json['horaires_ouverture'] as Map + : null, + ligneFixe: json['ligne_fixe'] as String?, + actif: json['actif'] as bool? ?? true, + notes: json['notes'] as String?, + ); + } +} diff --git a/frontend/lib/models/user.dart b/frontend/lib/models/user.dart index 7ecbe79..a07a176 100644 --- a/frontend/lib/models/user.dart +++ b/frontend/lib/models/user.dart @@ -13,6 +13,8 @@ class AppUser { final String? adresse; final String? ville; final String? codePostal; + final String? relaisId; + final String? relaisNom; AppUser({ required this.id, @@ -29,9 +31,15 @@ class AppUser { this.adresse, this.ville, this.codePostal, + this.relaisId, + this.relaisNom, }); factory AppUser.fromJson(Map json) { + final relaisJson = json['relais']; + final relaisMap = + relaisJson is Map ? relaisJson : {}; + return AppUser( id: json['id'] as String, email: json['email'] as String, @@ -56,6 +64,9 @@ class AppUser { adresse: json['adresse'] as String?, ville: json['ville'] as String?, codePostal: json['code_postal'] as String?, + relaisId: (json['relaisId'] ?? json['relais_id'] ?? relaisMap['id']) + ?.toString(), + relaisNom: relaisMap['nom']?.toString(), ); } @@ -75,6 +86,8 @@ class AppUser { 'adresse': adresse, 'ville': ville, 'code_postal': codePostal, + 'relais_id': relaisId, + 'relais_nom': relaisNom, }; } diff --git a/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart b/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart index 4707621..892eb8b 100644 --- a/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart +++ b/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart @@ -1,10 +1,7 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/services/configuration_service.dart'; -import 'package:p_tits_pas/widgets/admin/assistante_maternelle_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/admin_management_widget.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'; import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart'; @@ -18,7 +15,7 @@ class AdminDashboardScreen extends StatefulWidget { class _AdminDashboardScreenState extends State { bool? _setupCompleted; int mainTabIndex = 0; - int subIndex = 0; + int settingsSubIndex = 0; @override void initState() { @@ -26,6 +23,11 @@ class _AdminDashboardScreenState extends State { _loadSetupStatus(); } + @override + void dispose() { + super.dispose(); + } + Future _loadSetupStatus() async { try { final completed = await ConfigurationService.getSetupStatus(); @@ -35,10 +37,12 @@ class _AdminDashboardScreenState extends State { if (!completed) mainTabIndex = 1; }); } catch (e) { - if (mounted) setState(() { - _setupCompleted = false; - mainTabIndex = 1; - }); + if (mounted) { + setState(() { + _setupCompleted = false; + mainTabIndex = 1; + }); + } } } @@ -48,9 +52,9 @@ class _AdminDashboardScreenState extends State { }); } - void onSubTabChange(int index) { + void onSettingsSubTabChange(int index) { setState(() { - subIndex = index; + settingsSubIndex = index; }); } @@ -80,9 +84,11 @@ class _AdminDashboardScreenState extends State { body: Column( children: [ if (mainTabIndex == 0) - DashboardUserManagementSubBar( - selectedSubIndex: subIndex, - onSubTabChange: onSubTabChange, + const SizedBox.shrink() + else + DashboardSettingsSubBar( + selectedSubIndex: settingsSubIndex, + onSubTabChange: onSettingsSubTabChange, ), Expanded( child: _getBody(), @@ -95,19 +101,11 @@ class _AdminDashboardScreenState extends State { Widget _getBody() { if (mainTabIndex == 1) { - return ParametresPanel(redirectToLoginAfterSave: !_setupCompleted!); - } - switch (subIndex) { - case 0: - return const GestionnaireManagementWidget(); - case 1: - return const ParentManagementWidget(); - case 2: - return const AssistanteMaternelleManagementWidget(); - case 3: - return const AdminManagementWidget(); - default: - return const Center(child: Text('Page non trouvĂ©e')); + return ParametresPanel( + redirectToLoginAfterSave: !_setupCompleted!, + selectedSettingsTabIndex: settingsSubIndex, + ); } + return const AdminUserManagementPanel(); } } diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index a9b48fe..613146d 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -18,11 +18,13 @@ class ApiConfig { static const String gestionnaires = '/gestionnaires'; static const String parents = '/parents'; static const String assistantesMaternelles = '/assistantes-maternelles'; + static const String relais = '/relais'; // Configuration (admin) static const String configuration = '/configuration'; static const String configurationSetupStatus = '/configuration/setup/status'; - static const String configurationSetupComplete = '/configuration/setup/complete'; + static const String configurationSetupComplete = + '/configuration/setup/complete'; static const String configurationTestSmtp = '/configuration/test-smtp'; static const String configurationBulk = '/configuration/bulk'; @@ -33,14 +35,14 @@ class ApiConfig { static const String conversations = '/conversations'; static const String notifications = '/notifications'; - // Headers + // Headers static Map get headers => { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }; + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }; static Map authHeaders(String token) => { - ...headers, - 'Authorization': 'Bearer $token', - }; -} \ No newline at end of file + ...headers, + 'Authorization': 'Bearer $token', + }; +} diff --git a/frontend/lib/services/relais_service.dart b/frontend/lib/services/relais_service.dart new file mode 100644 index 0000000..d873965 --- /dev/null +++ b/frontend/lib/services/relais_service.dart @@ -0,0 +1,97 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:p_tits_pas/models/relais_model.dart'; +import 'package:p_tits_pas/services/api/api_config.dart'; +import 'package:p_tits_pas/services/api/tokenService.dart'; + +class RelaisService { + static Future> _headers() async { + final token = await TokenService.getToken(); + return token != null + ? ApiConfig.authHeaders(token) + : Map.from(ApiConfig.headers); + } + + static String _extractError(String body, String fallback) { + try { + final decoded = jsonDecode(body); + if (decoded is Map) { + final message = decoded['message']; + if (message is String && message.trim().isNotEmpty) { + return message; + } + } + } catch (_) {} + return fallback; + } + + static Future> getRelais() async { + final response = await http.get( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.relais}'), + headers: await _headers(), + ); + + if (response.statusCode != 200) { + throw Exception( + _extractError(response.body, 'Erreur chargement relais'), + ); + } + + final List data = jsonDecode(response.body); + return data + .whereType>() + .map(RelaisModel.fromJson) + .toList(); + } + + static Future createRelais(Map payload) async { + final response = await http.post( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.relais}'), + headers: await _headers(), + body: jsonEncode(payload), + ); + + if (response.statusCode != 201 && response.statusCode != 200) { + throw Exception( + _extractError(response.body, 'Erreur crĂ©ation relais'), + ); + } + + return RelaisModel.fromJson( + jsonDecode(response.body) as Map); + } + + static Future updateRelais( + String id, + Map payload, + ) async { + final response = await http.patch( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.relais}/$id'), + headers: await _headers(), + body: jsonEncode(payload), + ); + + if (response.statusCode != 200) { + throw Exception( + _extractError(response.body, 'Erreur mise Ă  jour relais'), + ); + } + + return RelaisModel.fromJson( + jsonDecode(response.body) as Map); + } + + static Future deleteRelais(String id) async { + final response = await http.delete( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.relais}/$id'), + headers: await _headers(), + ); + + if (response.statusCode != 200 && response.statusCode != 204) { + throw Exception( + _extractError(response.body, 'Erreur suppression relais'), + ); + } + } +} diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index 80c9cbd..da4f64e 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -29,7 +29,8 @@ class UserService { if (response.statusCode != 200) { final err = jsonDecode(response.body) as Map?; - throw Exception(_toStr(err?['message']) ?? 'Erreur chargement gestionnaires'); + throw Exception( + _toStr(err?['message']) ?? 'Erreur chargement gestionnaires'); } final List data = jsonDecode(response.body); @@ -53,7 +54,8 @@ class UserService { } // RĂ©cupĂ©rer la liste des assistantes maternelles - static Future> getAssistantesMaternelles() async { + static Future> + getAssistantesMaternelles() async { final response = await http.get( Uri.parse('${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}'), headers: await _headers(), @@ -87,8 +89,27 @@ class UserService { .toList(); } } catch (e) { - print('Erreur chargement admins: $e'); + // On garde un fallback vide pour ne pas bloquer l'UI admin. } return []; } + + static Future updateGestionnaireRelais({ + required String gestionnaireId, + required String? relaisId, + }) async { + final response = await http.patch( + Uri.parse( + '${ApiConfig.baseUrl}${ApiConfig.gestionnaires}/$gestionnaireId'), + headers: await _headers(), + body: jsonEncode({'relaisId': relaisId}), + ); + + if (response.statusCode != 200 && response.statusCode != 204) { + final err = jsonDecode(response.body) as Map?; + throw Exception( + _toStr(err?['message']) ?? 'Erreur rattachement relais au gestionnaire', + ); + } + } } diff --git a/frontend/lib/widgets/admin/admin_management_widget.dart b/frontend/lib/widgets/admin/admin_management_widget.dart index 58e5eeb..666a64e 100644 --- a/frontend/lib/widgets/admin/admin_management_widget.dart +++ b/frontend/lib/widgets/admin/admin_management_widget.dart @@ -1,9 +1,16 @@ import 'package:flutter/material.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_user_card.dart'; +import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; class AdminManagementWidget extends StatefulWidget { - const AdminManagementWidget({super.key}); + final String searchQuery; + + const AdminManagementWidget({ + super.key, + required this.searchQuery, + }); @override State createState() => _AdminManagementWidgetState(); @@ -13,21 +20,15 @@ class _AdminManagementWidgetState extends State { bool _isLoading = false; String? _error; List _admins = []; - List _filteredAdmins = []; - final TextEditingController _searchController = TextEditingController(); @override void initState() { super.initState(); _loadAdmins(); - _searchController.addListener(_onSearchChanged); } @override - void dispose() { - _searchController.dispose(); - super.dispose(); - } + void dispose() => super.dispose(); Future _loadAdmins() async { setState(() { @@ -39,7 +40,6 @@ class _AdminManagementWidgetState extends State { if (!mounted) return; setState(() { _admins = list; - _filteredAdmins = list; _isLoading = false; }); } catch (e) { @@ -51,91 +51,41 @@ class _AdminManagementWidgetState extends State { } } - void _onSearchChanged() { - final query = _searchController.text.toLowerCase(); - setState(() { - _filteredAdmins = _admins.where((u) { - final name = u.fullName.toLowerCase(); - final email = u.email.toLowerCase(); - return name.contains(query) || email.contains(query); - }).toList(); - }); - } - @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded( - child: TextField( - controller: _searchController, - decoration: const InputDecoration( - hintText: "Rechercher un administrateur...", - prefixIcon: Icon(Icons.search), - border: OutlineInputBorder(), - ), - ), - ), - const SizedBox(width: 16), - ElevatedButton.icon( - onPressed: () { - // TODO: CrĂ©er admin - }, - icon: const Icon(Icons.add), - label: const Text("CrĂ©er un admin"), - ), - ], - ), - const SizedBox(height: 24), - if (_isLoading) - const Center(child: CircularProgressIndicator()) - else if (_error != null) - Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red))) - else if (_filteredAdmins.isEmpty) - const Center(child: Text("Aucun administrateur trouvĂ©.")) - else - Expanded( - child: ListView.builder( - itemCount: _filteredAdmins.length, - itemBuilder: (context, index) { - final user = _filteredAdmins[index]; - return Card( - margin: const EdgeInsets.only(bottom: 12), - child: ListTile( - leading: CircleAvatar( - child: Text(user.fullName.isNotEmpty - ? user.fullName[0].toUpperCase() - : 'A'), - ), - title: Text(user.fullName.isNotEmpty - ? user.fullName - : 'Sans nom'), - subtitle: Text(user.email), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.edit), - onPressed: () {}, - ), - IconButton( - icon: const Icon(Icons.delete), - onPressed: () {}, - ), - ], - ), - ), - ); - }, - ), - ) - ], - ), + final query = widget.searchQuery.toLowerCase(); + final filteredAdmins = _admins.where((u) { + final name = u.fullName.toLowerCase(); + final email = u.email.toLowerCase(); + return name.contains(query) || email.contains(query); + }).toList(); + + return UserList( + isLoading: _isLoading, + error: _error, + isEmpty: filteredAdmins.isEmpty, + emptyMessage: 'Aucun administrateur trouvĂ©.', + itemCount: filteredAdmins.length, + itemBuilder: (context, index) { + final user = filteredAdmins[index]; + return AdminUserCard( + title: user.fullName, + subtitleLines: [ + user.email, + 'RĂŽle : ${user.role}', + ], + avatarUrl: user.photoUrl, + actions: [ + IconButton( + icon: const Icon(Icons.edit), + tooltip: 'Modifier', + onPressed: () { + // TODO: Modifier admin + }, + ), + ], + ); + }, ); } } diff --git a/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart b/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart index c8e1a19..d655055 100644 --- a/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart +++ b/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart @@ -1,9 +1,19 @@ 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/widgets/admin/common/admin_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'; class AssistanteMaternelleManagementWidget extends StatefulWidget { - const AssistanteMaternelleManagementWidget({super.key}); + final String searchQuery; + final int? capacityMin; + + const AssistanteMaternelleManagementWidget({ + super.key, + required this.searchQuery, + this.capacityMin, + }); @override State createState() => @@ -15,25 +25,15 @@ class _AssistanteMaternelleManagementWidgetState bool _isLoading = false; String? _error; List _assistantes = []; - List _filteredAssistantes = []; - - final TextEditingController _zoneController = TextEditingController(); - final TextEditingController _capacityController = TextEditingController(); @override void initState() { super.initState(); _loadAssistantes(); - _zoneController.addListener(_filter); - _capacityController.addListener(_filter); } @override - void dispose() { - _zoneController.dispose(); - _capacityController.dispose(); - super.dispose(); - } + void dispose() => super.dispose(); Future _loadAssistantes() async { setState(() { @@ -45,7 +45,6 @@ class _AssistanteMaternelleManagementWidgetState if (!mounted) return; setState(() { _assistantes = list; - _filter(); _isLoading = false; }); } catch (e) { @@ -57,117 +56,100 @@ class _AssistanteMaternelleManagementWidgetState } } - void _filter() { - final zoneQuery = _zoneController.text.toLowerCase(); - final capacityQuery = int.tryParse(_capacityController.text); - - setState(() { - _filteredAssistantes = _assistantes.where((am) { - final matchesZone = zoneQuery.isEmpty || - (am.residenceCity?.toLowerCase().contains(zoneQuery) ?? false); - final matchesCapacity = capacityQuery == null || - (am.maxChildren != null && am.maxChildren! >= capacityQuery); - return matchesZone && matchesCapacity; - }).toList(); - }); - } - @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 🔎 Zone de filtre - _buildFilterSection(), + final query = widget.searchQuery.toLowerCase(); + final filteredAssistantes = _assistantes.where((am) { + final matchesName = am.user.fullName.toLowerCase().contains(query) || + am.user.email.toLowerCase().contains(query) || + (am.residenceCity?.toLowerCase().contains(query) ?? false); + final matchesCapacity = widget.capacityMin == null || + (am.maxChildren != null && am.maxChildren! >= widget.capacityMin!); + return matchesName && matchesCapacity; + }).toList(); - const SizedBox(height: 16), - - // 📋 Liste des assistantes - if (_isLoading) - const Center(child: CircularProgressIndicator()) - else if (_error != null) - Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red))) - else if (_filteredAssistantes.isEmpty) - const Center(child: Text("Aucune assistante maternelle trouvĂ©e.")) - else - Expanded( - child: ListView.builder( - itemCount: _filteredAssistantes.length, - itemBuilder: (context, index) { - final assistante = _filteredAssistantes[index]; - return Card( - margin: const EdgeInsets.symmetric(vertical: 8), - child: ListTile( - leading: CircleAvatar( - backgroundImage: assistante.user.photoUrl != null - ? NetworkImage(assistante.user.photoUrl!) - : null, - child: assistante.user.photoUrl == null - ? const Icon(Icons.face) - : null, - ), - title: Text(assistante.user.fullName.isNotEmpty - ? assistante.user.fullName - : 'Sans nom'), - subtitle: Text( - "N° AgrĂ©ment : ${assistante.approvalNumber ?? 'N/A'}\nZone : ${assistante.residenceCity ?? 'N/A'} | CapacitĂ© : ${assistante.maxChildren ?? 0}"), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.edit), - onPressed: () { - // TODO: Ajouter modification - }, - ), - IconButton( - icon: const Icon(Icons.delete), - onPressed: () { - // TODO: Ajouter suppression - }, - ), - ], - ), - ), - ); - }, - ), + return UserList( + isLoading: _isLoading, + error: _error, + isEmpty: filteredAssistantes.isEmpty, + emptyMessage: 'Aucune assistante maternelle trouvĂ©e.', + itemCount: filteredAssistantes.length, + itemBuilder: (context, index) { + final assistante = filteredAssistantes[index]; + return AdminUserCard( + title: assistante.user.fullName, + avatarUrl: assistante.user.photoUrl, + fallbackIcon: Icons.face, + subtitleLines: [ + assistante.user.email, + 'Zone : ${assistante.residenceCity ?? 'N/A'} | CapacitĂ© : ${assistante.maxChildren ?? 0}', + ], + actions: [ + IconButton( + icon: const Icon(Icons.edit), + tooltip: 'Modifier', + onPressed: () { + _openAssistanteDetails(assistante); + }, ), + ], + ); + }, + ); + } + + 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), + ), + 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')), + ); + }, ), ); } - Widget _buildFilterSection() { - return Wrap( - spacing: 16, - runSpacing: 8, - children: [ - SizedBox( - width: 200, - child: TextField( - controller: _zoneController, - decoration: const InputDecoration( - labelText: "Zone gĂ©ographique", - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.location_on), - ), - ), - ), - SizedBox( - width: 200, - child: TextField( - controller: _capacityController, - decoration: const InputDecoration( - labelText: "CapacitĂ© minimum", - border: OutlineInputBorder(), - ), - keyboardType: TextInputType.number, - ), - ), - ], - ); - } + String _v(String? value) => (value == null || value.isEmpty) ? '-' : value; } diff --git a/frontend/lib/widgets/admin/common/admin_detail_modal.dart b/frontend/lib/widgets/admin/common/admin_detail_modal.dart new file mode 100644 index 0000000..affc0d8 --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_detail_modal.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; + +class AdminDetailField { + final String label; + final String value; + + const AdminDetailField({ + required this.label, + required this.value, + }); +} + +class AdminDetailModal extends StatelessWidget { + final String title; + final String? subtitle; + final List fields; + final VoidCallback onEdit; + final VoidCallback onDelete; + + const AdminDetailModal({ + super.key, + required this.title, + this.subtitle, + required this.fields, + required this.onEdit, + required this.onDelete, + }); + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 620), + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + if (subtitle != null && subtitle!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + subtitle!, + style: const TextStyle(color: Colors.black54), + ), + ], + ], + ), + ), + IconButton( + tooltip: 'Fermer', + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + const SizedBox(height: 8), + const Divider(height: 1), + const SizedBox(height: 12), + Flexible( + child: SingleChildScrollView( + child: Column( + children: fields + .map( + (field) => Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 180, + child: Text( + field.label, + style: const TextStyle( + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + ), + Expanded( + child: Text( + field.value, + style: const TextStyle(color: Colors.black87), + ), + ), + ], + ), + ), + ) + .toList(), + ), + ), + ), + const SizedBox(height: 14), + Align( + alignment: Alignment.centerRight, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + OutlinedButton.icon( + onPressed: onDelete, + icon: const Icon(Icons.delete_outline), + label: const Text('Supprimer'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red.shade700, + side: BorderSide(color: Colors.red.shade300), + ), + ), + const SizedBox(width: 10), + ElevatedButton.icon( + onPressed: onEdit, + icon: const Icon(Icons.edit), + label: const Text('Modifier'), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_list_state.dart b/frontend/lib/widgets/admin/common/admin_list_state.dart new file mode 100644 index 0000000..41b497c --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_list_state.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +class AdminListState extends StatelessWidget { + final bool isLoading; + final String? error; + final bool isEmpty; + final String emptyMessage; + final Widget list; + + const AdminListState({ + super.key, + required this.isLoading, + required this.error, + required this.isEmpty, + required this.emptyMessage, + required this.list, + }); + + @override + Widget build(BuildContext context) { + if (isLoading) { + return const Expanded( + child: Center(child: CircularProgressIndicator()), + ); + } + + if (error != null) { + return Expanded( + child: Center( + child: Text( + 'Erreur: $error', + style: const TextStyle(color: Colors.red), + textAlign: TextAlign.center, + ), + ), + ); + } + + if (isEmpty) { + return Expanded( + child: Center( + child: Text(emptyMessage), + ), + ); + } + + return Expanded(child: list); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_user_card.dart b/frontend/lib/widgets/admin/common/admin_user_card.dart new file mode 100644 index 0000000..914e218 --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_user_card.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; + +class AdminUserCard extends StatefulWidget { + final String title; + final List subtitleLines; + final String? avatarUrl; + final IconData fallbackIcon; + final List actions; + + const AdminUserCard({ + super.key, + required this.title, + required this.subtitleLines, + this.avatarUrl, + this.fallbackIcon = Icons.person, + this.actions = const [], + }); + + @override + State createState() => _AdminUserCardState(); +} + +class _AdminUserCardState extends State { + bool _isHovered = false; + + @override + Widget build(BuildContext context) { + final infoLine = + widget.subtitleLines.where((e) => e.trim().isNotEmpty).join(' '); + final actionsWidth = + widget.actions.isNotEmpty ? widget.actions.length * 30.0 : 0.0; + + return MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(10), + child: InkWell( + onTap: () {}, + 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: 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, + ), + const SizedBox(width: 10), + Expanded( + child: Row( + children: [ + Flexible( + fit: FlexFit.loose, + child: Text( + widget.title.isNotEmpty ? widget.title : 'Sans nom', + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + infoLine, + style: const TextStyle( + color: Colors.black54, + fontSize: 12, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + if (widget.actions.isNotEmpty) + SizedBox( + width: actionsWidth, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 120), + opacity: _isHovered ? 1 : 0, + child: IgnorePointer( + ignoring: !_isHovered, + child: IconTheme( + data: const IconThemeData(size: 17), + child: IconButtonTheme( + data: IconButtonThemeData( + style: IconButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.all(4), + minimumSize: const Size(28, 28), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: widget.actions, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/common/user_list.dart b/frontend/lib/widgets/admin/common/user_list.dart new file mode 100644 index 0000000..1df400c --- /dev/null +++ b/frontend/lib/widgets/admin/common/user_list.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_list_state.dart'; + +class UserList extends StatelessWidget { + final bool isLoading; + final String? error; + final bool isEmpty; + final String emptyMessage; + final int itemCount; + final Widget Function(BuildContext context, int index) itemBuilder; + final EdgeInsetsGeometry padding; + + const UserList({ + super.key, + required this.isLoading, + required this.error, + required this.isEmpty, + required this.emptyMessage, + required this.itemCount, + required this.itemBuilder, + this.padding = const EdgeInsets.all(16), + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: padding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AdminListState( + isLoading: isLoading, + error: error, + isEmpty: isEmpty, + emptyMessage: emptyMessage, + list: ListView.builder( + itemCount: itemCount, + itemBuilder: itemBuilder, + ), + ), + ], + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/dashboard_admin.dart b/frontend/lib/widgets/admin/dashboard_admin.dart index 12fbe8b..ff1aa3b 100644 --- a/frontend/lib/widgets/admin/dashboard_admin.dart +++ b/frontend/lib/widgets/admin/dashboard_admin.dart @@ -3,7 +3,8 @@ import 'package:go_router/go_router.dart'; import 'package:p_tits_pas/services/auth_service.dart'; /// Barre du dashboard admin : onglets Gestion des utilisateurs | ParamĂštres + dĂ©connexion. -class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidget { +class DashboardAppBarAdmin extends StatelessWidget + implements PreferredSizeWidget { final int selectedIndex; final ValueChanged onTabChange; final bool setupCompleted; @@ -36,7 +37,8 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge child: Row( mainAxisSize: MainAxisSize.min, children: [ - _buildNavItem(context, 'Gestion des utilisateurs', 0, enabled: setupCompleted), + _buildNavItem(context, 'Gestion des utilisateurs', 0, + enabled: setupCompleted), const SizedBox(width: 24), _buildNavItem(context, 'ParamĂštres', 1, enabled: true), ], @@ -78,7 +80,8 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge ); } - Widget _buildNavItem(BuildContext context, String title, int index, {bool enabled = true}) { + Widget _buildNavItem(BuildContext context, String title, int index, + {bool enabled = true}) { final bool isActive = index == selectedIndex; return InkWell( onTap: enabled ? () => onTabChange(index) : null, @@ -133,11 +136,124 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge class DashboardUserManagementSubBar extends StatelessWidget { final int selectedSubIndex; final ValueChanged onSubTabChange; + final TextEditingController searchController; + final String searchHint; + final Widget? filterControl; + final VoidCallback? onAddPressed; + final String addLabel; const DashboardUserManagementSubBar({ Key? key, required this.selectedSubIndex, required this.onSubTabChange, + required this.searchController, + required this.searchHint, + this.filterControl, + this.onAddPressed, + this.addLabel = '+ Ajouter', + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + height: 56, + decoration: BoxDecoration( + color: Colors.grey.shade100, + border: Border(bottom: BorderSide(color: Colors.grey.shade300)), + ), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6), + child: Row( + children: [ + _buildSubNavItem(context, 'Gestionnaires', 0), + const SizedBox(width: 12), + _buildSubNavItem(context, 'Parents', 1), + const SizedBox(width: 12), + _buildSubNavItem(context, 'Assistantes maternelles', 2), + const SizedBox(width: 12), + _buildSubNavItem(context, 'Administrateurs', 3), + 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, + ), + ), + ), + ), + if (filterControl != null) ...[ + const SizedBox(width: 12), + _pillField(width: 150, child: filterControl!), + ], + const Spacer(), + _buildAddButton(), + ], + ), + ); + } + + Widget _pillField({required double width, required Widget child}) { + return Container( + width: width, + height: 34, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: Colors.black26), + ), + alignment: Alignment.centerLeft, + child: child, + ); + } + + Widget _buildAddButton() { + return ElevatedButton.icon( + onPressed: onAddPressed, + icon: const Icon(Icons.add), + label: Text(addLabel), + ); + } + + Widget _buildSubNavItem(BuildContext context, String title, int index) { + final bool isActive = index == selectedSubIndex; + return InkWell( + onTap: () => onSubTabChange(index), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: BoxDecoration( + color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent, + borderRadius: BorderRadius.circular(16), + border: isActive ? null : Border.all(color: Colors.black26), + ), + child: Text( + title, + style: TextStyle( + color: isActive ? Colors.white : Colors.black87, + fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, + fontSize: 13, + ), + ), + ), + ); + } +} + +/// Sous-barre ParamĂštres : ParamĂštres gĂ©nĂ©raux | ParamĂštres territoriaux. +class DashboardSettingsSubBar extends StatelessWidget { + final int selectedSubIndex; + final ValueChanged onSubTabChange; + + const DashboardSettingsSubBar({ + Key? key, + required this.selectedSubIndex, + required this.onSubTabChange, }) : super(key: key); @override @@ -153,13 +269,9 @@ class DashboardUserManagementSubBar extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - _buildSubNavItem(context, 'Gestionnaires', 0), + _buildSubNavItem(context, 'ParamĂštres gĂ©nĂ©raux', 0), const SizedBox(width: 16), - _buildSubNavItem(context, 'Parents', 1), - const SizedBox(width: 16), - _buildSubNavItem(context, 'Assistantes maternelles', 2), - const SizedBox(width: 16), - _buildSubNavItem(context, 'Administrateurs', 3), + _buildSubNavItem(context, 'ParamĂštres territoriaux', 1), ], ), ), diff --git a/frontend/lib/widgets/admin/gestionnaire_card.dart b/frontend/lib/widgets/admin/gestionnaire_card.dart deleted file mode 100644 index 5d80255..0000000 --- a/frontend/lib/widgets/admin/gestionnaire_card.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:flutter/material.dart'; - -class GestionnaireCard extends StatelessWidget { - final String name; - final String email; - - const GestionnaireCard({ - Key? key, - required this.name, - required this.email, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Card( - margin: const EdgeInsets.only(bottom: 12), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // đŸ”č Infos principales - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(name, style: const TextStyle(fontWeight: FontWeight.bold)), - Text(email, style: const TextStyle(color: Colors.grey)), - ], - ), - const SizedBox(height: 12), - - // đŸ”č Attribution Ă  des RPE (dropdown fictif ici) - Row( - children: [ - const Text("RPE attribuĂ© : "), - const SizedBox(width: 8), - DropdownButton( - value: "RPE 1", - items: const [ - DropdownMenuItem(value: "RPE 1", child: Text("RPE 1")), - DropdownMenuItem(value: "RPE 2", child: Text("RPE 2")), - DropdownMenuItem(value: "RPE 3", child: Text("RPE 3")), - ], - onChanged: (value) {}, - ), - ], - ), - const SizedBox(height: 12), - - // đŸ”č Boutons d'action - Row( - children: [ - TextButton.icon( - onPressed: () { - // RĂ©initialisation mot de passe - }, - icon: const Icon(Icons.lock_reset), - label: const Text("RĂ©initialiser MDP"), - ), - const SizedBox(width: 12), - TextButton.icon( - onPressed: () { - // Suppression du compte - }, - icon: const Icon(Icons.delete, color: Colors.red), - label: const Text("Supprimer", style: TextStyle(color: Colors.red)), - ), - ], - ) - ], - ), - ), - ); - } -} diff --git a/frontend/lib/widgets/admin/gestionnaire_management_widget.dart b/frontend/lib/widgets/admin/gestionnaire_management_widget.dart index 82fa52f..80d5d91 100644 --- a/frontend/lib/widgets/admin/gestionnaire_management_widget.dart +++ b/frontend/lib/widgets/admin/gestionnaire_management_widget.dart @@ -1,10 +1,18 @@ 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/relais_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; -import 'package:p_tits_pas/widgets/admin/gestionnaire_card.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; +import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; class GestionnaireManagementWidget extends StatefulWidget { - const GestionnaireManagementWidget({Key? key}) : super(key: key); + final String searchQuery; + + const GestionnaireManagementWidget({ + Key? key, + required this.searchQuery, + }) : super(key: key); @override State createState() => @@ -16,21 +24,16 @@ class _GestionnaireManagementWidgetState bool _isLoading = false; String? _error; List _gestionnaires = []; - List _filteredGestionnaires = []; - final TextEditingController _searchController = TextEditingController(); + List _relais = []; @override void initState() { super.initState(); _loadGestionnaires(); - _searchController.addListener(_onSearchChanged); } @override - void dispose() { - _searchController.dispose(); - super.dispose(); - } + void dispose() => super.dispose(); Future _loadGestionnaires() async { setState(() { @@ -38,11 +41,18 @@ class _GestionnaireManagementWidgetState _error = null; }); try { - final list = await UserService.getGestionnaires(); + final gestionnaires = await UserService.getGestionnaires(); + List relais = []; + try { + relais = await RelaisService.getRelais(); + } catch (_) { + // L'ecran reste utilisable meme si la route Relais n'est pas disponible. + } + if (!mounted) return; setState(() { - _gestionnaires = list; - _filteredGestionnaires = list; + _gestionnaires = gestionnaires; + _relais = relais; _isLoading = false; }); } catch (e) { @@ -54,71 +64,125 @@ class _GestionnaireManagementWidgetState } } - void _onSearchChanged() { - final query = _searchController.text.toLowerCase(); - setState(() { - _filteredGestionnaires = _gestionnaires.where((u) { - final name = u.fullName.toLowerCase(); - final email = u.email.toLowerCase(); - return name.contains(query) || email.contains(query); - }).toList(); - }); + Future _openRelaisAssignmentDialog(AppUser user) async { + String? selectedRelaisId = user.relaisId; + final saved = await showDialog( + context: context, + builder: (ctx) { + return StatefulBuilder( + builder: (context, setStateDialog) { + return AlertDialog( + title: Text( + 'Rattacher ${user.fullName.isEmpty ? user.email : user.fullName}', + ), + content: DropdownButtonFormField( + value: selectedRelaisId, + isExpanded: true, + 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: (value) { + setStateDialog(() { + selectedRelaisId = value; + }); + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Annuler'), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Enregistrer'), + ), + ], + ); + }, + ); + }, + ); + + if (saved != true) return; + + try { + await UserService.updateGestionnaireRelais( + gestionnaireId: user.id, + relaisId: selectedRelaisId, + ); + if (!mounted) return; + await _loadGestionnaires(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Rattachement relais mis a jour.')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + e.toString().replaceAll('Exception: ', ''), + ), + backgroundColor: Colors.red.shade600, + ), + ); + } } @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // đŸ”č Barre du haut avec bouton - Row( - children: [ - Expanded( - child: TextField( - controller: _searchController, - decoration: const InputDecoration( - hintText: "Rechercher un gestionnaire...", - prefixIcon: Icon(Icons.search), - border: OutlineInputBorder(), - ), - ), - ), - const SizedBox(width: 16), - ElevatedButton.icon( - onPressed: () { - // TODO: Rediriger vers la page de crĂ©ation - }, - icon: const Icon(Icons.add), - label: const Text("CrĂ©er un gestionnaire"), - ), - ], - ), - const SizedBox(height: 24), + final query = widget.searchQuery.toLowerCase(); + final filteredGestionnaires = _gestionnaires.where((u) { + final name = u.fullName.toLowerCase(); + final email = u.email.toLowerCase(); + return name.contains(query) || email.contains(query); + }).toList(); - // đŸ”č Liste des gestionnaires - if (_isLoading) - const Center(child: CircularProgressIndicator()) - else if (_error != null) - Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red))) - else if (_filteredGestionnaires.isEmpty) - const Center(child: Text("Aucun gestionnaire trouvĂ©.")) - else - Expanded( - child: ListView.builder( - itemCount: _filteredGestionnaires.length, - itemBuilder: (context, index) { - final user = _filteredGestionnaires[index]; - return GestionnaireCard( - name: user.fullName.isNotEmpty ? user.fullName : "Sans nom", - email: user.email, - ); - }, - ), - ) - ], - ), + return UserList( + isLoading: _isLoading, + error: _error, + isEmpty: filteredGestionnaires.isEmpty, + emptyMessage: 'Aucun gestionnaire trouvĂ©.', + itemCount: filteredGestionnaires.length, + itemBuilder: (context, index) { + final user = filteredGestionnaires[index]; + return AdminUserCard( + title: user.fullName, + avatarUrl: user.photoUrl, + subtitleLines: [ + user.email, + 'Statut : ${user.statut ?? 'Inconnu'}', + 'Relais : ${user.relaisNom ?? 'Non rattachĂ©'}', + ], + actions: [ + IconButton( + icon: const Icon(Icons.location_city_outlined), + tooltip: 'Rattacher un relais', + onPressed: () => _openRelaisAssignmentDialog(user), + ), + IconButton( + icon: const Icon(Icons.edit), + tooltip: 'Modifier', + onPressed: () { + // TODO: Modifier gestionnaire. + }, + ), + ], + ); + }, ); } } diff --git a/frontend/lib/widgets/admin/parametres_panel.dart b/frontend/lib/widgets/admin/parametres_panel.dart index 22e7b7c..6c445d8 100644 --- a/frontend/lib/widgets/admin/parametres_panel.dart +++ b/frontend/lib/widgets/admin/parametres_panel.dart @@ -1,13 +1,19 @@ 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/widgets/admin/relais_management_panel.dart'; /// Panneau ParamĂštres admin : Email (SMTP), Personnalisation, AvancĂ©. class ParametresPanel extends StatefulWidget { /// Si true, aprĂšs sauvegarde on redirige vers le login (premiĂšre config). Sinon on reste sur la page. final bool redirectToLoginAfterSave; + final int selectedSettingsTabIndex; - const ParametresPanel({super.key, this.redirectToLoginAfterSave = false}); + const ParametresPanel({ + super.key, + this.redirectToLoginAfterSave = false, + this.selectedSettingsTabIndex = 0, + }); @override State createState() => _ParametresPanelState(); @@ -33,10 +39,18 @@ class _ParametresPanelState extends State { void _createControllers() { final keys = [ - 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_password', - 'email_from_name', 'email_from_address', - 'app_name', 'app_url', 'app_logo_url', - 'password_reset_token_expiry_days', 'jwt_expiry_hours', 'max_upload_size_mb', + 'smtp_host', + 'smtp_port', + 'smtp_user', + 'smtp_password', + 'email_from_name', + 'email_from_address', + 'app_name', + 'app_url', + 'app_logo_url', + 'password_reset_token_expiry_days', + 'jwt_expiry_hours', + 'max_upload_size_mb', ]; for (final k in keys) { _controllers[k] = TextEditingController(); @@ -93,18 +107,29 @@ class _ParametresPanelState extends State { payload['smtp_auth_required'] = _smtpAuthRequired; payload['smtp_user'] = _controllers['smtp_user']!.text.trim(); final pwd = _controllers['smtp_password']!.text.trim(); - if (pwd.isNotEmpty && pwd != '***********') payload['smtp_password'] = pwd; + if (pwd.isNotEmpty && pwd != '***********') { + payload['smtp_password'] = pwd; + } payload['email_from_name'] = _controllers['email_from_name']!.text.trim(); - payload['email_from_address'] = _controllers['email_from_address']!.text.trim(); + payload['email_from_address'] = + _controllers['email_from_address']!.text.trim(); payload['app_name'] = _controllers['app_name']!.text.trim(); payload['app_url'] = _controllers['app_url']!.text.trim(); payload['app_logo_url'] = _controllers['app_logo_url']!.text.trim(); - final tokenDays = int.tryParse(_controllers['password_reset_token_expiry_days']!.text.trim()); - if (tokenDays != null) payload['password_reset_token_expiry_days'] = tokenDays; - final jwtHours = int.tryParse(_controllers['jwt_expiry_hours']!.text.trim()); - if (jwtHours != null) payload['jwt_expiry_hours'] = jwtHours; + final tokenDays = int.tryParse( + _controllers['password_reset_token_expiry_days']!.text.trim()); + if (tokenDays != null) { + payload['password_reset_token_expiry_days'] = tokenDays; + } + final jwtHours = + int.tryParse(_controllers['jwt_expiry_hours']!.text.trim()); + if (jwtHours != null) { + payload['jwt_expiry_hours'] = jwtHours; + } final maxMb = int.tryParse(_controllers['max_upload_size_mb']!.text.trim()); - if (maxMb != null) payload['max_upload_size_mb'] = maxMb; + if (maxMb != null) { + payload['max_upload_size_mb'] = maxMb; + } return payload; } @@ -191,6 +216,10 @@ class _ParametresPanelState extends State { @override Widget build(BuildContext context) { + if (widget.selectedSettingsTabIndex == 1) { + return const RelaisManagementPanel(); + } + if (_isLoading) { return const Center(child: CircularProgressIndicator()); } @@ -214,7 +243,8 @@ class _ParametresPanelState extends State { } final isSuccess = _message != null && - (_message!.startsWith('Configuration') || _message!.startsWith('Connexion')); + (_message!.startsWith('Configuration') || + _message!.startsWith('Connexion')); return Form( key: _formKey, @@ -234,12 +264,21 @@ class _ParametresPanelState extends State { context, icon: Icons.email_outlined, title: 'Configuration Email (SMTP)', - child: Column( + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildField('smtp_host', 'Serveur SMTP', hint: 'mail.example.com'), + _buildField( + 'smtp_host', + 'Serveur SMTP', + hint: 'mail.example.com', + ), const SizedBox(height: 14), - _buildField('smtp_port', 'Port SMTP', keyboard: TextInputType.number, hint: '25, 465, 587'), + _buildField( + 'smtp_port', + 'Port SMTP', + keyboard: TextInputType.number, + hint: '25, 465, 587', + ), const SizedBox(height: 14), Padding( padding: const EdgeInsets.only(bottom: 14), @@ -247,14 +286,17 @@ class _ParametresPanelState extends State { children: [ Checkbox( value: _smtpSecure, - onChanged: (v) => setState(() => _smtpSecure = v ?? false), + onChanged: (v) => + setState(() => _smtpSecure = v ?? false), activeColor: const Color(0xFF9CC5C0), ), const Text('SSL/TLS (secure)'), const SizedBox(width: 24), Checkbox( value: _smtpAuthRequired, - onChanged: (v) => setState(() => _smtpAuthRequired = v ?? false), + onChanged: (v) => setState( + () => _smtpAuthRequired = v ?? false, + ), activeColor: const Color(0xFF9CC5C0), ), const Text('Authentification requise'), @@ -263,11 +305,19 @@ class _ParametresPanelState extends State { ), _buildField('smtp_user', 'Utilisateur SMTP'), const SizedBox(height: 14), - _buildField('smtp_password', 'Mot de passe SMTP', obscure: true), + _buildField( + 'smtp_password', + 'Mot de passe SMTP', + obscure: true, + ), const SizedBox(height: 14), _buildField('email_from_name', 'Nom expĂ©diteur'), const SizedBox(height: 14), - _buildField('email_from_address', 'Email expĂ©diteur', hint: 'no-reply@example.com'), + _buildField( + 'email_from_address', + 'Email expĂ©diteur', + hint: 'no-reply@example.com', + ), const SizedBox(height: 18), Align( alignment: Alignment.centerRight, @@ -277,8 +327,13 @@ class _ParametresPanelState extends State { label: const Text('Tester la connexion SMTP'), style: OutlinedButton.styleFrom( foregroundColor: const Color(0xFF2D6A4F), - side: const BorderSide(color: Color(0xFF9CC5C0)), - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + side: const BorderSide( + color: Color(0xFF9CC5C0), + ), + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), ), ), ), @@ -290,14 +345,22 @@ class _ParametresPanelState extends State { context, icon: Icons.palette_outlined, title: 'Personnalisation', - child: Column( + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildField('app_name', 'Nom de l\'application'), const SizedBox(height: 14), - _buildField('app_url', 'URL de l\'application', hint: 'https://app.example.com'), + _buildField( + 'app_url', + 'URL de l\'application', + hint: 'https://app.example.com', + ), const SizedBox(height: 14), - _buildField('app_logo_url', 'URL du logo', hint: '/assets/logo.png'), + _buildField( + 'app_logo_url', + 'URL du logo', + hint: '/assets/logo.png', + ), ], ), ), @@ -309,11 +372,23 @@ class _ParametresPanelState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildField('password_reset_token_expiry_days', 'ValiditĂ© token MDP (jours)', keyboard: TextInputType.number), + _buildField( + 'password_reset_token_expiry_days', + 'ValiditĂ© token MDP (jours)', + keyboard: TextInputType.number, + ), const SizedBox(height: 14), - _buildField('jwt_expiry_hours', 'ValiditĂ© session JWT (heures)', keyboard: TextInputType.number), + _buildField( + 'jwt_expiry_hours', + 'ValiditĂ© session JWT (heures)', + keyboard: TextInputType.number, + ), const SizedBox(height: 14), - _buildField('max_upload_size_mb', 'Taille max upload (MB)', keyboard: TextInputType.number), + _buildField( + 'max_upload_size_mb', + 'Taille max upload (MB)', + keyboard: TextInputType.number, + ), ], ), ), @@ -327,7 +402,14 @@ class _ParametresPanelState extends State { foregroundColor: Colors.white, ), child: _isSaving - ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) : const Text('Sauvegarder la configuration'), ), ), @@ -339,7 +421,8 @@ class _ParametresPanelState extends State { ); } - Widget _buildSectionCard(BuildContext context, {required IconData icon, required String title, required Widget child}) { + Widget _buildSectionCard(BuildContext context, + {required IconData icon, required String title, required Widget child}) { return Card( elevation: 2, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), @@ -369,7 +452,8 @@ class _ParametresPanelState extends State { ); } - Widget _buildField(String key, String label, {bool obscure = false, TextInputType? keyboard, String? hint}) { + Widget _buildField(String key, String label, + {bool obscure = false, TextInputType? keyboard, String? hint}) { final c = _controllers[key]; if (c == null) return const SizedBox.shrink(); return TextFormField( @@ -381,7 +465,8 @@ class _ParametresPanelState extends State { labelText: label, hintText: hint, border: const OutlineInputBorder(), - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 12), ), ); } diff --git a/frontend/lib/widgets/admin/parent_managmant_widget.dart b/frontend/lib/widgets/admin/parent_managmant_widget.dart index 1764056..cfa8637 100644 --- a/frontend/lib/widgets/admin/parent_managmant_widget.dart +++ b/frontend/lib/widgets/admin/parent_managmant_widget.dart @@ -1,9 +1,19 @@ 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_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'; class ParentManagementWidget extends StatefulWidget { - const ParentManagementWidget({super.key}); + final String searchQuery; + final String? statusFilter; + + const ParentManagementWidget({ + super.key, + required this.searchQuery, + this.statusFilter, + }); @override State createState() => _ParentManagementWidgetState(); @@ -13,23 +23,15 @@ class _ParentManagementWidgetState extends State { bool _isLoading = false; String? _error; List _parents = []; - List _filteredParents = []; - - final TextEditingController _searchController = TextEditingController(); - String? _selectedStatus; @override void initState() { super.initState(); _loadParents(); - _searchController.addListener(_filter); } @override - void dispose() { - _searchController.dispose(); - super.dispose(); - } + void dispose() => super.dispose(); Future _loadParents() async { setState(() { @@ -41,7 +43,6 @@ class _ParentManagementWidgetState extends State { if (!mounted) return; setState(() { _parents = list; - _filter(); // Apply initial filter (if any) _isLoading = false; }); } catch (e) { @@ -53,139 +54,101 @@ class _ParentManagementWidgetState extends State { } } - void _filter() { - final query = _searchController.text.toLowerCase(); - setState(() { - _filteredParents = _parents.where((p) { - final matchesName = p.user.fullName.toLowerCase().contains(query) || - p.user.email.toLowerCase().contains(query); - final matchesStatus = _selectedStatus == null || - _selectedStatus == 'Tous' || - (p.user.statut?.toLowerCase() == _selectedStatus?.toLowerCase()); - - // Mapping simple pour le statut affichĂ© vs backend - // Backend: en_attente, actif, suspendu - // Dropdown: En attente, Actif, Suspendu - - return matchesName && matchesStatus; - }).toList(); - }); - } - @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildSearchSection(), - const SizedBox(height: 16), - if (_isLoading) - const Center(child: CircularProgressIndicator()) - else if (_error != null) - Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red))) - else if (_filteredParents.isEmpty) - const Center(child: Text("Aucun parent trouvĂ©.")) - else - Expanded( - child: ListView.builder( - itemCount: _filteredParents.length, - itemBuilder: (context, index) { - final parent = _filteredParents[index]; - return Card( - margin: const EdgeInsets.symmetric(vertical: 8), - child: ListTile( - leading: CircleAvatar( - backgroundImage: parent.user.photoUrl != null - ? NetworkImage(parent.user.photoUrl!) - : null, - child: parent.user.photoUrl == null - ? const Icon(Icons.person) - : null, - ), - title: Text(parent.user.fullName.isNotEmpty - ? parent.user.fullName - : 'Sans nom'), - subtitle: Text( - "${parent.user.email}\nStatut : ${parent.user.statut ?? 'Inconnu'} | Enfants : ${parent.childrenCount}", - ), - isThreeLine: true, - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.visibility), - tooltip: "Voir dossier", - onPressed: () { - // TODO: Voir le statut du dossier - }, - ), - IconButton( - icon: const Icon(Icons.edit), - tooltip: "Modifier", - onPressed: () { - // TODO: Modifier parent - }, - ), - IconButton( - icon: const Icon(Icons.delete), - tooltip: "Supprimer", - onPressed: () { - // TODO: Supprimer compte - }, - ), - ], - ), - ), - ); - }, - ), + final query = widget.searchQuery.toLowerCase(); + final filteredParents = _parents.where((p) { + final matchesName = p.user.fullName.toLowerCase().contains(query) || + p.user.email.toLowerCase().contains(query); + final matchesStatus = + widget.statusFilter == null || p.user.statut == widget.statusFilter; + return matchesName && matchesStatus; + }).toList(); + + return UserList( + isLoading: _isLoading, + error: _error, + isEmpty: filteredParents.isEmpty, + emptyMessage: 'Aucun parent trouvĂ©.', + itemCount: filteredParents.length, + itemBuilder: (context, index) { + final parent = filteredParents[index]; + return AdminUserCard( + title: parent.user.fullName, + avatarUrl: parent.user.photoUrl, + subtitleLines: [ + parent.user.email, + 'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${parent.childrenCount}', + ], + actions: [ + IconButton( + icon: const Icon(Icons.edit), + tooltip: 'Modifier', + onPressed: () { + _openParentDetails(parent); + }, ), + ], + ); + }, + ); + } + + String _displayStatus(String? status) { + switch (status) { + case 'actif': + return 'Actif'; + case 'en_attente': + return 'En attente'; + case 'suspendu': + return 'Suspendu'; + default: + return 'Inconnu'; + } + } + + 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), + ), + 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')), + ); + }, ), ); } - Widget _buildSearchSection() { - return Wrap( - spacing: 16, - runSpacing: 8, - children: [ - SizedBox( - width: 220, - child: TextField( - controller: _searchController, - decoration: const InputDecoration( - labelText: "Nom du parent", - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.search), - ), - ), - ), - SizedBox( - width: 220, - child: DropdownButtonFormField( - decoration: const InputDecoration( - labelText: "Statut", - border: OutlineInputBorder(), - ), - value: _selectedStatus, - items: const [ - DropdownMenuItem(value: null, child: Text("Tous")), - DropdownMenuItem(value: "actif", child: Text("Actif")), - DropdownMenuItem(value: "en_attente", child: Text("En attente")), - DropdownMenuItem(value: "suspendu", child: Text("Suspendu")), - ], - onChanged: (value) { - setState(() { - _selectedStatus = value; - _filter(); - }); - }, - ), - ), - ], - ); - } + String _v(String? value) => (value == null || value.isEmpty) ? '-' : value; } diff --git a/frontend/lib/widgets/admin/relais_management_panel.dart b/frontend/lib/widgets/admin/relais_management_panel.dart new file mode 100644 index 0000000..0940dcd --- /dev/null +++ b/frontend/lib/widgets/admin/relais_management_panel.dart @@ -0,0 +1,1134 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:p_tits_pas/models/relais_model.dart'; +import 'package:p_tits_pas/services/relais_service.dart'; + +class RelaisManagementPanel extends StatefulWidget { + const RelaisManagementPanel({super.key}); + + @override + State createState() => _RelaisManagementPanelState(); +} + +class _RelaisManagementPanelState extends State { + bool _isLoading = false; + String? _error; + List _relais = []; + String? _hoveredRelaisId; + + @override + void initState() { + super.initState(); + _loadRelais(); + } + + Future _loadRelais() async { + setState(() { + _isLoading = true; + _error = null; + }); + try { + final list = await RelaisService.getRelais(); + if (!mounted) return; + setState(() { + _relais = list; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString().replaceAll('Exception: ', ''); + _isLoading = false; + }); + } + } + + Future _openRelaisForm({RelaisModel? relais}) async { + final result = await showDialog<_RelaisDialogResult>( + context: context, + builder: (context) => _RelaisFormDialog(initial: relais), + ); + + if (result == null) return; + if (!mounted) return; + + 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'), + ), + ], + ), + ); + + if (confirmed != true) return; + await RelaisService.deleteRelais(relais.id); + } else if (relais == null) { + await RelaisService.createRelais(result.payload!); + } else { + await RelaisService.updateRelais(relais.id, result.payload!); + } + if (!mounted) return; + await _loadRelais(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + result.action == _RelaisDialogAction.delete + ? 'Relais supprimĂ©.' + : (relais == null ? 'Relais créé.' : 'Relais mis Ă  jour.'), + ), + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + e.toString().replaceAll('Exception: ', ''), + ), + backgroundColor: Colors.red.shade600, + ), + ); + } + } + + String _horairesSummary(Map? horaires) { + if (horaires == null || horaires.isEmpty) { + return 'Horaires non renseignĂ©s'; + } + final actifs = horaires.entries.where((entry) { + final value = entry.value; + if (value is Map) { + final ferme = value['ferme'] == true; + if (ferme) return false; + final matinOuverture = value['matin_ouverture']?.toString() ?? ''; + final matinFermeture = value['matin_fermeture']?.toString() ?? ''; + final soirOuverture = value['soir_ouverture']?.toString() ?? ''; + final soirFermeture = value['soir_fermeture']?.toString() ?? ''; + final matinOuvert = + matinOuverture.isNotEmpty && matinFermeture.isNotEmpty; + final soirOuvert = soirOuverture.isNotEmpty && soirFermeture.isNotEmpty; + return matinOuvert || soirOuvert; + } + return false; + }).length; + return '$actifs jour(s) ouvert(s)'; + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Icon( + Icons.location_city_outlined, + size: 22, + color: Color(0xFF9CC5C0), + ), + const SizedBox(width: 10), + Text( + 'Gestion des relais', + style: + Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: const Color(0xFF2D6A4F), + ), + ), + const Spacer(), + ElevatedButton.icon( + onPressed: () => _openRelaisForm(), + icon: const Icon(Icons.add), + label: const Text('Ajouter un relais'), + ), + ], + ), + const SizedBox(height: 16), + Container( + height: 390, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.black12), + ), + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? Center( + child: Text( + _error!, + style: TextStyle(color: Colors.red.shade700), + ), + ) + : _relais.isEmpty + ? const Center( + child: Text('Aucun relais configurĂ©.'), + ) + : ListView.separated( + itemCount: _relais.length, + separatorBuilder: (_, __) => + const SizedBox(height: 10), + itemBuilder: (context, index) { + final relais = _relais[index]; + final isInactive = !relais.actif; + final isHovered = + _hoveredRelaisId == relais.id; + final subtitle = [ + relais.adresse, + if (relais.ligneFixe?.isNotEmpty == + true) + 'Ligne fixe : ${relais.ligneFixe}', + _horairesSummary( + relais.horairesOuverture), + 'Statut : ${relais.actif ? 'Actif' : 'Inactif'}', + if (relais.notes?.isNotEmpty == true) + 'Notes : ${relais.notes}', + ]; + + return MouseRegion( + onEnter: (_) => setState( + () => _hoveredRelaisId = relais.id, + ), + onExit: (_) => setState( + () => _hoveredRelaisId = null, + ), + child: Card( + color: isInactive + ? const Color(0xFFFFF4F4) + : null, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(10), + side: BorderSide( + color: isInactive + ? const Color(0xFFFFD0D0) + : Colors.transparent, + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + child: Row( + children: [ + const Icon( + Icons.location_city_outlined, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment + .start, + children: [ + Text( + relais.nom, + style: TextStyle( + fontWeight: + FontWeight.w600, + color: isInactive + ? const Color( + 0xFF8A3A3A) + : null, + ), + ), + const SizedBox(height: 2), + Text( + subtitle + .join(' ‱ '), + maxLines: 2, + overflow: TextOverflow + .ellipsis, + style: const TextStyle( + color: Colors.black54, + fontSize: 12, + ), + ), + ], + ), + ), + SizedBox( + width: 36, + child: AnimatedOpacity( + duration: const Duration( + milliseconds: 120, + ), + opacity: isHovered ? 1 : 0, + child: IgnorePointer( + ignoring: !isHovered, + child: IconButton( + onPressed: () => + _openRelaisForm( + relais: relais), + icon: const Icon( + Icons.edit), + tooltip: 'Modifier', + ), + ), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _RelaisFormDialog extends StatefulWidget { + final RelaisModel? initial; + + const _RelaisFormDialog({required this.initial}); + + @override + State<_RelaisFormDialog> createState() => _RelaisFormDialogState(); +} + +class _RelaisFormDialogState extends State<_RelaisFormDialog> { + static const double _targetTimeFieldWidth = 112; + static const double _minTimeFieldWidth = 96; + static const double _gap = 8; + static const double _dayLabelWidth = 70; + static const double _closedAreaWidth = 86; + static const double _separatorLineWidth = 1; + static const double _groupSeparatorWidth = (_gap * 2) + _separatorLineWidth; + static const double _modalInnerPadding = 16; + static const double _modalHorizontalSafetyMargin = 24; + + late final TextEditingController _nomCtrl; + late final TextEditingController _streetCtrl; + late final TextEditingController _postalCodeCtrl; + late final TextEditingController _cityCtrl; + late final TextEditingController _ligneFixeCtrl; + late final TextEditingController _notesCtrl; + final Map _morningOpenCtrls = {}; + final Map _morningCloseCtrls = {}; + final Map _eveningOpenCtrls = {}; + final Map _eveningCloseCtrls = {}; + final Map _closedByDay = {}; + bool _actif = true; + + static const List _days = [ + 'lundi', + 'mardi', + 'mercredi', + 'jeudi', + 'vendredi', + 'samedi', + 'dimanche', + ]; + + @override + void initState() { + super.initState(); + final initial = widget.initial; + _nomCtrl = TextEditingController(text: initial?.nom ?? ''); + final addressParts = _splitAddress(initial?.adresse); + _streetCtrl = TextEditingController(text: addressParts.street); + _postalCodeCtrl = TextEditingController(text: addressParts.postalCode); + _cityCtrl = TextEditingController(text: addressParts.city); + _ligneFixeCtrl = TextEditingController(text: initial?.ligneFixe ?? ''); + _notesCtrl = TextEditingController(text: initial?.notes ?? ''); + _actif = initial?.actif ?? true; + + final horaires = initial?.horairesOuverture ?? {}; + for (final day in _days) { + final value = horaires[day]; + String matinOuverture = ''; + String matinFermeture = ''; + String soirOuverture = ''; + String soirFermeture = ''; + bool ferme = false; + + if (value is Map) { + matinOuverture = _normalizeTime( + value['matin_ouverture']?.toString() ?? + value['ouverture']?.toString(), + ); + matinFermeture = _normalizeTime( + value['matin_fermeture']?.toString() ?? + value['fermeture']?.toString(), + ); + soirOuverture = _normalizeTime(value['soir_ouverture']?.toString()); + soirFermeture = _normalizeTime(value['soir_fermeture']?.toString()); + ferme = value['ferme'] == true; + } else if (value is String && value.contains('-')) { + final parts = value.split('-'); + if (parts.length == 2) { + matinOuverture = _normalizeTime(parts[0].trim()); + matinFermeture = _normalizeTime(parts[1].trim()); + } + } + + _morningOpenCtrls[day] = TextEditingController(text: matinOuverture); + _morningCloseCtrls[day] = TextEditingController(text: matinFermeture); + _eveningOpenCtrls[day] = TextEditingController(text: soirOuverture); + _eveningCloseCtrls[day] = TextEditingController(text: soirFermeture); + final isWeekend = day == 'samedi' || day == 'dimanche'; + _closedByDay[day] = widget.initial == null ? isWeekend : ferme; + } + } + + @override + void dispose() { + _nomCtrl.dispose(); + _streetCtrl.dispose(); + _postalCodeCtrl.dispose(); + _cityCtrl.dispose(); + _ligneFixeCtrl.dispose(); + _notesCtrl.dispose(); + for (final c in _morningOpenCtrls.values) { + c.dispose(); + } + for (final c in _morningCloseCtrls.values) { + c.dispose(); + } + for (final c in _eveningOpenCtrls.values) { + c.dispose(); + } + for (final c in _eveningCloseCtrls.values) { + c.dispose(); + } + super.dispose(); + } + + Map _buildPayload() { + final horaires = {}; + for (final day in _days) { + final ferme = _closedByDay[day] ?? false; + final matinOuverture = _morningOpenCtrls[day]!.text.trim(); + final matinFermeture = _morningCloseCtrls[day]!.text.trim(); + final soirOuverture = _eveningOpenCtrls[day]!.text.trim(); + final soirFermeture = _eveningCloseCtrls[day]!.text.trim(); + + horaires[day] = { + 'matin_ouverture': matinOuverture, + 'matin_fermeture': matinFermeture, + 'soir_ouverture': soirOuverture, + 'soir_fermeture': soirFermeture, + 'ferme': ferme, + }; + } + + final payload = { + 'nom': _nomCtrl.text.trim(), + 'adresse': _composeAddress(), + 'actif': _actif, + 'horaires_ouverture': horaires, + }; + + final ligneFixe = _ligneFixeCtrl.text.trim(); + if (ligneFixe.isNotEmpty) { + payload['ligne_fixe'] = ligneFixe; + } + + final notes = _notesCtrl.text.trim(); + if (notes.isNotEmpty) { + payload['notes'] = notes; + } + + return payload; + } + + bool _isValid() { + if (_nomCtrl.text.trim().isEmpty) { + return false; + } + if (_streetCtrl.text.trim().isEmpty || _cityCtrl.text.trim().isEmpty) { + return false; + } + if (!_isValidPostalCode(_postalCodeCtrl.text.trim())) { + return false; + } + + for (final day in _days) { + final ferme = _closedByDay[day] ?? false; + if (ferme) continue; + + final matinOuverture = _morningOpenCtrls[day]!.text.trim(); + final matinFermeture = _morningCloseCtrls[day]!.text.trim(); + final soirOuverture = _eveningOpenCtrls[day]!.text.trim(); + final soirFermeture = _eveningCloseCtrls[day]!.text.trim(); + + if (!_isValidSlot(matinOuverture, matinFermeture) || + !_isValidSlot(soirOuverture, soirFermeture)) { + return false; + } + } + + return true; + } + + bool _isValidSlot(String start, String end) { + final bothEmpty = start.isEmpty && end.isEmpty; + if (bothEmpty) return true; + if (start.isEmpty || end.isEmpty) return false; + return _isValidTime(start) && _isValidTime(end); + } + + bool _isValidPostalCode(String value) { + return RegExp(r'^\d{5}$').hasMatch(value); + } + + _AddressParts _splitAddress(String? rawAddress) { + if (rawAddress == null || rawAddress.trim().isEmpty) { + return const _AddressParts(street: '', postalCode: '', city: ''); + } + + final raw = rawAddress.trim().replaceAll(RegExp(r'\s+'), ' '); + final postalCityMatch = + RegExp(r'^(.+?)[,\s]+(\d{5})\s+(.+)$').firstMatch(raw); + + if (postalCityMatch != null) { + final street = (postalCityMatch.group(1) ?? '') + .replaceAll(RegExp(r'[,;\s]+$'), '') + .trim(); + final postalCode = (postalCityMatch.group(2) ?? '').trim(); + final city = (postalCityMatch.group(3) ?? '').trim(); + return _AddressParts( + street: street, + postalCode: postalCode, + city: city, + ); + } + + return _AddressParts(street: raw, postalCode: '', city: ''); + } + + String _composeAddress() { + final street = _streetCtrl.text.trim(); + final postalCode = _postalCodeCtrl.text.trim(); + final city = _cityCtrl.text.trim(); + if (postalCode.isEmpty && city.isEmpty) { + return street; + } + return '$street, $postalCode $city'.trim(); + } + + bool _isValidTime(String value) { + final match = RegExp(r'^([01]\d|2[0-3]):([0-5]\d)$').firstMatch(value); + return match != null; + } + + String _normalizeTime(String? raw) { + if (raw == null || raw.trim().isEmpty) return ''; + final compact = raw.replaceAll(RegExp(r'\D'), ''); + if (compact.length == 4) { + return '${compact.substring(0, 2)}:${compact.substring(2, 4)}'; + } + final trimmed = raw.trim(); + return _isValidTime(trimmed) ? trimmed : ''; + } + + Future _pickTime(TextEditingController controller) async { + final currentText = controller.text.trim(); + TimeOfDay initial = const TimeOfDay(hour: 9, minute: 0); + if (_isValidTime(currentText)) { + final parts = currentText.split(':'); + initial = TimeOfDay( + hour: int.parse(parts[0]), + minute: int.parse(parts[1]), + ); + } + + final picked = await showTimePicker( + context: context, + initialTime: initial, + builder: (context, child) { + return MediaQuery( + data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: true), + child: child ?? const SizedBox.shrink(), + ); + }, + ); + + if (picked == null) return; + final hh = picked.hour.toString().padLeft(2, '0'); + final mm = picked.minute.toString().padLeft(2, '0'); + setState(() { + controller.text = '$hh:$mm'; + }); + } + + @override + Widget build(BuildContext context) { + final isCreation = widget.initial == null; + final availableWidth = + MediaQuery.of(context).size.width - _modalHorizontalSafetyMargin; + const preferredHoursWidth = _dayLabelWidth + + ((_targetTimeFieldWidth * 2) + _gap) + + _groupSeparatorWidth + + ((_targetTimeFieldWidth * 2) + _gap) + + _gap + + _closedAreaWidth; + const preferredDialogWidth = preferredHoursWidth + (_modalInnerPadding * 2); + final dialogWidth = + preferredDialogWidth.clamp(360.0, availableWidth).toDouble(); + + return Dialog( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: dialogWidth, + minWidth: dialogWidth, + maxHeight: 700, + ), + child: Padding( + padding: const EdgeInsets.all(_modalInnerPadding), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Text( + isCreation ? 'Nouveau relais' : 'Modifier relais', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + const Spacer(), + if (!isCreation) + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + const SizedBox(height: 8), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 6), + _buildTechniqueFields(), + const SizedBox(height: 16), + _buildTerritorialFields(), + ], + ), + ), + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerRight, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isCreation) ...[ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Annuler'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: _isValid() + ? () => Navigator.of(context).pop( + _RelaisDialogResult( + action: _RelaisDialogAction.save, + payload: _buildPayload(), + ), + ) + : null, + child: const Text('CrĂ©er'), + ), + ] else ...[ + OutlinedButton( + onPressed: () => Navigator.of(context).pop( + const _RelaisDialogResult( + action: _RelaisDialogAction.delete, + ), + ), + child: const Text('Supprimer'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: _isValid() + ? () => Navigator.of(context).pop( + _RelaisDialogResult( + action: _RelaisDialogAction.save, + payload: _buildPayload(), + ), + ) + : null, + child: const Text('Modifier'), + ), + ], + ], + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildTechniqueFields() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _nomCtrl, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration( + labelText: 'Nom du relais *', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _ligneFixeCtrl, + keyboardType: TextInputType.phone, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(10), + _FrenchPhoneNumberFormatter(), + ], + decoration: const InputDecoration( + labelText: 'Ligne fixe', + hintText: '01 23 45 67 89', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Relais actif'), + value: _actif, + onChanged: (value) => setState(() => _actif = value), + ), + ], + ); + } + + Widget _buildTerritorialFields() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _RelaisAddressFields( + streetController: _streetCtrl, + postalCodeController: _postalCodeCtrl, + cityController: _cityCtrl, + onChanged: () => setState(() {}), + ), + const SizedBox(height: 12), + TextField( + controller: _notesCtrl, + maxLines: 3, + decoration: const InputDecoration( + labelText: 'Notes', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerLeft, + child: Text( + 'Horaires hebdomadaires', + style: TextStyle( + fontWeight: FontWeight.w600, + color: Colors.grey.shade800, + ), + ), + ), + const SizedBox(height: 6), + LayoutBuilder( + builder: (context, constraints) { + final availableHoursWidth = constraints.maxWidth; + const fixedWidth = _dayLabelWidth + + _groupSeparatorWidth + + _gap + + _closedAreaWidth + + (_gap * 2); + final computedTimeFieldWidth = + ((availableHoursWidth - fixedWidth) / 4) + .clamp(_minTimeFieldWidth, _targetTimeFieldWidth) + .toDouble(); + final groupWidth = (computedTimeFieldWidth * 2) + _gap; + final hoursContentWidth = _dayLabelWidth + + groupWidth + + _groupSeparatorWidth + + groupWidth + + _gap + + _closedAreaWidth; + + return SizedBox( + width: hoursContentWidth, + child: Stack( + children: [ + Positioned( + left: _dayLabelWidth + + groupWidth + + (_groupSeparatorWidth / 2), + top: 0, + bottom: 0, + child: Container( + width: _separatorLineWidth, + color: Colors.grey.shade400, + ), + ), + Column( + children: [ + Row( + children: [ + const SizedBox(width: _dayLabelWidth), + SizedBox( + width: groupWidth, + child: Center( + child: Text( + 'Matin', + style: TextStyle( + fontWeight: FontWeight.w600, + color: Colors.grey.shade800, + ), + ), + ), + ), + const SizedBox(width: _groupSeparatorWidth), + SizedBox( + width: groupWidth, + child: Center( + child: Text( + 'AprĂšs-midi', + style: TextStyle( + fontWeight: FontWeight.w600, + color: Colors.grey.shade800, + ), + ), + ), + ), + const SizedBox(width: _closedAreaWidth), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + const SizedBox(width: _dayLabelWidth), + SizedBox( + width: computedTimeFieldWidth, + child: const Center( + child: Text( + 'DĂ©but', + style: TextStyle(fontSize: 12), + ), + ), + ), + const SizedBox(width: _gap), + SizedBox( + width: computedTimeFieldWidth, + child: const Center( + child: Text( + 'Fin', + style: TextStyle(fontSize: 12), + ), + ), + ), + const SizedBox(width: _groupSeparatorWidth), + SizedBox( + width: computedTimeFieldWidth, + child: const Center( + child: Text( + 'DĂ©but', + style: TextStyle(fontSize: 12), + ), + ), + ), + const SizedBox(width: _gap), + SizedBox( + width: computedTimeFieldWidth, + child: const Center( + child: Text( + 'Fin', + style: TextStyle(fontSize: 12), + ), + ), + ), + const SizedBox(width: _gap), + const SizedBox(width: _closedAreaWidth), + ], + ), + const SizedBox(height: 6), + ..._days.map((day) { + final ferme = _closedByDay[day] ?? false; + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + SizedBox( + width: _dayLabelWidth, + child: Text( + '${day[0].toUpperCase()}${day.substring(1)}', + style: const TextStyle(fontSize: 13), + ), + ), + _buildTimeField( + controller: _morningOpenCtrls[day]!, + enabled: !ferme, + placeholder: '. . : . .', + width: computedTimeFieldWidth, + ), + const SizedBox(width: _gap), + _buildTimeField( + controller: _morningCloseCtrls[day]!, + enabled: !ferme, + placeholder: '. . : . .', + width: computedTimeFieldWidth, + ), + const SizedBox(width: _groupSeparatorWidth), + _buildTimeField( + controller: _eveningOpenCtrls[day]!, + enabled: !ferme, + placeholder: '. . : . .', + width: computedTimeFieldWidth, + ), + const SizedBox(width: _gap), + _buildTimeField( + controller: _eveningCloseCtrls[day]!, + enabled: !ferme, + placeholder: '. . : . .', + width: computedTimeFieldWidth, + ), + const SizedBox(width: _gap), + Checkbox( + value: ferme, + onChanged: (value) { + setState(() { + _closedByDay[day] = value ?? false; + }); + }, + ), + const Text('FermĂ©'), + ], + ), + ); + }), + ], + ), + ], + ), + ); + }, + ), + ], + ); + } + + Widget _buildTimeField({ + required TextEditingController controller, + required bool enabled, + required String placeholder, + required double width, + }) { + return SizedBox( + width: width, + child: TextField( + controller: controller, + enabled: enabled, + onChanged: (_) => setState(() {}), + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(4), + _HourMinuteFormatter(), + ], + decoration: InputDecoration( + hintText: placeholder, + border: const OutlineInputBorder(), + isDense: true, + suffixIcon: ExcludeFocus( + child: GestureDetector( + onTap: enabled ? () => _pickTime(controller) : null, + child: const Padding( + padding: EdgeInsets.all(8), + child: Icon(Icons.schedule, size: 18), + ), + ), + ), + ), + ), + ); + } +} + +class _FrenchPhoneNumberFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + final digits = newValue.text.replaceAll(RegExp(r'\D'), ''); + final buffer = StringBuffer(); + + for (var i = 0; i < digits.length; i++) { + if (i > 0 && i.isEven) { + buffer.write(' '); + } + buffer.write(digits[i]); + } + + final formatted = buffer.toString(); + return TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: formatted.length), + ); + } +} + +class _RelaisAddressFields extends StatelessWidget { + final TextEditingController streetController; + final TextEditingController postalCodeController; + final TextEditingController cityController; + final VoidCallback onChanged; + + const _RelaisAddressFields({ + required this.streetController, + required this.postalCodeController, + required this.cityController, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: streetController, + onChanged: (_) => onChanged(), + keyboardType: TextInputType.streetAddress, + textCapitalization: TextCapitalization.words, + autofillHints: const [AutofillHints.fullStreetAddress], + decoration: const InputDecoration( + labelText: 'Rue *', + hintText: 'NumĂ©ro et nom de rue', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.location_on_outlined), + ), + ), + const SizedBox(height: 10), + Row( + children: [ + SizedBox( + width: 140, + child: TextField( + controller: postalCodeController, + onChanged: (_) => onChanged(), + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(5), + ], + decoration: const InputDecoration( + labelText: 'Code postal *', + hintText: '5 chiffres', + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: TextField( + controller: cityController, + onChanged: (_) => onChanged(), + textCapitalization: TextCapitalization.words, + decoration: const InputDecoration( + labelText: 'Ville *', + hintText: 'Ville', + border: OutlineInputBorder(), + ), + ), + ), + ], + ), + ], + ); + } +} + +class _AddressParts { + final String street; + final String postalCode; + final String city; + + const _AddressParts({ + required this.street, + required this.postalCode, + required this.city, + }); +} + +enum _RelaisDialogAction { save, delete } + +class _RelaisDialogResult { + final _RelaisDialogAction action; + final Map? payload; + + const _RelaisDialogResult({ + required this.action, + this.payload, + }); +} + +class _HourMinuteFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + final digits = newValue.text.replaceAll(RegExp(r'\D'), ''); + final limited = digits.length > 4 ? digits.substring(0, 4) : digits; + + String text; + if (limited.length <= 2) { + text = limited; + } else { + text = '${limited.substring(0, 2)}:${limited.substring(2)}'; + } + + return TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + ); + } +} diff --git a/frontend/lib/widgets/admin/user_management_panel.dart b/frontend/lib/widgets/admin/user_management_panel.dart new file mode 100644 index 0000000..7f1e698 --- /dev/null +++ b/frontend/lib/widgets/admin/user_management_panel.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart'; +import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart'; +import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart'; +import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart'; +import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart'; + +class AdminUserManagementPanel extends StatefulWidget { + const AdminUserManagementPanel({super.key}); + + @override + State createState() => + _AdminUserManagementPanelState(); +} + +class _AdminUserManagementPanelState extends State { + int _subIndex = 0; + final TextEditingController _searchController = TextEditingController(); + final TextEditingController _amCapacityController = TextEditingController(); + String? _parentStatus; + + @override + void initState() { + super.initState(); + _searchController.addListener(_onFilterChanged); + _amCapacityController.addListener(_onFilterChanged); + } + + @override + void dispose() { + _searchController.removeListener(_onFilterChanged); + _amCapacityController.removeListener(_onFilterChanged); + _searchController.dispose(); + _amCapacityController.dispose(); + super.dispose(); + } + + void _onFilterChanged() { + if (!mounted) return; + setState(() {}); + } + + void _onSubTabChange(int index) { + setState(() { + _subIndex = index; + _searchController.clear(); + _parentStatus = null; + _amCapacityController.clear(); + }); + } + + String _searchHintForTab() { + switch (_subIndex) { + case 0: + return 'Rechercher un gestionnaire...'; + case 1: + return 'Rechercher un parent...'; + case 2: + return 'Rechercher une assistante...'; + case 3: + return 'Rechercher un administrateur...'; + default: + return 'Rechercher...'; + } + } + + Widget? _subBarFilterControl() { + if (_subIndex == 1) { + return DropdownButtonHideUnderline( + child: DropdownButton( + value: _parentStatus, + 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: 'actif', + child: Padding( + padding: EdgeInsets.only(left: 10), + child: Text('Actif', style: TextStyle(fontSize: 12)), + ), + ), + DropdownMenuItem( + value: 'en_attente', + child: Padding( + padding: EdgeInsets.only(left: 10), + child: Text('En attente', style: TextStyle(fontSize: 12)), + ), + ), + DropdownMenuItem( + value: 'suspendu', + child: Padding( + padding: EdgeInsets.only(left: 10), + child: Text('Suspendu', style: TextStyle(fontSize: 12)), + ), + ), + ], + onChanged: (value) { + setState(() { + _parentStatus = value; + }); + }, + ), + ); + } + + if (_subIndex == 2) { + return TextField( + controller: _amCapacityController, + decoration: const InputDecoration( + hintText: 'CapacitĂ© min', + hintStyle: TextStyle(fontSize: 12), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8), + ), + keyboardType: TextInputType.number, + ); + } + return null; + } + + Widget _buildBody() { + switch (_subIndex) { + case 0: + return GestionnaireManagementWidget( + searchQuery: _searchController.text, + ); + case 1: + return ParentManagementWidget( + searchQuery: _searchController.text, + statusFilter: _parentStatus, + ); + case 2: + return AssistanteMaternelleManagementWidget( + searchQuery: _searchController.text, + capacityMin: int.tryParse(_amCapacityController.text), + ); + case 3: + return AdminManagementWidget( + searchQuery: _searchController.text, + ); + default: + return const Center(child: Text('Page non trouvĂ©e')); + } + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + DashboardUserManagementSubBar( + selectedSubIndex: _subIndex, + onSubTabChange: _onSubTabChange, + searchController: _searchController, + searchHint: _searchHintForTab(), + filterControl: _subBarFilterControl(), + onAddPressed: () { + // TODO: brancher crĂ©ation selon onglet actif + }, + addLabel: 'Ajouter', + ), + Expanded(child: _buildBody()), + ], + ); + } +} From c136f28f12a2c761477008b36372a80030e3b5ec Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Mon, 23 Feb 2026 23:21:26 +0100 Subject: [PATCH 09/22] fix(backend): remove address from gestionnaire creation (#35) - Updated CreateGestionnaireDto to omit address field - Updated GestionnairesService to not map address on creation Co-authored-by: Cursor --- backend/src/routes/user/dto/create_gestionnaire.dto.ts | 2 +- backend/src/routes/user/gestionnaires/gestionnaires.service.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/routes/user/dto/create_gestionnaire.dto.ts b/backend/src/routes/user/dto/create_gestionnaire.dto.ts index 26f36ab..cea3687 100644 --- a/backend/src/routes/user/dto/create_gestionnaire.dto.ts +++ b/backend/src/routes/user/dto/create_gestionnaire.dto.ts @@ -2,7 +2,7 @@ import { ApiProperty, OmitType } from "@nestjs/swagger"; import { CreateUserDto } from "./create_user.dto"; import { IsOptional, IsUUID } from "class-validator"; -export class CreateGestionnaireDto extends OmitType(CreateUserDto, ['role'] as const) { +export class CreateGestionnaireDto extends OmitType(CreateUserDto, ['role', 'adresse'] as const) { @ApiProperty({ required: false, description: 'ID du relais de rattachement' }) @IsOptional() @IsUUID() diff --git a/backend/src/routes/user/gestionnaires/gestionnaires.service.ts b/backend/src/routes/user/gestionnaires/gestionnaires.service.ts index bb838d7..a8fa5a9 100644 --- a/backend/src/routes/user/gestionnaires/gestionnaires.service.ts +++ b/backend/src/routes/user/gestionnaires/gestionnaires.service.ts @@ -35,7 +35,7 @@ export class GestionnairesService { genre: dto.genre, statut: dto.statut, telephone: dto.telephone, - adresse: dto.adresse, + // adresse: dto.adresse, // Adresse retirĂ©e du formulaire de crĂ©ation photo_url: dto.photo_url, consentement_photo: dto.consentement_photo ?? false, date_consentement_photo: dto.date_consentement_photo From 04b910295cfa94576ba2523cfe684721b9103aad Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Mon, 23 Feb 2026 23:53:21 +0100 Subject: [PATCH 10/22] fix(backend): fix UpdateGestionnaireDto compilation error (#35) - Changed UpdateGestionnaireDto to inherit from PartialType(CreateUserDto) instead of CreateGestionnaireDto - Ensures all fields (like date_consentement_photo) are available for update logic Co-authored-by: Cursor --- backend/src/routes/user/dto/update_gestionnaire.dto.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/routes/user/dto/update_gestionnaire.dto.ts b/backend/src/routes/user/dto/update_gestionnaire.dto.ts index ab035db..f9a2360 100644 --- a/backend/src/routes/user/dto/update_gestionnaire.dto.ts +++ b/backend/src/routes/user/dto/update_gestionnaire.dto.ts @@ -1,4 +1,4 @@ import { PartialType } from "@nestjs/swagger"; -import { CreateGestionnaireDto } from "./create_gestionnaire.dto"; +import { CreateUserDto } from "./create_user.dto"; -export class UpdateGestionnaireDto extends PartialType(CreateGestionnaireDto) {} \ No newline at end of file +export class UpdateGestionnaireDto extends PartialType(CreateUserDto) {} \ No newline at end of file From f9477d3fbe39e008075350a79d496ea274f94cfd Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 24 Feb 2026 00:20:33 +0100 Subject: [PATCH 11/22] =?UTF-8?q?feat:=20livrer=20ticket=20#35=20et=20sync?= =?UTF-8?q?hroniser=20les=20=C3=A9volutions=20admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IntĂšgre en un seul commit les Ă©volutions de develop, avec la crĂ©ation/Ă©dition/suppression de gestionnaires via modale unifiĂ©e (#35) et les correctifs associĂ©s sur la gestion admin. Co-authored-by: Cursor --- .../creation/gestionnaires_create.dart | 448 +++++++++++++++++- frontend/lib/services/user_service.dart | 100 ++++ .../admin/gestionnaire_management_widget.dart | 96 +--- .../widgets/admin/user_management_panel.dart | 36 +- 4 files changed, 582 insertions(+), 98 deletions(-) diff --git a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart index de00a86..e658063 100644 --- a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart +++ b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart @@ -1,17 +1,451 @@ 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/relais_service.dart'; +import 'package:p_tits_pas/services/user_service.dart'; -class GestionnairesCreate extends StatelessWidget { - const GestionnairesCreate({super.key}); +class GestionnaireCreateDialog extends StatefulWidget { + final AppUser? initialUser; + + const GestionnaireCreateDialog({ + super.key, + this.initialUser, + }); + + @override + State createState() => + _GestionnaireCreateDialogState(); +} + +class _GestionnaireCreateDialogState extends State { + final _formKey = GlobalKey(); + final _nomController = TextEditingController(); + final _prenomController = TextEditingController(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + final _telephoneController = TextEditingController(); + + bool _isSubmitting = false; + bool _obscurePassword = true; + bool _isLoadingRelais = true; + List _relais = []; + String? _selectedRelaisId; + bool get _isEditMode => widget.initialUser != null; + + @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 = 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; + } + _loadRelais(); + } + + @override + void dispose() { + _nomController.dispose(); + _prenomController.dispose(); + _emailController.dispose(); + _passwordController.dispose(); + _telephoneController.dispose(); + super.dispose(); + } + + 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 { + _selectedRelaisId = null; + } + } + + setState(() { + _relais = filtered; + _isLoadingRelais = false; + }); + } catch (_) { + if (!mounted) return; + setState(() { + _selectedRelaisId = null; + _relais = []; + _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; + final email = value!.trim(); + final ok = RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(email); + if (!ok) return 'Format email invalide'; + 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 (_isSubmitting) return; + if (!_formKey.currentState!.validate()) return; + + setState(() { + _isSubmitting = true; + }); + + try { + if (_isEditMode) { + await UserService.updateGestionnaire( + gestionnaireId: widget.initialUser!.id, + nom: _nomController.text.trim(), + prenom: _prenomController.text.trim(), + email: _emailController.text.trim(), + telephone: _telephoneController.text.trim(), + relaisId: _selectedRelaisId, + password: _passwordController.text.trim().isEmpty + ? null + : _passwordController.text, + ); + } else { + await UserService.createGestionnaire( + nom: _nomController.text.trim(), + prenom: _prenomController.text.trim(), + email: _emailController.text.trim(), + password: _passwordController.text, + telephone: _telephoneController.text.trim(), + relaisId: _selectedRelaisId, + ); + } + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + _isEditMode + ? 'Gestionnaire modifiĂ© 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 (!_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'), + ), + ], + ); + }, + ); + + if (confirmed != true) 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 Scaffold( - appBar: AppBar( - title: const Text('CrĂ©er un gestionnaire'), + return AlertDialog( + title: Row( + children: [ + Expanded( + child: Text( + _isEditMode + ? 'Modifier un gestionnaire' + : 'CrĂ©er un gestionnaire', + ), + ), + if (_isEditMode) + IconButton( + icon: const Icon(Icons.close), + tooltip: 'Fermer', + onPressed: _isSubmitting + ? null + : () => Navigator.of(context).pop(false), + ), + ], ), - body: const Center( - child: Text('Formulaire de crĂ©ation de gestionnaire'), + content: SizedBox( + width: 620, + child: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded(child: _buildNomField()), + const SizedBox(width: 12), + Expanded(child: _buildPrenomField()), + ], + ), + const SizedBox(height: 12), + _buildEmailField(), + const SizedBox(height: 12), + Row( + children: [ + Expanded(child: _buildPasswordField()), + const SizedBox(width: 12), + Expanded(child: _buildTelephoneField()), + ], + ), + const SizedBox(height: 12), + _buildRelaisField(), + ], + ), + ), + ), ), + actions: [ + if (_isEditMode) ...[ + 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, + textCapitalization: TextCapitalization.words, + decoration: const InputDecoration( + labelText: 'Nom', + border: OutlineInputBorder(), + ), + validator: (v) => _required(v, 'Nom'), + ); + } + + Widget _buildPrenomField() { + return TextFormField( + controller: _prenomController, + textCapitalization: TextCapitalization.words, + decoration: const InputDecoration( + labelText: 'PrĂ©nom', + border: OutlineInputBorder(), + ), + validator: (v) => _required(v, 'PrĂ©nom'), + ); + } + + Widget _buildEmailField() { + return TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration( + labelText: 'Email', + border: OutlineInputBorder(), + ), + validator: _validateEmail, + ); + } + + Widget _buildPasswordField() { + return TextFormField( + controller: _passwordController, + 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: IconButton( + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + icon: Icon( + _obscurePassword ? Icons.visibility_off : Icons.visibility, + ), + ), + ), + validator: _validatePassword, + ); + } + + Widget _buildTelephoneField() { + return TextFormField( + controller: _telephoneController, + keyboardType: TextInputType.phone, + decoration: const InputDecoration( + labelText: 'TĂ©lĂ©phone', + border: OutlineInputBorder(), + ), + validator: (v) => _required(v, 'TĂ©lĂ©phone'), + ); + } + + 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 + ? null + : (value) { + setState(() { + _selectedRelaisId = value; + }); + }, + ), + if (_isLoadingRelais) ...[ + const SizedBox(height: 8), + const LinearProgressIndicator(minHeight: 2), + ], + ], ); } } \ No newline at end of file diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index da4f64e..d069bca 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -37,6 +37,44 @@ class UserService { return data.map((e) => AppUser.fromJson(e)).toList(); } + static Future createGestionnaire({ + required String nom, + required String prenom, + required String email, + required String password, + required String telephone, + String? relaisId, + }) async { + final response = await http.post( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.gestionnaires}'), + headers: await _headers(), + body: jsonEncode({ + 'nom': nom, + 'prenom': prenom, + 'email': email, + 'password': password, + 'telephone': telephone, + 'cguAccepted': true, + 'relaisId': relaisId, + }), + ); + + if (response.statusCode != 200 && response.statusCode != 201) { + 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 crĂ©ation gestionnaire'); + } + throw Exception('Erreur crĂ©ation gestionnaire'); + } + + final data = jsonDecode(response.body) as Map; + return AppUser.fromJson(data); + } + // RĂ©cupĂ©rer la liste des parents static Future> getParents() async { final response = await http.get( @@ -112,4 +150,66 @@ class UserService { ); } } + + static Future updateGestionnaire({ + required String gestionnaireId, + required String nom, + required String prenom, + required String email, + required String telephone, + required String? relaisId, + String? password, + }) async { + final body = { + 'nom': nom, + 'prenom': prenom, + 'email': email, + 'telephone': telephone, + 'relaisId': relaisId, + }; + + if (password != null && password.trim().isNotEmpty) { + body['password'] = password.trim(); + } + + final response = await http.patch( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.gestionnaires}/$gestionnaireId'), + headers: await _headers(), + body: jsonEncode(body), + ); + + if (response.statusCode != 200) { + 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 modification gestionnaire'); + } + throw Exception('Erreur modification gestionnaire'); + } + + final data = jsonDecode(response.body) as Map; + return AppUser.fromJson(data); + } + + 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'); + } + } } diff --git a/frontend/lib/widgets/admin/gestionnaire_management_widget.dart b/frontend/lib/widgets/admin/gestionnaire_management_widget.dart index 80d5d91..58b78e6 100644 --- a/frontend/lib/widgets/admin/gestionnaire_management_widget.dart +++ b/frontend/lib/widgets/admin/gestionnaire_management_widget.dart @@ -1,7 +1,6 @@ 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/relais_service.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/common/admin_user_card.dart'; import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; @@ -24,7 +23,6 @@ class _GestionnaireManagementWidgetState bool _isLoading = false; String? _error; List _gestionnaires = []; - List _relais = []; @override void initState() { @@ -42,17 +40,9 @@ class _GestionnaireManagementWidgetState }); try { final gestionnaires = await UserService.getGestionnaires(); - List relais = []; - try { - relais = await RelaisService.getRelais(); - } catch (_) { - // L'ecran reste utilisable meme si la route Relais n'est pas disponible. - } - if (!mounted) return; setState(() { _gestionnaires = gestionnaires; - _relais = relais; _isLoading = false; }); } catch (e) { @@ -64,81 +54,16 @@ class _GestionnaireManagementWidgetState } } - Future _openRelaisAssignmentDialog(AppUser user) async { - String? selectedRelaisId = user.relaisId; - final saved = await showDialog( + Future _openGestionnaireEditDialog(AppUser user) async { + final changed = await showDialog( context: context, - builder: (ctx) { - return StatefulBuilder( - builder: (context, setStateDialog) { - return AlertDialog( - title: Text( - 'Rattacher ${user.fullName.isEmpty ? user.email : user.fullName}', - ), - content: DropdownButtonFormField( - value: selectedRelaisId, - isExpanded: true, - 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: (value) { - setStateDialog(() { - selectedRelaisId = value; - }); - }, - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(false), - child: const Text('Annuler'), - ), - FilledButton( - onPressed: () => Navigator.of(ctx).pop(true), - child: const Text('Enregistrer'), - ), - ], - ); - }, - ); + barrierDismissible: false, + builder: (dialogContext) { + return GestionnaireCreateDialog(initialUser: user); }, ); - - if (saved != true) return; - - try { - await UserService.updateGestionnaireRelais( - gestionnaireId: user.id, - relaisId: selectedRelaisId, - ); - if (!mounted) return; + if (changed == true) { await _loadGestionnaires(); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Rattachement relais mis a jour.')), - ); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - e.toString().replaceAll('Exception: ', ''), - ), - backgroundColor: Colors.red.shade600, - ), - ); } } @@ -168,16 +93,11 @@ class _GestionnaireManagementWidgetState 'Relais : ${user.relaisNom ?? 'Non rattachĂ©'}', ], actions: [ - IconButton( - icon: const Icon(Icons.location_city_outlined), - tooltip: 'Rattacher un relais', - onPressed: () => _openRelaisAssignmentDialog(user), - ), IconButton( icon: const Icon(Icons.edit), tooltip: 'Modifier', onPressed: () { - // TODO: Modifier gestionnaire. + _openGestionnaireEditDialog(user); }, ), ], diff --git a/frontend/lib/widgets/admin/user_management_panel.dart b/frontend/lib/widgets/admin/user_management_panel.dart index 7f1e698..aa408c4 100644 --- a/frontend/lib/widgets/admin/user_management_panel.dart +++ b/frontend/lib/widgets/admin/user_management_panel.dart @@ -1,4 +1,5 @@ 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/assistante_maternelle_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart'; @@ -15,6 +16,7 @@ class AdminUserManagementPanel extends StatefulWidget { class _AdminUserManagementPanelState extends State { int _subIndex = 0; + int _gestionnaireRefreshTick = 0; final TextEditingController _searchController = TextEditingController(); final TextEditingController _amCapacityController = TextEditingController(); String? _parentStatus; @@ -133,6 +135,7 @@ class _AdminUserManagementPanelState extends State { switch (_subIndex) { case 0: return GestionnaireManagementWidget( + key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'), searchQuery: _searchController.text, ); case 1: @@ -164,13 +167,40 @@ class _AdminUserManagementPanelState extends State { searchController: _searchController, searchHint: _searchHintForTab(), filterControl: _subBarFilterControl(), - onAddPressed: () { - // TODO: brancher crĂ©ation selon onglet actif - }, + onAddPressed: _handleAddPressed, addLabel: 'Ajouter', ), Expanded(child: _buildBody()), ], ); } + + Future _handleAddPressed() async { + if (_subIndex != 0) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'La crĂ©ation est disponible uniquement pour les gestionnaires.', + ), + ), + ); + return; + } + + final created = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + return const GestionnaireCreateDialog(); + }, + ); + + if (!mounted) return; + if (created == true) { + setState(() { + _gestionnaireRefreshTick++; + }); + } + } } From 10ebc77ba1533db0a020842b4952c4d8e560ad6c Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 24 Feb 2026 10:26:24 +0100 Subject: [PATCH 12/22] feat(backend): update gestionnaire creation logic and clean up DTOs Co-authored-by: Cursor --- backend/src/entities/users.entity.ts | 2 +- .../user/dto/create_gestionnaire.dto.ts | 2 +- .../gestionnaires/gestionnaires.service.ts | 21 ++++++++++--------- check_hash.js | 7 +++++++ 4 files changed, 20 insertions(+), 12 deletions(-) create mode 100644 check_hash.js diff --git a/backend/src/entities/users.entity.ts b/backend/src/entities/users.entity.ts index 3f74dc5..649ddda 100644 --- a/backend/src/entities/users.entity.ts +++ b/backend/src/entities/users.entity.ts @@ -81,7 +81,7 @@ export class Users { type: 'enum', enum: StatutUtilisateurType, enumName: 'statut_utilisateur_type', // correspond Ă  l'enum de la db psql - default: StatutUtilisateurType.EN_ATTENTE, + default: StatutUtilisateurType.ACTIF, name: 'statut' }) statut: StatutUtilisateurType; diff --git a/backend/src/routes/user/dto/create_gestionnaire.dto.ts b/backend/src/routes/user/dto/create_gestionnaire.dto.ts index cea3687..0796c1e 100644 --- a/backend/src/routes/user/dto/create_gestionnaire.dto.ts +++ b/backend/src/routes/user/dto/create_gestionnaire.dto.ts @@ -2,7 +2,7 @@ import { ApiProperty, OmitType } from "@nestjs/swagger"; import { CreateUserDto } from "./create_user.dto"; import { IsOptional, IsUUID } from "class-validator"; -export class CreateGestionnaireDto extends OmitType(CreateUserDto, ['role', 'adresse'] as const) { +export class CreateGestionnaireDto extends OmitType(CreateUserDto, ['role', 'adresse', 'genre', 'statut', 'situation_familiale', 'ville', 'code_postal', 'photo_url', 'consentement_photo', 'date_consentement_photo', 'changement_mdp_obligatoire'] as const) { @ApiProperty({ required: false, description: 'ID du relais de rattachement' }) @IsOptional() @IsUUID() diff --git a/backend/src/routes/user/gestionnaires/gestionnaires.service.ts b/backend/src/routes/user/gestionnaires/gestionnaires.service.ts index a8fa5a9..45ccaf0 100644 --- a/backend/src/routes/user/gestionnaires/gestionnaires.service.ts +++ b/backend/src/routes/user/gestionnaires/gestionnaires.service.ts @@ -5,7 +5,7 @@ import { } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { RoleType, Users } from 'src/entities/users.entity'; +import { RoleType, StatutUtilisateurType, Users } from 'src/entities/users.entity'; import { CreateGestionnaireDto } from '../dto/create_gestionnaire.dto'; import { UpdateGestionnaireDto } from '../dto/update_gestionnaire.dto'; import * as bcrypt from 'bcrypt'; @@ -32,16 +32,17 @@ export class GestionnairesService { password: hashedPassword, prenom: dto.prenom, nom: dto.nom, - genre: dto.genre, - statut: dto.statut, + // genre: dto.genre, // RetirĂ© + // statut: dto.statut, // RetirĂ© + statut: StatutUtilisateurType.ACTIF, telephone: dto.telephone, - // adresse: dto.adresse, // Adresse retirĂ©e du formulaire de crĂ©ation - photo_url: dto.photo_url, - consentement_photo: dto.consentement_photo ?? false, - date_consentement_photo: dto.date_consentement_photo - ? new Date(dto.date_consentement_photo) - : undefined, - changement_mdp_obligatoire: true, // ForcĂ© Ă  true pour les nouveaux gestionnaires + // adresse: dto.adresse, // RetirĂ© + // photo_url: dto.photo_url, // RetirĂ© + // consentement_photo: dto.consentement_photo ?? false, // RetirĂ© + // date_consentement_photo: dto.date_consentement_photo // RetirĂ© + // ? new Date(dto.date_consentement_photo) + // : undefined, + changement_mdp_obligatoire: true, role: RoleType.GESTIONNAIRE, relaisId: dto.relaisId, }); diff --git a/check_hash.js b/check_hash.js new file mode 100644 index 0000000..cb8e7d6 --- /dev/null +++ b/check_hash.js @@ -0,0 +1,7 @@ +const bcrypt = require('bcrypt'); + +const pass = '!Bezons2014'; + +bcrypt.hash(pass, 10).then(hash => { + console.log('New Hash:', hash); +}).catch(err => console.error(err)); From 33cc7a91915407229fd85613fbfeb9243916838e Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 24 Feb 2026 11:07:14 +0100 Subject: [PATCH 13/22] feat(backend): add create admin API and update docs Co-authored-by: Cursor --- .../src/routes/user/dto/create_admin.dto.ts | 10 ++- backend/src/routes/user/user.controller.ts | 12 ++++ backend/src/routes/user/user.service.ts | 26 ++++++++ docs/23_LISTE-TICKETS.md | 62 +++++++++++++++++-- 4 files changed, 102 insertions(+), 8 deletions(-) diff --git a/backend/src/routes/user/dto/create_admin.dto.ts b/backend/src/routes/user/dto/create_admin.dto.ts index f35f781..b902948 100644 --- a/backend/src/routes/user/dto/create_admin.dto.ts +++ b/backend/src/routes/user/dto/create_admin.dto.ts @@ -1,4 +1,10 @@ -import { OmitType } from "@nestjs/swagger"; +import { PickType } from "@nestjs/swagger"; import { CreateUserDto } from "./create_user.dto"; -export class CreateAdminDto extends OmitType(CreateUserDto, ['role'] as const) {} +export class CreateAdminDto extends PickType(CreateUserDto, [ + 'nom', + 'prenom', + 'email', + 'password', + 'telephone' +] as const) {} diff --git a/backend/src/routes/user/user.controller.ts b/backend/src/routes/user/user.controller.ts index 54caa8a..af65ff1 100644 --- a/backend/src/routes/user/user.controller.ts +++ b/backend/src/routes/user/user.controller.ts @@ -6,6 +6,7 @@ import { User } from 'src/common/decorators/user.decorator'; import { RoleType, Users } from 'src/entities/users.entity'; import { UserService } from './user.service'; import { CreateUserDto } from './dto/create_user.dto'; +import { CreateAdminDto } from './dto/create_admin.dto'; import { UpdateUserDto } from './dto/update_user.dto'; @ApiTags('Utilisateurs') @@ -15,6 +16,17 @@ import { UpdateUserDto } from './dto/update_user.dto'; export class UserController { constructor(private readonly userService: UserService) { } + // CrĂ©ation d'un administrateur (rĂ©servĂ©e aux super admins) + @Post('admin') + @Roles(RoleType.SUPER_ADMIN) + @ApiOperation({ summary: 'CrĂ©er un nouvel administrateur (super admin seulement)' }) + createAdmin( + @Body() dto: CreateAdminDto, + @User() currentUser: Users + ) { + return this.userService.createAdmin(dto, currentUser); + } + // CrĂ©ation d'un utilisateur (rĂ©servĂ©e aux super admins) @Post() @Roles(RoleType.SUPER_ADMIN) diff --git a/backend/src/routes/user/user.service.ts b/backend/src/routes/user/user.service.ts index db775fb..08017f7 100644 --- a/backend/src/routes/user/user.service.ts +++ b/backend/src/routes/user/user.service.ts @@ -3,6 +3,7 @@ import { InjectRepository } from "@nestjs/typeorm"; import { RoleType, StatutUtilisateurType, Users } from "src/entities/users.entity"; import { In, Repository } from "typeorm"; import { CreateUserDto } from "./dto/create_user.dto"; +import { CreateAdminDto } from "./dto/create_admin.dto"; import { UpdateUserDto } from "./dto/update_user.dto"; import * as bcrypt from 'bcrypt'; import { StatutValidationType, Validation } from "src/entities/validations.entity"; @@ -106,6 +107,31 @@ export class UserService { return this.findOne(saved.id); } + 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'); + } + + const exist = await this.usersRepository.findOneBy({ email: dto.email }); + if (exist) throw new BadRequestException('Email dĂ©jĂ  utilisĂ©'); + + const salt = await bcrypt.genSalt(); + const hashedPassword = await bcrypt.hash(dto.password, salt); + + const entity = this.usersRepository.create({ + email: dto.email, + password: hashedPassword, + prenom: dto.prenom, + nom: dto.nom, + role: RoleType.ADMINISTRATEUR, + statut: StatutUtilisateurType.ACTIF, + telephone: dto.telephone, + changement_mdp_obligatoire: true, + }); + + return this.usersRepository.save(entity); + } + async findAll(): Promise { return this.usersRepository.find(); } diff --git a/docs/23_LISTE-TICKETS.md b/docs/23_LISTE-TICKETS.md index 3251059..16edd1e 100644 --- a/docs/23_LISTE-TICKETS.md +++ b/docs/23_LISTE-TICKETS.md @@ -1,9 +1,9 @@ # đŸŽ« Liste ComplĂšte des Tickets - Projet P'titsPas **Version** : 1.5 -**Date** : 17 FĂ©vrier 2026 +**Date** : 24 FĂ©vrier 2026 **Auteur** : Équipe PtitsPas -**Estimation totale** : ~184h +**Estimation totale** : ~208h --- @@ -33,6 +33,9 @@ | 93 | [Frontend] Panneau Admin - Homogeneiser la presentation des onglets | Ouvert | | 94 | [Backend] Relais - modele, API CRUD et liaison gestionnaire | ✅ TerminĂ© | | 95 | [Frontend] Admin - gestion des relais et rattachement gestionnaire | Ouvert | +| 96 | [Frontend] Admin - CrĂ©ation administrateur via modale (sans relais) | Ouvert | +| 97 | [Backend] Harmoniser API crĂ©ation administrateur avec le contrat frontend | ✅ TerminĂ© | +| 89 | Log des appels API en mode debug | Ouvert | *Gitea #1 et #2 = anciens tickets de test (fermĂ©s). Liste complĂšte : https://git.ptits-pas.fr/jmartin/petitspas/issues* @@ -662,6 +665,21 @@ Le back-office admin doit gĂ©rer des Relais avec des donnĂ©es rĂ©elles en base, --- +### Ticket #97 : [Backend] Harmoniser API crĂ©ation administrateur avec le contrat frontend +**Estimation** : 3h +**Labels** : `backend`, `p2`, `auth`, `admin` + +**Description** : +Rendre l'API de crĂ©ation administrateur cohĂ©rente et stable avec le besoin frontend (modale simplifiĂ©e), en dĂ©finissant un contrat clair et minimal. + +**TĂąches** : +- [ ] Introduire un DTO dĂ©diĂ© `CreateAdministrateurDto` +- [ ] Champs autorisĂ©s : nom, prenom, email, password, telephone +- [ ] Champs exclus : adresse, ville, photo, etc. +- [ ] RĂŽle forcĂ© Ă  `ADMINISTRATEUR` +- [ ] Validation stricte + +--- ## 🟱 PRIORITÉ 3 : Frontend - Interfaces ### Ticket #35 : [Frontend] Écran CrĂ©ation Gestionnaire @@ -1085,6 +1103,24 @@ Interface de gestion des Relais dans le dashboard admin et rattachement des gest --- +<<<<<<< HEAD +======= +### Ticket #96 : [Frontend] Admin - CrĂ©ation administrateur via modale (sans relais) +**Estimation** : 3h +**Labels** : `frontend`, `p3`, `admin` + +**Description** : +Permettre la crĂ©ation d'un administrateur via une modale simple depuis le dashboard admin. + +**TĂąches** : +- [ ] Bouton "CrĂ©er administrateur" dans l'onglet Administrateurs +- [ ] Modale avec formulaire simplifiĂ© (Nom, PrĂ©nom, Email, MDP, TĂ©lĂ©phone) +- [ ] Appel API `POST /users` (ou endpoint dĂ©diĂ© si #97 implĂ©mentĂ©) +- [ ] Gestion succĂšs/erreur et rafraĂźchissement liste + +--- + +>>>>>>> develop ## đŸ”” PRIORITÉ 4 : Tests & Documentation ### Ticket #52 : [Tests] Tests unitaires Backend @@ -1200,6 +1236,20 @@ Mettre en place un systĂšme de logs centralisĂ© avec Winston pour faciliter le d --- +### Ticket #89 : Log des appels API en mode debug +**Estimation** : 2h +**Labels** : `backend`, `monitoring` + +**Description** : +Ajouter des logs dĂ©taillĂ©s pour les appels API en mode debug pour faciliter le diagnostic. + +**TĂąches** : +- [ ] Middleware ou Intercepteur pour logger les requĂȘtes entrantes (mĂ©thode, URL, body) +- [ ] Logger les rĂ©ponses (status, temps d'exĂ©cution) +- [ ] Activable via variable d'environnement `DEBUG=true` ou niveau de log + +--- + ### Ticket #51 (rĂ©f.) : [Frontend] Écran Logs Admin (optionnel v1.1) **Estimation** : 4h **Labels** : `frontend`, `p3`, `monitoring`, `admin` @@ -1302,8 +1352,8 @@ RĂ©diger les documents lĂ©gaux gĂ©nĂ©riques (CGU et Politique de confidentialit ## 📊 RĂ©sumĂ© final -**Total** : 69 tickets -**Estimation** : ~200h de dĂ©veloppement +**Total** : 72 tickets +**Estimation** : ~208h de dĂ©veloppement ### Par prioritĂ© - **P0 (Bloquant BDD)** : 7 tickets (~5h) @@ -1338,7 +1388,7 @@ RĂ©diger les documents lĂ©gaux gĂ©nĂ©riques (CGU et Politique de confidentialit --- -**DerniĂšre mise Ă  jour** : 9 FĂ©vrier 2026 -**Version** : 1.4 +**DerniĂšre mise Ă  jour** : 24 FĂ©vrier 2026 +**Version** : 1.6 **Statut** : ✅ AlignĂ© avec le dĂ©pĂŽt Gitea From 119edbcfb4293b29d3b8c188783d70980756db94 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 24 Feb 2026 22:58:40 +0100 Subject: [PATCH 14/22] merge: squash develop into master MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IntĂšgre en un seul commit les Ă©volutions rĂ©centes de develop vers master, incluant la modale admin/gestionnaire, les protections super admin, les ajustements API associĂ©s et la mise Ă  jour documentaire des tickets/spec. Co-authored-by: Cursor --- .../src/routes/user/dto/create_user.dto.ts | 8 +- .../user/dto/update_gestionnaire.dto.ts | 4 +- backend/src/routes/user/user.controller.ts | 4 +- backend/src/routes/user/user.service.ts | 21 ++ docs/23_LISTE-TICKETS.md | 30 +- docs/SuperNounou_SSS-001.md | 10 +- .../creation/admin_create.dart | 357 ++++++++++++++++++ .../creation/gestionnaires_create.dart | 351 ++++++++++++++--- frontend/lib/services/user_service.dart | 162 +++++++- .../admin/admin_management_widget.dart | 71 +++- .../widgets/admin/common/admin_user_card.dart | 15 +- .../admin/gestionnaire_management_widget.dart | 3 +- .../admin/parent_managmant_widget.dart | 1 + .../widgets/admin/user_management_panel.dart | 63 +++- 14 files changed, 990 insertions(+), 110 deletions(-) create mode 100644 frontend/lib/screens/administrateurs/creation/admin_create.dart diff --git a/backend/src/routes/user/dto/create_user.dto.ts b/backend/src/routes/user/dto/create_user.dto.ts index cae620d..ee2f702 100644 --- a/backend/src/routes/user/dto/create_user.dto.ts +++ b/backend/src/routes/user/dto/create_user.dto.ts @@ -36,10 +36,10 @@ export class CreateUserDto { @MaxLength(100) nom: string; - @ApiProperty({ enum: GenreType, required: false, default: GenreType.AUTRE }) + @ApiProperty({ enum: GenreType, required: false }) @IsOptional() @IsEnum(GenreType) - genre?: GenreType = GenreType.AUTRE; + genre?: GenreType; @ApiProperty({ enum: RoleType }) @IsEnum(RoleType) @@ -86,7 +86,7 @@ export class CreateUserDto { @ApiProperty({ default: false }) @IsOptional() @IsBoolean() - consentement_photo?: boolean = false; + consentement_photo?: boolean; @ApiProperty({ required: false }) @IsOptional() @@ -96,7 +96,7 @@ export class CreateUserDto { @ApiProperty({ default: false }) @IsOptional() @IsBoolean() - changement_mdp_obligatoire?: boolean = false; + changement_mdp_obligatoire?: boolean; @ApiProperty({ example: true }) @IsBoolean() diff --git a/backend/src/routes/user/dto/update_gestionnaire.dto.ts b/backend/src/routes/user/dto/update_gestionnaire.dto.ts index f9a2360..c6956bc 100644 --- a/backend/src/routes/user/dto/update_gestionnaire.dto.ts +++ b/backend/src/routes/user/dto/update_gestionnaire.dto.ts @@ -1,4 +1,4 @@ import { PartialType } from "@nestjs/swagger"; -import { CreateUserDto } from "./create_user.dto"; +import { CreateGestionnaireDto } from "./create_gestionnaire.dto"; -export class UpdateGestionnaireDto extends PartialType(CreateUserDto) {} \ No newline at end of file +export class UpdateGestionnaireDto extends PartialType(CreateGestionnaireDto) {} diff --git a/backend/src/routes/user/user.controller.ts b/backend/src/routes/user/user.controller.ts index af65ff1..f5fc93f 100644 --- a/backend/src/routes/user/user.controller.ts +++ b/backend/src/routes/user/user.controller.ts @@ -55,9 +55,9 @@ export class UserController { return this.userService.findOne(id); } - // Modifier un utilisateur (rĂ©servĂ© super_admin) + // Modifier un utilisateur (rĂ©servĂ© super_admin et admin) @Patch(':id') - @Roles(RoleType.SUPER_ADMIN) + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) @ApiOperation({ summary: 'Mettre Ă  jour un utilisateur' }) @ApiParam({ name: 'id', description: "UUID de l'utilisateur" }) updateUser( diff --git a/backend/src/routes/user/user.service.ts b/backend/src/routes/user/user.service.ts index 08017f7..b8a88e0 100644 --- a/backend/src/routes/user/user.service.ts +++ b/backend/src/routes/user/user.service.ts @@ -155,11 +155,26 @@ export class UserService { async updateUser(id: string, dto: UpdateUserDto, currentUser: Users): Promise { const user = await this.findOne(id); + // Le super administrateur conserve une identitĂ© figĂ©e. + if ( + user.role === RoleType.SUPER_ADMIN && + (dto.nom !== undefined || dto.prenom !== undefined) + ) { + throw new ForbiddenException( + 'Le nom et le prĂ©nom du super administrateur ne peuvent pas ĂȘtre modifiĂ©s', + ); + } + // Interdire changement de rĂŽle si pas super admin if (dto.role && currentUser.role !== RoleType.SUPER_ADMIN) { throw new ForbiddenException('AccĂšs rĂ©servĂ© aux super admins'); } + // Un admin ne peut pas modifier un super admin + if (currentUser.role === RoleType.ADMINISTRATEUR && user.role === RoleType.SUPER_ADMIN) { + throw new ForbiddenException('Vous ne pouvez pas modifier un super administrateur'); + } + // EmpĂȘcher de modifier le flag changement_mdp_obligatoire pour admin/gestionnaire if ( (user.role === RoleType.ADMINISTRATEUR || user.role === RoleType.GESTIONNAIRE) && @@ -251,6 +266,12 @@ export class UserService { if (currentUser.role !== RoleType.SUPER_ADMIN) { throw new ForbiddenException('AccĂšs rĂ©servĂ© aux super admins'); } + const user = await this.findOne(id); + if (user.role === RoleType.SUPER_ADMIN) { + throw new ForbiddenException( + 'Le super administrateur ne peut pas ĂȘtre supprimĂ©', + ); + } const result = await this.usersRepository.delete(id); if (result.affected === 0) { throw new NotFoundException('Utilisateur introuvable'); diff --git a/docs/23_LISTE-TICKETS.md b/docs/23_LISTE-TICKETS.md index 16edd1e..80dee9f 100644 --- a/docs/23_LISTE-TICKETS.md +++ b/docs/23_LISTE-TICKETS.md @@ -30,10 +30,10 @@ | 17 | [Backend] API CrĂ©ation gestionnaire | ✅ TerminĂ© | | 91 | [Frontend] Inscription AM – Branchement soumission formulaire Ă  l'API | Ouvert | | 92 | [Frontend] Dashboard Admin - DonnĂ©es rĂ©elles et branchement API | ✅ TerminĂ© | -| 93 | [Frontend] Panneau Admin - Homogeneiser la presentation des onglets | Ouvert | +| 93 | [Frontend] Panneau Admin - Homogeneiser la presentation des onglets | ✅ FermĂ© | | 94 | [Backend] Relais - modele, API CRUD et liaison gestionnaire | ✅ TerminĂ© | -| 95 | [Frontend] Admin - gestion des relais et rattachement gestionnaire | Ouvert | -| 96 | [Frontend] Admin - CrĂ©ation administrateur via modale (sans relais) | Ouvert | +| 95 | [Frontend] Admin - gestion des relais et rattachement gestionnaire | ✅ FermĂ© | +| 96 | [Frontend] Admin - CrĂ©ation administrateur via modale (sans relais) | ✅ TerminĂ© | | 97 | [Backend] Harmoniser API crĂ©ation administrateur avec le contrat frontend | ✅ TerminĂ© | | 89 | Log des appels API en mode debug | Ouvert | @@ -665,9 +665,10 @@ Le back-office admin doit gĂ©rer des Relais avec des donnĂ©es rĂ©elles en base, --- -### Ticket #97 : [Backend] Harmoniser API crĂ©ation administrateur avec le contrat frontend +### Ticket #97 : [Backend] Harmoniser API crĂ©ation administrateur avec le contrat frontend ✅ **Estimation** : 3h **Labels** : `backend`, `p2`, `auth`, `admin` +**Statut** : ✅ TERMINÉ (FermĂ© le 2026-02-24) **Description** : Rendre l'API de crĂ©ation administrateur cohĂ©rente et stable avec le besoin frontend (modale simplifiĂ©e), en dĂ©finissant un contrat clair et minimal. @@ -680,6 +681,7 @@ Rendre l'API de crĂ©ation administrateur cohĂ©rente et stable avec le besoin fro - [ ] Validation stricte --- + ## 🟱 PRIORITÉ 3 : Frontend - Interfaces ### Ticket #35 : [Frontend] Écran CrĂ©ation Gestionnaire @@ -1073,9 +1075,10 @@ Branchement du formulaire d'inscription AM (Ă©tape 4) Ă  l'endpoint d'inscriptio --- -### Ticket #93 : [Frontend] Panneau Admin - HomogĂ©nĂ©isation des onglets +### Ticket #93 : [Frontend] Panneau Admin - HomogĂ©nĂ©isation des onglets ✅ **Estimation** : 4h **Labels** : `frontend`, `p3`, `admin`, `ux` +**Statut** : ✅ TERMINÉ (FermĂ© le 2026-02-24) **Description** : Uniformiser l'UI/UX des 4 onglets du dashboard admin (Gestionnaires, Parents, AM, Admins). @@ -1088,9 +1091,10 @@ Uniformiser l'UI/UX des 4 onglets du dashboard admin (Gestionnaires, Parents, AM --- -### Ticket #95 : [Frontend] Admin - Gestion des Relais et rattachement gestionnaire +### Ticket #95 : [Frontend] Admin - Gestion des Relais et rattachement gestionnaire ✅ **Estimation** : 5h **Labels** : `frontend`, `p3`, `admin` +**Statut** : ✅ TERMINÉ (FermĂ© le 2026-02-24) **Description** : Interface de gestion des Relais dans le dashboard admin et rattachement des gestionnaires. @@ -1103,24 +1107,22 @@ Interface de gestion des Relais dans le dashboard admin et rattachement des gest --- -<<<<<<< HEAD -======= -### Ticket #96 : [Frontend] Admin - CrĂ©ation administrateur via modale (sans relais) +### Ticket #96 : [Frontend] Admin - CrĂ©ation administrateur via modale (sans relais) ✅ **Estimation** : 3h **Labels** : `frontend`, `p3`, `admin` +**Statut** : ✅ TERMINÉ (FermĂ© le 2026-02-24) **Description** : Permettre la crĂ©ation d'un administrateur via une modale simple depuis le dashboard admin. **TĂąches** : -- [ ] Bouton "CrĂ©er administrateur" dans l'onglet Administrateurs -- [ ] Modale avec formulaire simplifiĂ© (Nom, PrĂ©nom, Email, MDP, TĂ©lĂ©phone) -- [ ] Appel API `POST /users` (ou endpoint dĂ©diĂ© si #97 implĂ©mentĂ©) -- [ ] Gestion succĂšs/erreur et rafraĂźchissement liste +- [x] Bouton "CrĂ©er administrateur" dans l'onglet Administrateurs +- [x] Modale avec formulaire simplifiĂ© (Nom, PrĂ©nom, Email, MDP, TĂ©lĂ©phone) +- [x] Appel API `POST /users` (ou endpoint dĂ©diĂ© si #97 implĂ©mentĂ©) +- [x] Gestion succĂšs/erreur et rafraĂźchissement liste --- ->>>>>>> develop ## đŸ”” PRIORITÉ 4 : Tests & Documentation ### Ticket #52 : [Tests] Tests unitaires Backend diff --git a/docs/SuperNounou_SSS-001.md b/docs/SuperNounou_SSS-001.md index 68553e3..2cf8699 100644 --- a/docs/SuperNounou_SSS-001.md +++ b/docs/SuperNounou_SSS-001.md @@ -1,6 +1,6 @@ # SuperNounou – SSS-001 ## SpĂ©cification technique & opĂ©rationnelle unifiĂ©e -_Version 0.2 – 24 avril 2025_ +_Version 0.3 – 27 janvier 2026_ --- @@ -62,6 +62,13 @@ Collection Postman, scripts cURL, guide « Appeler l’API ». ### B.4 IntĂ©grations futures SSO LDAP/SAML, webhook `contract.validated`, export statistiques CSV. +### B.5 Contrat de gestion des comptes d'administration +- CrĂ©ation d'un administrateur avec un contrat minimal stable : `nom`, `prenom`, `email`, `password`, `telephone`. +- Le rĂŽle n'est jamais fourni par le frontend pour ce flux ; le backend impose `ADMINISTRATEUR`. +- Les champs hors pĂ©rimĂštre (adresse complĂšte, photo, mĂ©tadonnĂ©es mĂ©tier non nĂ©cessaires) ne sont pas requis. +- Les protections d'autorisation restent actives : un `SUPER_ADMIN` n'est pas supprimable et son identitĂ© (`nom`, `prenom`) est non modifiable. +- CĂŽtĂ© interface d'administration, les actions d'Ă©dition sont conditionnĂ©es aux droits ; les entrĂ©es non Ă©ditables restent consultables en lecture seule. + --- # C – DĂ©ploiement, CI/CD et ObservabilitĂ© *(nouveau)* @@ -106,3 +113,4 @@ AES-256, JWT, KMS, OpenAPI, RPO, RTO, rate-limit, HMAC, Compose, CI/CD
 |---------|------------|------------------|---------------------------------| | 0.1-draft | 2025-04-24 | Équipe projet | CrĂ©ation du SSS unifiĂ© | | 0.2 | 2025-04-24 | ChatGPT & Julien | Ajout dĂ©ploiement / CI/CD / logs | +| 0.3 | 2026-01-27 | Équipe projet | Contrat admin harmonisĂ© et rĂšgles d'autorisation | diff --git a/frontend/lib/screens/administrateurs/creation/admin_create.dart b/frontend/lib/screens/administrateurs/creation/admin_create.dart new file mode 100644 index 0000000..e57ac71 --- /dev/null +++ b/frontend/lib/screens/administrateurs/creation/admin_create.dart @@ -0,0 +1,357 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/user.dart'; +import 'package:p_tits_pas/services/user_service.dart'; + +class AdminCreateDialog extends StatefulWidget { + final AppUser? initialUser; + + const AdminCreateDialog({ + super.key, + this.initialUser, + }); + + @override + State createState() => _AdminCreateDialogState(); +} + +class _AdminCreateDialogState extends State { + final _formKey = GlobalKey(); + final _nomController = TextEditingController(); + final _prenomController = TextEditingController(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + final _telephoneController = TextEditingController(); + + bool _isSubmitting = false; + bool _obscurePassword = true; + bool get _isEditMode => widget.initialUser != null; + + @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 = user.telephone ?? ''; + // En Ă©dition, on ne prĂ©remplit jamais le mot de passe. + _passwordController.clear(); + } + } + + @override + void dispose() { + _nomController.dispose(); + _prenomController.dispose(); + _emailController.dispose(); + _passwordController.dispose(); + _telephoneController.dispose(); + super.dispose(); + } + + 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; + final email = value!.trim(); + final ok = RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(email); + if (!ok) return 'Format email invalide'; + 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 (_isSubmitting) return; + if (!_formKey.currentState!.validate()) return; + + setState(() { + _isSubmitting = true; + }); + + try { + if (_isEditMode) { + await UserService.updateAdmin( + adminId: widget.initialUser!.id, + nom: _nomController.text.trim(), + prenom: _prenomController.text.trim(), + email: _emailController.text.trim(), + telephone: _telephoneController.text.trim(), + password: _passwordController.text.trim().isEmpty + ? null + : _passwordController.text, + ); + } else { + await UserService.createAdmin( + nom: _nomController.text.trim(), + prenom: _prenomController.text.trim(), + email: _emailController.text.trim(), + password: _passwordController.text, + telephone: _telephoneController.text.trim(), + ); + } + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + _isEditMode + ? 'Administrateur modifiĂ© avec succĂšs.' + : 'Administrateur 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 (!_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'), + ), + ], + ); + }, + ); + + if (confirmed != true) return; + + setState(() { + _isSubmitting = true; + }); + try { + await UserService.deleteUser(widget.initialUser!.id); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Administrateur 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: [ + Expanded( + child: Text( + _isEditMode + ? 'Modifier un administrateur' + : 'CrĂ©er un administrateur', + ), + ), + if (_isEditMode) + 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: _buildNomField()), + const SizedBox(width: 12), + Expanded(child: _buildPrenomField()), + ], + ), + const SizedBox(height: 12), + _buildEmailField(), + const SizedBox(height: 12), + Row( + children: [ + Expanded(child: _buildPasswordField()), + const SizedBox(width: 12), + Expanded(child: _buildTelephoneField()), + ], + ), + ], + ), + ), + ), + ), + actions: [ + if (_isEditMode) ...[ + 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, + textCapitalization: TextCapitalization.words, + decoration: const InputDecoration( + labelText: 'Nom', + border: OutlineInputBorder(), + ), + validator: (v) => _required(v, 'Nom'), + ); + } + + Widget _buildPrenomField() { + return TextFormField( + controller: _prenomController, + textCapitalization: TextCapitalization.words, + decoration: const InputDecoration( + labelText: 'PrĂ©nom', + border: OutlineInputBorder(), + ), + validator: (v) => _required(v, 'PrĂ©nom'), + ); + } + + Widget _buildEmailField() { + return TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration( + labelText: 'Email', + border: OutlineInputBorder(), + ), + validator: _validateEmail, + ); + } + + Widget _buildPasswordField() { + return TextFormField( + controller: _passwordController, + 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: IconButton( + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + icon: Icon( + _obscurePassword ? Icons.visibility_off : Icons.visibility, + ), + ), + ), + validator: _validatePassword, + ); + } + + Widget _buildTelephoneField() { + return TextFormField( + controller: _telephoneController, + keyboardType: TextInputType.phone, + decoration: const InputDecoration( + labelText: 'TĂ©lĂ©phone', + border: OutlineInputBorder(), + ), + validator: (v) => _required(v, 'TĂ©lĂ©phone'), + ); + } +} diff --git a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart index e658063..3a2ce05 100644 --- a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart +++ b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart @@ -1,29 +1,37 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:p_tits_pas/models/relais_model.dart'; import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/services/relais_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; -class GestionnaireCreateDialog extends StatefulWidget { +class AdminUserFormDialog extends StatefulWidget { final AppUser? initialUser; + final bool withRelais; + final bool adminMode; + final bool readOnly; - const GestionnaireCreateDialog({ + const AdminUserFormDialog({ super.key, this.initialUser, + this.withRelais = true, + this.adminMode = false, + this.readOnly = false, }); @override - State createState() => - _GestionnaireCreateDialogState(); + State createState() => _AdminUserFormDialogState(); } -class _GestionnaireCreateDialogState extends State { +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; @@ -31,6 +39,50 @@ class _GestionnaireCreateDialogState extends State { List _relais = []; String? _selectedRelaisId; bool get _isEditMode => widget.initialUser != null; + bool get _isSuperAdminTarget => + widget.initialUser?.role.toLowerCase() == 'super_admin'; + bool get _isLockedAdminIdentity => + _isEditMode && widget.adminMode && _isSuperAdminTarget; + String get _targetRoleKey { + if (widget.initialUser != null) { + return widget.initialUser!.role.toLowerCase(); + } + return widget.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() { @@ -40,7 +92,7 @@ class _GestionnaireCreateDialogState extends State { _nomController.text = user.nom ?? ''; _prenomController.text = user.prenom ?? ''; _emailController.text = user.email; - _telephoneController.text = user.telephone ?? ''; + _telephoneController.text = _formatPhoneForDisplay(user.telephone ?? ''); // En Ă©dition, on ne prĂ©remplit jamais le mot de passe. _passwordController.clear(); final initialRelaisId = user.relaisId?.trim(); @@ -49,7 +101,11 @@ class _GestionnaireCreateDialogState extends State { ? null : initialRelaisId; } - _loadRelais(); + if (widget.withRelais) { + _loadRelais(); + } else { + _isLoadingRelais = false; + } } @override @@ -59,6 +115,7 @@ class _GestionnaireCreateDialogState extends State { _emailController.dispose(); _passwordController.dispose(); _telephoneController.dispose(); + _passwordToggleFocusNode.dispose(); super.dispose(); } @@ -122,7 +179,68 @@ class _GestionnaireCreateDialogState extends State { 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; + final digits = _normalizePhone(value!); + if (digits.length != 10) { + return 'Le tĂ©lĂ©phone doit contenir 10 chiffres'; + } + if (!digits.startsWith('0')) { + return 'Le tĂ©lĂ©phone doit commencer par 0'; + } + return null; + } + + String _normalizePhone(String raw) { + return raw.replaceAll(RegExp(r'\D'), ''); + } + + String _formatPhoneForDisplay(String raw) { + final normalized = _normalizePhone(raw); + final digits = + normalized.length > 10 ? normalized.substring(0, 10) : normalized; + final buffer = StringBuffer(); + for (var i = 0; i < digits.length; i++) { + if (i > 0 && i.isEven) buffer.write(' '); + buffer.write(digits[i]); + } + return buffer.toString(); + } + + String _toTitleCase(String raw) { + final trimmed = raw.trim(); + if (trimmed.isEmpty) return trimmed; + 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; @@ -131,35 +249,87 @@ class _GestionnaireCreateDialogState extends State { }); try { + final normalizedNom = _toTitleCase(_nomController.text); + final normalizedPrenom = _toTitleCase(_prenomController.text); + final normalizedPhone = _normalizePhone(_telephoneController.text); + final passwordProvided = _passwordController.text.trim().isNotEmpty; + if (_isEditMode) { - await UserService.updateGestionnaire( - gestionnaireId: widget.initialUser!.id, - nom: _nomController.text.trim(), - prenom: _prenomController.text.trim(), - email: _emailController.text.trim(), - telephone: _telephoneController.text.trim(), - relaisId: _selectedRelaisId, - password: _passwordController.text.trim().isEmpty - ? null - : _passwordController.text, - ); + 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 { - await UserService.createGestionnaire( - nom: _nomController.text.trim(), - prenom: _prenomController.text.trim(), - email: _emailController.text.trim(), - password: _passwordController.text, - telephone: _telephoneController.text.trim(), - relaisId: _selectedRelaisId, - ); + 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 - ? 'Gestionnaire modifiĂ© avec succĂšs.' - : 'Gestionnaire créé avec succĂšs.', + ? (widget.adminMode + ? 'Administrateur modifiĂ© avec succĂšs.' + : 'Gestionnaire modifiĂ© avec succĂšs.') + : (widget.adminMode + ? 'Administrateur créé avec succĂšs.' + : 'Gestionnaire créé avec succĂšs.'), ), ), ); @@ -183,6 +353,8 @@ class _GestionnaireCreateDialogState extends State { } Future _delete() async { + if (widget.readOnly) return; + if (_isSuperAdminTarget) return; if (!_isEditMode || _isSubmitting) return; final confirmed = await showDialog( @@ -239,14 +411,26 @@ class _GestionnaireCreateDialogState extends State { 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 - ? 'Modifier un gestionnaire' - : 'CrĂ©er un gestionnaire', + ? (widget.readOnly + ? 'Consulter un "$_targetRoleLabel"' + : 'Modifier un "$_targetRoleLabel"') + : 'CrĂ©er un "$_targetRoleLabel"', ), ), - if (_isEditMode) + if (_isEditMode && !widget.readOnly) IconButton( icon: const Icon(Icons.close), tooltip: 'Fermer', @@ -266,9 +450,9 @@ class _GestionnaireCreateDialogState extends State { children: [ Row( children: [ - Expanded(child: _buildNomField()), - const SizedBox(width: 12), Expanded(child: _buildPrenomField()), + const SizedBox(width: 12), + Expanded(child: _buildNomField()), ], ), const SizedBox(height: 12), @@ -281,20 +465,28 @@ class _GestionnaireCreateDialogState extends State { Expanded(child: _buildTelephoneField()), ], ), - const SizedBox(height: 12), - _buildRelaisField(), + if (widget.withRelais) ...[ + const SizedBox(height: 12), + _buildRelaisField(), + ], ], ), ), ), ), actions: [ - if (_isEditMode) ...[ - OutlinedButton( - onPressed: _isSubmitting ? null : _delete, - style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700), - child: const Text('Supprimer'), + if (widget.readOnly) ...[ + FilledButton( + onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(false), + child: const Text('Fermer'), ), + ] else if (_isEditMode) ...[ + if (!_isSuperAdminTarget) + OutlinedButton( + onPressed: _isSubmitting ? null : _delete, + style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700), + child: const Text('Supprimer'), + ), FilledButton.icon( onPressed: _isSubmitting ? null : _submit, icon: _isSubmitting @@ -331,42 +523,50 @@ class _GestionnaireCreateDialogState extends State { Widget _buildNomField() { return TextFormField( controller: _nomController, + readOnly: widget.readOnly || _isLockedAdminIdentity, textCapitalization: TextCapitalization.words, decoration: const InputDecoration( labelText: 'Nom', border: OutlineInputBorder(), ), - validator: (v) => _required(v, 'Nom'), + 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: (v) => _required(v, 'PrĂ©nom'), + validator: (widget.readOnly || _isLockedAdminIdentity) + ? null + : (v) => _required(v, 'PrĂ©nom'), ); } Widget _buildEmailField() { return TextFormField( controller: _emailController, + readOnly: widget.readOnly, keyboardType: TextInputType.emailAddress, decoration: const InputDecoration( labelText: 'Email', border: OutlineInputBorder(), ), - validator: _validateEmail, + validator: widget.readOnly ? null : _validateEmail, ); } Widget _buildPasswordField() { return TextFormField( controller: _passwordController, + readOnly: widget.readOnly, obscureText: _obscurePassword, enableSuggestions: false, autocorrect: false, @@ -378,30 +578,43 @@ class _GestionnaireCreateDialogState extends State { ? 'Nouveau mot de passe' : 'Mot de passe', border: const OutlineInputBorder(), - suffixIcon: IconButton( - onPressed: () { - setState(() { - _obscurePassword = !_obscurePassword; - }); - }, - icon: Icon( - _obscurePassword ? Icons.visibility_off : Icons.visibility, - ), - ), + suffixIcon: widget.readOnly + ? null + : ExcludeFocus( + child: IconButton( + focusNode: _passwordToggleFocusNode, + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + icon: Icon( + _obscurePassword ? Icons.visibility_off : Icons.visibility, + ), + ), + ), ), - validator: _validatePassword, + validator: widget.readOnly ? null : _validatePassword, ); } Widget _buildTelephoneField() { return TextFormField( controller: _telephoneController, + readOnly: widget.readOnly, keyboardType: TextInputType.phone, + inputFormatters: widget.readOnly + ? null + : [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(10), + _FrenchPhoneNumberFormatter(), + ], decoration: const InputDecoration( - labelText: 'TĂ©lĂ©phone', + labelText: 'TĂ©lĂ©phone (ex: 06 12 34 56 78)', border: OutlineInputBorder(), ), - validator: (v) => _required(v, 'TĂ©lĂ©phone'), + validator: widget.readOnly ? null : _validatePhone, ); } @@ -433,7 +646,7 @@ class _GestionnaireCreateDialogState extends State { ), ), ], - onChanged: _isLoadingRelais + onChanged: (_isLoadingRelais || widget.readOnly) ? null : (value) { setState(() { @@ -448,4 +661,28 @@ class _GestionnaireCreateDialogState extends State { ], ); } +} + +class _FrenchPhoneNumberFormatter extends TextInputFormatter { + const _FrenchPhoneNumberFormatter(); + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + final digits = newValue.text.replaceAll(RegExp(r'\D'), ''); + final normalized = digits.length > 10 ? digits.substring(0, 10) : digits; + final buffer = StringBuffer(); + for (var i = 0; i < normalized.length; i++) { + if (i > 0 && i.isEven) buffer.write(' '); + buffer.write(normalized[i]); + } + final formatted = buffer.toString(); + + return TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: formatted.length), + ); + } } \ No newline at end of file diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index d069bca..ab8e5a7 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -75,6 +75,41 @@ class UserService { return AppUser.fromJson(data); } + static Future createAdministrateur({ + required String nom, + required String prenom, + required String email, + required String password, + required String telephone, + }) async { + final response = await http.post( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/admin'), + headers: await _headers(), + body: jsonEncode({ + 'nom': nom, + 'prenom': prenom, + 'email': email, + 'password': password, + 'telephone': telephone, + }), + ); + + if (response.statusCode != 200 && response.statusCode != 201) { + 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 crĂ©ation administrateur'); + } + throw Exception('Erreur crĂ©ation administrateur'); + } + + final data = jsonDecode(response.body) as Map; + return AppUser.fromJson(data); + } + // RĂ©cupĂ©rer la liste des parents static Future> getParents() async { final response = await http.get( @@ -132,6 +167,82 @@ class UserService { return []; } + static Future createAdmin({ + required String nom, + required String prenom, + required String email, + required String password, + required String telephone, + }) async { + final response = await http.post( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/admin'), + headers: await _headers(), + body: jsonEncode({ + 'nom': nom, + 'prenom': prenom, + 'email': email, + 'password': password, + 'telephone': telephone, + }), + ); + + if (response.statusCode != 200 && response.statusCode != 201) { + 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 crĂ©ation administrateur'); + } + throw Exception('Erreur crĂ©ation administrateur'); + } + + final data = jsonDecode(response.body) as Map; + return AppUser.fromJson(data); + } + + static Future updateAdmin({ + required String adminId, + required String nom, + required String prenom, + required String email, + required String telephone, + String? password, + }) async { + final body = { + 'nom': nom, + 'prenom': prenom, + 'email': email, + 'telephone': telephone, + }; + + if (password != null && password.trim().isNotEmpty) { + body['password'] = password.trim(); + } + + final response = await http.patch( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$adminId'), + headers: await _headers(), + body: jsonEncode(body), + ); + + if (response.statusCode != 200) { + 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 modification administrateur'); + } + throw Exception('Erreur modification administrateur'); + } + + final data = jsonDecode(response.body) as Map; + return AppUser.fromJson(data); + } + static Future updateGestionnaireRelais({ required String gestionnaireId, required String? relaisId, @@ -156,7 +267,7 @@ class UserService { required String nom, required String prenom, required String email, - required String telephone, + String? telephone, required String? relaisId, String? password, }) async { @@ -164,10 +275,13 @@ class UserService { 'nom': nom, 'prenom': prenom, 'email': email, - 'telephone': telephone, 'relaisId': relaisId, }; + if (telephone != null && telephone.trim().isNotEmpty) { + body['telephone'] = telephone.trim(); + } + if (password != null && password.trim().isNotEmpty) { body['password'] = password.trim(); } @@ -194,6 +308,50 @@ class UserService { return AppUser.fromJson(data); } + static Future updateAdministrateur({ + required String adminId, + required String nom, + required String prenom, + required String email, + String? telephone, + String? password, + }) async { + final body = { + 'nom': nom, + 'prenom': prenom, + 'email': email, + }; + + if (telephone != null && telephone.trim().isNotEmpty) { + body['telephone'] = telephone.trim(); + } + + if (password != null && password.trim().isNotEmpty) { + body['password'] = password.trim(); + } + + final response = await http.patch( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$adminId'), + headers: await _headers(), + body: jsonEncode(body), + ); + + if (response.statusCode != 200) { + 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 modification administrateur'); + } + throw Exception('Erreur modification administrateur'); + } + + final data = jsonDecode(response.body) as Map; + return AppUser.fromJson(data); + } + static Future deleteUser(String userId) async { final response = await http.delete( Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId'), diff --git a/frontend/lib/widgets/admin/admin_management_widget.dart b/frontend/lib/widgets/admin/admin_management_widget.dart index 666a64e..be51365 100644 --- a/frontend/lib/widgets/admin/admin_management_widget.dart +++ b/frontend/lib/widgets/admin/admin_management_widget.dart @@ -1,5 +1,7 @@ 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/widgets/admin/common/admin_user_card.dart'; import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; @@ -20,10 +22,12 @@ class _AdminManagementWidgetState extends State { bool _isLoading = false; String? _error; List _admins = []; + String? _currentUserRole; @override void initState() { super.initState(); + _loadCurrentUserRole(); _loadAdmins(); } @@ -51,6 +55,48 @@ class _AdminManagementWidgetState extends State { } } + Future _loadCurrentUserRole() async { + final cached = await AuthService.getCurrentUser(); + if (!mounted) return; + if (cached != null) { + setState(() { + _currentUserRole = cached.role.toLowerCase(); + }); + return; + } + final refreshed = await AuthService.refreshCurrentUser(); + if (!mounted || refreshed == null) return; + setState(() { + _currentUserRole = refreshed.role.toLowerCase(); + }); + } + + bool _isSuperAdmin(AppUser user) => user.role.toLowerCase() == 'super_admin'; + + bool _canEditAdmin(AppUser target) { + if (!_isSuperAdmin(target)) return true; + return _currentUserRole == 'super_admin'; + } + + Future _openAdminEditDialog(AppUser user) async { + final canEdit = _canEditAdmin(user); + final changed = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + return AdminUserFormDialog( + initialUser: user, + adminMode: true, + withRelais: false, + readOnly: !canEdit, + ); + }, + ); + if (changed == true && canEdit) { + await _loadAdmins(); + } + } + @override Widget build(BuildContext context) { final query = widget.searchQuery.toLowerCase(); @@ -68,19 +114,36 @@ class _AdminManagementWidgetState extends State { itemCount: filteredAdmins.length, itemBuilder: (context, index) { final user = filteredAdmins[index]; + final isSuperAdmin = _isSuperAdmin(user); + final canEdit = _canEditAdmin(user); return AdminUserCard( title: user.fullName, + fallbackIcon: isSuperAdmin + ? Icons.verified_user_outlined + : Icons.manage_accounts_outlined, subtitleLines: [ user.email, - 'RĂŽle : ${user.role}', + 'TĂ©lĂ©phone : ${user.telephone?.trim().isNotEmpty == true ? user.telephone : 'Non renseignĂ©'}', ], avatarUrl: user.photoUrl, + borderColor: isSuperAdmin + ? const Color(0xFF8E6AC8) + : Colors.grey.shade300, + backgroundColor: isSuperAdmin + ? const Color(0xFFF4EEFF) + : Colors.white, + titleColor: isSuperAdmin ? const Color(0xFF5D2F99) : null, + infoColor: isSuperAdmin + ? const Color(0xFF6D4EA1) + : Colors.black54, actions: [ IconButton( - icon: const Icon(Icons.edit), - tooltip: 'Modifier', + icon: Icon( + canEdit ? Icons.edit_outlined : Icons.visibility_outlined, + ), + tooltip: canEdit ? 'Modifier' : 'Consulter', onPressed: () { - // TODO: Modifier admin + _openAdminEditDialog(user); }, ), ], diff --git a/frontend/lib/widgets/admin/common/admin_user_card.dart b/frontend/lib/widgets/admin/common/admin_user_card.dart index 914e218..92d4237 100644 --- a/frontend/lib/widgets/admin/common/admin_user_card.dart +++ b/frontend/lib/widgets/admin/common/admin_user_card.dart @@ -6,6 +6,10 @@ class AdminUserCard extends StatefulWidget { final String? avatarUrl; final IconData fallbackIcon; final List actions; + final Color? borderColor; + final Color? backgroundColor; + final Color? titleColor; + final Color? infoColor; const AdminUserCard({ super.key, @@ -14,6 +18,10 @@ class AdminUserCard extends StatefulWidget { this.avatarUrl, this.fallbackIcon = Icons.person, this.actions = const [], + this.borderColor, + this.backgroundColor, + this.titleColor, + this.infoColor, }); @override @@ -43,9 +51,10 @@ class _AdminUserCardState extends State { child: Card( margin: const EdgeInsets.only(bottom: 12), elevation: 0, + color: widget.backgroundColor, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), - side: BorderSide(color: Colors.grey.shade300), + side: BorderSide(color: widget.borderColor ?? Colors.grey.shade300), ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), @@ -76,7 +85,7 @@ class _AdminUserCardState extends State { style: const TextStyle( fontWeight: FontWeight.w600, fontSize: 14, - ), + ).copyWith(color: widget.titleColor), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -88,7 +97,7 @@ class _AdminUserCardState extends State { style: const TextStyle( color: Colors.black54, fontSize: 12, - ), + ).copyWith(color: widget.infoColor), maxLines: 1, overflow: TextOverflow.ellipsis, ), diff --git a/frontend/lib/widgets/admin/gestionnaire_management_widget.dart b/frontend/lib/widgets/admin/gestionnaire_management_widget.dart index 58b78e6..2692831 100644 --- a/frontend/lib/widgets/admin/gestionnaire_management_widget.dart +++ b/frontend/lib/widgets/admin/gestionnaire_management_widget.dart @@ -59,7 +59,7 @@ class _GestionnaireManagementWidgetState context: context, barrierDismissible: false, builder: (dialogContext) { - return GestionnaireCreateDialog(initialUser: user); + return AdminUserFormDialog(initialUser: user); }, ); if (changed == true) { @@ -86,6 +86,7 @@ class _GestionnaireManagementWidgetState final user = filteredGestionnaires[index]; return AdminUserCard( title: user.fullName, + fallbackIcon: Icons.assignment_ind_outlined, avatarUrl: user.photoUrl, subtitleLines: [ user.email, diff --git a/frontend/lib/widgets/admin/parent_managmant_widget.dart b/frontend/lib/widgets/admin/parent_managmant_widget.dart index cfa8637..5b8e20a 100644 --- a/frontend/lib/widgets/admin/parent_managmant_widget.dart +++ b/frontend/lib/widgets/admin/parent_managmant_widget.dart @@ -75,6 +75,7 @@ class _ParentManagementWidgetState extends State { final parent = filteredParents[index]; return AdminUserCard( title: parent.user.fullName, + fallbackIcon: Icons.supervisor_account_outlined, avatarUrl: parent.user.photoUrl, subtitleLines: [ parent.user.email, diff --git a/frontend/lib/widgets/admin/user_management_panel.dart b/frontend/lib/widgets/admin/user_management_panel.dart index aa408c4..63bc921 100644 --- a/frontend/lib/widgets/admin/user_management_panel.dart +++ b/frontend/lib/widgets/admin/user_management_panel.dart @@ -17,6 +17,7 @@ class AdminUserManagementPanel extends StatefulWidget { class _AdminUserManagementPanelState extends State { int _subIndex = 0; int _gestionnaireRefreshTick = 0; + int _adminRefreshTick = 0; final TextEditingController _searchController = TextEditingController(); final TextEditingController _amCapacityController = TextEditingController(); String? _parentStatus; @@ -150,6 +151,7 @@ class _AdminUserManagementPanelState extends State { ); case 3: return AdminManagementWidget( + key: ValueKey('admins-$_adminRefreshTick'), searchQuery: _searchController.text, ); default: @@ -176,31 +178,52 @@ class _AdminUserManagementPanelState extends State { } Future _handleAddPressed() async { - if (_subIndex != 0) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'La crĂ©ation est disponible uniquement pour les gestionnaires.', - ), - ), + if (_subIndex == 0) { + final created = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + return const AdminUserFormDialog(); + }, ); + + if (!mounted) return; + if (created == true) { + setState(() { + _gestionnaireRefreshTick++; + }); + } return; } - final created = await showDialog( - context: context, - barrierDismissible: false, - builder: (dialogContext) { - return const GestionnaireCreateDialog(); - }, - ); + if (_subIndex == 3) { + final created = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + return const AdminUserFormDialog( + adminMode: true, + withRelais: false, + ); + }, + ); + + if (!mounted) return; + if (created == true) { + setState(() { + _adminRefreshTick++; + }); + } + return; + } if (!mounted) return; - if (created == true) { - setState(() { - _gestionnaireRefreshTick++; - }); - } + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'La crĂ©ation est disponible pour les gestionnaires et administrateurs.', + ), + ), + ); } } From 6749f2025a47e6f1b972ce3bfb9d5cd83c417ea7 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 24 Feb 2026 23:15:29 +0100 Subject: [PATCH 15/22] fix(backend): remove date_consentement_photo from gestionnaire update Co-authored-by: Cursor --- .../user/gestionnaires/gestionnaires.service.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/src/routes/user/gestionnaires/gestionnaires.service.ts b/backend/src/routes/user/gestionnaires/gestionnaires.service.ts index 45ccaf0..25b48cc 100644 --- a/backend/src/routes/user/gestionnaires/gestionnaires.service.ts +++ b/backend/src/routes/user/gestionnaires/gestionnaires.service.ts @@ -91,13 +91,13 @@ export class GestionnairesService { gestionnaire.password = await bcrypt.hash(dto.password, salt); } - if (dto.date_consentement_photo !== undefined) { - gestionnaire.date_consentement_photo = dto.date_consentement_photo - ? new Date(dto.date_consentement_photo) - : undefined; - } + // if (dto.date_consentement_photo !== undefined) { + // gestionnaire.date_consentement_photo = dto.date_consentement_photo + // ? new Date(dto.date_consentement_photo) + // : undefined; + // } - const { password, date_consentement_photo, ...rest } = dto; + const { password, ...rest } = dto; Object.entries(rest).forEach(([key, value]) => { if (value !== undefined) { (gestionnaire as any)[key] = value; From 619e39219f153adfd1b082611d149a47d6db80ca Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Wed, 25 Feb 2026 12:00:51 +0100 Subject: [PATCH 16/22] merge: squash develop into master (login autofill + clavier #98) Co-authored-by: Cursor --- frontend/lib/screens/auth/login_screen.dart | 432 ++++++++++-------- .../lib/widgets/custom_app_text_field.dart | 51 ++- frontend/lib/widgets/image_button.dart | 52 ++- 3 files changed, 308 insertions(+), 227 deletions(-) diff --git a/frontend/lib/screens/auth/login_screen.dart b/frontend/lib/screens/auth/login_screen.dart index b441062..8efaf2e 100644 --- a/frontend/lib/screens/auth/login_screen.dart +++ b/frontend/lib/screens/auth/login_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:go_router/go_router.dart'; @@ -20,7 +21,7 @@ class _LoginPageState extends State with WidgetsBindingObserver { final _formKey = GlobalKey(); final _emailController = TextEditingController(); final _passwordController = TextEditingController(); - + bool _isLoading = false; String? _errorMessage; @@ -63,6 +64,11 @@ class _LoginPageState extends State with WidgetsBindingObserver { return null; } + void _handlePasswordSubmitted(String _) { + if (_isLoading) return; + _handleLogin(); + } + /// GĂšre la connexion de l'utilisateur Future _handleLogin() async { // RĂ©initialiser le message d'erreur @@ -90,7 +96,7 @@ class _LoginPageState extends State with WidgetsBindingObserver { // VĂ©rifier si l'utilisateur doit changer son mot de passe if (user.changementMdpObligatoire) { if (!mounted) return; - + // Afficher la modale de changement de mot de passe (non-dismissible) final result = await showDialog( context: context, @@ -106,6 +112,9 @@ class _LoginPageState extends State with WidgetsBindingObserver { if (!mounted) return; + // Laisse au navigateur/OS la possibilitĂ© de mĂ©moriser les identifiants. + TextInput.finishAutofillContext(shouldSave: true); + // Rediriger selon le rĂŽle de l'utilisateur _redirectUserByRole(user.role); } catch (e) { @@ -152,47 +161,49 @@ class _LoginPageState extends State with WidgetsBindingObserver { final w = constraints.maxWidth; final h = constraints.maxHeight; return FutureBuilder( - future: _getImageDimensions(), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const Center(child: CircularProgressIndicator()); - } + future: _getImageDimensions(), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Center(child: CircularProgressIndicator()); + } - final imageDimensions = snapshot.data!; - final imageHeight = h; - final imageWidth = imageHeight * (imageDimensions.width / imageDimensions.height); - final remainingWidth = w - imageWidth; - final leftMargin = remainingWidth / 4; + final imageDimensions = snapshot.data!; + final imageHeight = h; + final imageWidth = imageHeight * + (imageDimensions.width / imageDimensions.height); + final remainingWidth = w - imageWidth; + final leftMargin = remainingWidth / 4; - return Stack( - children: [ - // Fond en papier - Positioned.fill( - child: Image.asset( - 'assets/images/paper2.png', - fit: BoxFit.cover, - repeat: ImageRepeat.repeat, - ), + return Stack( + children: [ + // Fond en papier + Positioned.fill( + child: Image.asset( + 'assets/images/paper2.png', + fit: BoxFit.cover, + repeat: ImageRepeat.repeat, ), - // Image principale - Positioned( - left: leftMargin, - top: 0, - height: imageHeight, - width: imageWidth, - child: Image.asset( - 'assets/images/river_logo_desktop.png', - fit: BoxFit.contain, - ), + ), + // Image principale + Positioned( + left: leftMargin, + top: 0, + height: imageHeight, + width: imageWidth, + child: Image.asset( + 'assets/images/river_logo_desktop.png', + fit: BoxFit.contain, ), - // Formulaire dans le cadran en bas Ă  droite - Positioned( - right: 0, - bottom: 0, - width: w * 0.6, // 60% de la largeur de l'Ă©cran - height: h * 0.5, // 50% de la hauteur de l'Ă©cran - child: Padding( - padding: EdgeInsets.all(w * 0.02), // 2% de padding + ), + // Formulaire dans le cadran en bas Ă  droite + Positioned( + right: 0, + bottom: 0, + width: w * 0.6, // 60% de la largeur de l'Ă©cran + height: h * 0.5, // 50% de la hauteur de l'Ă©cran + child: Padding( + padding: EdgeInsets.all(w * 0.02), // 2% de padding + child: AutofillGroup( child: Form( key: _formKey, child: Column( @@ -207,6 +218,12 @@ class _LoginPageState extends State with WidgetsBindingObserver { controller: _emailController, labelText: 'Email', hintText: 'Votre adresse email', + keyboardType: TextInputType.emailAddress, + autofillHints: const [ + AutofillHints.username, + AutofillHints.email, + ], + textInputAction: TextInputAction.next, validator: _validateEmail, style: CustomAppTextFieldStyle.lavande, fieldHeight: 53, @@ -220,6 +237,12 @@ class _LoginPageState extends State with WidgetsBindingObserver { labelText: 'Mot de passe', hintText: 'Votre mot de passe', obscureText: true, + autofillHints: const [ + AutofillHints.password + ], + textInputAction: TextInputAction.done, + onFieldSubmitted: + _handlePasswordSubmitted, validator: _validatePassword, style: CustomAppTextFieldStyle.jaune, fieldHeight: 53, @@ -229,7 +252,7 @@ class _LoginPageState extends State with WidgetsBindingObserver { ], ), const SizedBox(height: 20), - + // Message d'erreur if (_errorMessage != null) Container( @@ -242,7 +265,8 @@ class _LoginPageState extends State with WidgetsBindingObserver { ), child: Row( children: [ - Icon(Icons.error_outline, color: Colors.red[700], size: 20), + Icon(Icons.error_outline, + color: Colors.red[700], size: 20), const SizedBox(width: 10), Expanded( child: Text( @@ -256,7 +280,7 @@ class _LoginPageState extends State with WidgetsBindingObserver { ], ), ), - + // Bouton centrĂ© Center( child: _isLoading @@ -309,67 +333,68 @@ class _LoginPageState extends State with WidgetsBindingObserver { ), ), ), - // Pied de page (Wrap pour Ă©viter overflow sur petite largeur) - Positioned( - left: 0, - right: 0, - bottom: 0, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 8.0), - decoration: const BoxDecoration( - color: Colors.transparent, - ), - child: Wrap( - alignment: WrapAlignment.center, - runSpacing: 8, - children: [ - _FooterLink( - text: 'Contact support', - onTap: () async { - final Uri emailLaunchUri = Uri( - scheme: 'mailto', - path: 'support@supernounou.local', - ); - if (await canLaunchUrl(emailLaunchUri)) { - await launchUrl(emailLaunchUri); - } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Impossible d\'ouvrir le client mail', - style: GoogleFonts.merienda(), - ), + ), + // Pied de page (Wrap pour Ă©viter overflow sur petite largeur) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8.0), + decoration: const BoxDecoration( + color: Colors.transparent, + ), + child: Wrap( + alignment: WrapAlignment.center, + runSpacing: 8, + children: [ + _FooterLink( + text: 'Contact support', + onTap: () async { + final Uri emailLaunchUri = Uri( + scheme: 'mailto', + path: 'support@supernounou.local', + ); + if (await canLaunchUrl(emailLaunchUri)) { + await launchUrl(emailLaunchUri); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Impossible d\'ouvrir le client mail', + style: GoogleFonts.merienda(), ), - ); - } - }, - ), - _FooterLink( - text: 'Signaler un bug', - onTap: () { - _showBugReportDialog(context); - }, - ), - _FooterLink( - text: 'Mentions lĂ©gales', - onTap: () { - context.go('/legal'); - }, - ), - _FooterLink( - text: 'Politique de confidentialitĂ©', - onTap: () { - context.go('/privacy'); - }, - ), - ], - ), + ), + ); + } + }, + ), + _FooterLink( + text: 'Signaler un bug', + onTap: () { + _showBugReportDialog(context); + }, + ), + _FooterLink( + text: 'Mentions lĂ©gales', + onTap: () { + context.go('/legal'); + }, + ), + _FooterLink( + text: 'Politique de confidentialitĂ©', + onTap: () { + context.go('/privacy'); + }, + ), + ], ), ), - ], - ); - }, - ); + ), + ], + ); + }, + ); }, ), ); @@ -378,6 +403,7 @@ class _LoginPageState extends State with WidgetsBindingObserver { /// Dimensions de river_logo_mobile.png (Ă  mettre Ă  jour si l'asset change). static const int _riverLogoMobileWidth = 600; static const int _riverLogoMobileHeight = 1080; + /// Fraction de la hauteur de l'image oĂč se termine visuellement le slogan (0 = haut, 1 = bas). static const double _sloganEndFraction = 0.42; static const double _gapBelowSlogan = 12.0; @@ -388,7 +414,8 @@ class _LoginPageState extends State with WidgetsBindingObserver { final h = constraints.maxHeight; final w = constraints.maxWidth; final imageAspectRatio = _riverLogoMobileHeight / _riverLogoMobileWidth; - final formTop = w * imageAspectRatio * _sloganEndFraction + _gapBelowSlogan; + final formTop = + w * imageAspectRatio * _sloganEndFraction + _gapBelowSlogan; return Stack( clipBehavior: Clip.none, children: [ @@ -428,95 +455,115 @@ class _LoginPageState extends State with WidgetsBindingObserver { children: [ Expanded( child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20), + padding: const EdgeInsets.symmetric( + horizontal: 24, vertical: 20), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 400), - child: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 16), - CustomAppTextField( - controller: _emailController, - labelText: 'Email', - showLabel: false, - hintText: 'Votre adresse email', - validator: _validateEmail, - style: CustomAppTextFieldStyle.lavande, - fieldHeight: 48, - fieldWidth: double.infinity, - ), - const SizedBox(height: 12), - CustomAppTextField( - controller: _passwordController, - labelText: 'Mot de passe', - showLabel: false, - hintText: 'Votre mot de passe', - obscureText: true, - validator: _validatePassword, - style: CustomAppTextFieldStyle.jaune, - fieldHeight: 48, - fieldWidth: double.infinity, - ), - if (_errorMessage != null) ...[ - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.red.shade50, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: Colors.red.shade300), - ), - child: Row( - children: [ - Icon(Icons.error_outline, color: Colors.red.shade700, size: 20), - const SizedBox(width: 10), - Expanded( - child: Text( - _errorMessage!, - style: GoogleFonts.merienda(fontSize: 12, color: Colors.red.shade700), - ), + child: AutofillGroup( + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 16), + CustomAppTextField( + controller: _emailController, + labelText: 'Email', + showLabel: false, + hintText: 'Votre adresse email', + keyboardType: TextInputType.emailAddress, + autofillHints: const [ + AutofillHints.username, + AutofillHints.email, + ], + textInputAction: TextInputAction.next, + validator: _validateEmail, + style: CustomAppTextFieldStyle.lavande, + fieldHeight: 48, + fieldWidth: double.infinity, + ), + const SizedBox(height: 12), + CustomAppTextField( + controller: _passwordController, + labelText: 'Mot de passe', + showLabel: false, + hintText: 'Votre mot de passe', + obscureText: true, + autofillHints: const [ + AutofillHints.password + ], + textInputAction: TextInputAction.done, + onFieldSubmitted: _handlePasswordSubmitted, + validator: _validatePassword, + style: CustomAppTextFieldStyle.jaune, + fieldHeight: 48, + fieldWidth: double.infinity, + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: Colors.red.shade300), + ), + child: Row( + children: [ + Icon(Icons.error_outline, + color: Colors.red.shade700, + size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + _errorMessage!, + style: GoogleFonts.merienda( + fontSize: 12, + color: Colors.red.shade700), + ), + ), + ], + ), + ), + ], + const SizedBox(height: 12), + _isLoading + ? const CircularProgressIndicator() + : ImageButton( + bg: 'assets/images/bg_green.png', + width: double.infinity, + height: 44, + text: 'Se connecter', + textColor: const Color(0xFF2D6A4F), + onPressed: _handleLogin, + ), + const SizedBox(height: 12), + TextButton( + onPressed: () {/* TODO */}, + child: Text( + 'Mot de passe oubliĂ© ?', + style: GoogleFonts.merienda( + fontSize: 14, + color: const Color(0xFF2D6A4F), + decoration: TextDecoration.underline, + ), + ), + ), + TextButton( + onPressed: () => + context.go('/register-choice'), + child: Text( + 'CrĂ©er un compte', + style: GoogleFonts.merienda( + fontSize: 16, + color: const Color(0xFF2D6A4F), + decoration: TextDecoration.underline, + ), + ), + ), + ], ), - ], - ), - ), - ], - const SizedBox(height: 12), - _isLoading - ? const CircularProgressIndicator() - : ImageButton( - bg: 'assets/images/bg_green.png', - width: double.infinity, - height: 44, - text: 'Se connecter', - textColor: const Color(0xFF2D6A4F), - onPressed: _handleLogin, - ), - const SizedBox(height: 12), - TextButton( - onPressed: () { /* TODO */ }, - child: Text( - 'Mot de passe oubliĂ© ?', - style: GoogleFonts.merienda( - fontSize: 14, - color: const Color(0xFF2D6A4F), - decoration: TextDecoration.underline, - ), - ), - ), - TextButton( - onPressed: () => context.go('/register-choice'), - child: Text( - 'CrĂ©er un compte', - style: GoogleFonts.merienda( - fontSize: 16, - color: const Color(0xFF2D6A4F), - decoration: TextDecoration.underline, - ), - ), - ), - ], ), ), ), @@ -533,12 +580,17 @@ class _LoginPageState extends State with WidgetsBindingObserver { text: 'Contact support', fontSize: 11, onTap: () async { - final uri = Uri(scheme: 'mailto', path: 'support@supernounou.local'); + final uri = Uri( + scheme: 'mailto', + path: 'support@supernounou.local'); if (await canLaunchUrl(uri)) { await launchUrl(uri); } else if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Impossible d\'ouvrir le client mail', style: GoogleFonts.merienda())), + SnackBar( + content: Text( + 'Impossible d\'ouvrir le client mail', + style: GoogleFonts.merienda())), ); } }, @@ -707,4 +759,4 @@ class _FooterLink extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/frontend/lib/widgets/custom_app_text_field.dart b/frontend/lib/widgets/custom_app_text_field.dart index f593a15..0645bb1 100644 --- a/frontend/lib/widgets/custom_app_text_field.dart +++ b/frontend/lib/widgets/custom_app_text_field.dart @@ -10,6 +10,7 @@ enum CustomAppTextFieldStyle { class CustomAppTextField extends StatefulWidget { final TextEditingController controller; + final FocusNode? focusNode; final String labelText; final String hintText; final double fieldWidth; @@ -26,10 +27,14 @@ class CustomAppTextField extends StatefulWidget { final double labelFontSize; final double inputFontSize; final bool showLabel; + final Iterable? autofillHints; + final TextInputAction? textInputAction; + final ValueChanged? onFieldSubmitted; const CustomAppTextField({ super.key, required this.controller, + this.focusNode, required this.labelText, this.showLabel = true, this.hintText = '', @@ -46,6 +51,9 @@ class CustomAppTextField extends StatefulWidget { this.suffixIcon, this.labelFontSize = 18.0, this.inputFontSize = 18.0, + this.autofillHints, + this.textInputAction, + this.onFieldSubmitted, }); @override @@ -68,7 +76,7 @@ class _CustomAppTextFieldState extends State { @override Widget build(BuildContext context) { const double fontHeightMultiplier = 1.2; - const double internalVerticalPadding = 16.0; + const double internalVerticalPadding = 16.0; final double dynamicFieldHeight = widget.fieldHeight; return Column( @@ -90,7 +98,7 @@ class _CustomAppTextFieldState extends State { width: widget.fieldWidth, height: dynamicFieldHeight, child: Stack( - alignment: Alignment.centerLeft, + alignment: Alignment.centerLeft, children: [ Positioned.fill( child: Image.asset( @@ -99,40 +107,49 @@ class _CustomAppTextFieldState extends State { ), ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 18.0, vertical: 8.0), + padding: + const EdgeInsets.symmetric(horizontal: 18.0, vertical: 8.0), child: TextFormField( controller: widget.controller, + focusNode: widget.focusNode, obscureText: widget.obscureText, keyboardType: widget.keyboardType, + autofillHints: widget.autofillHints, + textInputAction: widget.textInputAction, + onFieldSubmitted: widget.onFieldSubmitted, enabled: widget.enabled, readOnly: widget.readOnly, onTap: widget.onTap, style: GoogleFonts.merienda( - fontSize: widget.inputFontSize, - color: widget.enabled ? Colors.black87 : Colors.grey - ), + fontSize: widget.inputFontSize, + color: widget.enabled ? Colors.black87 : Colors.grey), validator: widget.validator ?? (value) { - if (!widget.enabled || widget.readOnly) return null; - if (widget.isRequired && (value == null || value.isEmpty)) { - return 'Ce champ est obligatoire'; - } - return null; - }, + if (!widget.enabled || widget.readOnly) return null; + if (widget.isRequired && + (value == null || value.isEmpty)) { + return 'Ce champ est obligatoire'; + } + return null; + }, decoration: InputDecoration( hintText: widget.hintText, - hintStyle: GoogleFonts.merienda(fontSize: widget.inputFontSize, color: Colors.black54.withOpacity(0.7)), + hintStyle: GoogleFonts.merienda( + fontSize: widget.inputFontSize, + color: Colors.black54.withOpacity(0.7)), border: InputBorder.none, contentPadding: EdgeInsets.zero, - suffixIcon: widget.suffixIcon != null + suffixIcon: widget.suffixIcon != null ? Padding( padding: const EdgeInsets.only(right: 0.0), - child: Icon(widget.suffixIcon, color: Colors.black54, size: widget.inputFontSize * 1.1), + child: Icon(widget.suffixIcon, + color: Colors.black54, + size: widget.inputFontSize * 1.1), ) : null, isDense: true, ), - textAlignVertical: TextAlignVertical.center, + textAlignVertical: TextAlignVertical.center, ), ), ], @@ -141,4 +158,4 @@ class _CustomAppTextFieldState extends State { ], ); } -} \ No newline at end of file +} diff --git a/frontend/lib/widgets/image_button.dart b/frontend/lib/widgets/image_button.dart index 2d81362..9d63bad 100644 --- a/frontend/lib/widgets/image_button.dart +++ b/frontend/lib/widgets/image_button.dart @@ -23,26 +23,38 @@ class ImageButton extends StatelessWidget { @override Widget build(BuildContext context) { - return MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: onPressed, - child: Container( - width: width, - height: height, - decoration: BoxDecoration( - image: DecorationImage( - image: AssetImage(bg), - fit: BoxFit.fill, + return SizedBox( + width: width, + height: height, + child: Semantics( + button: true, + label: text, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: TextButton( + onPressed: onPressed, + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: + const RoundedRectangleBorder(borderRadius: BorderRadius.zero), ), - ), - child: Center( - child: Text( - text, - style: GoogleFonts.merienda( - color: textColor, - fontSize: fontSize, // Utilisation du paramĂštre - fontWeight: FontWeight.bold, + child: Ink( + decoration: BoxDecoration( + image: DecorationImage( + image: AssetImage(bg), + fit: BoxFit.fill, + ), + ), + child: Center( + child: Text( + text, + style: GoogleFonts.merienda( + color: textColor, + fontSize: fontSize, // Utilisation du paramĂštre + fontWeight: FontWeight.bold, + ), + ), ), ), ), @@ -50,4 +62,4 @@ class ImageButton extends StatelessWidget { ), ); } -} \ No newline at end of file +} From fffe8cd202d98a6cf5eef04921318104357cb89a Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Wed, 25 Feb 2026 16:37:15 +0100 Subject: [PATCH 17/22] merge: squash develop into master (#44 Dashboard Gestionnaire - Structure) Co-authored-by: Cursor --- frontend/lib/config/app_router.dart | 5 ++ .../admin_dashboardScreen.dart | 2 +- frontend/lib/screens/auth/login_screen.dart | 4 +- .../gestionnaire_dashboard_screen.dart | 47 +++++++++++++++++++ .../lib/widgets/admin/dashboard_admin.dart | 42 +++++++++++------ .../widgets/admin/user_management_panel.dart | 20 +++++--- 6 files changed, 98 insertions(+), 22 deletions(-) create mode 100644 frontend/lib/screens/gestionnaire/gestionnaire_dashboard_screen.dart diff --git a/frontend/lib/config/app_router.dart b/frontend/lib/config/app_router.dart index d5e2ad6..8408cf4 100644 --- a/frontend/lib/config/app_router.dart +++ b/frontend/lib/config/app_router.dart @@ -20,6 +20,7 @@ import '../screens/auth/am_register_step3_screen.dart'; import '../screens/auth/am_register_step4_screen.dart'; import '../screens/home/home_screen.dart'; import '../screens/administrateurs/admin_dashboardScreen.dart'; +import '../screens/gestionnaire/gestionnaire_dashboard_screen.dart'; import '../screens/home/parent_screen/ParentDashboardScreen.dart'; import '../screens/unknown_screen.dart'; @@ -53,6 +54,10 @@ class AppRouter { path: '/admin-dashboard', builder: (BuildContext context, GoRouterState state) => const AdminDashboardScreen(), ), + GoRoute( + path: '/gestionnaire-dashboard', + builder: (BuildContext context, GoRouterState state) => const GestionnaireDashboardScreen(), + ), GoRoute( path: '/parent-dashboard', builder: (BuildContext context, GoRouterState state) => const ParentDashboardScreen(), diff --git a/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart b/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart index 892eb8b..43d3ca6 100644 --- a/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart +++ b/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart @@ -106,6 +106,6 @@ class _AdminDashboardScreenState extends State { selectedSettingsTabIndex: settingsSubIndex, ); } - return const AdminUserManagementPanel(); + return const UserManagementPanel(); } } diff --git a/frontend/lib/screens/auth/login_screen.dart b/frontend/lib/screens/auth/login_screen.dart index 8efaf2e..96064e7 100644 --- a/frontend/lib/screens/auth/login_screen.dart +++ b/frontend/lib/screens/auth/login_screen.dart @@ -131,9 +131,11 @@ class _LoginPageState extends State with WidgetsBindingObserver { switch (role.toLowerCase()) { case 'super_admin': case 'administrateur': - case 'gestionnaire': context.go('/admin-dashboard'); break; + case 'gestionnaire': + context.go('/gestionnaire-dashboard'); + break; case 'parent': context.go('/parent-dashboard'); break; diff --git a/frontend/lib/screens/gestionnaire/gestionnaire_dashboard_screen.dart b/frontend/lib/screens/gestionnaire/gestionnaire_dashboard_screen.dart new file mode 100644 index 0000000..f712dca --- /dev/null +++ b/frontend/lib/screens/gestionnaire/gestionnaire_dashboard_screen.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart'; +import 'package:p_tits_pas/widgets/admin/user_management_panel.dart'; +import 'package:p_tits_pas/widgets/app_footer.dart'; + +/// Dashboard gestionnaire – mĂȘme shell que l'admin, sans onglet ParamĂštres. +/// RĂ©utilise [UserManagementPanel]. +class GestionnaireDashboardScreen extends StatefulWidget { + const GestionnaireDashboardScreen({super.key}); + + @override + State createState() => + _GestionnaireDashboardScreenState(); +} + +class _GestionnaireDashboardScreenState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: PreferredSize( + preferredSize: const Size.fromHeight(60.0), + child: Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: Colors.grey.shade300), + ), + ), + child: DashboardAppBarAdmin( + selectedIndex: 0, + onTabChange: (_) {}, + showSettingsTab: false, + roleLabel: 'Gestionnaire', + setupCompleted: true, + ), + ), + ), + body: Column( + children: [ + Expanded( + child: UserManagementPanel(showAdministrateursTab: false), + ), + const AppFooter(), + ], + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/dashboard_admin.dart b/frontend/lib/widgets/admin/dashboard_admin.dart index ff1aa3b..13bbb43 100644 --- a/frontend/lib/widgets/admin/dashboard_admin.dart +++ b/frontend/lib/widgets/admin/dashboard_admin.dart @@ -3,17 +3,22 @@ import 'package:go_router/go_router.dart'; import 'package:p_tits_pas/services/auth_service.dart'; /// Barre du dashboard admin : onglets Gestion des utilisateurs | ParamĂštres + dĂ©connexion. +/// Pour le dashboard gestionnaire : [showSettingsTab] = false, [roleLabel] = 'Gestionnaire'. class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidget { final int selectedIndex; final ValueChanged onTabChange; final bool setupCompleted; + final bool showSettingsTab; + final String roleLabel; const DashboardAppBarAdmin({ Key? key, required this.selectedIndex, required this.onTabChange, this.setupCompleted = true, + this.showSettingsTab = true, + this.roleLabel = 'Admin', }) : super(key: key); @override @@ -39,8 +44,10 @@ class DashboardAppBarAdmin extends StatelessWidget children: [ _buildNavItem(context, 'Gestion des utilisateurs', 0, enabled: setupCompleted), - const SizedBox(width: 24), - _buildNavItem(context, 'ParamĂštres', 1, enabled: true), + if (showSettingsTab) ...[ + const SizedBox(width: 24), + _buildNavItem(context, 'ParamĂštres', 1, enabled: true), + ], ], ), ), @@ -48,12 +55,12 @@ class DashboardAppBarAdmin extends StatelessWidget ], ), actions: [ - const Padding( - padding: EdgeInsets.symmetric(horizontal: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), child: Center( child: Text( - 'Admin', - style: TextStyle( + roleLabel, + style: const TextStyle( color: Colors.black, fontSize: 16, fontWeight: FontWeight.w500, @@ -132,7 +139,8 @@ class DashboardAppBarAdmin extends StatelessWidget } } -/// Sous-barre : Gestionnaires | Parents | Assistantes maternelles | Administrateurs. +/// Sous-barre : Gestionnaires | Parents | Assistantes maternelles | [Administrateurs]. +/// [subTabCount] = 3 pour masquer l'onglet Administrateurs (dashboard gestionnaire). class DashboardUserManagementSubBar extends StatelessWidget { final int selectedSubIndex; final ValueChanged onSubTabChange; @@ -141,6 +149,14 @@ class DashboardUserManagementSubBar extends StatelessWidget { final Widget? filterControl; final VoidCallback? onAddPressed; final String addLabel; + final int subTabCount; + + static const List _tabLabels = [ + 'Gestionnaires', + 'Parents', + 'Assistantes maternelles', + 'Administrateurs', + ]; const DashboardUserManagementSubBar({ Key? key, @@ -151,6 +167,7 @@ class DashboardUserManagementSubBar extends StatelessWidget { this.filterControl, this.onAddPressed, this.addLabel = '+ Ajouter', + this.subTabCount = 4, }) : super(key: key); @override @@ -164,13 +181,10 @@ class DashboardUserManagementSubBar extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6), child: Row( children: [ - _buildSubNavItem(context, 'Gestionnaires', 0), - const SizedBox(width: 12), - _buildSubNavItem(context, 'Parents', 1), - const SizedBox(width: 12), - _buildSubNavItem(context, 'Assistantes maternelles', 2), - const SizedBox(width: 12), - _buildSubNavItem(context, 'Administrateurs', 3), + for (int i = 0; i < subTabCount; i++) ...[ + if (i > 0) const SizedBox(width: 12), + _buildSubNavItem(context, _tabLabels[i], i), + ], const SizedBox(width: 36), _pillField( width: 320, diff --git a/frontend/lib/widgets/admin/user_management_panel.dart b/frontend/lib/widgets/admin/user_management_panel.dart index 63bc921..18ba940 100644 --- a/frontend/lib/widgets/admin/user_management_panel.dart +++ b/frontend/lib/widgets/admin/user_management_panel.dart @@ -6,15 +6,20 @@ import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart'; import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart'; -class AdminUserManagementPanel extends StatefulWidget { - const AdminUserManagementPanel({super.key}); +class UserManagementPanel extends StatefulWidget { + /// Afficher l'onglet Administrateurs (sinon 3 onglets : Gestionnaires, Parents, AM). + final bool showAdministrateursTab; + + const UserManagementPanel({ + super.key, + this.showAdministrateursTab = true, + }); @override - State createState() => - _AdminUserManagementPanelState(); + State createState() => _UserManagementPanelState(); } -class _AdminUserManagementPanelState extends State { +class _UserManagementPanelState extends State { int _subIndex = 0; int _gestionnaireRefreshTick = 0; int _adminRefreshTick = 0; @@ -44,8 +49,9 @@ class _AdminUserManagementPanelState extends State { } void _onSubTabChange(int index) { + final maxIndex = widget.showAdministrateursTab ? 3 : 2; setState(() { - _subIndex = index; + _subIndex = index.clamp(0, maxIndex); _searchController.clear(); _parentStatus = null; _amCapacityController.clear(); @@ -161,6 +167,7 @@ class _AdminUserManagementPanelState extends State { @override Widget build(BuildContext context) { + final subTabCount = widget.showAdministrateursTab ? 4 : 3; return Column( children: [ DashboardUserManagementSubBar( @@ -171,6 +178,7 @@ class _AdminUserManagementPanelState extends State { filterControl: _subBarFilterControl(), onAddPressed: _handleAddPressed, addLabel: 'Ajouter', + subTabCount: subTabCount, ), Expanded(child: _buildBody()), ], From 51d279e341ef4a46513da8f4c4d6bd372801d449 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Wed, 25 Feb 2026 16:37:54 +0100 Subject: [PATCH 18/22] docs: fermeture ticket #44 (Dashboard Gestionnaire - Structure) Co-authored-by: Cursor --- docs/23_LISTE-TICKETS.md | 9 +++++---- docs/POINT_TICKETS_FRONT_API.txt | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/23_LISTE-TICKETS.md b/docs/23_LISTE-TICKETS.md index 80dee9f..3c3666f 100644 --- a/docs/23_LISTE-TICKETS.md +++ b/docs/23_LISTE-TICKETS.md @@ -839,7 +839,7 @@ CrĂ©er l'Ă©cran de crĂ©ation de mot de passe (lien reçu par email). --- -### Ticket #44 : [Frontend] Dashboard Gestionnaire - Structure +### Ticket #44 : [Frontend] Dashboard Gestionnaire - Structure ✅ **Estimation** : 2h **Labels** : `frontend`, `p3`, `gestionnaire` @@ -847,9 +847,10 @@ CrĂ©er l'Ă©cran de crĂ©ation de mot de passe (lien reçu par email). CrĂ©er la structure du dashboard gestionnaire avec 2 onglets. **TĂąches** : -- [ ] Layout avec 2 onglets (Parents / AM) -- [ ] Navigation entre onglets -- [ ] État vide ("Aucune demande") +- [x] Dashboard gestionnaire = mĂȘme shell que admin (sans onglet ParamĂštres), libellĂ© « Gestionnaire » +- [x] RĂ©utilisation du widget UserManagementPanel (ex-AdminUserManagementPanel) avec 3 onglets (Gestionnaires, Parents, Assistantes maternelles) ; onglet Administrateurs masquĂ© +- [x] Redirection login rĂŽle `gestionnaire` vers `/gestionnaire-dashboard` +- [ ] État vide dĂ©diĂ© ("Aucune demande") — optionnel, contenu actuel = listes existantes --- diff --git a/docs/POINT_TICKETS_FRONT_API.txt b/docs/POINT_TICKETS_FRONT_API.txt index f78be3d..7f32219 100644 --- a/docs/POINT_TICKETS_FRONT_API.txt +++ b/docs/POINT_TICKETS_FRONT_API.txt @@ -14,7 +14,7 @@ Num | Etat | Titre 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 | open | [Frontend] Dashboard Gestionnaire - Structure + 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 From e713c05da19df79fe78e0726b117bd1067e8aad5 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Wed, 25 Feb 2026 21:48:38 +0100 Subject: [PATCH 19/22] =?UTF-8?q?feat:=20Bandeau=20g=C3=A9n=C3=A9rique,=20?= =?UTF-8?q?dashboards=20et=20doc=20(squash=20develop,=20Closes=20#100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bandeau gĂ©nĂ©rique (DashboardBandeau) pour Parent, Admin, Gestionnaire, AM - ParentDashboardScreen, AdminDashboardScreen, GestionnaireDashboardScreen, AM dashboard - AppFooter responsive, scripts Gitea (create/list issues parent API) - Doc: ticket #101 Inscription Parent API, mise Ă  jour 23_LISTE-TICKETS - User.fromJson robustesse (nullable id/email/role) - Suppression dashboard_app_bar.dart au profit de dashboard_bandeau.dart Refs: #100, #101 Made-with: Cursor --- .../scripts/create-gitea-issue-parent-api.js | 89 ++++++ backend/scripts/list-gitea-issues.js | 64 ++++ docs/23_LISTE-TICKETS.md | 21 ++ frontend/lib/config/app_router.dart | 14 +- frontend/lib/models/user.dart | 6 +- .../admin_dashboardScreen.dart | 40 ++- .../lib/screens/am/am_dashboard_screen.dart | 78 +++++ .../gestionnaire_dashboard_screen.dart | 53 +++- .../parent_screen/ParentDashboardScreen.dart | 58 ++-- .../lib/widgets/admin/dashboard_admin.dart | 164 ---------- frontend/lib/widgets/app_footer.dart | 7 +- .../widgets/dashboard/dashboard_bandeau.dart | 299 ++++++++++++++++++ .../dashbord_parent/dashboard_app_bar.dart | 156 --------- 13 files changed, 678 insertions(+), 371 deletions(-) create mode 100644 backend/scripts/create-gitea-issue-parent-api.js create mode 100644 backend/scripts/list-gitea-issues.js create mode 100644 frontend/lib/screens/am/am_dashboard_screen.dart create mode 100644 frontend/lib/widgets/dashboard/dashboard_bandeau.dart delete mode 100644 frontend/lib/widgets/dashbord_parent/dashboard_app_bar.dart diff --git a/backend/scripts/create-gitea-issue-parent-api.js b/backend/scripts/create-gitea-issue-parent-api.js new file mode 100644 index 0000000..68f2541 --- /dev/null +++ b/backend/scripts/create-gitea-issue-parent-api.js @@ -0,0 +1,89 @@ +/** + * CrĂ©e l'issue Gitea "[Frontend] Inscription Parent – Branchement soumission formulaire Ă  l'API" + * Usage: node backend/scripts/create-gitea-issue-parent-api.js + * Token : .gitea-token (racine du dĂ©pĂŽt), sinon GITEA_TOKEN, sinon docs/BRIEFING-FRONTEND.md (voir PROCEDURE-API-GITEA.md) + */ +const https = require('https'); +const fs = require('fs'); +const path = require('path'); + +const repoRoot = path.join(__dirname, '../..'); +let token = process.env.GITEA_TOKEN; +if (!token) { + try { + const tokenFile = path.join(repoRoot, '.gitea-token'); + if (fs.existsSync(tokenFile)) { + token = fs.readFileSync(tokenFile, 'utf8').trim(); + } + } catch (_) {} +} +if (!token) { + try { + const briefing = fs.readFileSync(path.join(repoRoot, 'docs/BRIEFING-FRONTEND.md'), 'utf8'); + const m = briefing.match(/Token:\s*(giteabu_[a-f0-9]+)/); + if (m) token = m[1].trim(); + } catch (_) {} +} +if (!token) { + console.error('Token non trouvĂ© : crĂ©er .gitea-token Ă  la racine ou export GITEA_TOKEN (voir docs/PROCEDURE-API-GITEA.md)'); + process.exit(1); +} + +const body = `## Description + +Branchement du formulaire d'inscription parent (Ă©tape 5, rĂ©capitulatif) Ă  l'endpoint d'inscription. Aujourd'hui la soumission n'appelle pas l'API : elle affiche uniquement une modale puis redirige vers le login. + +**Estimation** : 4h | **Labels** : frontend, p3, auth, cdc + +## TĂąches + +- [ ] CrĂ©er un service ou mĂ©thode (ex. AuthService.registerParent) appelant POST /api/v1/auth/register/parent +- [ ] Construire le body (DTO) Ă  partir de UserRegistrationData (parent1, parent2, children, motivationText, CGU) en cohĂ©rence avec le backend (#18) +- [ ] Dans ParentRegisterStep5Screen, au clic « Soumettre » : appel API puis modale + redirection ou message d'erreur +- [ ] Gestion des photos enfants (base64 ou multipart selon API) + +## RĂ©fĂ©rence + +20_WORKFLOW-CREATION-COMPTE.md § Étape 3 – Inscription d'un parent, backend #18`; + +const payload = JSON.stringify({ + title: "[Frontend] Inscription Parent – Branchement soumission formulaire Ă  l'API", + body, +}); + +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(payload), + }, +}; + +const req = https.request(opts, (res) => { + let d = ''; + res.on('data', (c) => (d += c)); + res.on('end', () => { + try { + const o = JSON.parse(d); + if (o.number) { + console.log('NUMBER:', o.number); + console.log('URL:', o.html_url); + } else { + console.error('Erreur API:', o.message || d); + process.exit(1); + } + } catch (e) { + console.error('RĂ©ponse:', d); + process.exit(1); + } + }); +}); +req.on('error', (e) => { + console.error(e); + process.exit(1); +}); +req.write(payload); +req.end(); diff --git a/backend/scripts/list-gitea-issues.js b/backend/scripts/list-gitea-issues.js new file mode 100644 index 0000000..57742c8 --- /dev/null +++ b/backend/scripts/list-gitea-issues.js @@ -0,0 +1,64 @@ +/** + * Liste toutes les issues Gitea (ouvertes + fermĂ©es) pour jmartin/petitspas. + * Token : .gitea-token (racine), GITEA_TOKEN, ou docs/BRIEFING-FRONTEND.md + */ +const https = require('https'); +const fs = require('fs'); +const path = require('path'); + +const repoRoot = path.join(__dirname, '../..'); +let token = process.env.GITEA_TOKEN; +if (!token) { + try { + const tokenFile = path.join(repoRoot, '.gitea-token'); + if (fs.existsSync(tokenFile)) token = fs.readFileSync(tokenFile, 'utf8').trim(); + } catch (_) {} +} +if (!token) { + try { + const briefing = fs.readFileSync(path.join(repoRoot, 'docs/BRIEFING-FRONTEND.md'), 'utf8'); + const m = briefing.match(/Token:\s*(giteabu_[a-f0-9]+)/); + if (m) token = m[1].trim(); + } catch (_) {} +} +if (!token) { + console.error('Token non trouvĂ©'); + process.exit(1); +} + +function get(path) { + return new Promise((resolve, reject) => { + const opts = { hostname: 'git.ptits-pas.fr', path, method: 'GET', headers: { Authorization: 'token ' + token } }; + const req = https.request(opts, (res) => { + let d = ''; + res.on('data', (c) => (d += c)); + res.on('end', () => { + try { resolve(JSON.parse(d)); } catch (e) { reject(e); } + }); + }); + req.on('error', reject); + req.end(); + }); +} + +async function main() { + const seen = new Map(); + for (const state of ['open', 'closed']) { + for (let page = 1; ; page++) { + const raw = await get('/api/v1/repos/jmartin/petitspas/issues?state=' + state + '&limit=50&page=' + page + '&type=issues'); + if (raw && raw.message && !Array.isArray(raw)) { + console.error('API:', raw.message); + process.exit(1); + } + const list = Array.isArray(raw) ? raw : []; + for (const i of list) { + if (!i.pull_request) seen.set(i.number, { number: i.number, title: i.title, state: i.state }); + } + if (list.length < 50) break; + } + } + const all = [...seen.values()].sort((a, b) => a.number - b.number); + console.log(JSON.stringify(all, null, 2)); +} + +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/docs/23_LISTE-TICKETS.md b/docs/23_LISTE-TICKETS.md index 3c3666f..5a879ac 100644 --- a/docs/23_LISTE-TICKETS.md +++ b/docs/23_LISTE-TICKETS.md @@ -29,6 +29,7 @@ | 16 | [Doc] Documentation configuration on-premise | Ouvert | | 17 | [Backend] API CrĂ©ation gestionnaire | ✅ TerminĂ© | | 91 | [Frontend] Inscription AM – Branchement soumission formulaire Ă  l'API | Ouvert | +| 101 | [Frontend] Inscription Parent – Branchement soumission formulaire Ă  l'API | Ouvert | | 92 | [Frontend] Dashboard Admin - DonnĂ©es rĂ©elles et branchement API | ✅ TerminĂ© | | 93 | [Frontend] Panneau Admin - Homogeneiser la presentation des onglets | ✅ FermĂ© | | 94 | [Backend] Relais - modele, API CRUD et liaison gestionnaire | ✅ TerminĂ© | @@ -1076,6 +1077,26 @@ Branchement du formulaire d'inscription AM (Ă©tape 4) Ă  l'endpoint d'inscriptio --- +### Ticket #101 : [Frontend] Inscription Parent – Branchement soumission formulaire Ă  l'API +**Estimation** : 4h +**Labels** : `frontend`, `p3`, `auth`, `cdc` + +**Description** : +Branchement du formulaire d'inscription parent (Ă©tape 5, rĂ©capitulatif) Ă  l'endpoint d'inscription. Aujourd'hui la soumission n'appelle pas l'API : elle affiche uniquement une modale de confirmation puis redirige vers le login. Ce ticket vise Ă  envoyer les donnĂ©es collectĂ©es (Parent 1, Parent 2 optionnel, enfants, prĂ©sentation, CGU) Ă  l'API. + +**TĂąches** : +- [ ] CrĂ©er un service ou mĂ©thode (ex. `AuthService.registerParent` ou `UserService`) appelant `POST /api/v1/auth/register/parent` +- [ ] Construire le body (DTO) Ă  partir de `UserRegistrationData` (parent1, parent2, children, motivationText, CGU acceptĂ©e, etc.) en cohĂ©rence avec le contrat backend (voir ticket #18 refonte) +- [ ] Dans `ParentRegisterStep5Screen`, au clic « Soumettre » : appel API puis en cas de succĂšs afficher la modale et redirection vers `/login` ; en cas d'erreur afficher le message (SnackBar/dialog) +- [ ] Gestion des photos enfants (base64 ou multipart selon API) +- [ ] Optionnel : rĂ©initialiser ou conserver `UserRegistrationData` aprĂšs succĂšs (selon UX) + +**RĂ©fĂ©rence** : [20_WORKFLOW-CREATION-COMPTE.md](./20_WORKFLOW-CREATION-COMPTE.md#Ă©tape-3--inscription-dun-parent), backend #18 (refonte API inscription parent). + +**CrĂ©ation** : issue Gitea #101 créée. Pour recrĂ©er ou script : `node backend/scripts/create-gitea-issue-parent-api.js` (token dans `.gitea-token` ou voir [PROCEDURE-API-GITEA.md](./PROCEDURE-API-GITEA.md)). + +--- + ### Ticket #93 : [Frontend] Panneau Admin - HomogĂ©nĂ©isation des onglets ✅ **Estimation** : 4h **Labels** : `frontend`, `p3`, `admin`, `ux` diff --git a/frontend/lib/config/app_router.dart b/frontend/lib/config/app_router.dart index 8408cf4..074262f 100644 --- a/frontend/lib/config/app_router.dart +++ b/frontend/lib/config/app_router.dart @@ -22,6 +22,9 @@ import '../screens/home/home_screen.dart'; import '../screens/administrateurs/admin_dashboardScreen.dart'; import '../screens/gestionnaire/gestionnaire_dashboard_screen.dart'; import '../screens/home/parent_screen/ParentDashboardScreen.dart'; +import '../screens/am/am_dashboard_screen.dart'; +import '../screens/legal/privacy_page.dart'; +import '../screens/legal/legal_page.dart'; import '../screens/unknown_screen.dart'; // --- Provider Instances --- @@ -64,7 +67,16 @@ class AppRouter { ), GoRoute( path: '/am-dashboard', - builder: (BuildContext context, GoRouterState state) => const HomeScreen(), + builder: (BuildContext context, GoRouterState state) => + const AmDashboardScreen(), + ), + GoRoute( + path: '/privacy', + builder: (BuildContext context, GoRouterState state) => const PrivacyPage(), + ), + GoRoute( + path: '/legal', + builder: (BuildContext context, GoRouterState state) => const LegalPage(), ), // --- Parent Registration Flow --- diff --git a/frontend/lib/models/user.dart b/frontend/lib/models/user.dart index a07a176..01b89ce 100644 --- a/frontend/lib/models/user.dart +++ b/frontend/lib/models/user.dart @@ -41,9 +41,9 @@ class AppUser { relaisJson is Map ? relaisJson : {}; return AppUser( - id: json['id'] as String, - email: json['email'] as String, - role: json['role'] as String, + id: (json['id'] as String?) ?? '', + email: (json['email'] as String?) ?? '', + role: (json['role'] as String?) ?? '', createdAt: json['cree_le'] != null ? DateTime.parse(json['cree_le'] as String) : (json['createdAt'] != null diff --git a/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart b/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart index 43d3ca6..20a9678 100644 --- a/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart +++ b/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart @@ -1,9 +1,12 @@ 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/admin/parametres_panel.dart'; import 'package:p_tits_pas/widgets/admin/user_management_panel.dart'; import 'package:p_tits_pas/widgets/app_footer.dart'; -import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart'; +import 'package:p_tits_pas/widgets/dashboard/dashboard_bandeau.dart'; class AdminDashboardScreen extends StatefulWidget { const AdminDashboardScreen({super.key}); @@ -14,6 +17,7 @@ class AdminDashboardScreen extends StatefulWidget { class _AdminDashboardScreenState extends State { bool? _setupCompleted; + AppUser? _user; int mainTabIndex = 0; int settingsSubIndex = 0; @@ -31,9 +35,11 @@ class _AdminDashboardScreenState extends State { Future _loadSetupStatus() async { try { final completed = await ConfigurationService.getSetupStatus(); + final user = await AuthService.getCurrentUser(); if (!mounted) return; setState(() { _setupCompleted = completed; + _user = user; if (!completed) mainTabIndex = 1; }); } catch (e) { @@ -68,17 +74,29 @@ class _AdminDashboardScreenState extends State { return Scaffold( appBar: PreferredSize( preferredSize: const Size.fromHeight(60.0), - child: Container( - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: Colors.grey.shade300), + child: DashboardBandeau( + tabItems: [ + DashboardTabItem( + label: 'Gestion des utilisateurs', + enabled: _setupCompleted!, ), - ), - child: DashboardAppBarAdmin( - selectedIndex: mainTabIndex, - onTabChange: onMainTabChange, - setupCompleted: _setupCompleted!, - ), + const DashboardTabItem(label: 'ParamĂštres'), + ], + selectedTabIndex: mainTabIndex, + onTabSelected: onMainTabChange, + userDisplayName: _user?.fullName.isNotEmpty == true + ? _user!.fullName + : 'Admin', + userEmail: _user?.email, + userRole: _user?.role, + onProfileTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Modification du profil – Ă  venir')), + ); + }, + onSettingsTap: () => onMainTabChange(1), + onLogout: () {}, + showLogoutConfirmation: true, ), ), body: Column( diff --git a/frontend/lib/screens/am/am_dashboard_screen.dart b/frontend/lib/screens/am/am_dashboard_screen.dart new file mode 100644 index 0000000..fea0afe --- /dev/null +++ b/frontend/lib/screens/am/am_dashboard_screen.dart @@ -0,0 +1,78 @@ +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/app_footer.dart'; +import 'package:p_tits_pas/widgets/dashboard/dashboard_bandeau.dart'; + +/// Dashboard assistante maternelle – page blanche avec bandeau gĂ©nĂ©rique. +/// Contenu dĂ©taillĂ© Ă  venir. +class AmDashboardScreen extends StatefulWidget { + const AmDashboardScreen({super.key}); + + @override + State createState() => _AmDashboardScreenState(); +} + +class _AmDashboardScreenState extends State { + int selectedTabIndex = 0; + AppUser? _user; + + @override + void initState() { + super.initState(); + _loadUser(); + } + + Future _loadUser() async { + final user = await AuthService.getCurrentUser(); + if (mounted) setState(() => _user = user); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: PreferredSize( + preferredSize: const Size.fromHeight(60.0), + child: DashboardBandeau( + tabItems: const [ + DashboardTabItem(label: 'Mon tableau de bord'), + DashboardTabItem(label: 'ParamĂštres'), + ], + selectedTabIndex: selectedTabIndex, + onTabSelected: (index) => setState(() => selectedTabIndex = index), + userDisplayName: _user?.fullName.isNotEmpty == true + ? _user!.fullName + : 'Assistante maternelle', + userEmail: _user?.email, + userRole: _user?.role, + onProfileTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Modification du profil – Ă  venir')), + ); + }, + onSettingsTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('ParamĂštres – Ă  venir')), + ); + }, + onLogout: () {}, + showLogoutConfirmation: true, + ), + ), + body: Column( + children: [ + Expanded( + child: Center( + child: Text( + 'Dashboard AM – Ă  venir', + style: Theme.of(context).textTheme.titleLarge, + ), + ), + ), + const AppFooter(), + ], + ), + ); + } +} diff --git a/frontend/lib/screens/gestionnaire/gestionnaire_dashboard_screen.dart b/frontend/lib/screens/gestionnaire/gestionnaire_dashboard_screen.dart index f712dca..921ad0f 100644 --- a/frontend/lib/screens/gestionnaire/gestionnaire_dashboard_screen.dart +++ b/frontend/lib/screens/gestionnaire/gestionnaire_dashboard_screen.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:p_tits_pas/widgets/admin/dashboard_admin.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/app_footer.dart'; +import 'package:p_tits_pas/widgets/dashboard/dashboard_bandeau.dart'; /// Dashboard gestionnaire – mĂȘme shell que l'admin, sans onglet ParamĂštres. /// RĂ©utilise [UserManagementPanel]. @@ -14,24 +16,47 @@ class GestionnaireDashboardScreen extends StatefulWidget { } class _GestionnaireDashboardScreenState extends State { + AppUser? _user; + + @override + void initState() { + super.initState(); + _loadUser(); + } + + Future _loadUser() async { + final user = await AuthService.getCurrentUser(); + if (mounted) setState(() => _user = user); + } + @override Widget build(BuildContext context) { return Scaffold( appBar: PreferredSize( preferredSize: const Size.fromHeight(60.0), - child: Container( - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: Colors.grey.shade300), - ), - ), - child: DashboardAppBarAdmin( - selectedIndex: 0, - onTabChange: (_) {}, - showSettingsTab: false, - roleLabel: 'Gestionnaire', - setupCompleted: true, - ), + child: DashboardBandeau( + tabItems: const [ + DashboardTabItem(label: 'Gestion des utilisateurs'), + ], + selectedTabIndex: 0, + onTabSelected: (_) {}, + userDisplayName: _user?.fullName.isNotEmpty == true + ? _user!.fullName + : 'Gestionnaire', + userEmail: _user?.email, + userRole: _user?.role, + onProfileTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Modification du profil – Ă  venir')), + ); + }, + onSettingsTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('ParamĂštres – Ă  venir')), + ); + }, + onLogout: () {}, + showLogoutConfirmation: true, ), ), body: Column( diff --git a/frontend/lib/screens/home/parent_screen/ParentDashboardScreen.dart b/frontend/lib/screens/home/parent_screen/ParentDashboardScreen.dart index 81deb26..c71e4dc 100644 --- a/frontend/lib/screens/home/parent_screen/ParentDashboardScreen.dart +++ b/frontend/lib/screens/home/parent_screen/ParentDashboardScreen.dart @@ -1,11 +1,12 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/controllers/parent_dashboard_controller.dart'; +import 'package:p_tits_pas/models/user.dart'; +import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/dashboardService.dart'; import 'package:p_tits_pas/widgets/app_footer.dart'; -import 'package:p_tits_pas/widgets/dashbord_parent/app_layout.dart'; import 'package:p_tits_pas/widgets/dashbord_parent/children_sidebar.dart'; -import 'package:p_tits_pas/widgets/dashbord_parent/dashboard_app_bar.dart'; import 'package:p_tits_pas/widgets/dashbord_parent/wid_dashbord.dart'; +import 'package:p_tits_pas/widgets/dashboard/dashboard_bandeau.dart'; import 'package:p_tits_pas/widgets/main_content_area.dart'; import 'package:p_tits_pas/widgets/messaging_sidebar.dart'; import 'package:provider/provider.dart'; @@ -19,6 +20,7 @@ class ParentDashboardScreen extends StatefulWidget { class _ParentDashboardScreenState extends State { int selectedIndex = 0; + AppUser? _user; void onTabChange(int index) { setState(() { @@ -29,12 +31,18 @@ class _ParentDashboardScreenState extends State { @override void initState() { super.initState(); + _loadUser(); // Initialiser les donnĂ©es du dashboard WidgetsBinding.instance.addPostFrameCallback((_) { context.read().initDashboard(); }); } + Future _loadUser() async { + final user = await AuthService.getCurrentUser(); + if (mounted) setState(() => _user = user); + } + Widget _getBody() { switch (selectedIndex) { case 0: @@ -53,29 +61,43 @@ class _ParentDashboardScreenState extends State { return ChangeNotifierProvider( create: (context) => ParentDashboardController(DashboardService())..initDashboard(), child: Scaffold( - appBar: PreferredSize(preferredSize: const Size.fromHeight(60.0), - child: Container( - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: Colors.grey.shade300), - ), - ), - child: DashboardAppBar( - selectedIndex: selectedIndex, - onTabChange: onTabChange, + appBar: PreferredSize( + preferredSize: const Size.fromHeight(60.0), + child: DashboardBandeau( + tabItems: const [ + DashboardTabItem(label: 'Mon tableau de bord'), + DashboardTabItem(label: 'Trouver une nounou'), + DashboardTabItem(label: 'ParamĂštres'), + ], + selectedTabIndex: selectedIndex, + onTabSelected: onTabChange, + userDisplayName: _user?.fullName.isNotEmpty == true + ? _user!.fullName + : 'Parent', + userEmail: _user?.email, + userRole: _user?.role, + onProfileTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Modification du profil – Ă  venir')), + ); + }, + onSettingsTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('ParamĂštres – Ă  venir')), + ); + }, + onLogout: () {}, + showLogoutConfirmation: true, ), ), - ), body: Column( children: [ - Expanded (child: _getBody(), - ), + Expanded(child: _getBody()), const AppFooter(), ], ), - ) - // body: _buildResponsiveBody(context, controller), - // footer: const AppFooter(), + ), ); } diff --git a/frontend/lib/widgets/admin/dashboard_admin.dart b/frontend/lib/widgets/admin/dashboard_admin.dart index 13bbb43..5a7bf32 100644 --- a/frontend/lib/widgets/admin/dashboard_admin.dart +++ b/frontend/lib/widgets/admin/dashboard_admin.dart @@ -1,143 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:p_tits_pas/services/auth_service.dart'; - -/// Barre du dashboard admin : onglets Gestion des utilisateurs | ParamĂštres + dĂ©connexion. -/// Pour le dashboard gestionnaire : [showSettingsTab] = false, [roleLabel] = 'Gestionnaire'. -class DashboardAppBarAdmin extends StatelessWidget - implements PreferredSizeWidget { - final int selectedIndex; - final ValueChanged onTabChange; - final bool setupCompleted; - final bool showSettingsTab; - final String roleLabel; - - const DashboardAppBarAdmin({ - Key? key, - required this.selectedIndex, - required this.onTabChange, - this.setupCompleted = true, - this.showSettingsTab = true, - this.roleLabel = 'Admin', - }) : super(key: key); - - @override - Size get preferredSize => const Size.fromHeight(kToolbarHeight + 10); - - @override - Widget build(BuildContext context) { - return AppBar( - elevation: 0, - automaticallyImplyLeading: false, - title: Row( - children: [ - const SizedBox(width: 24), - Image.asset( - 'assets/images/logo.png', - height: 40, - fit: BoxFit.contain, - ), - Expanded( - child: Center( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _buildNavItem(context, 'Gestion des utilisateurs', 0, - enabled: setupCompleted), - if (showSettingsTab) ...[ - const SizedBox(width: 24), - _buildNavItem(context, 'ParamĂštres', 1, enabled: true), - ], - ], - ), - ), - ), - ], - ), - actions: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Center( - child: Text( - roleLabel, - style: const TextStyle( - color: Colors.black, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.only(right: 16), - child: TextButton( - onPressed: () => _handleLogout(context), - style: TextButton.styleFrom( - backgroundColor: const Color(0xFF9CC5C0), - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), - ), - ), - child: const Text('Se dĂ©connecter'), - ), - ), - ], - ); - } - - Widget _buildNavItem(BuildContext context, String title, int index, - {bool enabled = true}) { - final bool isActive = index == selectedIndex; - return InkWell( - onTap: enabled ? () => onTabChange(index) : null, - child: Opacity( - opacity: enabled ? 1.0 : 0.5, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent, - borderRadius: BorderRadius.circular(20), - border: isActive ? null : Border.all(color: Colors.black26), - ), - child: Text( - title, - style: TextStyle( - color: isActive ? Colors.white : Colors.black, - fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, - fontSize: 14, - ), - ), - ), - ), - ); - } - - void _handleLogout(BuildContext context) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('DĂ©connexion'), - content: const Text('Êtes-vous sĂ»r de vouloir vous dĂ©connecter ?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Annuler'), - ), - ElevatedButton( - onPressed: () async { - Navigator.pop(context); - await AuthService.logout(); - if (context.mounted) context.go('/login'); - }, - child: const Text('DĂ©connecter'), - ), - ], - ), - ); - } -} /// Sous-barre : Gestionnaires | Parents | Assistantes maternelles | [Administrateurs]. /// [subTabCount] = 3 pour masquer l'onglet Administrateurs (dashboard gestionnaire). @@ -212,31 +73,6 @@ class DashboardUserManagementSubBar extends StatelessWidget { ), ); } - - Widget _pillField({required double width, required Widget child}) { - return Container( - width: width, - height: 34, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(18), - border: Border.all(color: Colors.black26), - ), - alignment: Alignment.centerLeft, - child: child, - ); - } - - Widget _buildAddButton() { - return ElevatedButton.icon( - onPressed: onAddPressed, - icon: const Icon(Icons.add), - label: Text(addLabel), - ); - } - - Widget _buildSubNavItem(BuildContext context, String title, int index) { - final bool isActive = index == selectedSubIndex; return InkWell( onTap: () => onSubTabChange(index), child: Container( diff --git a/frontend/lib/widgets/app_footer.dart b/frontend/lib/widgets/app_footer.dart index a4559d2..b87f82b 100644 --- a/frontend/lib/widgets/app_footer.dart +++ b/frontend/lib/widgets/app_footer.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:p_tits_pas/models/m_dashbord/child_model.dart'; import 'package:p_tits_pas/services/bug_report_service.dart'; @@ -185,13 +186,11 @@ class AppFooter extends StatelessWidget { } void _handleLegalNotices(BuildContext context) { - // Handle legal notices action - Navigator.pushNamed(context, '/legal'); + context.push('/legal'); } void _handlePrivacyPolicy(BuildContext context) { - // Handle privacy policy action - Navigator.pushNamed(context, '/privacy'); + context.push('/privacy'); } void _handleContactSupport(BuildContext context) { diff --git a/frontend/lib/widgets/dashboard/dashboard_bandeau.dart b/frontend/lib/widgets/dashboard/dashboard_bandeau.dart new file mode 100644 index 0000000..6ac6560 --- /dev/null +++ b/frontend/lib/widgets/dashboard/dashboard_bandeau.dart @@ -0,0 +1,299 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:p_tits_pas/services/auth_service.dart'; + +/// Item d'onglet pour le bandeau (label + enabled). +class DashboardTabItem { + final String label; + final bool enabled; + + const DashboardTabItem({ + required this.label, + this.enabled = true, + }); +} + +/// IcĂŽne associĂ©e au rĂŽle utilisateur (alignĂ©e sur le panneau admin). +IconData _iconForRole(String? role) { + if (role == null || role.isEmpty) return Icons.person_outline; + final r = role.toLowerCase(); + if (r == 'super_admin') return Icons.verified_user_outlined; + if (r == 'admin' || r == 'administrateur') return Icons.manage_accounts_outlined; + if (r == 'gestionnaire') return Icons.assignment_ind_outlined; + if (r == 'parent') return Icons.supervisor_account_outlined; + if (r == 'assistante_maternelle') return Icons.face; + return Icons.person_outline; +} + +/// Bandeau gĂ©nĂ©rique type Gitea : icĂŽne | onglets | capsule (PrĂ©nom Nom â–Œ) → menu (email, Profil, ParamĂštres, DĂ©connexion). +class DashboardBandeau extends StatelessWidget implements PreferredSizeWidget { + final Widget? leading; + final List tabItems; + final int selectedTabIndex; + final ValueChanged onTabSelected; + final String userDisplayName; + final String? userEmail; + /// RĂŽle de l'utilisateur pour afficher l'icĂŽne correspondante (mĂȘme que panneau admin). + final String? userRole; + final VoidCallback? onProfileTap; + final VoidCallback? onSettingsTap; + final VoidCallback? onLogout; + final bool showLogoutConfirmation; + final bool bottomBorder; + final double? preferredHeight; + + const DashboardBandeau({ + super.key, + this.leading, + required this.tabItems, + required this.selectedTabIndex, + required this.onTabSelected, + required this.userDisplayName, + this.userEmail, + this.userRole, + this.onProfileTap, + this.onSettingsTap, + this.onLogout, + this.showLogoutConfirmation = true, + this.bottomBorder = true, + this.preferredHeight, + }); + + @override + Size get preferredSize => + Size.fromHeight(preferredHeight ?? (kToolbarHeight + 10)); + + Widget _defaultLeading() { + return Image.asset( + 'assets/images/logo.png', + height: 40, + fit: BoxFit.contain, + ); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).appBarTheme.backgroundColor ?? Colors.white, + border: bottomBorder + ? Border(bottom: BorderSide(color: Colors.grey.shade300)) + : null, + ), + child: AppBar( + elevation: 0, + automaticallyImplyLeading: false, + title: Row( + children: [ + const SizedBox(width: 24), + leading ?? _defaultLeading(), + Expanded( + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (int i = 0; i < tabItems.length; i++) ...[ + if (i > 0) const SizedBox(width: 24), + _buildNavItem( + context, + title: tabItems[i].label, + index: i, + enabled: tabItems[i].enabled, + ), + ], + ], + ), + ), + ), + ], + ), + actions: [ + _buildUserCapsule(context), + ], + ), + ); + } + + Widget _buildNavItem( + BuildContext context, { + required String title, + required int index, + bool enabled = true, + }) { + final isActive = index == selectedTabIndex; + return InkWell( + onTap: enabled ? () => onTabSelected(index) : null, + child: Opacity( + opacity: enabled ? 1.0 : 0.5, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent, + borderRadius: BorderRadius.circular(20), + border: isActive ? null : Border.all(color: Colors.black26), + ), + child: Text( + title, + style: TextStyle( + color: isActive ? Colors.white : Colors.black, + fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, + fontSize: 14, + ), + ), + ), + ), + ); + } + + Widget _buildUserCapsule(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(right: 16), + child: PopupMenuButton( + offset: const Offset(0, 45), + position: PopupMenuPosition.under, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + onSelected: (value) { + switch (value) { + case 'profile': + onProfileTap?.call(); + break; + case 'settings': + onSettingsTap?.call(); + break; + case 'logout': + _handleLogout(context); + break; + } + }, + itemBuilder: (context) { + final entries = >[]; + if (userEmail != null && userEmail!.isNotEmpty) { + entries.add( + PopupMenuItem( + enabled: false, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.email_outlined, size: 16, color: Colors.grey.shade700), + const SizedBox(width: 8), + Flexible( + child: Text( + userEmail!, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade700, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + entries.add(const PopupMenuDivider()); + } + if (onProfileTap != null) { + entries.add( + const PopupMenuItem( + value: 'profile', + child: ListTile( + leading: Icon(Icons.person_outline, size: 20), + title: Text('Modification du profil'), + contentPadding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + ), + ), + ); + } + if (onSettingsTap != null) { + entries.add( + const PopupMenuItem( + value: 'settings', + child: ListTile( + leading: Icon(Icons.settings_outlined, size: 20), + title: Text('ParamĂštres'), + contentPadding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + ), + ), + ); + } + if (onLogout != null) { + if (entries.isNotEmpty) entries.add(const PopupMenuDivider()); + entries.add( + const PopupMenuItem( + value: 'logout', + child: ListTile( + leading: Icon(Icons.logout, size: 20), + title: Text('DĂ©connexion'), + contentPadding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + ), + ), + ); + } + return entries; + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Colors.grey.shade300), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(_iconForRole(userRole), size: 18, color: Colors.grey.shade700), + const SizedBox(width: 6), + Text( + userDisplayName, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.black87, + ), + ), + const SizedBox(width: 4), + Icon(Icons.keyboard_arrow_down, size: 20, color: Colors.grey.shade700), + ], + ), + ), + ), + ); + } + + void _handleLogout(BuildContext context) { + if (showLogoutConfirmation) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('DĂ©connexion'), + content: const Text( + 'Êtes-vous sĂ»r de vouloir vous dĂ©connecter ?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () async { + Navigator.pop(ctx); + onLogout?.call(); + await AuthService.logout(); + if (context.mounted) context.go('/login'); + }, + child: const Text('DĂ©connecter'), + ), + ], + ), + ); + } else { + onLogout?.call(); + AuthService.logout().then((_) { + if (context.mounted) context.go('/login'); + }); + } + } +} + diff --git a/frontend/lib/widgets/dashbord_parent/dashboard_app_bar.dart b/frontend/lib/widgets/dashbord_parent/dashboard_app_bar.dart deleted file mode 100644 index c89fd6e..0000000 --- a/frontend/lib/widgets/dashbord_parent/dashboard_app_bar.dart +++ /dev/null @@ -1,156 +0,0 @@ -import 'package:flutter/material.dart'; - -class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget { - final int selectedIndex; - final ValueChanged onTabChange; - - const DashboardAppBar({Key? key, required this.selectedIndex, required this.onTabChange}) : super(key: key); - - @override - Size get preferredSize => const Size.fromHeight(kToolbarHeight + 10); - - @override - Widget build(BuildContext context) { - final isMobile = MediaQuery.of(context).size.width < 768; - return AppBar( - // backgroundColor: Colors.white, - elevation: 0, - title: Row( - children: [ - // Logo de la ville - // Container( - // height: 32, - // width: 32, - // decoration: BoxDecoration( - // color: Colors.white, - // borderRadius: BorderRadius.circular(8), - // ), - // child: const Icon( - // Icons.location_city, - // color: Color(0xFF9CC5C0), - // size: 20, - // ), - // ), - SizedBox(width: MediaQuery.of(context).size.width * 0.19), - const Text( - "P'tit Pas", - style: TextStyle( - color: Color(0xFF9CC5C0), - fontWeight: FontWeight.bold, - fontSize: 18, - ), - ), - SizedBox(width: MediaQuery.of(context).size.width * 0.1), - - // Navigation principale - _buildNavItem(context, 'Mon tableau de bord', 0), - const SizedBox(width: 24), - _buildNavItem(context, 'Trouver une nounou', 1), - const SizedBox(width: 24), - _buildNavItem(context, 'ParamĂštres', 2), - ], - ), - actions: isMobile - ? [_buildMobileMenu(context)] - : [ - // Nom de l'utilisateur - const Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: Center( - child: Text( - 'Jean Dupont', - style: TextStyle( - color: Colors.black, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - - // Bouton dĂ©connexion - Padding( - padding: const EdgeInsets.only(right: 16), - child: TextButton( - onPressed: () => _handleLogout(context), - style: TextButton.styleFrom( - backgroundColor: const Color(0xFF9CC5C0), - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), - ), - ), - child: const Text('Se dĂ©connecter'), - ), - ), - SizedBox(width: MediaQuery.of(context).size.width * 0.1), - ], - ); - } - - Widget _buildNavItem(BuildContext context, String title, int index) { - final bool isActive = index == selectedIndex; - return InkWell( - onTap: () => onTabChange(index), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent, - borderRadius: BorderRadius.circular(20), - border: isActive ? null : Border.all(color: Colors.black26), - ), - child: Text( - title, - style: TextStyle( - color: isActive ? Colors.white : Colors.black, - fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, - fontSize: 14, - ), - ), - ), - ); -} - - - Widget _buildMobileMenu(BuildContext context) { - return PopupMenuButton( - icon: const Icon(Icons.menu, color: Colors.white), - onSelected: (value) { - if (value == 3) { - _handleLogout(context); - } - }, - itemBuilder: (context) => [ - const PopupMenuItem(value: 0, child: Text("Mon tableau de bord")), - const PopupMenuItem(value: 1, child: Text("Trouver une nounou")), - const PopupMenuItem(value: 2, child: Text("ParamĂštres")), - const PopupMenuDivider(), - const PopupMenuItem(value: 3, child: Text("Se dĂ©connecter")), - ], - ); - } - - void _handleLogout(BuildContext context) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('DĂ©connexion'), - content: const Text('Êtes-vous sĂ»r de vouloir vous dĂ©connecter ?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Annuler'), - ), - ElevatedButton( - onPressed: () { - Navigator.pop(context); - // TODO: ImplĂ©menter la logique de dĂ©connexion - }, - child: const Text('DĂ©connecter'), - ), - ], - ), - ); - } -} \ No newline at end of file From b1a80f85c97b420a77162b19a93548a3c1dd0ae5 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 26 Feb 2026 10:44:04 +0100 Subject: [PATCH 20/22] Squash merge develop into master (feat #25 API users/pending, dashboards, login) Made-with: Cursor --- .../gestionnaires/gestionnaires.service.ts | 6 ----- backend/src/routes/user/user.controller.ts | 12 ++++++++- backend/src/routes/user/user.service.ts | 8 ++++++ .../lib/widgets/admin/dashboard_admin.dart | 25 +++++++++++++++++++ 4 files changed, 44 insertions(+), 7 deletions(-) diff --git a/backend/src/routes/user/gestionnaires/gestionnaires.service.ts b/backend/src/routes/user/gestionnaires/gestionnaires.service.ts index 25b48cc..efa795d 100644 --- a/backend/src/routes/user/gestionnaires/gestionnaires.service.ts +++ b/backend/src/routes/user/gestionnaires/gestionnaires.service.ts @@ -91,12 +91,6 @@ export class GestionnairesService { gestionnaire.password = await bcrypt.hash(dto.password, salt); } - // if (dto.date_consentement_photo !== undefined) { - // gestionnaire.date_consentement_photo = dto.date_consentement_photo - // ? new Date(dto.date_consentement_photo) - // : undefined; - // } - const { password, ...rest } = dto; Object.entries(rest).forEach(([key, value]) => { if (value !== undefined) { diff --git a/backend/src/routes/user/user.controller.ts b/backend/src/routes/user/user.controller.ts index f5fc93f..84682bc 100644 --- a/backend/src/routes/user/user.controller.ts +++ b/backend/src/routes/user/user.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { AuthGuard } from 'src/common/guards/auth.guard'; import { Roles } from 'src/common/decorators/roles.decorator'; @@ -38,6 +38,16 @@ export class UserController { return this.userService.createUser(dto, currentUser); } + // Lister les utilisateurs en attente de validation + @Get('pending') + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) + @ApiOperation({ summary: 'Lister les utilisateurs en attente de validation' }) + findPendingUsers( + @Query('role') role?: RoleType + ) { + return this.userService.findPendingUsers(role); + } + // Lister tous les utilisateurs (super_admin uniquement) @Get() @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) diff --git a/backend/src/routes/user/user.service.ts b/backend/src/routes/user/user.service.ts index b8a88e0..69ccaac 100644 --- a/backend/src/routes/user/user.service.ts +++ b/backend/src/routes/user/user.service.ts @@ -132,6 +132,14 @@ export class UserService { return this.usersRepository.save(entity); } + async findPendingUsers(role?: RoleType): Promise { + const where: any = { statut: StatutUtilisateurType.EN_ATTENTE }; + if (role) { + where.role = role; + } + return this.usersRepository.find({ where }); + } + async findAll(): Promise { return this.usersRepository.find(); } diff --git a/frontend/lib/widgets/admin/dashboard_admin.dart b/frontend/lib/widgets/admin/dashboard_admin.dart index 5a7bf32..55c99be 100644 --- a/frontend/lib/widgets/admin/dashboard_admin.dart +++ b/frontend/lib/widgets/admin/dashboard_admin.dart @@ -73,6 +73,31 @@ class DashboardUserManagementSubBar extends StatelessWidget { ), ); } + + Widget _pillField({required double width, required Widget child}) { + return Container( + width: width, + height: 34, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: Colors.black26), + ), + alignment: Alignment.centerLeft, + child: child, + ); + } + + Widget _buildAddButton() { + return ElevatedButton.icon( + onPressed: onAddPressed, + icon: const Icon(Icons.add), + label: Text(addLabel), + ); + } + + Widget _buildSubNavItem(BuildContext context, String title, int index) { + final bool isActive = index == selectedSubIndex; return InkWell( onTap: () => onSubTabChange(index), child: Container( From ca98821b3eb8a280831905ad8129801d050d6baa Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 26 Feb 2026 13:55:42 +0100 Subject: [PATCH 21/22] Merge develop into master (squash): ticket #102 NIR harmonisation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: DTO NIR 15 car 2A/2B, validation format+clĂ©, warning cohĂ©rence - BDD: nir_chiffre NOT NULL, migration pour bases existantes - Seeds: 02 nir_chiffre, 03 Marie 2A / Fatima 99 - Frontend: nir_utils, nir_text_field, formulaire pro, mock inscription AM Made-with: Cursor --- backend/src/common/utils/nir.util.ts | 109 +++++++++++++++++ backend/src/routes/auth/auth.service.ts | 15 ++- .../auth/dto/register-am-complet.dto.ts | 6 +- database/BDD.sql | 2 +- .../migrations/2026_nir_chiffre_not_null.sql | 16 +++ database/seed/02_seed.sql | 6 +- database/seed/03_seed_test_data.sql | 9 +- .../auth/am_register_step2_screen.dart | 8 +- frontend/lib/utils/nir_utils.dart | 113 ++++++++++++++++++ .../lib/widgets/custom_app_text_field.dart | 4 + frontend/lib/widgets/nir_text_field.dart | 55 +++++++++ .../professional_info_form_screen.dart | 50 ++++---- 12 files changed, 355 insertions(+), 38 deletions(-) create mode 100644 backend/src/common/utils/nir.util.ts create mode 100644 database/migrations/2026_nir_chiffre_not_null.sql create mode 100644 frontend/lib/utils/nir_utils.dart create mode 100644 frontend/lib/widgets/nir_text_field.dart diff --git a/backend/src/common/utils/nir.util.ts b/backend/src/common/utils/nir.util.ts new file mode 100644 index 0000000..30ecb87 --- /dev/null +++ b/backend/src/common/utils/nir.util.ts @@ -0,0 +1,109 @@ +/** + * Utilitaire de validation du NIR (numĂ©ro de sĂ©curitĂ© sociale français). + * - Format 15 caractĂšres (chiffres ou 2A/2B pour la Corse). + * - ClĂ© de contrĂŽle : 97 - (NIR13 mod 97). Pour 2A/2B, conversion temporaire (INSEE : 2A→19, 2B→20). + * - En cas d'incohĂ©rence avec les donnĂ©es (sexe, date, lieu) : warning uniquement, pas de rejet. + */ + +const NIR_CORSE_2A = '19'; +const NIR_CORSE_2B = '20'; + +/** Regex 15 caractĂšres : sexe (1-3) + 4 chiffres + (2A|2B|2 chiffres) + 6 chiffres + 2 chiffres clĂ© */ +const NIR_FORMAT = /^[1-3]\d{4}(?:2A|2B|\d{2})\d{6}\d{2}$/i; + +/** + * Convertit le NIR en chaĂźne de 13 chiffres pour le calcul de la clĂ© (2A→19, 2B→20). + */ +export function nirTo13Digits(nir: string): string { + const n = nir.toUpperCase().replace(/\s/g, ''); + if (n.length !== 15) return ''; + const dept = n.slice(5, 7); + let deptNum: string; + if (dept === '2A') deptNum = NIR_CORSE_2A; + else if (dept === '2B') deptNum = NIR_CORSE_2B; + else deptNum = dept; + return n.slice(0, 5) + deptNum + n.slice(7, 13); +} + +/** + * VĂ©rifie que le format NIR est valide (15 caractĂšres, 2A/2B acceptĂ©s). + */ +export function isNirFormatValid(nir: string): boolean { + if (!nir || typeof nir !== 'string') return false; + const n = nir.replace(/\s/g, '').toUpperCase(); + return NIR_FORMAT.test(n); +} + +/** + * Calcule la clĂ© de contrĂŽle attendue (97 - (NIR13 mod 97)). + * Retourne un nombre entre 1 et 97. + */ +export function computeNirKey(nir13: string): number { + const num = parseInt(nir13, 10); + if (Number.isNaN(num) || nir13.length !== 13) return -1; + return 97 - (num % 97); +} + +/** + * VĂ©rifie la clĂ© de contrĂŽle du NIR (15 caractĂšres). + * Retourne true si le NIR est valide (format + clĂ©). + */ +export function isNirKeyValid(nir: string): boolean { + const n = nir.replace(/\s/g, '').toUpperCase(); + if (n.length !== 15) return false; + const nir13 = nirTo13Digits(n); + if (nir13.length !== 13) return false; + const expectedKey = computeNirKey(nir13); + const actualKey = parseInt(n.slice(13, 15), 10); + return expectedKey === actualKey; +} + +export interface NirValidationResult { + valid: boolean; + error?: string; + warning?: string; +} + +/** + * Valide le NIR (format + clĂ©). En cas d'incohĂ©rence avec date de naissance ou sexe, ajoute un warning sans invalider. + */ +export function validateNir( + nir: string, + options?: { dateNaissance?: string; genre?: 'H' | 'F' }, +): NirValidationResult { + const n = (nir || '').replace(/\s/g, '').toUpperCase(); + if (n.length === 0) return { valid: false, error: 'Le NIR est requis' }; + if (!isNirFormatValid(n)) { + return { valid: false, error: 'Le NIR doit contenir 15 caractĂšres (chiffres, ou 2A/2B pour la Corse)' }; + } + if (!isNirKeyValid(n)) { + return { valid: false, error: 'ClĂ© de contrĂŽle du NIR invalide' }; + } + let warning: string | undefined; + if (options?.genre) { + const sexNir = n[0]; + const expectedSex = options.genre === 'F' ? '2' : '1'; + if (sexNir !== expectedSex) { + warning = 'Le NIR ne correspond pas au genre indiquĂ© (position 1 du NIR).'; + } + } + if (options?.dateNaissance) { + try { + const d = new Date(options.dateNaissance); + if (!Number.isNaN(d.getTime())) { + const year2 = d.getFullYear() % 100; + const month = d.getMonth() + 1; + const nirYear = parseInt(n.slice(1, 3), 10); + const nirMonth = parseInt(n.slice(3, 5), 10); + if (nirYear !== year2 || nirMonth !== month) { + warning = warning + ? `${warning} Le NIR ne correspond pas Ă  la date de naissance (positions 2-5).` + : 'Le NIR ne correspond pas Ă  la date de naissance indiquĂ©e (positions 2-5).'; + } + } + } catch { + // ignore + } + } + return { valid: true, warning }; +} diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index 1fdb8cd..76f2ddd 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -23,6 +23,7 @@ import { ParentsChildren } from 'src/entities/parents_children.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { LoginDto } from './dto/login.dto'; import { AppConfigService } from 'src/modules/config/config.service'; +import { validateNir } from 'src/common/utils/nir.util'; @Injectable() export class AuthService { @@ -325,6 +326,18 @@ export class AuthService { ); } + const nirNormalized = (dto.nir || '').replace(/\s/g, '').toUpperCase(); + const nirValidation = validateNir(nirNormalized, { + dateNaissance: dto.date_naissance, + }); + if (!nirValidation.valid) { + 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); + } + const existe = await this.usersService.findByEmailOrNull(dto.email); if (existe) { throw new ConflictException('Un compte avec cet email existe dĂ©jĂ '); @@ -370,7 +383,7 @@ export class AuthService { const am = amRepo.create({ user_id: userEnregistre.id, approval_number: dto.numero_agrement, - nir: dto.nir, + nir: nirNormalized, max_children: dto.capacite_accueil, biography: dto.biographie, residence_city: dto.ville ?? undefined, diff --git a/backend/src/routes/auth/dto/register-am-complet.dto.ts b/backend/src/routes/auth/dto/register-am-complet.dto.ts index 72728ca..5800bdd 100644 --- a/backend/src/routes/auth/dto/register-am-complet.dto.ts +++ b/backend/src/routes/auth/dto/register-am-complet.dto.ts @@ -103,10 +103,12 @@ export class RegisterAMCompletDto { @MaxLength(100) lieu_naissance_pays?: string; - @ApiProperty({ example: '123456789012345', description: 'NIR 15 chiffres' }) + @ApiProperty({ example: '123456789012345', description: 'NIR 15 caractĂšres (chiffres, ou 2A/2B pour la Corse)' }) @IsString() @IsNotEmpty({ message: 'Le NIR est requis' }) - @Matches(/^\d{15}$/, { message: 'Le NIR doit contenir exactement 15 chiffres' }) + @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({ example: 'AGR-2024-12345', description: "NumĂ©ro d'agrĂ©ment" }) diff --git a/database/BDD.sql b/database/BDD.sql index 6a26917..46a741e 100644 --- a/database/BDD.sql +++ b/database/BDD.sql @@ -80,7 +80,7 @@ CREATE INDEX idx_utilisateurs_token_creation_mdp CREATE TABLE assistantes_maternelles ( id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE, numero_agrement VARCHAR(50), - nir_chiffre CHAR(15), + nir_chiffre CHAR(15) NOT NULL, nb_max_enfants INT, biographie TEXT, disponible BOOLEAN DEFAULT true, diff --git a/database/migrations/2026_nir_chiffre_not_null.sql b/database/migrations/2026_nir_chiffre_not_null.sql new file mode 100644 index 0000000..0c94d35 --- /dev/null +++ b/database/migrations/2026_nir_chiffre_not_null.sql @@ -0,0 +1,16 @@ +-- Migration : rendre nir_chiffre NOT NULL (ticket #102) +-- À exĂ©cuter sur les bases existantes avant dĂ©ploiement du schĂ©ma avec nir_chiffre NOT NULL. +-- Les lignes sans NIR reçoivent un NIR de test valide (format + clĂ©) pour satisfaire la contrainte. + +BEGIN; + +-- Renseigner un NIR de test valide pour toute ligne oĂč nir_chiffre est NULL +UPDATE assistantes_maternelles +SET nir_chiffre = '275119900100102' +WHERE nir_chiffre IS NULL; + +-- Appliquer la contrainte NOT NULL +ALTER TABLE assistantes_maternelles + ALTER COLUMN nir_chiffre SET NOT NULL; + +COMMIT; diff --git a/database/seed/02_seed.sql b/database/seed/02_seed.sql index c8ef3b4..0cda26f 100644 --- a/database/seed/02_seed.sql +++ b/database/seed/02_seed.sql @@ -58,9 +58,9 @@ INSERT INTO parents (id_utilisateur, id_co_parent) VALUES ('55555555-5555-5555-5555-555555555555', NULL) -- parent2 sans co-parent ON CONFLICT (id_utilisateur) DO NOTHING; --- assistantes_maternelles -INSERT INTO assistantes_maternelles (id_utilisateur, numero_agrement, nb_max_enfants, disponible, ville_residence) -VALUES ('66666666-6666-6666-6666-666666666666', 'AGR-2025-0001', 3, true, 'Lille') +-- assistantes_maternelles (nir_chiffre NOT NULL depuis ticket #102) +INSERT INTO assistantes_maternelles (id_utilisateur, numero_agrement, nir_chiffre, nb_max_enfants, disponible, ville_residence) +VALUES ('66666666-6666-6666-6666-666666666666', 'AGR-2025-0001', '275119900100102', 3, true, 'Lille') ON CONFLICT (id_utilisateur) DO NOTHING; -- ------------------------------------------------------------ diff --git a/database/seed/03_seed_test_data.sql b/database/seed/03_seed_test_data.sql index 1aa805f..be8569d 100644 --- a/database/seed/03_seed_test_data.sql +++ b/database/seed/03_seed_test_data.sql @@ -2,6 +2,9 @@ -- 03_seed_test_data.sql : DonnĂ©es de test complĂštes (dashboard admin) -- AlignĂ© sur utilisateurs-test-complet.json -- Mot de passe universel : password (bcrypt) +-- NIR : numĂ©ros de test (non rĂ©els), cohĂ©rents avec les donnĂ©es (date naissance, genre). +-- - Marie Dubois : nĂ©e en Corse Ă  Ajaccio → NIR 2A (test exception Corse). +-- - Fatima El Mansouri : nĂ©e Ă  l'Ă©tranger → NIR 99. -- À exĂ©cuter aprĂšs BDD.sql (init DB) -- ============================================================ @@ -36,10 +39,12 @@ VALUES ON CONFLICT (id_utilisateur) DO NOTHING; -- ========== ASSISTANTES MATERNELLES ========== +-- Marie Dubois (a0000003) : nĂ©e en Corse Ă  Ajaccio – NIR 2A pour test exception Corse (1980-06-08, F). +-- Fatima El Mansouri (a0000004) : nĂ©e Ă  l'Ă©tranger – NIR 99 pour test (1975-11-12, F). INSERT INTO assistantes_maternelles (id_utilisateur, numero_agrement, nir_chiffre, nb_max_enfants, biographie, date_agrement, ville_residence, disponible, place_disponible) VALUES - ('a0000003-0003-0003-0003-000000000003', 'AGR-2019-095001', '280069512345671', 4, 'Assistante maternelle agréée depuis 2019. SpĂ©cialitĂ© bĂ©bĂ©s 0-18 mois. Accueil bienveillant et cadre sĂ©curisant. 2 places disponibles.', '2019-09-01', 'Bezons', true, 2), - ('a0000004-0004-0004-0004-000000000004', 'AGR-2017-095002', '275119512345672', 3, 'Assistante maternelle expĂ©rimentĂ©e. SpĂ©cialitĂ© 1-3 ans. Accueil Ă  la journĂ©e. 1 place disponible.', '2017-06-15', 'Bezons', true, 1) + ('a0000003-0003-0003-0003-000000000003', 'AGR-2019-095001', '280062A00100191', 4, 'Assistante maternelle agréée depuis 2019. NĂ©e en Corse Ă  Ajaccio. SpĂ©cialitĂ© bĂ©bĂ©s 0-18 mois. Accueil bienveillant et cadre sĂ©curisant. 2 places disponibles.', '2019-09-01', 'Bezons', true, 2), + ('a0000004-0004-0004-0004-000000000004', 'AGR-2017-095002', '275119900100102', 3, 'Assistante maternelle expĂ©rimentĂ©e. NĂ©e Ă  l''Ă©tranger. SpĂ©cialitĂ© 1-3 ans. Accueil Ă  la journĂ©e. 1 place disponible.', '2017-06-15', 'Bezons', true, 1) ON CONFLICT (id_utilisateur) DO NOTHING; -- ========== ENFANTS ========== diff --git a/frontend/lib/screens/auth/am_register_step2_screen.dart b/frontend/lib/screens/auth/am_register_step2_screen.dart index 1496c6f..447280a 100644 --- a/frontend/lib/screens/auth/am_register_step2_screen.dart +++ b/frontend/lib/screens/auth/am_register_step2_screen.dart @@ -54,15 +54,15 @@ class _AmRegisterStep2ScreenState extends State { capacity: registrationData.capacity, ); - // GĂ©nĂ©rer des donnĂ©es de test si les champs sont vides + // GĂ©nĂ©rer des donnĂ©es de test si les champs sont vides (NIR = Marie Dubois du seed, Corse 2A) if (registrationData.dateOfBirth == null && registrationData.nir.isEmpty) { initialData = ProfessionalInfoData( photoPath: 'assets/images/icon_assmat.png', photoConsent: true, - dateOfBirth: DateTime(1985, 3, 15), - birthCity: DataGenerator.city(), + dateOfBirth: DateTime(1980, 6, 8), + birthCity: 'Ajaccio', birthCountry: 'France', - nir: '${DataGenerator.randomIntInRange(1, 3)}${DataGenerator.randomIntInRange(80, 96)}${DataGenerator.randomIntInRange(1, 13).toString().padLeft(2, '0')}${DataGenerator.randomIntInRange(1, 100).toString().padLeft(2, '0')}${DataGenerator.randomIntInRange(100, 1000).toString().padLeft(3, '0')}${DataGenerator.randomIntInRange(100, 1000).toString().padLeft(3, '0')}${DataGenerator.randomIntInRange(10, 100).toString().padLeft(2, '0')}', + nir: '280062A00100191', agrementNumber: 'AM${DataGenerator.randomIntInRange(10000, 100000)}', capacity: DataGenerator.randomIntInRange(1, 5), ); diff --git a/frontend/lib/utils/nir_utils.dart b/frontend/lib/utils/nir_utils.dart new file mode 100644 index 0000000..ea8d072 --- /dev/null +++ b/frontend/lib/utils/nir_utils.dart @@ -0,0 +1,113 @@ +import 'package:flutter/services.dart'; + +/// Utilitaires NIR (NumĂ©ro d'Inscription au RĂ©pertoire) – INSEE, 15 caractĂšres. +/// Corse : 2A (2A) et 2B (2B) au lieu de 19/20. ClĂ© de contrĂŽle : 97 - (NIR13 mod 97). + +/// Normalise le NIR : 15 caractĂšres, sans espaces ni sĂ©parateurs. Corse conservĂ©e (2A/2B). +String normalizeNir(String input) { + if (input.isEmpty) return ''; + final cleaned = input.replaceAll(' ', '').replaceAll('-', '').replaceAll('/', '').toUpperCase(); + final buf = StringBuffer(); + int i = 0; + while (i < cleaned.length && buf.length < 15) { + final c = cleaned[i]; + if (buf.length < 5) { + if (c.compareTo('0') >= 0 && c.compareTo('9') <= 0) buf.write(c); + i++; + } else if (buf.length == 5) { + if (c == '2' && i + 1 < cleaned.length && (cleaned[i + 1] == 'A' || cleaned[i + 1] == 'B')) { + buf.write('2'); + buf.write(cleaned[i + 1]); + i += 2; + } else if ((c == 'A' || c == 'B')) { + buf.write('2'); + buf.write(c); + i++; + } else if (c.compareTo('0') >= 0 && c.compareTo('9') <= 0) { + buf.write(c); + if (i + 1 < cleaned.length && cleaned[i + 1].compareTo('0') >= 0 && cleaned[i + 1].compareTo('9') <= 0) { + buf.write(cleaned[i + 1]); + i += 2; + } else { + i++; + } + } else { + i++; + } + } else { + if (c.compareTo('0') >= 0 && c.compareTo('9') <= 0) buf.write(c); + i++; + } + } + return buf.toString().length > 15 ? buf.toString().substring(0, 15) : buf.toString(); +} + +/// Retourne la chaĂźne brute Ă  15 caractĂšres (chiffres + 2A ou 2B). +String nirToRaw(String normalized) { + String s = normalized.replaceAll(' ', '').replaceAll('-', '').replaceAll('/', ''); + if (s.length > 15) s = s.substring(0, 15); + return s; +} + +/// Formate pour affichage : 1 12 34 56 789 012-34 ou 1 12 34 2A 789 012-34 +String formatNir(String raw) { + final r = nirToRaw(raw); + if (r.length < 15) return r; + final dept = r.substring(5, 7); + final isCorsica = dept == '2A' || dept == '2B'; + if (isCorsica) { + return '${r.substring(0, 5)} ${r.substring(5, 7)} ${r.substring(7, 10)} ${r.substring(10, 13)}-${r.substring(13, 15)}'; + } + return '${r.substring(0, 1)} ${r.substring(1, 3)} ${r.substring(3, 5)} ${r.substring(5, 7)} ${r.substring(7, 10)} ${r.substring(10, 13)}-${r.substring(13, 15)}'; +} + +/// VĂ©rifie le format : 15 caractĂšres, structure 1+2+2+2+3+3+2, dĂ©partement 2A/2B autorisĂ©. +bool _isFormatValid(String raw) { + if (raw.length != 15) return false; + final dept = raw.substring(5, 7); + final restDigits = raw.substring(0, 5) + (dept == '2A' ? '19' : dept == '2B' ? '18' : dept) + raw.substring(7, 15); + if (!RegExp(r'^[12]\d{12}\d{2}$').hasMatch(restDigits)) return false; + return RegExp(r'^[12]\d{4}(?:\d{2}|2A|2B)\d{6}$').hasMatch(raw); +} + +/// Calcule la clĂ© de contrĂŽle (97 - (NIR13 mod 97)). Pour 2A→19, 2B→18. +int _controlKey(String raw13) { + String n = raw13; + if (raw13.length >= 7 && (raw13.substring(5, 7) == '2A' || raw13.substring(5, 7) == '2B')) { + n = raw13.substring(0, 5) + (raw13.substring(5, 7) == '2A' ? '19' : '18') + raw13.substring(7); + } + final big = int.tryParse(n); + if (big == null) return -1; + return 97 - (big % 97); +} + +/// Valide le NIR (format + clĂ©). Retourne null si valide, message d'erreur sinon. +String? validateNir(String? value) { + if (value == null || value.isEmpty) return 'NIR requis'; + final raw = nirToRaw(value).toUpperCase(); + if (raw.length != 15) return 'Le NIR doit contenir 15 caractĂšres (chiffres, ou 2A/2B pour la Corse)'; + if (!_isFormatValid(raw)) return 'Format NIR invalide (ex. 1 12 34 56 789 012-34 ou 2A pour la Corse)'; + final key = _controlKey(raw.substring(0, 13)); + final keyStr = key >= 0 && key <= 99 ? key.toString().padLeft(2, '0') : ''; + final expectedKey = raw.substring(13, 15); + if (key < 0 || keyStr != expectedKey) return 'ClĂ© de contrĂŽle NIR invalide'; + return null; +} + +/// Formateur de saisie : affiche le NIR formatĂ© (1 12 34 56 789 012-34) et limite Ă  15 caractĂšres utiles. +class NirInputFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + final raw = normalizeNir(newValue.text); + if (raw.isEmpty) return newValue; + final formatted = formatNir(raw); + final offset = formatted.length; + return TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: offset), + ); + } +} diff --git a/frontend/lib/widgets/custom_app_text_field.dart b/frontend/lib/widgets/custom_app_text_field.dart index 0645bb1..967865a 100644 --- a/frontend/lib/widgets/custom_app_text_field.dart +++ b/frontend/lib/widgets/custom_app_text_field.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; // DĂ©finition de l'enum pour les styles de couleur/fond @@ -30,6 +31,7 @@ class CustomAppTextField extends StatefulWidget { final Iterable? autofillHints; final TextInputAction? textInputAction; final ValueChanged? onFieldSubmitted; + final List? inputFormatters; const CustomAppTextField({ super.key, @@ -54,6 +56,7 @@ class CustomAppTextField extends StatefulWidget { this.autofillHints, this.textInputAction, this.onFieldSubmitted, + this.inputFormatters, }); @override @@ -114,6 +117,7 @@ class _CustomAppTextFieldState extends State { focusNode: widget.focusNode, obscureText: widget.obscureText, keyboardType: widget.keyboardType, + inputFormatters: widget.inputFormatters, autofillHints: widget.autofillHints, textInputAction: widget.textInputAction, onFieldSubmitted: widget.onFieldSubmitted, diff --git a/frontend/lib/widgets/nir_text_field.dart b/frontend/lib/widgets/nir_text_field.dart new file mode 100644 index 0000000..e1beca1 --- /dev/null +++ b/frontend/lib/widgets/nir_text_field.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +import '../utils/nir_utils.dart'; +import 'custom_app_text_field.dart'; + +/// Champ de saisie dĂ©diĂ© au NIR (NumĂ©ro d'Inscription au RĂ©pertoire – 15 caractĂšres). +/// Format affichĂ© : 1 12 34 56 789 012-34 ou 1 12 34 2A 789 012-34 pour la Corse. +/// La valeur envoyĂ©e au [controller] est formatĂ©e ; utiliser [normalizeNir](controller.text) Ă  la soumission. +class NirTextField extends StatelessWidget { + final TextEditingController controller; + final String labelText; + final String hintText; + final String? Function(String?)? validator; + final double fieldWidth; + final double fieldHeight; + final double labelFontSize; + final double inputFontSize; + final bool enabled; + final bool readOnly; + final CustomAppTextFieldStyle style; + + const NirTextField({ + super.key, + required this.controller, + this.labelText = 'N° SĂ©curitĂ© Sociale (NIR)', + this.hintText = '15 car. (ex. 1 12 34 56 789 012-34 ou 2A Corse)', + this.validator, + this.fieldWidth = double.infinity, + this.fieldHeight = 53.0, + this.labelFontSize = 18.0, + this.inputFontSize = 18.0, + this.enabled = true, + this.readOnly = false, + this.style = CustomAppTextFieldStyle.beige, + }); + + @override + Widget build(BuildContext context) { + return CustomAppTextField( + controller: controller, + labelText: labelText, + hintText: hintText, + fieldWidth: fieldWidth, + fieldHeight: fieldHeight, + labelFontSize: labelFontSize, + inputFontSize: inputFontSize, + keyboardType: TextInputType.text, + validator: validator ?? validateNir, + inputFormatters: [NirInputFormatter()], + enabled: enabled, + readOnly: readOnly, + style: style, + ); + } +} diff --git a/frontend/lib/widgets/professional_info_form_screen.dart b/frontend/lib/widgets/professional_info_form_screen.dart index 24b8f56..1923b96 100644 --- a/frontend/lib/widgets/professional_info_form_screen.dart +++ b/frontend/lib/widgets/professional_info_form_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; @@ -6,7 +7,9 @@ import 'dart:math' as math; import 'dart:io'; import '../models/card_assets.dart'; import '../config/display_config.dart'; +import '../utils/nir_utils.dart'; import 'custom_app_text_field.dart'; +import 'nir_text_field.dart'; import 'form_field_wrapper.dart'; import 'app_custom_checkbox.dart'; import 'hover_relief_widget.dart'; @@ -97,7 +100,8 @@ class _ProfessionalInfoFormScreenState extends State : ''; _birthCityController.text = data.birthCity; _birthCountryController.text = data.birthCountry; - _nirController.text = data.nir; + final nirRaw = nirToRaw(data.nir); + _nirController.text = nirRaw.length == 15 ? formatNir(nirRaw) : data.nir; _agrementController.text = data.agrementNumber; _capacityController.text = data.capacity?.toString() ?? ''; _photoPathFramework = data.photoPath; @@ -161,7 +165,7 @@ class _ProfessionalInfoFormScreenState extends State dateOfBirth: _selectedDate, birthCity: _birthCityController.text, birthCountry: _birthCountryController.text, - nir: _nirController.text, + nir: normalizeNir(_nirController.text), agrementNumber: _agrementController.text, capacity: int.tryParse(_capacityController.text), ); @@ -499,7 +503,7 @@ class _ProfessionalInfoFormScreenState extends State children: [ Expanded(flex: 2, child: _buildReadonlyField('Date de naissance', _dateOfBirthController.text)), const SizedBox(width: 16), - Expanded(flex: 3, child: _buildReadonlyField('NIR', _nirController.text)), + Expanded(flex: 3, child: _buildReadonlyField('NIR', _formatNirForDisplay(_nirController.text))), ], ), const SizedBox(height: 12), @@ -525,6 +529,12 @@ class _ProfessionalInfoFormScreenState extends State ); } + /// NIR formatĂ© pour affichage (1 12 34 56 789 012-34 ou 2A pour la Corse). + String _formatNirForDisplay(String value) { + final raw = nirToRaw(value); + return raw.length == 15 ? formatNir(raw) : value; + } + /// Helper pour champ Readonly style "Beige" Widget _buildReadonlyField(String label, String value) { return Column( @@ -609,18 +619,12 @@ class _ProfessionalInfoFormScreenState extends State ], ), SizedBox(height: verticalSpacing), - _buildField( - config: config, - label: 'N° SĂ©curitĂ© Sociale (NIR)', + NirTextField( controller: _nirController, - hint: 'Votre NIR Ă  13 chiffres', - keyboardType: TextInputType.number, - validator: (v) { - if (v == null || v.isEmpty) return 'NIR requis'; - if (v.length != 13) return 'Le NIR doit contenir 13 chiffres'; - if (!RegExp(r'^[1-3]').hasMatch(v[0])) return 'Le NIR doit commencer par 1, 2 ou 3'; - return null; - }, + fieldWidth: double.infinity, + fieldHeight: config.isMobile ? 45.0 : 53.0, + labelFontSize: config.isMobile ? 15.0 : 22.0, + inputFontSize: config.isMobile ? 14.0 : 20.0, ), SizedBox(height: verticalSpacing), Row( @@ -695,18 +699,12 @@ class _ProfessionalInfoFormScreenState extends State ), const SizedBox(height: 12), - _buildField( - config: config, - label: 'N° SĂ©curitĂ© Sociale (NIR)', + NirTextField( controller: _nirController, - hint: 'Votre NIR Ă  13 chiffres', - keyboardType: TextInputType.number, - validator: (v) { - if (v == null || v.isEmpty) return 'NIR requis'; - if (v.length != 13) return 'Le NIR doit contenir 13 chiffres'; - if (!RegExp(r'^[1-3]').hasMatch(v[0])) return 'Le NIR doit commencer par 1, 2 ou 3'; - return null; - }, + fieldWidth: double.infinity, + fieldHeight: 45.0, + labelFontSize: 15.0, + inputFontSize: 14.0, ), const SizedBox(height: 12), @@ -796,6 +794,7 @@ class _ProfessionalInfoFormScreenState extends State VoidCallback? onTap, IconData? suffixIcon, String? Function(String?)? validator, + List? inputFormatters, }) { if (config.isReadonly) { return FormFieldWrapper( @@ -817,6 +816,7 @@ class _ProfessionalInfoFormScreenState extends State onTap: onTap, suffixIcon: suffixIcon, validator: validator, + inputFormatters: inputFormatters, ); } } From ae0be0496401f58dc12cdfde638b331d4ee58d2a Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 26 Feb 2026 19:10:04 +0100 Subject: [PATCH 22/22] =?UTF-8?q?test(inscription=20AM):=20Pr=C3=A9remplis?= =?UTF-8?q?sage=20donn=C3=A9es=20de=20test=20Marie=20DUBOIS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Étapes 1 Ă  3 du formulaire d'inscription AM : remplacer les donnĂ©es alĂ©atoires par le jeu de test officiel (03_seed_test_data.sql). Made-with: Cursor --- .../auth/am_register_step1_screen.dart | 19 ++++++++----------- .../auth/am_register_step2_screen.dart | 9 ++++----- .../auth/am_register_step3_screen.dart | 4 ++-- 3 files changed, 14 insertions(+), 18 deletions(-) diff --git a/frontend/lib/screens/auth/am_register_step1_screen.dart b/frontend/lib/screens/auth/am_register_step1_screen.dart index 06a8fa2..14a4db1 100644 --- a/frontend/lib/screens/auth/am_register_step1_screen.dart +++ b/frontend/lib/screens/auth/am_register_step1_screen.dart @@ -3,7 +3,6 @@ import 'package:provider/provider.dart'; import 'package:go_router/go_router.dart'; import '../../models/am_registration_data.dart'; -import '../../utils/data_generator.dart'; import '../../widgets/personal_info_form_screen.dart'; import '../../models/card_assets.dart'; @@ -14,19 +13,17 @@ class AmRegisterStep1Screen extends StatelessWidget { Widget build(BuildContext context) { final registrationData = Provider.of(context, listen: false); - // GĂ©nĂ©rer des donnĂ©es de test si vide + // DonnĂ©es de test : Marie DUBOIS (jeu de test 03_seed_test_data.sql / docs/test-data) PersonalInfoData initialData; if (registrationData.firstName.isEmpty) { - final genFirstName = DataGenerator.firstName(); - final genLastName = DataGenerator.lastName(); initialData = PersonalInfoData( - firstName: genFirstName, - lastName: genLastName, - phone: DataGenerator.phone(), - email: DataGenerator.email(genFirstName, genLastName), - address: DataGenerator.address(), - postalCode: DataGenerator.postalCode(), - city: DataGenerator.city(), + firstName: 'Marie', + lastName: 'DUBOIS', + phone: '0696345678', + email: 'marie.dubois@ptits-pas.fr', + address: '25 Rue de la RĂ©publique', + postalCode: '95870', + city: 'Bezons', ); } else { initialData = PersonalInfoData( diff --git a/frontend/lib/screens/auth/am_register_step2_screen.dart b/frontend/lib/screens/auth/am_register_step2_screen.dart index 447280a..bf354d4 100644 --- a/frontend/lib/screens/auth/am_register_step2_screen.dart +++ b/frontend/lib/screens/auth/am_register_step2_screen.dart @@ -6,7 +6,6 @@ import 'dart:io'; import '../../models/am_registration_data.dart'; import '../../models/card_assets.dart'; -import '../../utils/data_generator.dart'; import '../../widgets/professional_info_form_screen.dart'; class AmRegisterStep2Screen extends StatefulWidget { @@ -54,17 +53,17 @@ class _AmRegisterStep2ScreenState extends State { capacity: registrationData.capacity, ); - // GĂ©nĂ©rer des donnĂ©es de test si les champs sont vides (NIR = Marie Dubois du seed, Corse 2A) + // DonnĂ©es de test : Marie DUBOIS (jeu de test 03_seed_test_data.sql / docs/test-data) if (registrationData.dateOfBirth == null && registrationData.nir.isEmpty) { initialData = ProfessionalInfoData( photoPath: 'assets/images/icon_assmat.png', photoConsent: true, dateOfBirth: DateTime(1980, 6, 8), - birthCity: 'Ajaccio', + birthCity: 'Bezons', birthCountry: 'France', nir: '280062A00100191', - agrementNumber: 'AM${DataGenerator.randomIntInRange(10000, 100000)}', - capacity: DataGenerator.randomIntInRange(1, 5), + agrementNumber: 'AGR-2019-095001', + capacity: 4, ); } diff --git a/frontend/lib/screens/auth/am_register_step3_screen.dart b/frontend/lib/screens/auth/am_register_step3_screen.dart index 1fff3cb..7bda43f 100644 --- a/frontend/lib/screens/auth/am_register_step3_screen.dart +++ b/frontend/lib/screens/auth/am_register_step3_screen.dart @@ -13,12 +13,12 @@ class AmRegisterStep3Screen extends StatelessWidget { Widget build(BuildContext context) { final data = Provider.of(context, listen: false); - // GĂ©nĂ©rer un texte de test si vide + // DonnĂ©es de test : Marie DUBOIS (jeu de test 03_seed_test_data.sql / docs/test-data) String initialText = data.presentationText; bool initialCgu = data.cguAccepted; if (initialText.isEmpty) { - initialText = 'Disponible immĂ©diatement, plus de 10 ans d\'expĂ©rience avec les tout-petits. Formation aux premiers secours Ă  jour. Je dispose d\'un jardin sĂ©curisĂ© et d\'un espace de jeu adaptĂ©.'; + initialText = 'Assistante maternelle agréée depuis 2019. SpĂ©cialitĂ© bĂ©bĂ©s 0-18 mois. Accueil bienveillant et cadre sĂ©curisant. 2 places disponibles.'; initialCgu = true; }