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/screens/administrateurs/admin_dashboardScreen.dart b/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart index 12395f3..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,6 @@ class AdminDashboardScreen extends StatefulWidget { class _AdminDashboardScreenState extends State { bool? _setupCompleted; int mainTabIndex = 0; - int subIndex = 0; int settingsSubIndex = 0; @override @@ -27,6 +23,11 @@ class _AdminDashboardScreenState extends State { _loadSetupStatus(); } + @override + void dispose() { + super.dispose(); + } + Future _loadSetupStatus() async { try { final completed = await ConfigurationService.getSetupStatus(); @@ -51,12 +52,6 @@ class _AdminDashboardScreenState extends State { }); } - void onSubTabChange(int index) { - setState(() { - subIndex = index; - }); - } - void onSettingsSubTabChange(int index) { setState(() { settingsSubIndex = index; @@ -89,10 +84,7 @@ class _AdminDashboardScreenState extends State { body: Column( children: [ if (mainTabIndex == 0) - DashboardUserManagementSubBar( - selectedSubIndex: subIndex, - onSubTabChange: onSubTabChange, - ) + const SizedBox.shrink() else DashboardSettingsSubBar( selectedSubIndex: settingsSubIndex, @@ -114,17 +106,6 @@ class _AdminDashboardScreenState extends State { selectedSettingsTabIndex: settingsSubIndex, ); } - 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 const AdminUserManagementPanel(); } } 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 18d16ea..ff1aa3b 100644 --- a/frontend/lib/widgets/admin/dashboard_admin.dart +++ b/frontend/lib/widgets/admin/dashboard_admin.dart @@ -136,39 +136,91 @@ class DashboardAppBarAdmin extends StatelessWidget 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: 48, + 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: 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), + 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( 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 b9c9651..80d5d91 100644 --- a/frontend/lib/widgets/admin/gestionnaire_management_widget.dart +++ b/frontend/lib/widgets/admin/gestionnaire_management_widget.dart @@ -3,9 +3,16 @@ 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/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() => @@ -18,21 +25,15 @@ class _GestionnaireManagementWidgetState String? _error; List _gestionnaires = []; List _relais = []; - List _filteredGestionnaires = []; - final TextEditingController _searchController = TextEditingController(); @override void initState() { super.initState(); _loadGestionnaires(); - _searchController.addListener(_onSearchChanged); } @override - void dispose() { - _searchController.dispose(); - super.dispose(); - } + void dispose() => super.dispose(); Future _loadGestionnaires() async { setState(() { @@ -52,7 +53,6 @@ class _GestionnaireManagementWidgetState setState(() { _gestionnaires = gestionnaires; _relais = relais; - _filteredGestionnaires = gestionnaires; _isLoading = false; }); } catch (e) { @@ -64,17 +64,6 @@ 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( @@ -155,92 +144,45 @@ class _GestionnaireManagementWidgetState @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 gestionnaire...', - prefixIcon: Icon(Icons.search), - border: OutlineInputBorder(), - ), - ), - ), - const SizedBox(width: 16), - ElevatedButton.icon( - onPressed: () { - // TODO: Rediriger vers la page de creation. - }, - icon: const Icon(Icons.add), - label: const Text('Creer un gestionnaire'), - ), - ], - ), - 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 (_filteredGestionnaires.isEmpty) - const Center(child: Text('Aucun gestionnaire trouve.')) - else - Expanded( - child: ListView.builder( - itemCount: _filteredGestionnaires.length, - itemBuilder: (context, index) { - final user = _filteredGestionnaires[index]; - return Card( - margin: const EdgeInsets.only(bottom: 10), - child: ListTile( - leading: CircleAvatar( - child: Text( - (user.prenom?.isNotEmpty == true - ? user.prenom!.substring(0, 1) - : user.email.substring(0, 1)) - .toUpperCase(), - ), - ), - title: Text( - user.fullName.isNotEmpty ? user.fullName : 'Sans nom', - ), - subtitle: Text( - '${user.email} • Relais: ${user.relaisNom ?? 'Non rattache'}', - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - 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. - }, - ), - ], - ), - ), - ); - }, - ), + 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(); + + 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/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/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()), + ], + ); + } +}