diff --git a/unionflow-mobile-apps/android/app/src/main/res/xml/network_security_config.xml b/unionflow-mobile-apps/android/app/src/main/res/xml/network_security_config.xml index fd2dbb8..8556e47 100644 --- a/unionflow-mobile-apps/android/app/src/main/res/xml/network_security_config.xml +++ b/unionflow-mobile-apps/android/app/src/main/res/xml/network_security_config.xml @@ -6,7 +6,7 @@ - 192.168.1.145 + 192.168.1.11 localhost 10.0.2.2 127.0.0.1 diff --git a/unionflow-mobile-apps/lib/core/auth/services/keycloak_auth_service.dart b/unionflow-mobile-apps/lib/core/auth/services/keycloak_auth_service.dart index a7bad95..d36b71b 100644 --- a/unionflow-mobile-apps/lib/core/auth/services/keycloak_auth_service.dart +++ b/unionflow-mobile-apps/lib/core/auth/services/keycloak_auth_service.dart @@ -15,7 +15,7 @@ import 'keycloak_webview_auth_service.dart'; /// Configuration Keycloak pour votre instance class KeycloakConfig { /// URL de base de votre Keycloak - static const String baseUrl = 'http://192.168.1.145:8180'; + static const String baseUrl = 'http://192.168.1.11:8180'; /// Realm UnionFlow static const String realm = 'unionflow'; @@ -193,9 +193,9 @@ class KeycloakAuthService { lastName: lastName, primaryRole: primaryRole, - organizationContexts: [], // À implémenter selon vos besoins + organizationContexts: const [], // À implémenter selon vos besoins additionalPermissions: permissions, - revokedPermissions: [], + revokedPermissions: const [], preferences: const UserPreferences(), lastLoginAt: DateTime.now(), createdAt: DateTime.now(), // À récupérer depuis Keycloak si disponible diff --git a/unionflow-mobile-apps/lib/core/auth/services/keycloak_webview_auth_service.dart b/unionflow-mobile-apps/lib/core/auth/services/keycloak_webview_auth_service.dart index 66b9cc9..35394f0 100644 --- a/unionflow-mobile-apps/lib/core/auth/services/keycloak_webview_auth_service.dart +++ b/unionflow-mobile-apps/lib/core/auth/services/keycloak_webview_auth_service.dart @@ -27,7 +27,7 @@ import 'keycloak_role_mapper.dart'; /// Configuration Keycloak pour l'authentification WebView class KeycloakWebViewConfig { /// URL de base de l'instance Keycloak - static const String baseUrl = 'http://192.168.1.145:8180'; + static const String baseUrl = 'http://192.168.1.11:8180'; /// Realm UnionFlow static const String realm = 'unionflow'; @@ -273,7 +273,7 @@ class KeycloakWebViewAuthService { }, body: body, ) - .timeout(Duration(seconds: KeycloakWebViewConfig.httpTimeoutSeconds)); + .timeout(const Duration(seconds: KeycloakWebViewConfig.httpTimeoutSeconds)); debugPrint('📡 Réponse token endpoint: ${response.statusCode}'); @@ -371,7 +371,7 @@ class KeycloakWebViewAuthService { } // Vérifier l'issuer - final String expectedIssuer = '${KeycloakWebViewConfig.baseUrl}/realms/${KeycloakWebViewConfig.realm}'; + const String expectedIssuer = '${KeycloakWebViewConfig.baseUrl}/realms/${KeycloakWebViewConfig.realm}'; if (payload['iss'] != expectedIssuer) { throw KeycloakWebViewAuthException( 'Token JWT invalide: issuer incorrect (attendu: $expectedIssuer, reçu: ${payload['iss']})', diff --git a/unionflow-mobile-apps/lib/core/models/membre_search_criteria.dart b/unionflow-mobile-apps/lib/core/models/membre_search_criteria.dart new file mode 100644 index 0000000..4a0efcf --- /dev/null +++ b/unionflow-mobile-apps/lib/core/models/membre_search_criteria.dart @@ -0,0 +1,328 @@ +/// Modèle pour les critères de recherche avancée des membres +/// Correspond au DTO Java MembreSearchCriteria +class MembreSearchCriteria { + /// Terme de recherche général (nom, prénom, email) + final String? query; + + /// Recherche par nom exact ou partiel + final String? nom; + + /// Recherche par prénom exact ou partiel + final String? prenom; + + /// Recherche par email exact ou partiel + final String? email; + + /// Filtre par numéro de téléphone + final String? telephone; + + /// Liste des IDs d'organisations + final List? organisationIds; + + /// Liste des rôles à rechercher + final List? roles; + + /// Filtre par statut d'activité + final String? statut; + + /// Date d'adhésion minimum (format ISO 8601) + final String? dateAdhesionMin; + + /// Date d'adhésion maximum (format ISO 8601) + final String? dateAdhesionMax; + + /// Âge minimum + final int? ageMin; + + /// Âge maximum + final int? ageMax; + + /// Filtre par région + final String? region; + + /// Filtre par ville + final String? ville; + + /// Filtre par profession + final String? profession; + + /// Filtre par nationalité + final String? nationalite; + + /// Filtre membres du bureau uniquement + final bool? membreBureau; + + /// Filtre responsables uniquement + final bool? responsable; + + /// Inclure les membres inactifs dans la recherche + final bool includeInactifs; + + const MembreSearchCriteria({ + this.query, + this.nom, + this.prenom, + this.email, + this.telephone, + this.organisationIds, + this.roles, + this.statut, + this.dateAdhesionMin, + this.dateAdhesionMax, + this.ageMin, + this.ageMax, + this.region, + this.ville, + this.profession, + this.nationalite, + this.membreBureau, + this.responsable, + this.includeInactifs = false, + }); + + /// Factory constructor pour créer depuis JSON + factory MembreSearchCriteria.fromJson(Map json) { + return MembreSearchCriteria( + query: json['query'] as String?, + nom: json['nom'] as String?, + prenom: json['prenom'] as String?, + email: json['email'] as String?, + telephone: json['telephone'] as String?, + organisationIds: (json['organisationIds'] as List?)?.cast(), + roles: (json['roles'] as List?)?.cast(), + statut: json['statut'] as String?, + dateAdhesionMin: json['dateAdhesionMin'] as String?, + dateAdhesionMax: json['dateAdhesionMax'] as String?, + ageMin: json['ageMin'] as int?, + ageMax: json['ageMax'] as int?, + region: json['region'] as String?, + ville: json['ville'] as String?, + profession: json['profession'] as String?, + nationalite: json['nationalite'] as String?, + membreBureau: json['membreBureau'] as bool?, + responsable: json['responsable'] as bool?, + includeInactifs: json['includeInactifs'] as bool? ?? false, + ); + } + + /// Convertit vers JSON + Map toJson() { + return { + 'query': query, + 'nom': nom, + 'prenom': prenom, + 'email': email, + 'telephone': telephone, + 'organisationIds': organisationIds, + 'roles': roles, + 'statut': statut, + 'dateAdhesionMin': dateAdhesionMin, + 'dateAdhesionMax': dateAdhesionMax, + 'ageMin': ageMin, + 'ageMax': ageMax, + 'region': region, + 'ville': ville, + 'profession': profession, + 'nationalite': nationalite, + 'membreBureau': membreBureau, + 'responsable': responsable, + 'includeInactifs': includeInactifs, + }; + } + + /// Vérifie si au moins un critère de recherche est défini + bool get hasAnyCriteria { + return query?.isNotEmpty == true || + nom?.isNotEmpty == true || + prenom?.isNotEmpty == true || + email?.isNotEmpty == true || + telephone?.isNotEmpty == true || + organisationIds?.isNotEmpty == true || + roles?.isNotEmpty == true || + statut?.isNotEmpty == true || + dateAdhesionMin?.isNotEmpty == true || + dateAdhesionMax?.isNotEmpty == true || + ageMin != null || + ageMax != null || + region?.isNotEmpty == true || + ville?.isNotEmpty == true || + profession?.isNotEmpty == true || + nationalite?.isNotEmpty == true || + membreBureau != null || + responsable != null; + } + + /// Valide la cohérence des critères de recherche + bool get isValid { + // Validation des âges + if (ageMin != null && ageMax != null) { + if (ageMin! > ageMax!) { + return false; + } + } + + // Validation des dates (si implémentée) + if (dateAdhesionMin != null && dateAdhesionMax != null) { + try { + final dateMin = DateTime.parse(dateAdhesionMin!); + final dateMax = DateTime.parse(dateAdhesionMax!); + if (dateMin.isAfter(dateMax)) { + return false; + } + } catch (e) { + return false; + } + } + + return true; + } + + /// Retourne une description textuelle des critères actifs + String get description { + final parts = []; + + if (query?.isNotEmpty == true) parts.add("Recherche: '$query'"); + if (nom?.isNotEmpty == true) parts.add("Nom: '$nom'"); + if (prenom?.isNotEmpty == true) parts.add("Prénom: '$prenom'"); + if (email?.isNotEmpty == true) parts.add("Email: '$email'"); + if (statut?.isNotEmpty == true) parts.add("Statut: $statut"); + if (organisationIds?.isNotEmpty == true) { + parts.add("Organisations: ${organisationIds!.length}"); + } + if (roles?.isNotEmpty == true) { + parts.add("Rôles: ${roles!.join(', ')}"); + } + if (dateAdhesionMin?.isNotEmpty == true) { + parts.add("Adhésion >= $dateAdhesionMin"); + } + if (dateAdhesionMax?.isNotEmpty == true) { + parts.add("Adhésion <= $dateAdhesionMax"); + } + if (ageMin != null) parts.add("Âge >= $ageMin"); + if (ageMax != null) parts.add("Âge <= $ageMax"); + if (region?.isNotEmpty == true) parts.add("Région: '$region'"); + if (ville?.isNotEmpty == true) parts.add("Ville: '$ville'"); + if (profession?.isNotEmpty == true) parts.add("Profession: '$profession'"); + if (nationalite?.isNotEmpty == true) parts.add("Nationalité: '$nationalite'"); + if (membreBureau == true) parts.add("Membre bureau"); + if (responsable == true) parts.add("Responsable"); + + return parts.join(' • '); + } + + /// Crée une copie avec des modifications + MembreSearchCriteria copyWith({ + String? query, + String? nom, + String? prenom, + String? email, + String? telephone, + List? organisationIds, + List? roles, + String? statut, + String? dateAdhesionMin, + String? dateAdhesionMax, + int? ageMin, + int? ageMax, + String? region, + String? ville, + String? profession, + String? nationalite, + bool? membreBureau, + bool? responsable, + bool? includeInactifs, + }) { + return MembreSearchCriteria( + query: query ?? this.query, + nom: nom ?? this.nom, + prenom: prenom ?? this.prenom, + email: email ?? this.email, + telephone: telephone ?? this.telephone, + organisationIds: organisationIds ?? this.organisationIds, + roles: roles ?? this.roles, + statut: statut ?? this.statut, + dateAdhesionMin: dateAdhesionMin ?? this.dateAdhesionMin, + dateAdhesionMax: dateAdhesionMax ?? this.dateAdhesionMax, + ageMin: ageMin ?? this.ageMin, + ageMax: ageMax ?? this.ageMax, + region: region ?? this.region, + ville: ville ?? this.ville, + profession: profession ?? this.profession, + nationalite: nationalite ?? this.nationalite, + membreBureau: membreBureau ?? this.membreBureau, + responsable: responsable ?? this.responsable, + includeInactifs: includeInactifs ?? this.includeInactifs, + ); + } + + /// Critères vides + static const empty = MembreSearchCriteria(); + + /// Critères pour recherche rapide par nom/prénom + static MembreSearchCriteria quickSearch(String query) { + return MembreSearchCriteria(query: query); + } + + /// Critères pour membres actifs uniquement + static const activeMembers = MembreSearchCriteria( + statut: 'ACTIF', + includeInactifs: false, + ); + + /// Critères pour membres du bureau + static const bureauMembers = MembreSearchCriteria( + membreBureau: true, + statut: 'ACTIF', + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MembreSearchCriteria && + runtimeType == other.runtimeType && + query == other.query && + nom == other.nom && + prenom == other.prenom && + email == other.email && + telephone == other.telephone && + organisationIds == other.organisationIds && + roles == other.roles && + statut == other.statut && + dateAdhesionMin == other.dateAdhesionMin && + dateAdhesionMax == other.dateAdhesionMax && + ageMin == other.ageMin && + ageMax == other.ageMax && + region == other.region && + ville == other.ville && + profession == other.profession && + nationalite == other.nationalite && + membreBureau == other.membreBureau && + responsable == other.responsable && + includeInactifs == other.includeInactifs; + + @override + int get hashCode => Object.hashAll([ + query, + nom, + prenom, + email, + telephone, + organisationIds, + roles, + statut, + dateAdhesionMin, + dateAdhesionMax, + ageMin, + ageMax, + region, + ville, + profession, + nationalite, + membreBureau, + responsable, + includeInactifs, + ]); + + @override + String toString() => 'MembreSearchCriteria(${description.isNotEmpty ? description : 'empty'})'; +} diff --git a/unionflow-mobile-apps/lib/core/models/membre_search_result.dart b/unionflow-mobile-apps/lib/core/models/membre_search_result.dart new file mode 100644 index 0000000..a84490b --- /dev/null +++ b/unionflow-mobile-apps/lib/core/models/membre_search_result.dart @@ -0,0 +1,269 @@ +import 'membre_search_criteria.dart'; +import '../../features/members/data/models/membre_model.dart'; + +/// Modèle pour les résultats de recherche avancée des membres +/// Correspond au DTO Java MembreSearchResultDTO +class MembreSearchResult { + /// Liste des membres trouvés + final List membres; + + /// Nombre total de résultats (toutes pages confondues) + final int totalElements; + + /// Nombre total de pages + final int totalPages; + + /// Numéro de la page actuelle (0-based) + final int currentPage; + + /// Taille de la page + final int pageSize; + + /// Nombre d'éléments sur la page actuelle + final int numberOfElements; + + /// Indique s'il y a une page suivante + final bool hasNext; + + /// Indique s'il y a une page précédente + final bool hasPrevious; + + /// Indique si c'est la première page + final bool isFirst; + + /// Indique si c'est la dernière page + final bool isLast; + + /// Critères de recherche utilisés + final MembreSearchCriteria criteria; + + /// Temps d'exécution de la recherche en millisecondes + final int executionTimeMs; + + /// Statistiques de recherche + final SearchStatistics? statistics; + + const MembreSearchResult({ + required this.membres, + required this.totalElements, + required this.totalPages, + required this.currentPage, + required this.pageSize, + required this.numberOfElements, + required this.hasNext, + required this.hasPrevious, + required this.isFirst, + required this.isLast, + required this.criteria, + required this.executionTimeMs, + this.statistics, + }); + + /// Factory constructor pour créer depuis JSON + factory MembreSearchResult.fromJson(Map json) { + return MembreSearchResult( + membres: (json['membres'] as List?) + ?.map((e) => MembreModel.fromJson(e as Map)) + .toList() ?? [], + totalElements: json['totalElements'] as int? ?? 0, + totalPages: json['totalPages'] as int? ?? 0, + currentPage: json['currentPage'] as int? ?? 0, + pageSize: json['pageSize'] as int? ?? 20, + numberOfElements: json['numberOfElements'] as int? ?? 0, + hasNext: json['hasNext'] as bool? ?? false, + hasPrevious: json['hasPrevious'] as bool? ?? false, + isFirst: json['isFirst'] as bool? ?? true, + isLast: json['isLast'] as bool? ?? true, + criteria: MembreSearchCriteria.fromJson(json['criteria'] as Map? ?? {}), + executionTimeMs: json['executionTimeMs'] as int? ?? 0, + statistics: json['statistics'] != null + ? SearchStatistics.fromJson(json['statistics'] as Map) + : null, + ); + } + + /// Convertit vers JSON + Map toJson() { + return { + 'membres': membres.map((e) => e.toJson()).toList(), + 'totalElements': totalElements, + 'totalPages': totalPages, + 'currentPage': currentPage, + 'pageSize': pageSize, + 'numberOfElements': numberOfElements, + 'hasNext': hasNext, + 'hasPrevious': hasPrevious, + 'isFirst': isFirst, + 'isLast': isLast, + 'criteria': criteria.toJson(), + 'executionTimeMs': executionTimeMs, + 'statistics': statistics?.toJson(), + }; + } + + /// Vérifie si les résultats sont vides + bool get isEmpty => membres.isEmpty; + + /// Vérifie si les résultats ne sont pas vides + bool get isNotEmpty => membres.isNotEmpty; + + /// Retourne le numéro de la page suivante (1-based pour affichage) + int get nextPageNumber => hasNext ? currentPage + 2 : -1; + + /// Retourne le numéro de la page précédente (1-based pour affichage) + int get previousPageNumber => hasPrevious ? currentPage : -1; + + /// Retourne une description textuelle des résultats + String get resultDescription { + if (isEmpty) { + return 'Aucun membre trouvé'; + } + + if (totalElements == 1) { + return '1 membre trouvé'; + } + + if (totalPages == 1) { + return '$totalElements membres trouvés'; + } + + final startElement = currentPage * pageSize + 1; + final endElement = (startElement + numberOfElements - 1).clamp(1, totalElements); + + return 'Membres $startElement-$endElement sur $totalElements (page ${currentPage + 1}/$totalPages)'; + } + + /// Résultat vide + static MembreSearchResult empty(MembreSearchCriteria criteria) { + return MembreSearchResult( + membres: const [], + totalElements: 0, + totalPages: 0, + currentPage: 0, + pageSize: 20, + numberOfElements: 0, + hasNext: false, + hasPrevious: false, + isFirst: true, + isLast: true, + criteria: criteria, + executionTimeMs: 0, + ); + } + + @override + String toString() => 'MembreSearchResult($resultDescription, ${executionTimeMs}ms)'; +} + +/// Statistiques sur les résultats de recherche +class SearchStatistics { + /// Nombre de membres actifs dans les résultats + final int membresActifs; + + /// Nombre de membres inactifs dans les résultats + final int membresInactifs; + + /// Âge moyen des membres trouvés + final double ageMoyen; + + /// Âge minimum des membres trouvés + final int ageMin; + + /// Âge maximum des membres trouvés + final int ageMax; + + /// Nombre d'organisations représentées + final int nombreOrganisations; + + /// Nombre de régions représentées + final int nombreRegions; + + /// Ancienneté moyenne en années + final double ancienneteMoyenne; + + const SearchStatistics({ + required this.membresActifs, + required this.membresInactifs, + required this.ageMoyen, + required this.ageMin, + required this.ageMax, + required this.nombreOrganisations, + required this.nombreRegions, + required this.ancienneteMoyenne, + }); + + /// Factory constructor pour créer depuis JSON + factory SearchStatistics.fromJson(Map json) { + return SearchStatistics( + membresActifs: json['membresActifs'] as int? ?? 0, + membresInactifs: json['membresInactifs'] as int? ?? 0, + ageMoyen: (json['ageMoyen'] as num?)?.toDouble() ?? 0.0, + ageMin: json['ageMin'] as int? ?? 0, + ageMax: json['ageMax'] as int? ?? 0, + nombreOrganisations: json['nombreOrganisations'] as int? ?? 0, + nombreRegions: json['nombreRegions'] as int? ?? 0, + ancienneteMoyenne: (json['ancienneteMoyenne'] as num?)?.toDouble() ?? 0.0, + ); + } + + /// Convertit vers JSON + Map toJson() { + return { + 'membresActifs': membresActifs, + 'membresInactifs': membresInactifs, + 'ageMoyen': ageMoyen, + 'ageMin': ageMin, + 'ageMax': ageMax, + 'nombreOrganisations': nombreOrganisations, + 'nombreRegions': nombreRegions, + 'ancienneteMoyenne': ancienneteMoyenne, + }; + } + + /// Nombre total de membres + int get totalMembres => membresActifs + membresInactifs; + + /// Pourcentage de membres actifs + double get pourcentageActifs { + if (totalMembres == 0) return 0.0; + return (membresActifs / totalMembres) * 100; + } + + /// Pourcentage de membres inactifs + double get pourcentageInactifs { + if (totalMembres == 0) return 0.0; + return (membresInactifs / totalMembres) * 100; + } + + /// Tranche d'âge + String get trancheAge { + if (ageMin == ageMax) return '$ageMin ans'; + return '$ageMin-$ageMax ans'; + } + + /// Description textuelle des statistiques + String get description { + final parts = []; + + if (totalMembres > 0) { + parts.add('$totalMembres membres'); + if (membresActifs > 0) { + parts.add('${pourcentageActifs.toStringAsFixed(1)}% actifs'); + } + if (ageMoyen > 0) { + parts.add('âge moyen: ${ageMoyen.toStringAsFixed(1)} ans'); + } + if (nombreOrganisations > 0) { + parts.add('$nombreOrganisations organisations'); + } + if (ancienneteMoyenne > 0) { + parts.add('ancienneté: ${ancienneteMoyenne.toStringAsFixed(1)} ans'); + } + } + + return parts.join(' • '); + } + + @override + String toString() => 'SearchStatistics($description)'; +} diff --git a/unionflow-mobile-apps/lib/core/navigation/app_router.dart b/unionflow-mobile-apps/lib/core/navigation/app_router.dart new file mode 100644 index 0000000..53a79b6 --- /dev/null +++ b/unionflow-mobile-apps/lib/core/navigation/app_router.dart @@ -0,0 +1,43 @@ +import 'package:go_router/go_router.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../auth/bloc/auth_bloc.dart'; +import '../../features/auth/presentation/pages/login_page.dart'; +import 'main_navigation_layout.dart'; + +/// Configuration du routeur principal de l'application +class AppRouter { + static final GoRouter router = GoRouter( + initialLocation: '/', + redirect: (context, state) { + final authState = context.read().state; + final isAuthenticated = authState is AuthAuthenticated; + final isOnLoginPage = state.matchedLocation == '/login'; + + // Si pas authentifié et pas sur la page de login, rediriger vers login + if (!isAuthenticated && !isOnLoginPage) { + return '/login'; + } + + // Si authentifié et sur la page de login, rediriger vers dashboard + if (isAuthenticated && isOnLoginPage) { + return '/'; + } + + return null; // Pas de redirection + }, + routes: [ + GoRoute( + path: '/login', + name: 'login', + builder: (context, state) => const LoginPage(), + ), + GoRoute( + path: '/', + name: 'main', + builder: (context, state) => const MainNavigationLayout(), + ), + ], + ); +} diff --git a/unionflow-mobile-apps/lib/core/navigation/main_navigation_layout.dart b/unionflow-mobile-apps/lib/core/navigation/main_navigation_layout.dart new file mode 100644 index 0000000..42d48da --- /dev/null +++ b/unionflow-mobile-apps/lib/core/navigation/main_navigation_layout.dart @@ -0,0 +1,419 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../auth/bloc/auth_bloc.dart'; +import '../auth/models/user_role.dart'; + +import '../design_system/tokens/tokens.dart'; +import '../../features/dashboard/presentation/pages/role_dashboards/role_dashboards.dart'; +import '../../features/members/presentation/pages/members_page.dart'; +import '../../features/events/presentation/pages/events_page.dart'; + +/// Layout principal avec navigation hybride +/// Bottom Navigation pour les sections principales + Drawer pour fonctions avancées +class MainNavigationLayout extends StatefulWidget { + const MainNavigationLayout({super.key}); + + @override + State createState() => _MainNavigationLayoutState(); +} + +class _MainNavigationLayoutState extends State { + int _selectedIndex = 0; + + /// Obtient le dashboard approprié selon le rôle de l'utilisateur + Widget _getDashboardForRole(UserRole role) { + switch (role) { + case UserRole.superAdmin: + return const SuperAdminDashboard(); + case UserRole.orgAdmin: + return const OrgAdminDashboard(); + case UserRole.moderator: + return const ModeratorDashboard(); + case UserRole.activeMember: + return const ActiveMemberDashboard(); + case UserRole.simpleMember: + return const SimpleMemberDashboard(); + case UserRole.visitor: + return const VisitorDashboard(); + } + } + + List _getPages(UserRole role) { + return [ + _getDashboardForRole(role), + const MembersPage(), + const EventsPage(), + const MorePage(), // Page "Plus" qui affiche les options avancées + ]; + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state is! AuthAuthenticated) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + + return Scaffold( + body: IndexedStack( + index: _selectedIndex, + children: _getPages(state.effectiveRole), + ), + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + currentIndex: _selectedIndex, + onTap: (index) { + setState(() { + _selectedIndex = index; + }); + }, + selectedItemColor: ColorTokens.primary, + unselectedItemColor: Colors.grey, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.dashboard), + label: 'Dashboard', + ), + BottomNavigationBarItem( + icon: Icon(Icons.people), + label: 'Membres', + ), + BottomNavigationBarItem( + icon: Icon(Icons.event), + label: 'Événements', + ), + BottomNavigationBarItem( + icon: Icon(Icons.more_horiz), + label: 'Plus', + ), + ], + ), + ); + }, + ); + } +} + +/// Page "Plus" avec les fonctions avancées selon le rôle +class MorePage extends StatelessWidget { + const MorePage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state is! AuthAuthenticated) { + return Container( + color: const Color(0xFFF8F9FA), + child: const Center(child: CircularProgressIndicator()), + ); + } + + return Container( + color: const Color(0xFFF8F9FA), + child: SingleChildScrollView( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Titre de la section + const Text( + 'Plus d\'Options', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Color(0xFF6C5CE7), + fontSize: 20, + ), + ), + const SizedBox(height: 12), + + // Profil utilisateur + _buildUserProfile(state), + const SizedBox(height: 16), + + // Options selon le rôle + ..._buildRoleBasedOptions(state), + + const SizedBox(height: 16), + + // Options communes + ..._buildCommonOptions(context), + ], + ), + ), + ); + }, + ); + } + + Widget _buildUserProfile(AuthAuthenticated state) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: const Color(0xFF6C5CE7), + borderRadius: BorderRadius.circular(25), + ), + child: Center( + child: Text( + state.user.firstName.isNotEmpty ? state.user.firstName[0].toUpperCase() : 'U', + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${state.user.firstName} ${state.user.lastName}', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF374151), + ), + ), + const SizedBox(height: 2), + Text( + state.effectiveRole.displayName, + style: const TextStyle( + fontSize: 14, + color: Color(0xFF6C5CE7), + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + state.user.email, + style: const TextStyle( + fontSize: 12, + color: Color(0xFF6B7280), + ), + ), + ], + ), + ), + ], + ), + ); + } + + List _buildRoleBasedOptions(AuthAuthenticated state) { + final options = []; + + // Options Super Admin + if (state.effectiveRole == UserRole.superAdmin) { + options.addAll([ + _buildSectionTitle('Administration Système'), + _buildOptionTile( + icon: Icons.settings, + title: 'Paramètres Système', + subtitle: 'Configuration globale', + onTap: () {}, + ), + _buildOptionTile( + icon: Icons.backup, + title: 'Sauvegarde', + subtitle: 'Gestion des sauvegardes', + onTap: () {}, + ), + _buildOptionTile( + icon: Icons.analytics, + title: 'Logs Système', + subtitle: 'Surveillance et logs', + onTap: () {}, + ), + ]); + } + + // Options Admin Organisation + if (state.effectiveRole == UserRole.orgAdmin || state.effectiveRole == UserRole.superAdmin) { + options.addAll([ + _buildSectionTitle('Administration'), + _buildOptionTile( + icon: Icons.business, + title: 'Gestion Organisation', + subtitle: 'Paramètres organisation', + onTap: () {}, + ), + _buildOptionTile( + icon: Icons.assessment, + title: 'Rapports', + subtitle: 'Rapports et statistiques', + onTap: () {}, + ), + ]); + } + + // Options RH + if (state.effectiveRole == UserRole.moderator || state.effectiveRole == UserRole.superAdmin) { + options.addAll([ + _buildSectionTitle('Ressources Humaines'), + _buildOptionTile( + icon: Icons.people_alt, + title: 'Gestion RH', + subtitle: 'Outils RH avancés', + onTap: () {}, + ), + ]); + } + + return options; + } + + List _buildCommonOptions(BuildContext context) { + return [ + _buildSectionTitle('Général'), + _buildOptionTile( + icon: Icons.person, + title: 'Mon Profil', + subtitle: 'Modifier mes informations', + onTap: () {}, + ), + _buildOptionTile( + icon: Icons.notifications, + title: 'Notifications', + subtitle: 'Gérer les notifications', + onTap: () {}, + ), + _buildOptionTile( + icon: Icons.help, + title: 'Aide & Support', + subtitle: 'Documentation et support', + onTap: () {}, + ), + _buildOptionTile( + icon: Icons.info, + title: 'À propos', + subtitle: 'Version et informations', + onTap: () {}, + ), + const SizedBox(height: 16), + _buildOptionTile( + icon: Icons.logout, + title: 'Déconnexion', + subtitle: 'Se déconnecter de l\'application', + color: Colors.red, + onTap: () { + context.read().add(AuthLogoutRequested()); + }, + ), + ]; + } + + Widget _buildSectionTitle(String title) { + return Padding( + padding: const EdgeInsets.only( + top: 16, + bottom: 8, + ), + child: Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xFF6C5CE7), + ), + ), + ); + } + + Widget _buildOptionTile({ + required IconData icon, + required String title, + required String subtitle, + required VoidCallback onTap, + Color? color, + }) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: (color ?? const Color(0xFF6C5CE7)).withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + icon, + color: color ?? const Color(0xFF6C5CE7), + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: color ?? const Color(0xFF374151), + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: const TextStyle( + fontSize: 12, + color: Color(0xFF6B7280), + ), + ), + ], + ), + ), + const Icon( + Icons.chevron_right, + color: Color(0xFF6B7280), + size: 16, + ), + ], + ), + ), + ), + ); + } +} diff --git a/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/dashboard_page_stable.dart b/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/dashboard_page_stable.dart deleted file mode 100644 index d55fcba..0000000 --- a/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/dashboard_page_stable.dart +++ /dev/null @@ -1,178 +0,0 @@ -/// Dashboard Page Stable - Redirecteur vers Dashboard Adaptatif -/// Redirige automatiquement vers le nouveau système de dashboard adaptatif -library dashboard_page_stable; - -import 'package:flutter/material.dart'; -import 'adaptive_dashboard_page.dart'; - -/// Page Dashboard Stable - Maintenant un redirecteur -/// -/// Cette page redirige automatiquement vers le nouveau système -/// de dashboard adaptatif basé sur les rôles utilisateurs. -class DashboardPageStable extends StatefulWidget { - const DashboardPageStable({super.key}); - - @override - State createState() => _DashboardPageStableState(); -} - -class _DashboardPageStableState extends State { - final GlobalKey _refreshKey = GlobalKey(); - bool _isLoading = false; - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: ColorTokens.surface, - appBar: AppBar( - title: Text( - 'UnionFlow Dashboard', - style: TypographyTokens.headlineSmall.copyWith( - fontWeight: FontWeight.w700, - color: ColorTokens.onSurface, - ), - ), - backgroundColor: ColorTokens.surface, - elevation: 0, - actions: [ - IconButton( - onPressed: () => _showNotifications(), - icon: const Icon(Icons.notifications_outlined), - tooltip: 'Notifications', - ), - IconButton( - onPressed: () => _showSettings(), - icon: const Icon(Icons.settings_outlined), - tooltip: 'Paramètres', - ), - ], - ), - drawer: DashboardDrawer( - onNavigate: _onNavigate, - onLogout: _onLogout, - ), - body: RefreshIndicator( - key: _refreshKey, - onRefresh: _refreshData, - child: SingleChildScrollView( - padding: const EdgeInsets.all(SpacingTokens.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Message de bienvenue - DashboardWelcomeSection( - title: 'Bienvenue sur UnionFlow', - subtitle: 'Votre plateforme de gestion d\'union familiale', - ), - - const SizedBox(height: SpacingTokens.xl), - - // Statistiques - DashboardStatsGrid( - onStatTap: _onStatTap, - ), - - const SizedBox(height: SpacingTokens.xl), - - // Actions rapides - DashboardQuickActionsGrid( - onActionTap: _onActionTap, - ), - - const SizedBox(height: SpacingTokens.xl), - - // Activité récente - DashboardRecentActivitySection( - onActivityTap: _onActivityTap, - ), - - const SizedBox(height: SpacingTokens.xl), - - // Insights - DashboardInsightsSection( - onMetricTap: _onMetricTap, - ), - ], - ), - ), - ), - ); - } - - // === CALLBACKS POUR LES WIDGETS MODULAIRES === - - /// Callback pour les actions sur les statistiques - void _onStatTap(String statType) { - debugPrint('Statistique tapée: $statType'); - // TODO: Implémenter la navigation vers les détails - } - - /// Callback pour les actions rapides - void _onActionTap(String actionType) { - debugPrint('Action rapide: $actionType'); - // TODO: Implémenter les actions spécifiques - } - - /// Callback pour les activités récentes - void _onActivityTap(String activityId) { - debugPrint('Activité tapée: $activityId'); - // TODO: Implémenter la navigation vers les détails - } - - /// Callback pour les métriques d'insights - void _onMetricTap(String metricType) { - debugPrint('Métrique tapée: $metricType'); - // TODO: Implémenter la navigation vers les rapports - } - - /// Callback pour la navigation du drawer - void _onNavigate(String route) { - Navigator.of(context).pop(); // Fermer le drawer - debugPrint('Navigation vers: $route'); - // TODO: Implémenter la navigation - } - - /// Callback pour la déconnexion - void _onLogout() { - Navigator.of(context).pop(); // Fermer le drawer - debugPrint('Déconnexion demandée'); - // TODO: Implémenter la déconnexion - } - - // === MÉTHODES UTILITAIRES === - - /// Actualise les données du dashboard - Future _refreshData() async { - setState(() { - _isLoading = true; - }); - - // Simulation d'un appel API - await Future.delayed(const Duration(seconds: 1)); - - setState(() { - _isLoading = false; - }); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Données actualisées'), - duration: Duration(seconds: 2), - ), - ); - } - } - - /// Affiche les notifications - void _showNotifications() { - debugPrint('Afficher les notifications'); - // TODO: Implémenter l'affichage des notifications - } - - /// Affiche les paramètres - void _showSettings() { - debugPrint('Afficher les paramètres'); - // TODO: Implémenter l'affichage des paramètres - } -} diff --git a/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/role_dashboards/active_member_dashboard.dart b/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/role_dashboards/active_member_dashboard.dart index e59dce9..2434505 100644 --- a/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/role_dashboards/active_member_dashboard.dart +++ b/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/role_dashboards/active_member_dashboard.dart @@ -1,322 +1,275 @@ -/// Dashboard Membre Actif - Activity Center Personnalisé -/// Interface personnalisée pour participation active -library active_member_dashboard; - import 'package:flutter/material.dart'; -import '../../../../../core/design_system/tokens/tokens.dart'; -import '../../widgets/widgets.dart'; -/// Dashboard Activity Center pour Membre Actif +/// Dashboard simple pour Membre Actif class ActiveMemberDashboard extends StatelessWidget { const ActiveMemberDashboard({super.key}); @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: ColorTokens.surface, - body: CustomScrollView( - slivers: [ - // App Bar Membre Actif - SliverAppBar( - expandedHeight: 160, - floating: false, - pinned: true, - backgroundColor: const Color(0xFF00B894), // Vert communauté - flexibleSpace: FlexibleSpaceBar( - title: const Text( - 'Activity Center', - style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold), - ), - background: Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [Color(0xFF00B894), Color(0xFF00A085)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - child: const Center( - child: Icon(Icons.groups, color: Colors.white, size: 60), + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // En-tête de bienvenue + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF00B894), Color(0xFF00CEC9)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, ), + borderRadius: BorderRadius.circular(16), ), - ), - ), - - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(SpacingTokens.md), - child: Column( + child: const Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Bienvenue personnalisé - _buildPersonalizedWelcome(), - const SizedBox(height: SpacingTokens.xl), - - // Mes statistiques - _buildMyStats(), - const SizedBox(height: SpacingTokens.xl), - - // Actions membres - _buildMemberActions(), - const SizedBox(height: SpacingTokens.xl), - - // Événements à venir - _buildUpcomingEvents(), - const SizedBox(height: SpacingTokens.xl), - - // Mon activité - _buildMyActivity(), + Text( + 'Bonjour !', + style: TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + SizedBox(height: 8), + Text( + 'Bienvenue sur votre espace membre', + style: TextStyle( + color: Colors.white70, + fontSize: 16, + ), + ), ], ), ), - ), - ], - ), - ); - } - - Widget _buildPersonalizedWelcome() { - return Container( - padding: const EdgeInsets.all(SpacingTokens.lg), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Color(0xFF00B894), Color(0xFF00CEC9)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(RadiusTokens.lg), - ), - child: Row( - children: [ - const CircleAvatar( - radius: 30, - backgroundColor: Colors.white, - child: Icon(Icons.person, color: Color(0xFF00B894), size: 30), - ), - const SizedBox(width: SpacingTokens.md), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + + const SizedBox(height: 24), + + // Statistiques rapides + const Text( + 'Mes Statistiques', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + + GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 2, + childAspectRatio: 1.2, + crossAxisSpacing: 16, + mainAxisSpacing: 16, children: [ - Text( - 'Bonjour, Marie !', - style: TypographyTokens.headlineMedium.copyWith( - color: Colors.white, - fontWeight: FontWeight.bold, - ), + _buildStatCard( + icon: Icons.event_available, + value: '12', + title: 'Événements', + color: const Color(0xFF00B894), ), - Text( - 'Membre depuis 2 ans • Niveau Actif', - style: TypographyTokens.bodyMedium.copyWith( - color: Colors.white.withOpacity(0.9), - ), + _buildStatCard( + icon: Icons.volunteer_activism, + value: '3', + title: 'Solidarité', + color: const Color(0xFF00CEC9), + ), + _buildStatCard( + icon: Icons.payment, + value: 'À jour', + title: 'Cotisations', + color: const Color(0xFF0984E3), + ), + _buildStatCard( + icon: Icons.star, + value: '4.8', + title: 'Engagement', + color: const Color(0xFFE17055), ), ], ), - ), - ], + + const SizedBox(height: 24), + + // Actions rapides + const Text( + 'Actions Rapides', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + + GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 2, + childAspectRatio: 1.5, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + children: [ + _buildActionCard( + icon: Icons.event, + title: 'Créer Événement', + color: const Color(0xFF00B894), + onTap: () {}, + ), + _buildActionCard( + icon: Icons.volunteer_activism, + title: 'Demande Aide', + color: const Color(0xFF00CEC9), + onTap: () {}, + ), + _buildActionCard( + icon: Icons.account_circle, + title: 'Mon Profil', + color: const Color(0xFF0984E3), + onTap: () {}, + ), + _buildActionCard( + icon: Icons.message, + title: 'Contacter', + color: const Color(0xFFE17055), + onTap: () {}, + ), + ], + ), + + const SizedBox(height: 24), + + // Activités récentes + const Text( + 'Activités Récentes', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + + Card( + child: Column( + children: [ + _buildActivityItem( + icon: Icons.check_circle, + title: 'Participation confirmée', + subtitle: 'Assemblée Générale - Il y a 2h', + color: const Color(0xFF00B894), + ), + const Divider(height: 1), + _buildActivityItem( + icon: Icons.payment, + title: 'Cotisation payée', + subtitle: 'Décembre 2024 - Il y a 1j', + color: const Color(0xFF0984E3), + ), + const Divider(height: 1), + _buildActivityItem( + icon: Icons.event, + title: 'Événement créé', + subtitle: 'Sortie ski de fond - Il y a 3j', + color: const Color(0xFF00CEC9), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildStatCard({ + required IconData icon, + required String value, + required String title, + required Color color, + }) { + return Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: color, size: 32), + const SizedBox(height: 8), + Text( + value, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + title, + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + textAlign: TextAlign.center, + ), + ], + ), ), ); } - Widget _buildMyStats() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Mes Statistiques', - style: TypographyTokens.headlineMedium.copyWith(fontWeight: FontWeight.bold), - ), - const SizedBox(height: SpacingTokens.md), - DashboardStatsGrid( - stats: [ - DashboardStat( - icon: Icons.event_available, - value: '12', - title: 'Événements', - color: const Color(0xFF00B894), - onTap: () {}, - ), - DashboardStat( - icon: Icons.volunteer_activism, - value: '3', - title: 'Solidarité', - color: const Color(0xFF00CEC9), - onTap: () {}, - ), - DashboardStat( - icon: Icons.payment, - value: 'À jour', - title: 'Cotisations', - color: const Color(0xFF0984E3), - onTap: () {}, - ), - DashboardStat( - icon: Icons.star, - value: '4.8', - title: 'Engagement', - color: const Color(0xFFE17055), - onTap: () {}, - ), - ], - onStatTap: (type) {}, - ), - ], - ); - } - - Widget _buildMemberActions() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Actions Rapides', - style: TypographyTokens.headlineMedium.copyWith(fontWeight: FontWeight.bold), - ), - const SizedBox(height: SpacingTokens.md), - DashboardQuickActionsGrid( - actions: [ - DashboardQuickAction( - icon: Icons.event, - title: 'Créer Événement', - subtitle: 'Organiser activité', - color: const Color(0xFF00B894), - onTap: () {}, - ), - DashboardQuickAction( - icon: Icons.volunteer_activism, - title: 'Demande Aide', - subtitle: 'Solidarité', - color: const Color(0xFF00CEC9), - onTap: () {}, - ), - DashboardQuickAction( - icon: Icons.account_circle, - title: 'Mon Profil', - subtitle: 'Modifier infos', - color: const Color(0xFF0984E3), - onTap: () {}, - ), - DashboardQuickAction( - icon: Icons.message, - title: 'Contacter', - subtitle: 'Support', - color: const Color(0xFFE17055), - onTap: () {}, - ), - ], - onActionTap: (type) {}, - ), - ], - ); - } - - Widget _buildUpcomingEvents() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - 'Événements à Venir', - style: TypographyTokens.headlineMedium.copyWith(fontWeight: FontWeight.bold), - ), - const Spacer(), - TextButton( - onPressed: () {}, - child: const Text('Voir tout'), - ), - ], - ), - const SizedBox(height: SpacingTokens.md), - Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(RadiusTokens.md), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), + Widget _buildActionCard({ + required IconData icon, + required String title, + required Color color, + required VoidCallback onTap, + }) { + return Card( + elevation: 2, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.all(16), child: Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ - ListTile( - leading: Container( - width: 50, - height: 50, - decoration: BoxDecoration( - color: const Color(0xFF00B894).withOpacity(0.1), - borderRadius: BorderRadius.circular(25), - ), - child: const Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('15', style: TextStyle(fontWeight: FontWeight.bold)), - Text('DÉC', style: TextStyle(fontSize: 10)), - ], - ), + Icon(icon, color: color, size: 28), + const SizedBox(height: 8), + Text( + title, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, ), - title: const Text('Assemblée Générale'), - subtitle: const Text('Salle communale • 19h00'), - trailing: const Icon(Icons.arrow_forward_ios, size: 16), - ), - const Divider(height: 1), - ListTile( - leading: Container( - width: 50, - height: 50, - decoration: BoxDecoration( - color: const Color(0xFF00CEC9).withOpacity(0.1), - borderRadius: BorderRadius.circular(25), - ), - child: const Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('22', style: TextStyle(fontWeight: FontWeight.bold)), - Text('DÉC', style: TextStyle(fontSize: 10)), - ], - ), - ), - title: const Text('Soirée de Noël'), - subtitle: const Text('Restaurant Le Gourmet • 20h00'), - trailing: const Icon(Icons.arrow_forward_ios, size: 16), + textAlign: TextAlign.center, ), ], ), ), - ], + ), ); } - Widget _buildMyActivity() { - return DashboardRecentActivitySection( - activities: [ - DashboardActivity( - title: 'Participation confirmée', - subtitle: 'Assemblée Générale', - icon: Icons.check_circle, - color: const Color(0xFF00B894), - time: 'Il y a 2h', - ), - DashboardActivity( - title: 'Cotisation payée', - subtitle: 'Décembre 2024', - icon: Icons.payment, - color: const Color(0xFF0984E3), - time: 'Il y a 1j', - ), - DashboardActivity( - title: 'Événement créé', - subtitle: 'Sortie ski de fond', - icon: Icons.event, - color: const Color(0xFF00CEC9), - time: 'Il y a 3j', - ), - ], - onActivityTap: (id) {}, + Widget _buildActivityItem({ + required IconData icon, + required String title, + required String subtitle, + required Color color, + }) { + return ListTile( + leading: CircleAvatar( + backgroundColor: color.withOpacity(0.1), + child: Icon(icon, color: color, size: 20), + ), + title: Text( + title, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + subtitle: Text(subtitle), + trailing: const Icon(Icons.arrow_forward_ios, size: 16), ); } } diff --git a/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/role_dashboards/consultant_dashboard.dart b/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/role_dashboards/consultant_dashboard.dart new file mode 100644 index 0000000..58219dc --- /dev/null +++ b/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/role_dashboards/consultant_dashboard.dart @@ -0,0 +1,725 @@ +/// Dashboard Consultant - Interface Limitée +/// Interface spécialisée pour consultants externes +library consultant_dashboard; + +import 'package:flutter/material.dart'; + +/// Dashboard pour Consultant Externe +class ConsultantDashboard extends StatefulWidget { + const ConsultantDashboard({super.key}); + + @override + State createState() => _ConsultantDashboardState(); +} + +class _ConsultantDashboardState extends State { + int _selectedIndex = 0; + + final List _consultantSections = [ + 'Mes Projets', + 'Contacts', + 'Profil', + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF8F9FA), + appBar: AppBar( + title: Text( + 'Consultant - ${_consultantSections[_selectedIndex]}', + style: const TextStyle( + color: Color(0xFF6C5CE7), + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), + backgroundColor: Colors.white, + elevation: 2, + centerTitle: false, + actions: [ + // Notifications consultant + IconButton( + icon: const Icon(Icons.notifications_outlined, color: Color(0xFF6C5CE7)), + onPressed: () => _showConsultantNotifications(), + tooltip: 'Mes notifications', + ), + // Menu consultant + PopupMenuButton( + icon: const Icon(Icons.more_vert, color: Color(0xFF6C5CE7)), + onSelected: (value) { + switch (value) { + case 'profile': + _editProfile(); + break; + case 'contact': + _contactSupport(); + break; + case 'help': + _showHelp(); + break; + } + }, + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'profile', + child: Row( + children: [ + Icon(Icons.person, size: 20, color: Color(0xFF6C5CE7)), + SizedBox(width: 12), + Text('Mon Profil'), + ], + ), + ), + const PopupMenuItem( + value: 'contact', + child: Row( + children: [ + Icon(Icons.support_agent, size: 20, color: Color(0xFF6C5CE7)), + SizedBox(width: 12), + Text('Support'), + ], + ), + ), + const PopupMenuItem( + value: 'help', + child: Row( + children: [ + Icon(Icons.help, size: 20, color: Color(0xFF6C5CE7)), + SizedBox(width: 12), + Text('Aide'), + ], + ), + ), + ], + ), + ], + ), + drawer: _buildConsultantDrawer(), + body: Stack( + children: [ + _buildSelectedContent(), + // Navigation rapide consultant + Positioned( + bottom: 20, + left: 20, + right: 20, + child: _buildConsultantQuickNavigation(), + ), + ], + ), + ); + } + + /// Drawer de navigation consultant + Widget _buildConsultantDrawer() { + return Drawer( + child: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xFF6C5CE7), + Color(0xFF5A4FCF), + Color(0xFF4834D4), + ], + ), + ), + child: Column( + children: [ + // Header consultant + Container( + padding: const EdgeInsets.fromLTRB(20, 60, 20, 20), + child: Row( + children: [ + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(30), + border: Border.all( + color: Colors.white.withOpacity(0.3), + width: 2, + ), + ), + child: const Icon( + Icons.business_center, + color: Colors.white, + size: 30, + ), + ), + const SizedBox(width: 16), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Sophie Martin', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + Text( + 'Consultant IT', + style: TextStyle( + color: Colors.white70, + fontSize: 14, + ), + ), + ], + ), + ), + ], + ), + ), + + // Menu de navigation + Expanded( + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 8), + itemCount: _consultantSections.length, + itemBuilder: (context, index) { + final isSelected = _selectedIndex == index; + return Container( + margin: const EdgeInsets.symmetric(vertical: 2), + decoration: BoxDecoration( + color: isSelected + ? Colors.white.withOpacity(0.2) + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + ), + child: ListTile( + leading: Icon( + _getConsultantSectionIcon(index), + color: Colors.white, + size: 22, + ), + title: Text( + _consultantSections[index], + style: TextStyle( + color: Colors.white, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + ), + ), + onTap: () { + setState(() => _selectedIndex = index); + Navigator.pop(context); + }, + ), + ); + }, + ), + ), + + // Footer avec déconnexion + Container( + padding: const EdgeInsets.all(16), + child: ElevatedButton.icon( + onPressed: () { + Navigator.of(context).pop(); + // TODO: Implémenter la déconnexion + }, + icon: const Icon(Icons.logout, size: 16), + label: const Text('Déconnexion'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white.withOpacity(0.2), + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 40), + ), + ), + ), + ], + ), + ), + ); + } + + /// Icône pour chaque section consultant + IconData _getConsultantSectionIcon(int index) { + switch (index) { + case 0: return Icons.work; + case 1: return Icons.contacts; + case 2: return Icons.person; + default: return Icons.work; + } + } + + /// Contenu de la section sélectionnée + Widget _buildSelectedContent() { + switch (_selectedIndex) { + case 0: + return _buildProjectsContent(); + case 1: + return _buildContactsContent(); + case 2: + return _buildProfileContent(); + default: + return _buildProjectsContent(); + } + } + + /// Mes Projets - Vue des projets assignés + Widget _buildProjectsContent() { + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 80), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header projets + _buildProjectsHeader(), + const SizedBox(height: 20), + + // Projets actifs + _buildActiveProjects(), + const SizedBox(height: 20), + + // Tâches en cours + _buildCurrentTasks(), + const SizedBox(height: 20), + + // Statistiques consultant + _buildConsultantStats(), + ], + ), + ); + } + + /// Placeholder pour les autres sections + Widget _buildContactsContent() { + return const Center( + child: Text( + 'Contacts\n(En développement)', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 18), + ), + ); + } + + Widget _buildProfileContent() { + return const Center( + child: Text( + 'Mon Profil\n(En développement)', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 18), + ), + ); + } + + /// Header projets + Widget _buildProjectsHeader() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: const Row( + children: [ + Icon(Icons.work, color: Color(0xFF6C5CE7), size: 24), + SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Mes Projets Assignés', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + Text( + '3 projets actifs', + style: TextStyle( + color: Colors.grey, + fontSize: 14, + ), + ), + ], + ), + ), + ], + ), + ); + } + + /// Projets actifs + Widget _buildActiveProjects() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Projets Actifs', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + _buildProjectCard( + 'Refonte Site Web', + 'Développement frontend', + '75%', + const Color(0xFF00B894), + ), + const SizedBox(height: 8), + _buildProjectCard( + 'App Mobile', + 'Interface utilisateur', + '45%', + const Color(0xFF0984E3), + ), + const SizedBox(height: 8), + _buildProjectCard( + 'API Backend', + 'Architecture serveur', + '90%', + const Color(0xFFE17055), + ), + ], + ); + } + + /// Widget pour une carte de projet + Widget _buildProjectCard(String title, String description, String progress, Color color) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(Icons.folder, color: color, size: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16, + ), + ), + Text( + description, + style: TextStyle( + color: Colors.grey[600], + fontSize: 14, + ), + ), + ], + ), + ), + Text( + progress, + style: TextStyle( + color: color, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 12), + LinearProgressIndicator( + value: double.parse(progress.replaceAll('%', '')) / 100, + backgroundColor: color.withOpacity(0.2), + valueColor: AlwaysStoppedAnimation(color), + ), + ], + ), + ); + } + + /// Tâches en cours + Widget _buildCurrentTasks() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Tâches du Jour', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + _buildTaskItem('Révision code frontend', true), + const SizedBox(height: 8), + _buildTaskItem('Réunion client 15h', false), + const SizedBox(height: 8), + _buildTaskItem('Tests unitaires', false), + ], + ), + ), + ], + ); + } + + /// Widget pour un élément de tâche + Widget _buildTaskItem(String task, bool completed) { + return Row( + children: [ + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: completed ? const Color(0xFF6C5CE7) : Colors.transparent, + border: Border.all(color: const Color(0xFF6C5CE7), width: 2), + borderRadius: BorderRadius.circular(4), + ), + child: completed + ? const Icon(Icons.check, color: Colors.white, size: 14) + : null, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + task, + style: TextStyle( + fontSize: 14, + decoration: completed ? TextDecoration.lineThrough : null, + color: completed ? Colors.grey[600] : Colors.black, + ), + ), + ), + ], + ); + } + + /// Statistiques consultant + Widget _buildConsultantStats() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Mes Statistiques', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildStatCard('Projets', '3', Icons.work, const Color(0xFF6C5CE7)), + ), + const SizedBox(width: 12), + Expanded( + child: _buildStatCard('Tâches', '12', Icons.task, const Color(0xFF00B894)), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildStatCard('Heures', '156h', Icons.schedule, const Color(0xFF0984E3)), + ), + const SizedBox(width: 12), + Expanded( + child: _buildStatCard('Éval.', '4.8/5', Icons.star, const Color(0xFFFDAB00)), + ), + ], + ), + ], + ); + } + + /// Widget pour une carte de statistique + Widget _buildStatCard(String title, String value, IconData icon, Color color) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, color: color, size: 24), + ), + const SizedBox(height: 8), + Text( + value, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + Text( + title, + style: TextStyle( + color: Colors.grey[600], + fontSize: 12, + ), + ), + ], + ), + ); + } + + /// Navigation rapide consultant + Widget _buildConsultantQuickNavigation() { + return Container( + height: 60, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(30), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 20, + offset: const Offset(0, 5), + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildNavItem(Icons.work, 'Projets', 0), + _buildNavItem(Icons.contacts, 'Contacts', 1), + _buildNavItem(Icons.person, 'Profil', 2), + ], + ), + ); + } + + /// Widget pour un élément de navigation + Widget _buildNavItem(IconData icon, String label, int index) { + final isSelected = _selectedIndex == index; + return GestureDetector( + onTap: () => setState(() => _selectedIndex = index), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isSelected + ? const Color(0xFF6C5CE7).withOpacity(0.1) + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + icon, + size: 18, + color: isSelected + ? const Color(0xFF6C5CE7) + : Colors.grey[600], + ), + ), + const SizedBox(height: 2), + Text( + label, + style: TextStyle( + fontSize: 9, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected + ? const Color(0xFF6C5CE7) + : Colors.grey[600], + ), + ), + ], + ), + ), + ); + } + + // Méthodes d'action + void _showConsultantNotifications() { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Notifications consultant - Fonctionnalité à implémenter'), + backgroundColor: Color(0xFF6C5CE7), + ), + ); + } + + void _editProfile() { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Éditer profil - Fonctionnalité à implémenter'), + backgroundColor: Color(0xFF6C5CE7), + ), + ); + } + + void _contactSupport() { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Contacter support - Fonctionnalité à implémenter'), + backgroundColor: Color(0xFF6C5CE7), + ), + ); + } + + void _showHelp() { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Aide - Fonctionnalité à implémenter'), + backgroundColor: Color(0xFF6C5CE7), + ), + ); + } +} diff --git a/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/role_dashboards/hr_manager_dashboard.dart b/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/role_dashboards/hr_manager_dashboard.dart new file mode 100644 index 0000000..28ce21d --- /dev/null +++ b/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/role_dashboards/hr_manager_dashboard.dart @@ -0,0 +1,914 @@ +/// Dashboard Gestionnaire RH - Interface Ressources Humaines +/// Outils spécialisés pour la gestion des employés et RH +library hr_manager_dashboard; + +import 'package:flutter/material.dart'; + +/// Dashboard spécialisé pour Gestionnaire RH +/// +/// Fonctionnalités RH : +/// - Gestion des employés +/// - Recrutement et onboarding +/// - Évaluations de performance +/// - Congés et absences +/// - Reporting RH +/// - Formation et développement +class HRManagerDashboard extends StatefulWidget { + const HRManagerDashboard({super.key}); + + @override + State createState() => _HRManagerDashboardState(); +} + +class _HRManagerDashboardState extends State + with TickerProviderStateMixin { + late TabController _tabController; + int _selectedIndex = 0; + + final List _hrSections = [ + 'Vue d\'ensemble', + 'Employés', + 'Recrutement', + 'Évaluations', + 'Congés', + 'Formation', + ]; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: _hrSections.length, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF8F9FA), + appBar: AppBar( + title: Text( + 'RH Manager - ${_hrSections[_selectedIndex]}', + style: const TextStyle( + color: Color(0xFF00B894), + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), + backgroundColor: Colors.white, + elevation: 2, + centerTitle: false, + actions: [ + // Recherche employés + IconButton( + icon: const Icon(Icons.search, color: Color(0xFF00B894)), + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Recherche avancée - Fonctionnalité à implémenter'), + backgroundColor: Color(0xFF00B894), + ), + ); + }, + tooltip: 'Rechercher employés', + ), + // Notifications RH + IconButton( + icon: const Icon(Icons.notifications_outlined, color: Color(0xFF00B894)), + onPressed: () => _showHRNotifications(), + tooltip: 'Notifications RH', + ), + // Menu RH + PopupMenuButton( + icon: const Icon(Icons.more_vert, color: Color(0xFF00B894)), + onSelected: (value) { + switch (value) { + case 'reports': + _generateHRReports(); + break; + case 'settings': + _openHRSettings(); + break; + case 'export': + _exportHRData(); + break; + } + }, + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'reports', + child: Row( + children: [ + Icon(Icons.assessment, size: 20, color: Color(0xFF00B894)), + SizedBox(width: 12), + Text('Rapports RH'), + ], + ), + ), + const PopupMenuItem( + value: 'settings', + child: Row( + children: [ + Icon(Icons.settings, size: 20, color: Color(0xFF00B894)), + SizedBox(width: 12), + Text('Paramètres RH'), + ], + ), + ), + const PopupMenuItem( + value: 'export', + child: Row( + children: [ + Icon(Icons.download, size: 20, color: Color(0xFF00B894)), + SizedBox(width: 12), + Text('Exporter données'), + ], + ), + ), + ], + ), + ], + ), + drawer: _buildHRDrawer(), + body: Stack( + children: [ + _buildSelectedContent(), + // Navigation rapide RH + Positioned( + bottom: 20, + left: 20, + right: 20, + child: _buildHRQuickNavigation(), + ), + ], + ), + ); + } + + /// Drawer de navigation RH + Widget _buildHRDrawer() { + return Drawer( + child: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xFF00B894), + Color(0xFF00A085), + Color(0xFF008B75), + ], + ), + ), + child: Column( + children: [ + // Header RH + Container( + padding: const EdgeInsets.fromLTRB(20, 60, 20, 20), + child: Row( + children: [ + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(30), + border: Border.all( + color: Colors.white.withOpacity(0.3), + width: 2, + ), + ), + child: const Icon( + Icons.people, + color: Colors.white, + size: 30, + ), + ), + const SizedBox(width: 16), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Gestionnaire RH', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + Text( + 'Ressources Humaines', + style: TextStyle( + color: Colors.white70, + fontSize: 14, + ), + ), + ], + ), + ), + ], + ), + ), + + // Menu de navigation + Expanded( + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 8), + itemCount: _hrSections.length, + itemBuilder: (context, index) { + final isSelected = _selectedIndex == index; + return Container( + margin: const EdgeInsets.symmetric(vertical: 2), + decoration: BoxDecoration( + color: isSelected + ? Colors.white.withOpacity(0.2) + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + ), + child: ListTile( + leading: Icon( + _getHRSectionIcon(index), + color: Colors.white, + size: 22, + ), + title: Text( + _hrSections[index], + style: TextStyle( + color: Colors.white, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + ), + ), + onTap: () { + setState(() => _selectedIndex = index); + Navigator.pop(context); + }, + ), + ); + }, + ), + ), + + // Footer avec déconnexion + Container( + padding: const EdgeInsets.all(16), + child: ElevatedButton.icon( + onPressed: () { + Navigator.of(context).pop(); + // TODO: Implémenter la déconnexion + }, + icon: const Icon(Icons.logout, size: 16), + label: const Text('Déconnexion'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white.withOpacity(0.2), + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 40), + ), + ), + ), + ], + ), + ), + ); + } + + /// Icône pour chaque section RH + IconData _getHRSectionIcon(int index) { + switch (index) { + case 0: return Icons.dashboard; + case 1: return Icons.people; + case 2: return Icons.person_add; + case 3: return Icons.star_rate; + case 4: return Icons.event_busy; + case 5: return Icons.school; + default: return Icons.dashboard; + } + } + + /// Contenu de la section sélectionnée + Widget _buildSelectedContent() { + switch (_selectedIndex) { + case 0: + return _buildOverviewContent(); + case 1: + return _buildEmployeesContent(); + case 2: + return _buildRecruitmentContent(); + case 3: + return _buildEvaluationsContent(); + case 4: + return _buildLeavesContent(); + case 5: + return _buildTrainingContent(); + default: + return _buildOverviewContent(); + } + } + + /// Vue d'ensemble RH + Widget _buildOverviewContent() { + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 80), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header avec statut RH + _buildHRStatusHeader(), + const SizedBox(height: 20), + + // KPIs RH + _buildHRKPIsSection(), + const SizedBox(height: 20), + + // Actions rapides RH + _buildHRQuickActions(), + const SizedBox(height: 20), + + // Alertes RH importantes + _buildHRAlerts(), + ], + ), + ); + } + + /// Placeholder pour les autres sections + Widget _buildEmployeesContent() { + return const Center( + child: Text( + 'Gestion des Employés\n(En développement)', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 18), + ), + ); + } + + Widget _buildRecruitmentContent() { + return const Center( + child: Text( + 'Recrutement\n(En développement)', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 18), + ), + ); + } + + Widget _buildEvaluationsContent() { + return const Center( + child: Text( + 'Évaluations\n(En développement)', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 18), + ), + ); + } + + Widget _buildLeavesContent() { + return const Center( + child: Text( + 'Congés et Absences\n(En développement)', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 18), + ), + ); + } + + Widget _buildTrainingContent() { + return const Center( + child: Text( + 'Formation\n(En développement)', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 18), + ), + ); + } + + /// Header avec statut RH + Widget _buildHRStatusHeader() { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF00B894), Color(0xFF00A085)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: const Color(0xFF00B894).withOpacity(0.3), + blurRadius: 15, + offset: const Offset(0, 5), + ), + ], + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Département RH Actif', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + 'Dernière sync: ${DateTime.now().hour.toString().padLeft(2, '0')}:${DateTime.now().minute.toString().padLeft(2, '0')}', + style: TextStyle( + color: Colors.white.withOpacity(0.8), + fontSize: 12, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.people, + color: Colors.white, + size: 28, + ), + ), + ], + ), + ); + } + + /// Section KPIs RH + Widget _buildHRKPIsSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Indicateurs RH', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildHRKPICard( + 'Employés Actifs', + '247', + '+12 ce mois', + Icons.people, + const Color(0xFF00B894), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildHRKPICard( + 'Candidatures', + '34', + '+8 cette semaine', + Icons.person_add, + const Color(0xFF0984E3), + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildHRKPICard( + 'En Congé', + '18', + '7.3% de l\'effectif', + Icons.event_busy, + const Color(0xFFFDAB00), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildHRKPICard( + 'Évaluations', + '156', + '89% complétées', + Icons.star_rate, + const Color(0xFFE17055), + ), + ), + ], + ), + ], + ); + } + + /// Widget pour une carte KPI RH + Widget _buildHRKPICard( + String title, + String value, + String subtitle, + IconData icon, + Color color, + ) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + icon, + color: color, + size: 20, + ), + ), + const Spacer(), + ], + ), + const SizedBox(height: 12), + Text( + value, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + title, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: TextStyle( + fontSize: 10, + color: Colors.grey[600], + ), + ), + ], + ), + ); + } + + /// Actions rapides RH + Widget _buildHRQuickActions() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Actions Rapides', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 2, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 1.5, + children: [ + _buildHRActionCard( + 'Nouveau Employé', + Icons.person_add, + const Color(0xFF00B894), + () => _addNewEmployee(), + ), + _buildHRActionCard( + 'Demandes Congés', + Icons.event_busy, + const Color(0xFFFDAB00), + () => _viewLeaveRequests(), + ), + _buildHRActionCard( + 'Évaluations', + Icons.star_rate, + const Color(0xFFE17055), + () => _viewEvaluations(), + ), + _buildHRActionCard( + 'Recrutement', + Icons.work, + const Color(0xFF0984E3), + () => _viewRecruitment(), + ), + ], + ), + ], + ); + } + + /// Widget pour une action RH + Widget _buildHRActionCard( + String title, + IconData icon, + Color color, + VoidCallback onTap, + ) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withOpacity(0.2)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + icon, + color: color, + size: 24, + ), + ), + const SizedBox(height: 8), + Text( + title, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 12, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + /// Alertes RH importantes + Widget _buildHRAlerts() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Alertes Importantes', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + _buildHRAlertItem( + 'Évaluations en retard', + '12 évaluations annuelles à finaliser', + Icons.warning, + const Color(0xFFE17055), + ), + const SizedBox(height: 8), + _buildHRAlertItem( + 'Congés à approuver', + '5 demandes de congé en attente', + Icons.pending_actions, + const Color(0xFFFDAB00), + ), + const SizedBox(height: 8), + _buildHRAlertItem( + 'Nouveaux candidats', + '8 candidatures reçues cette semaine', + Icons.person_add, + const Color(0xFF0984E3), + ), + ], + ); + } + + /// Widget pour un élément d'alerte RH + Widget _buildHRAlertItem( + String title, + String message, + IconData icon, + Color color, + ) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withOpacity(0.3)), + ), + child: Row( + children: [ + Icon(icon, color: color, size: 20), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 12, + color: color, + ), + ), + Text( + message, + style: TextStyle( + fontSize: 11, + color: Colors.grey[700], + ), + ), + ], + ), + ), + ], + ), + ); + } + + /// Navigation rapide RH + Widget _buildHRQuickNavigation() { + return Container( + height: 60, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(30), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 20, + offset: const Offset(0, 5), + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildHRNavItem(Icons.dashboard, 'Vue', 0), + _buildHRNavItem(Icons.people, 'Employés', 1), + _buildHRNavItem(Icons.person_add, 'Recrutement', 2), + _buildHRNavItem(Icons.star_rate, 'Évaluations', 3), + _buildHRNavItem(Icons.event_busy, 'Congés', 4), + ], + ), + ); + } + + /// Widget pour un élément de navigation RH + Widget _buildHRNavItem(IconData icon, String label, int index) { + final isSelected = _selectedIndex == index; + return GestureDetector( + onTap: () => setState(() => _selectedIndex = index), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isSelected + ? const Color(0xFF00B894).withOpacity(0.1) + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + icon, + size: 18, + color: isSelected + ? const Color(0xFF00B894) + : Colors.grey[600], + ), + ), + const SizedBox(height: 2), + Text( + label, + style: TextStyle( + fontSize: 9, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected + ? const Color(0xFF00B894) + : Colors.grey[600], + ), + ), + ], + ), + ), + ); + } + + // Méthodes d'action + void _showHRNotifications() { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Notifications RH - Fonctionnalité à implémenter'), + backgroundColor: Color(0xFF00B894), + ), + ); + } + + void _generateHRReports() { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Rapports RH - Fonctionnalité à implémenter'), + backgroundColor: Color(0xFF00B894), + ), + ); + } + + void _openHRSettings() { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Paramètres RH - Fonctionnalité à implémenter'), + backgroundColor: Color(0xFF00B894), + ), + ); + } + + void _exportHRData() { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Export données RH - Fonctionnalité à implémenter'), + backgroundColor: Color(0xFF00B894), + ), + ); + } + + void _addNewEmployee() { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Ajouter employé - Fonctionnalité à implémenter'), + backgroundColor: Color(0xFF00B894), + ), + ); + } + + void _viewLeaveRequests() { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Demandes de congé - Fonctionnalité à implémenter'), + backgroundColor: Color(0xFFFDAB00), + ), + ); + } + + void _viewEvaluations() { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Évaluations - Fonctionnalité à implémenter'), + backgroundColor: Color(0xFFE17055), + ), + ); + } + + void _viewRecruitment() { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Recrutement - Fonctionnalité à implémenter'), + backgroundColor: Color(0xFF0984E3), + ), + ); + } +} diff --git a/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/role_dashboards/org_admin_dashboard.dart b/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/role_dashboards/org_admin_dashboard.dart index 95b4d3d..04a8d03 100644 --- a/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/role_dashboards/org_admin_dashboard.dart +++ b/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/role_dashboards/org_admin_dashboard.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import '../../../../../core/design_system/tokens/tokens.dart'; import '../../widgets/widgets.dart'; + /// Dashboard Control Panel pour Administrateur d'Organisation /// /// Fonctionnalités exclusives : @@ -34,6 +35,89 @@ class _OrgAdminDashboardState extends State { floating: false, pinned: true, backgroundColor: const Color(0xFF0984E3), // Bleu corporate + actions: [ + // Recherche des membres + IconButton( + icon: const Icon(Icons.search, color: Colors.white), + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Recherche avancée - Fonctionnalité à implémenter'), + backgroundColor: Color(0xFF0984E3), + ), + ); + }, + tooltip: 'Rechercher des membres', + ), + // Notifications organisation + IconButton( + icon: const Icon(Icons.notifications_outlined, color: Colors.white), + onPressed: () => _showOrgNotifications(), + tooltip: 'Notifications organisation', + ), + // Menu d'options + PopupMenuButton( + icon: const Icon(Icons.more_vert, color: Colors.white), + onSelected: (value) { + switch (value) { + case 'settings': + _openOrgSettings(); + break; + case 'reports': + _generateReports(); + break; + case 'export': + _exportOrgData(); + break; + case 'backup': + _backupOrgData(); + break; + } + }, + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'settings', + child: Row( + children: [ + Icon(Icons.settings, size: 20, color: Color(0xFF0984E3)), + SizedBox(width: 12), + Text('Paramètres Org'), + ], + ), + ), + const PopupMenuItem( + value: 'reports', + child: Row( + children: [ + Icon(Icons.assessment, size: 20, color: Color(0xFF0984E3)), + SizedBox(width: 12), + Text('Rapports'), + ], + ), + ), + const PopupMenuItem( + value: 'export', + child: Row( + children: [ + Icon(Icons.download, size: 20, color: Color(0xFF0984E3)), + SizedBox(width: 12), + Text('Exporter données'), + ], + ), + ), + const PopupMenuItem( + value: 'backup', + child: Row( + children: [ + Icon(Icons.backup, size: 20, color: Color(0xFF0984E3)), + SizedBox(width: 12), + Text('Sauvegarde'), + ], + ), + ), + ], + ), + ], flexibleSpace: FlexibleSpaceBar( title: const Text( 'Control Panel', @@ -419,7 +503,7 @@ class _OrgAdminDashboardState extends State { ), ), const SizedBox(height: 2), - Icon( + const Icon( Icons.arrow_forward_ios, size: 12, color: ColorTokens.textSecondary, @@ -443,25 +527,25 @@ class _OrgAdminDashboardState extends State { ), const SizedBox(height: SpacingTokens.md), - DashboardInsightsSection( + const DashboardInsightsSection( metrics: [ DashboardMetric( label: 'Cotisations collectées', value: '89%', progress: 0.89, - color: const Color(0xFF00B894), + color: Color(0xFF00B894), ), DashboardMetric( label: 'Budget utilisé', value: '67%', progress: 0.67, - color: const Color(0xFF0984E3), + color: Color(0xFF0984E3), ), DashboardMetric( label: 'Objectif annuel', value: '78%', progress: 0.78, - color: const Color(0xFFE17055), + color: Color(0xFFE17055), ), ], ), @@ -483,26 +567,26 @@ class _OrgAdminDashboardState extends State { const SizedBox(height: SpacingTokens.md), DashboardRecentActivitySection( - activities: [ + activities: const [ DashboardActivity( title: 'Nouveau membre approuvé', subtitle: 'Sophie Laurent rejoint l\'organisation', icon: Icons.person_add, - color: const Color(0xFF00B894), + color: Color(0xFF00B894), time: 'Il y a 2h', ), DashboardActivity( title: 'Budget mis à jour', subtitle: 'Allocation événements modifiée', icon: Icons.account_balance_wallet, - color: const Color(0xFF0984E3), + color: Color(0xFF0984E3), time: 'Il y a 4h', ), DashboardActivity( title: 'Rapport généré', subtitle: 'Rapport mensuel d\'activité', icon: Icons.assessment, - color: const Color(0xFF6C5CE7), + color: Color(0xFF6C5CE7), time: 'Il y a 1j', ), ], @@ -533,6 +617,319 @@ class _OrgAdminDashboardState extends State { void _onActivityTap(String activityId) { // Navigation vers les détails de l'activité } + + /// Afficher les notifications de l'organisation + void _showOrgNotifications() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => Container( + height: MediaQuery.of(context).size.height * 0.7, + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: Color(0xFF0984E3), + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Row( + children: [ + const Icon(Icons.business, color: Colors.white), + const SizedBox(width: 12), + const Text( + 'Notifications Organisation', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + IconButton( + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close, color: Colors.white), + ), + ], + ), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildOrgNotificationItem( + 'Nouveau membre', + 'Marie Dubois a rejoint le département Marketing', + Icons.person_add, + const Color(0xFF00B894), + '10 min', + ), + _buildOrgNotificationItem( + 'Budget dépassé', + 'Le département IT a dépassé son budget mensuel', + Icons.warning, + const Color(0xFFE17055), + '1h', + ), + _buildOrgNotificationItem( + 'Rapport mensuel', + 'Le rapport d\'activité de mars est disponible', + Icons.assessment, + const Color(0xFF0984E3), + '2h', + ), + _buildOrgNotificationItem( + 'Demande de congé', + '3 nouvelles demandes de congé en attente', + Icons.event_busy, + const Color(0xFFFDAB00), + '3h', + ), + ], + ), + ), + ], + ), + ), + ); + } + + /// Widget pour un élément de notification organisation + Widget _buildOrgNotificationItem( + String title, + String message, + IconData icon, + Color color, + String time, + ) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey[50], + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey[200]!), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, color: color, size: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + const SizedBox(height: 4), + Text( + message, + style: TextStyle( + color: Colors.grey[600], + fontSize: 12, + ), + ), + ], + ), + ), + Text( + time, + style: TextStyle( + color: Colors.grey[500], + fontSize: 11, + ), + ), + ], + ), + ); + } + + /// Ouvrir les paramètres de l'organisation + void _openOrgSettings() { + // TODO: Naviguer vers la page des paramètres organisation + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Paramètres Organisation - Fonctionnalité à implémenter'), + backgroundColor: Color(0xFF0984E3), + ), + ); + } + + /// Générer des rapports + void _generateReports() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Générer un rapport'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Sélectionnez le type de rapport :'), + const SizedBox(height: 16), + ListTile( + leading: const Icon(Icons.people, color: Color(0xFF0984E3)), + title: const Text('Rapport Membres'), + onTap: () { + Navigator.pop(context); + _generateMemberReport(); + }, + ), + ListTile( + leading: const Icon(Icons.attach_money, color: Color(0xFF00B894)), + title: const Text('Rapport Financier'), + onTap: () { + Navigator.pop(context); + _generateFinancialReport(); + }, + ), + ListTile( + leading: const Icon(Icons.analytics, color: Color(0xFFE17055)), + title: const Text('Rapport d\'Activité'), + onTap: () { + Navigator.pop(context); + _generateActivityReport(); + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler'), + ), + ], + ), + ); + } + + /// Générer rapport des membres + void _generateMemberReport() { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Génération du rapport membres en cours...'), + backgroundColor: Color(0xFF0984E3), + ), + ); + } + + /// Générer rapport financier + void _generateFinancialReport() { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Génération du rapport financier en cours...'), + backgroundColor: Color(0xFF00B894), + ), + ); + } + + /// Générer rapport d'activité + void _generateActivityReport() { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Génération du rapport d\'activité en cours...'), + backgroundColor: Color(0xFFE17055), + ), + ); + } + + /// Exporter les données de l'organisation + void _exportOrgData() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Exporter les données'), + content: const Text( + 'Sélectionnez le format d\'export souhaité :', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Export CSV en cours...'), + backgroundColor: Color(0xFF00B894), + ), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0984E3), + ), + child: const Text('CSV', style: TextStyle(color: Colors.white)), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Export Excel en cours...'), + backgroundColor: Color(0xFF00B894), + ), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0984E3), + ), + child: const Text('Excel', style: TextStyle(color: Colors.white)), + ), + ], + ), + ); + } + + /// Sauvegarder les données de l'organisation + void _backupOrgData() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Sauvegarde Organisation'), + content: const Text( + 'Voulez-vous créer une sauvegarde complète des données de l\'organisation ?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Sauvegarde en cours...'), + backgroundColor: Color(0xFF0984E3), + ), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0984E3), + ), + child: const Text('Confirmer', style: TextStyle(color: Colors.white)), + ), + ], + ), + ); + } } /// Painter pour le motif corporate de l'en-tête diff --git a/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/role_dashboards/super_admin_dashboard.dart b/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/role_dashboards/super_admin_dashboard.dart index f90156d..d703d00 100644 --- a/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/role_dashboards/super_admin_dashboard.dart +++ b/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/role_dashboards/super_admin_dashboard.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; + + /// Dashboard pour le Super Administrateur /// Interface mobile optimisée avec drawer de navigation class SuperAdminDashboard extends StatefulWidget { @@ -10,284 +12,23 @@ class SuperAdminDashboard extends StatefulWidget { } class _SuperAdminDashboardState extends State { - int _selectedIndex = 0; - - final List _sectionTitles = [ - 'Vue Globale', - 'Organisations', - 'Utilisateurs', - 'Système', - 'Analytics', - 'Sécurité', - 'Configuration', - ]; - @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: const Color(0xFFF8F9FA), - appBar: AppBar( - title: Text( - 'Command Center - ${_sectionTitles[_selectedIndex]}', - style: const TextStyle( - color: Color(0xFF6C5CE7), - fontWeight: FontWeight.bold, - fontSize: 18, - ), - ), - backgroundColor: Colors.white, - elevation: 2, - centerTitle: false, - ), - drawer: _buildDrawer(context), - body: _buildSelectedContent(), - ); - } - - /// Drawer de navigation mobile - Widget _buildDrawer(BuildContext context) { - return Drawer( - child: Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Color(0xFF6C5CE7), - Color(0xFF5A4FCF), - Color(0xFF4834D4), - ], - ), - ), - child: SafeArea( - child: Column( - children: [ - // Profil utilisateur - _buildUserProfile(), - const SizedBox(height: 16), - - // Menu de navigation - Expanded( - child: _buildNavigationMenu(), - ), - - // Actions du bas - _buildBottomActions(), - ], - ), - ), - ), - ); - } - - /// Profil utilisateur dans la sidebar - Widget _buildUserProfile() { return Container( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - // Photo de profil - Container( - width: 60, - height: 60, - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(30), - border: Border.all( - color: Colors.white.withOpacity(0.3), - width: 2, - ), - ), - child: const Icon( - Icons.admin_panel_settings, - color: Colors.white, - size: 30, - ), - ), - - const SizedBox(height: 8), - - // Nom et email - const Text( - 'Super Admin', - style: TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - const Text( - 'superadmin@unionflow.com', - style: TextStyle( - color: Colors.white70, - fontSize: 11, - ), - ), - - const SizedBox(height: 8), - - // Badge de rôle - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(4), - border: Border.all( - color: Colors.white.withOpacity(0.3), - ), - ), - child: const Text( - 'SUPER_ADMINISTRATEUR', - style: TextStyle( - color: Colors.white, - fontSize: 9, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, - ), - ), - ), - ], - ), + color: const Color(0xFFF8F9FA), + child: _buildGlobalOverviewContent(), ); } - /// Menu de navigation dans la sidebar - Widget _buildNavigationMenu() { - return ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 12), - itemCount: _sectionTitles.length, - itemBuilder: (context, index) { - final isSelected = _selectedIndex == index; - return Container( - margin: const EdgeInsets.only(bottom: 4), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () { - Navigator.of(context).pop(); // Fermer le drawer - setState(() { - _selectedIndex = index; - }); - }, - borderRadius: BorderRadius.circular(8), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - decoration: BoxDecoration( - color: isSelected - ? Colors.white.withOpacity(0.2) - : Colors.transparent, - borderRadius: BorderRadius.circular(8), - border: isSelected - ? Border.all(color: Colors.white.withOpacity(0.3)) - : null, - ), - child: Row( - children: [ - Icon( - _getSectionIcon(index), - color: Colors.white, - size: 20, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - _sectionTitles[index], - style: TextStyle( - color: Colors.white, - fontWeight: isSelected - ? FontWeight.w600 - : FontWeight.normal, - ), - ), - ), - if (isSelected) - const Icon( - Icons.chevron_right, - color: Colors.white, - size: 16, - ), - ], - ), - ), - ), - ), - ); - }, - ); - } - /// Actions du bas - Widget _buildBottomActions() { - return Container( - padding: const EdgeInsets.all(16), - child: SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: () { - Navigator.of(context).pop(); - // TODO: Implémenter la déconnexion - }, - icon: const Icon(Icons.logout, size: 16), - label: const Text('Déconnexion'), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white.withOpacity(0.2), - foregroundColor: Colors.white, - elevation: 0, - padding: const EdgeInsets.symmetric(vertical: 8), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - side: BorderSide( - color: Colors.white.withOpacity(0.3), - ), - ), - ), - ), - ), - ); - } - /// Contenu de la section sélectionnée - Widget _buildSelectedContent() { - switch (_selectedIndex) { - case 0: - return _buildGlobalOverviewContent(); - case 1: - return _buildOrganizationsContent(); - case 2: - return _buildUsersContent(); - case 3: - return _buildSystemContent(); - case 4: - return _buildAnalyticsContent(); - case 5: - return _buildSecurityContent(); - case 6: - return _buildConfigurationContent(); - default: - return _buildGlobalOverviewContent(); - } - } - /// Icône pour chaque section - IconData _getSectionIcon(int index) { - switch (index) { - case 0: return Icons.dashboard; - case 1: return Icons.business; - case 2: return Icons.people; - case 3: return Icons.computer; - case 4: return Icons.analytics; - case 5: return Icons.security; - case 6: return Icons.settings; - default: return Icons.dashboard; - } - } + + + + + + /// Vue Globale - Métriques système simplifiées Widget _buildGlobalOverviewContent() { @@ -296,6 +37,10 @@ class _SuperAdminDashboardState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Header avec heure et statut système + _buildSystemStatusHeader(), + const SizedBox(height: 16), + // KPIs système en temps réel _buildSimpleKPIsSection(), const SizedBox(height: 16), @@ -310,6 +55,10 @@ class _SuperAdminDashboardState extends State { // Activité récente _buildSimpleActivitySection(), + const SizedBox(height: 16), + + // Actions rapides système + _buildSystemQuickActions(), ], ), ); @@ -653,11 +402,129 @@ class _SuperAdminDashboardState extends State { /// Users Content Widget _buildUsersContent() { - return const Center( - child: Text( - 'Gestion des Utilisateurs\n(En développement)', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 18), + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header avec titre et bouton de recherche + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Gestion des Utilisateurs', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ElevatedButton.icon( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Recherche avancée - Fonctionnalité à implémenter'), + backgroundColor: Color(0xFF6C5CE7), + ), + ); + }, + icon: const Icon(Icons.search, size: 18), + label: const Text('Recherche Avancée'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6C5CE7), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + ), + ), + ], + ), + const SizedBox(height: 24), + + // Statistiques rapides + Row( + children: [ + Expanded( + child: _buildUserStatsCard( + 'Total Utilisateurs', + '15,847', + Icons.people, + const Color(0xFF00B894), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildUserStatsCard( + 'Actifs ce mois', + '12,456', + Icons.trending_up, + const Color(0xFF0984E3), + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _buildUserStatsCard( + 'Nouveaux', + '1,234', + Icons.person_add, + const Color(0xFFE17055), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildUserStatsCard( + 'En attente', + '89', + Icons.hourglass_empty, + const Color(0xFFFDAB00), + ), + ), + ], + ), + const SizedBox(height: 24), + + // Actions rapides + const Text( + 'Actions Rapides', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _buildQuickActionChip( + 'Utilisateurs Récents', + Icons.access_time, + () { + // TODO: Naviguer vers la liste des utilisateurs récents + }, + ), + _buildQuickActionChip( + 'Comptes Suspendus', + Icons.block, + () { + // TODO: Naviguer vers les comptes suspendus + }, + ), + _buildQuickActionChip( + 'Demandes d\'accès', + Icons.pending_actions, + () { + // TODO: Naviguer vers les demandes d'accès + }, + ), + ], + ), + ], ), ); } @@ -705,4 +572,600 @@ class _SuperAdminDashboardState extends State { ), ); } + + /// Widget pour les statistiques utilisateurs + Widget _buildUserStatsCard( + String title, + String value, + IconData icon, + Color color, + ) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + icon, + color: color, + size: 20, + ), + ), + const Spacer(), + ], + ), + const SizedBox(height: 12), + Text( + value, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + title, + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + ], + ), + ); + } + + /// Widget pour les actions rapides + Widget _buildQuickActionChip( + String label, + IconData icon, + VoidCallback onTap, + ) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: Colors.grey[300]!, + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 16, + color: const Color(0xFF6C5CE7), + ), + const SizedBox(width: 6), + Text( + label, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + } + + /// Afficher les notifications + void _showNotifications() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => Container( + height: MediaQuery.of(context).size.height * 0.7, + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: Color(0xFF6C5CE7), + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Row( + children: [ + const Icon(Icons.notifications, color: Colors.white), + const SizedBox(width: 12), + const Text( + 'Notifications', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + IconButton( + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.close, color: Colors.white), + ), + ], + ), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildNotificationItem( + 'Nouvelle demande d\'accès', + 'Jean Dupont souhaite rejoindre l\'organisation TechCorp', + Icons.person_add, + const Color(0xFF00B894), + '5 min', + ), + _buildNotificationItem( + 'Sauvegarde terminée', + 'La sauvegarde quotidienne s\'est terminée avec succès', + Icons.backup, + const Color(0xFF0984E3), + '1h', + ), + _buildNotificationItem( + 'Alerte sécurité', + 'Tentative de connexion suspecte détectée', + Icons.security, + const Color(0xFFE17055), + '2h', + ), + _buildNotificationItem( + 'Mise à jour système', + 'Une nouvelle version est disponible', + Icons.system_update, + const Color(0xFFFDAB00), + '1j', + ), + ], + ), + ), + ], + ), + ), + ); + } + + /// Widget pour un élément de notification + Widget _buildNotificationItem( + String title, + String message, + IconData icon, + Color color, + String time, + ) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey[50], + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey[200]!), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, color: color, size: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + const SizedBox(height: 4), + Text( + message, + style: TextStyle( + color: Colors.grey[600], + fontSize: 12, + ), + ), + ], + ), + ), + Text( + time, + style: TextStyle( + color: Colors.grey[500], + fontSize: 11, + ), + ), + ], + ), + ); + } + + /// Ouvrir les paramètres + void _openSettings() { + // TODO: Naviguer vers la page des paramètres + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Paramètres - Fonctionnalité à implémenter'), + backgroundColor: Color(0xFF6C5CE7), + ), + ); + } + + /// Exporter les données + void _exportData() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Exporter les données'), + content: const Text( + 'Sélectionnez le format d\'export souhaité :', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(context); + // TODO: Implémenter l'export CSV + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Export CSV en cours...'), + backgroundColor: Color(0xFF00B894), + ), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6C5CE7), + ), + child: const Text('CSV', style: TextStyle(color: Colors.white)), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(context); + // TODO: Implémenter l'export Excel + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Export Excel en cours...'), + backgroundColor: Color(0xFF00B894), + ), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6C5CE7), + ), + child: const Text('Excel', style: TextStyle(color: Colors.white)), + ), + ], + ), + ); + } + + /// Créer une sauvegarde + void _createBackup() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Créer une sauvegarde'), + content: const Text( + 'Voulez-vous créer une sauvegarde complète du système ?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(context); + // TODO: Implémenter la sauvegarde + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Sauvegarde en cours...'), + backgroundColor: Color(0xFF0984E3), + ), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6C5CE7), + ), + child: const Text('Confirmer', style: TextStyle(color: Colors.white)), + ), + ], + ), + ); + } + + /// Voir les logs système + void _viewSystemLogs() { + // TODO: Naviguer vers la page des logs + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Logs système - Fonctionnalité à implémenter'), + backgroundColor: Color(0xFF6C5CE7), + ), + ); + } + + /// Actualiser le dashboard + void _refreshDashboard() { + // TODO: Implémenter l'actualisation des données + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Dashboard actualisé'), + backgroundColor: Color(0xFF00B894), + duration: Duration(seconds: 1), + ), + ); + } + + + + /// Header avec statut système et heure + Widget _buildSystemStatusHeader() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF6C5CE7), Color(0xFF5A4FCF)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: const Color(0xFF6C5CE7).withOpacity(0.3), + blurRadius: 15, + offset: const Offset(0, 5), + ), + ], + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Système Opérationnel', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + 'Dernière mise à jour: ${DateTime.now().hour.toString().padLeft(2, '0')}:${DateTime.now().minute.toString().padLeft(2, '0')}', + style: TextStyle( + color: Colors.white.withOpacity(0.8), + fontSize: 12, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Color(0xFF00B894), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + const Text( + 'En ligne', + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + ); + } + + /// Actions rapides système + Widget _buildSystemQuickActions() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Actions Rapides', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 2, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 1.5, + children: [ + _buildSystemActionCard( + 'Sauvegarde', + Icons.backup, + const Color(0xFF0984E3), + () => _createBackup(), + ), + _buildSystemActionCard( + 'Logs Système', + Icons.article, + const Color(0xFFE17055), + () => _viewSystemLogs(), + ), + _buildSystemActionCard( + 'Maintenance', + Icons.build, + const Color(0xFFFDAB00), + () => _startMaintenance(), + ), + _buildSystemActionCard( + 'Monitoring', + Icons.monitor_heart, + const Color(0xFF00B894), + () => _openMonitoring(), + ), + ], + ), + ], + ); + } + + /// Widget pour une action système + Widget _buildSystemActionCard( + String title, + IconData icon, + Color color, + VoidCallback onTap, + ) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withOpacity(0.2)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + icon, + color: color, + size: 24, + ), + ), + const SizedBox(height: 8), + Text( + title, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 12, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + /// Démarrer la maintenance + void _startMaintenance() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Mode Maintenance'), + content: const Text( + 'Voulez-vous activer le mode maintenance ? Les utilisateurs ne pourront plus accéder au système.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Mode maintenance activé'), + backgroundColor: Color(0xFFFDAB00), + ), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFFDAB00), + ), + child: const Text('Activer', style: TextStyle(color: Colors.white)), + ), + ], + ), + ); + } + + /// Ouvrir le monitoring + void _openMonitoring() { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Monitoring système - Fonctionnalité à implémenter'), + backgroundColor: Color(0xFF00B894), + ), + ); + } } diff --git a/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/dashboard_quick_action_button.dart b/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/dashboard_quick_action_button.dart index 5768fed..458b52b 100644 --- a/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/dashboard_quick_action_button.dart +++ b/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/dashboard_quick_action_button.dart @@ -3,9 +3,7 @@ library dashboard_quick_action_button; import 'package:flutter/material.dart'; -import '../../../../core/design_system/tokens/color_tokens.dart'; import '../../../../core/design_system/tokens/spacing_tokens.dart'; -import '../../../../core/design_system/tokens/typography_tokens.dart'; /// Modèle de données pour une action rapide class DashboardQuickAction { diff --git a/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/dashboard_widgets.dart b/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/dashboard_widgets.dart new file mode 100644 index 0000000..210ef2e --- /dev/null +++ b/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/dashboard_widgets.dart @@ -0,0 +1,297 @@ +import 'package:flutter/material.dart'; +import '../../../../core/design_system/tokens/tokens.dart'; + +/// Widget pour afficher une grille d'actions rapides +class DashboardQuickActionsGrid extends StatelessWidget { + final List children; + final int crossAxisCount; + + const DashboardQuickActionsGrid({ + super.key, + required this.children, + this.crossAxisCount = 2, + }); + + @override + Widget build(BuildContext context) { + return GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: crossAxisCount, + childAspectRatio: 1.2, + crossAxisSpacing: SpacingTokens.md, + mainAxisSpacing: SpacingTokens.md, + children: children, + ); + } +} + +/// Widget pour une action rapide +class DashboardQuickAction extends StatelessWidget { + final String title; + final IconData icon; + final Color? color; + final VoidCallback? onTap; + + const DashboardQuickAction({ + super.key, + required this.title, + required this.icon, + this.color, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Card( + elevation: 2, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(RadiusTokens.md), + child: Padding( + padding: const EdgeInsets.all(SpacingTokens.lg), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 32, + color: color ?? ColorTokens.primary, + ), + const SizedBox(height: SpacingTokens.sm), + Text( + title, + style: TypographyTokens.bodyMedium, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + ); + } +} + +/// Widget pour afficher une section d'activité récente +class DashboardRecentActivitySection extends StatelessWidget { + final List children; + final String title; + + const DashboardRecentActivitySection({ + super.key, + required this.children, + this.title = 'Activité Récente', + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TypographyTokens.headlineSmall, + ), + const SizedBox(height: SpacingTokens.md), + ...children, + ], + ); + } +} + +/// Widget pour une activité +class DashboardActivity extends StatelessWidget { + final String title; + final String subtitle; + final IconData icon; + final Color? color; + + const DashboardActivity({ + super.key, + required this.title, + required this.subtitle, + required this.icon, + this.color, + }); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.only(bottom: SpacingTokens.sm), + child: ListTile( + leading: CircleAvatar( + backgroundColor: color ?? ColorTokens.primary, + child: Icon(icon, color: Colors.white), + ), + title: Text(title), + subtitle: Text(subtitle), + ), + ); + } +} + +/// Widget pour une section d'insights +class DashboardInsightsSection extends StatelessWidget { + final List children; + + const DashboardInsightsSection({ + super.key, + required this.children, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Insights', + style: TypographyTokens.headlineSmall, + ), + const SizedBox(height: SpacingTokens.md), + ...children, + ], + ); + } +} + +/// Widget pour une statistique +class DashboardStat extends StatelessWidget { + final String title; + final String value; + final IconData icon; + final Color? color; + final VoidCallback? onTap; + + const DashboardStat({ + super.key, + required this.title, + required this.value, + required this.icon, + this.color, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Card( + elevation: 2, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(RadiusTokens.md), + child: Padding( + padding: const EdgeInsets.all(SpacingTokens.lg), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 32, + color: color ?? ColorTokens.primary, + ), + const SizedBox(height: SpacingTokens.sm), + Text( + value, + style: TypographyTokens.headlineSmall.copyWith( + fontWeight: FontWeight.bold, + color: color ?? ColorTokens.primary, + ), + ), + const SizedBox(height: SpacingTokens.xs), + Text( + title, + style: TypographyTokens.bodySmall, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + ); + } +} + +/// Widget pour la grille de statistiques +class DashboardStatsGrid extends StatelessWidget { + final List children; + final int crossAxisCount; + + const DashboardStatsGrid({ + super.key, + required this.children, + this.crossAxisCount = 2, + }); + + @override + Widget build(BuildContext context) { + return GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: crossAxisCount, + childAspectRatio: 1.2, + crossAxisSpacing: SpacingTokens.md, + mainAxisSpacing: SpacingTokens.md, + children: children, + ); + } +} + +/// Widget pour le drawer du dashboard +class DashboardDrawer extends StatelessWidget { + const DashboardDrawer({super.key}); + + @override + Widget build(BuildContext context) { + return Drawer( + child: ListView( + padding: EdgeInsets.zero, + children: [ + const DrawerHeader( + decoration: BoxDecoration( + color: ColorTokens.primary, + ), + child: Text( + 'UnionFlow', + style: TextStyle( + color: Colors.white, + fontSize: 24, + ), + ), + ), + ListTile( + leading: const Icon(Icons.dashboard), + title: const Text('Dashboard'), + onTap: () { + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(Icons.people), + title: const Text('Membres'), + onTap: () { + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(Icons.event), + title: const Text('Événements'), + onTap: () { + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(Icons.settings), + title: const Text('Paramètres'), + onTap: () { + Navigator.pop(context); + }, + ), + ], + ), + ); + } +} diff --git a/unionflow-mobile-apps/lib/features/events/presentation/pages/events_page.dart b/unionflow-mobile-apps/lib/features/events/presentation/pages/events_page.dart new file mode 100644 index 0000000..4cfb465 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/events/presentation/pages/events_page.dart @@ -0,0 +1,1355 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../core/auth/bloc/auth_bloc.dart'; +import '../../../../core/auth/models/user_role.dart'; +import '../../../../core/design_system/tokens/tokens.dart'; + +/// Page de gestion des événements - Interface sophistiquée et exhaustive +/// +/// Cette page offre une interface complète pour la gestion des événements +/// avec des fonctionnalités avancées de recherche, filtrage, statistiques, +/// vue calendrier et actions de gestion basées sur les permissions utilisateur. +class EventsPage extends StatefulWidget { + const EventsPage({super.key}); + + @override + State createState() => _EventsPageState(); +} + +class _EventsPageState extends State with TickerProviderStateMixin { + // Controllers et état + final TextEditingController _searchController = TextEditingController(); + late TabController _tabController; + + // État de l'interface + String _searchQuery = ''; + String _selectedFilter = 'Tous'; + + // Données de démonstration enrichies + final List> _allEvents = [ + { + 'id': '1', + 'title': 'Assemblée Générale Annuelle 2024', + 'description': 'Assemblée générale ordinaire avec présentation du bilan annuel, vote du budget et élection du bureau.', + 'startDate': DateTime(2024, 10, 15, 14, 0), + 'endDate': DateTime(2024, 10, 15, 17, 0), + 'location': 'Salle des fêtes municipale', + 'address': '12 Place de la Mairie, 75001 Paris', + 'type': 'Officiel', + 'status': 'Confirmé', + 'maxParticipants': 100, + 'currentParticipants': 67, + 'organizer': 'Bureau Exécutif', + 'priority': 'Haute', + 'isPublic': true, + 'requiresRegistration': true, + 'cost': 0.0, + 'tags': ['AG', 'Obligatoire', 'Annuel'], + 'createdBy': 'Marie Dubois', + 'createdAt': DateTime(2024, 8, 1), + 'lastModified': DateTime(2024, 9, 15), + }, + { + 'id': '2', + 'title': 'Sortie Ski de Fond - Les Rousses', + 'description': 'Sortie ski de fond dans le Jura. Matériel fourni, tous niveaux acceptés. Repas chaud inclus.', + 'startDate': DateTime(2024, 12, 22, 9, 0), + 'endDate': DateTime(2024, 12, 22, 17, 0), + 'location': 'Station des Rousses', + 'address': 'Les Rousses, 39220 Jura', + 'type': 'Loisir', + 'status': 'En attente', + 'maxParticipants': 25, + 'currentParticipants': 18, + 'organizer': 'Commission Sports', + 'priority': 'Moyenne', + 'isPublic': true, + 'requiresRegistration': true, + 'cost': 35.0, + 'tags': ['Sport', 'Hiver', 'Nature'], + 'createdBy': 'Pierre Martin', + 'createdAt': DateTime(2024, 9, 10), + 'lastModified': DateTime(2024, 9, 18), + }, + { + 'id': '3', + 'title': 'Formation Premiers Secours PSC1', + 'description': 'Formation complète aux gestes de premiers secours. Certification officielle délivrée.', + 'startDate': DateTime(2024, 11, 5, 9, 0), + 'endDate': DateTime(2024, 11, 5, 17, 0), + 'location': 'Centre de Formation', + 'address': '45 Avenue des Formations, 75015 Paris', + 'type': 'Formation', + 'status': 'Confirmé', + 'maxParticipants': 12, + 'currentParticipants': 10, + 'organizer': 'Commission Formation', + 'priority': 'Haute', + 'isPublic': false, + 'requiresRegistration': true, + 'cost': 60.0, + 'tags': ['Formation', 'Sécurité', 'Certification'], + 'createdBy': 'Sophie Laurent', + 'createdAt': DateTime(2024, 8, 20), + 'lastModified': DateTime(2024, 9, 12), + }, + { + 'id': '4', + 'title': 'Réunion Bureau Mensuelle', + 'description': 'Réunion mensuelle du bureau pour faire le point sur les activités et prendre les décisions courantes.', + 'startDate': DateTime(2024, 10, 28, 19, 30), + 'endDate': DateTime(2024, 10, 28, 21, 30), + 'location': 'Mairie - Salle du Conseil', + 'address': '1 Place de la République, 75001 Paris', + 'type': 'Administratif', + 'status': 'Confirmé', + 'maxParticipants': 15, + 'currentParticipants': 12, + 'organizer': 'Président', + 'priority': 'Moyenne', + 'isPublic': false, + 'requiresRegistration': false, + 'cost': 0.0, + 'tags': ['Bureau', 'Mensuel', 'Décisions'], + 'createdBy': 'Thomas Durand', + 'createdAt': DateTime(2024, 9, 1), + 'lastModified': DateTime(2024, 9, 20), + }, + { + 'id': '5', + 'title': 'Soirée Galette des Rois', + 'description': 'Soirée conviviale avec dégustation de galettes, animations et tirage des rois et reines.', + 'startDate': DateTime(2024, 1, 13, 19, 0), + 'endDate': DateTime(2024, 1, 13, 23, 0), + 'location': 'Salle Communale', + 'address': '8 Rue de la Convivialité, 75012 Paris', + 'type': 'Social', + 'status': 'Terminé', + 'maxParticipants': 50, + 'currentParticipants': 42, + 'organizer': 'Commission Festivités', + 'priority': 'Basse', + 'isPublic': true, + 'requiresRegistration': true, + 'cost': 12.0, + 'tags': ['Social', 'Tradition', 'Convivialité'], + 'createdBy': 'Emma Rousseau', + 'createdAt': DateTime(2023, 12, 1), + 'lastModified': DateTime(2024, 1, 10), + }, + { + 'id': '6', + 'title': 'Conférence Développement Durable', + 'description': 'Conférence sur les enjeux du développement durable avec experts et table ronde.', + 'startDate': DateTime(2024, 11, 20, 18, 30), + 'endDate': DateTime(2024, 11, 20, 21, 0), + 'location': 'Amphithéâtre Universitaire', + 'address': '123 Boulevard de la Connaissance, 75013 Paris', + 'type': 'Culturel', + 'status': 'Confirmé', + 'maxParticipants': 200, + 'currentParticipants': 89, + 'organizer': 'Commission Culture', + 'priority': 'Moyenne', + 'isPublic': true, + 'requiresRegistration': true, + 'cost': 5.0, + 'tags': ['Conférence', 'Environnement', 'Éducation'], + 'createdBy': 'Lucas Bernard', + 'createdAt': DateTime(2024, 9, 5), + 'lastModified': DateTime(2024, 9, 19), + }, + { + 'id': '7', + 'title': 'Atelier Cuisine Collaborative', + 'description': 'Atelier de cuisine collaborative avec préparation d\'un repas complet et dégustation.', + 'startDate': DateTime(2024, 10, 25, 18, 0), + 'endDate': DateTime(2024, 10, 25, 22, 0), + 'location': 'Cuisine Pédagogique', + 'address': '67 Rue des Saveurs, 75011 Paris', + 'type': 'Loisir', + 'status': 'En cours', + 'maxParticipants': 16, + 'currentParticipants': 14, + 'organizer': 'Commission Loisirs', + 'priority': 'Basse', + 'isPublic': true, + 'requiresRegistration': true, + 'cost': 25.0, + 'tags': ['Cuisine', 'Créatif', 'Partage'], + 'createdBy': 'Camille Moreau', + 'createdAt': DateTime(2024, 9, 8), + 'lastModified': DateTime(2024, 10, 20), + }, + { + 'id': '8', + 'title': 'Randonnée Forêt de Fontainebleau', + 'description': 'Randonnée découverte de 12km en forêt de Fontainebleau avec guide naturaliste.', + 'startDate': DateTime(2024, 11, 10, 9, 30), + 'endDate': DateTime(2024, 11, 10, 16, 0), + 'location': 'Forêt de Fontainebleau', + 'address': 'Parking Carrefour de la Croix du Grand Maître, 77300 Fontainebleau', + 'type': 'Sport', + 'status': 'Annulé', + 'maxParticipants': 20, + 'currentParticipants': 8, + 'organizer': 'Commission Nature', + 'priority': 'Basse', + 'isPublic': true, + 'requiresRegistration': true, + 'cost': 8.0, + 'tags': ['Randonnée', 'Nature', 'Découverte'], + 'createdBy': 'Marie Dubois', + 'createdAt': DateTime(2024, 8, 25), + 'lastModified': DateTime(2024, 10, 15), + }, + ]; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 5, vsync: this); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state is! AuthAuthenticated) { + return Container( + color: const Color(0xFFF8F9FA), + child: const Center(child: CircularProgressIndicator()), + ); + } + + final canManageEvents = _canManageEvents(state.effectiveRole); + + return Container( + color: const Color(0xFFF8F9FA), + child: Column( + children: [ + // Métriques et statistiques + _buildEventMetrics(), + + // Barre de recherche et filtres + _buildSearchAndFilters(), + + // Navigation par onglets + _buildTabBar(), + + // Contenu des onglets + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + _buildAllEventsView(), + _buildUpcomingEventsView(), + _buildOngoingEventsView(), + _buildPastEventsView(), + _buildCalendarView(), + ], + ), + ), + ], + ), + ); + }, + ); + } + + /// Vérifie si l'utilisateur peut gérer les événements + bool _canManageEvents(UserRole role) { + return role == UserRole.superAdmin || + role == UserRole.orgAdmin || + role == UserRole.moderator; + } + + + + /// Métriques et statistiques des événements + Widget _buildEventMetrics() { + final now = DateTime.now(); + final upcomingEvents = _allEvents.where((event) => + (event['startDate'] as DateTime).isAfter(now) && + event['status'] != 'Annulé' + ).length; + + final ongoingEvents = _allEvents.where((event) => + event['status'] == 'En cours' + ).length; + + final totalParticipants = _allEvents.fold(0, (sum, event) => + sum + (event['currentParticipants'] as int) + ); + + final averageParticipation = _allEvents.isNotEmpty + ? (_allEvents.fold(0, (sum, event) { + final current = event['currentParticipants'] as int; + final max = event['maxParticipants'] as int; + return sum + (max > 0 ? (current / max) * 100 : 0); + }) / _allEvents.length).round() + : 0; + + return Container( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Métriques Événements', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Color(0xFF6C5CE7), + fontSize: 20, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildSimpleKPICard( + 'À Venir', + upcomingEvents.toString(), + '+2 ce mois', + Icons.event_available, + const Color(0xFF10B981), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _buildSimpleKPICard( + 'En Cours', + ongoingEvents.toString(), + 'Actifs maintenant', + Icons.play_circle_filled, + const Color(0xFF3B82F6), + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _buildSimpleKPICard( + 'Participants', + totalParticipants.toString(), + 'Total inscrits', + Icons.people, + const Color(0xFF8B5CF6), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _buildSimpleKPICard( + 'Taux Moyen', + '$averageParticipation%', + 'Participation', + Icons.trending_up, + const Color(0xFFF59E0B), + ), + ), + ], + ), + ], + ), + ); + } + + /// Carte KPI simple alignée sur le design system + Widget _buildSimpleKPICard(String title, String value, String subtitle, IconData icon, Color color) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Icon(icon, color: color, size: 16), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF6B7280), + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + value, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Color(0xFF374151), + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: const TextStyle( + fontSize: 10, + color: Color(0xFF9CA3AF), + ), + ), + ], + ), + ); + } + + /// Barre de recherche et filtres + Widget _buildSearchAndFilters() { + return Container( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Barre de recherche simple + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: TextField( + controller: _searchController, + onChanged: (value) => setState(() => _searchQuery = value), + decoration: InputDecoration( + hintText: 'Rechercher un événement...', + hintStyle: const TextStyle(color: Color(0xFF9CA3AF), fontSize: 14), + prefixIcon: const Icon(Icons.search, color: Color(0xFF6B7280), size: 20), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + onPressed: () { + _searchController.clear(); + setState(() => _searchQuery = ''); + }, + icon: const Icon(Icons.clear, color: Color(0xFF6B7280), size: 20), + ) + : null, + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ), + ), + ), + + const SizedBox(height: 8), + + // Filtres rapides simplifiés + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _buildSimpleFilter('Tous', _selectedFilter == 'Tous'), + _buildSimpleFilter('À venir', _selectedFilter == 'À venir'), + _buildSimpleFilter('En cours', _selectedFilter == 'En cours'), + _buildSimpleFilter('Terminés', _selectedFilter == 'Terminés'), + ], + ), + ), + ], + ), + ); + } + + /// Filtre simple aligné sur le design system + Widget _buildSimpleFilter(String label, bool isSelected) { + return Container( + margin: const EdgeInsets.only(right: 6), + child: InkWell( + onTap: () { + setState(() { + _selectedFilter = isSelected ? 'Tous' : label; + }); + }, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: isSelected ? const Color(0xFF6C5CE7) : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isSelected ? const Color(0xFF6C5CE7) : const Color(0xFFE5E7EB), + width: 1, + ), + ), + child: Text( + label, + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF6B7280), + fontSize: 12, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + ), + ), + ), + ), + ); + } + + /// Navigation par onglets simplifiée + Widget _buildTabBar() { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: TabBar( + controller: _tabController, + isScrollable: true, + labelColor: const Color(0xFF6C5CE7), + unselectedLabelColor: const Color(0xFF6B7280), + indicatorColor: const Color(0xFF6C5CE7), + indicatorSize: TabBarIndicatorSize.tab, + labelStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + unselectedLabelStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.normal), + tabs: const [ + Tab(text: 'Tous'), + Tab(text: 'À Venir'), + Tab(text: 'En Cours'), + Tab(text: 'Passés'), + Tab(text: 'Calendrier'), + ], + ), + ); + } + + /// Vue de tous les événements + Widget _buildAllEventsView() { + final filteredEvents = _getFilteredEvents(_allEvents); + return _buildEventsListView(filteredEvents, 'all'); + } + + /// Vue des événements à venir + Widget _buildUpcomingEventsView() { + final now = DateTime.now(); + final upcomingEvents = _allEvents.where((event) => + (event['startDate'] as DateTime).isAfter(now) && + event['status'] != 'Annulé' + ).toList(); + final filteredEvents = _getFilteredEvents(upcomingEvents); + return _buildEventsListView(filteredEvents, 'upcoming'); + } + + /// Vue des événements en cours + Widget _buildOngoingEventsView() { + final ongoingEvents = _allEvents.where((event) => + event['status'] == 'En cours' + ).toList(); + final filteredEvents = _getFilteredEvents(ongoingEvents); + return _buildEventsListView(filteredEvents, 'ongoing'); + } + + /// Vue des événements passés + Widget _buildPastEventsView() { + final now = DateTime.now(); + final pastEvents = _allEvents.where((event) => + (event['startDate'] as DateTime).isBefore(now) && + event['status'] == 'Terminé' + ).toList(); + final filteredEvents = _getFilteredEvents(pastEvents); + return _buildEventsListView(filteredEvents, 'past'); + } + + /// Vue calendrier + Widget _buildCalendarView() { + return Container( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: const Color(0xFF6C5CE7).withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: const Column( + children: [ + Icon( + Icons.calendar_month, + size: 48, + color: Color(0xFF6C5CE7), + ), + SizedBox(height: 16), + Text( + 'Vue Calendrier', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Color(0xFF374151), + ), + ), + SizedBox(height: 8), + Text( + 'La vue calendrier interactive sera bientôt disponible', + style: TextStyle( + color: Color(0xFF6B7280), + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ], + ), + ); + } + + /// Filtre les événements selon les critères sélectionnés + List> _getFilteredEvents(List> events) { + var filtered = events.where((event) { + // Filtre par recherche textuelle + if (_searchQuery.isNotEmpty) { + final query = _searchQuery.toLowerCase(); + final title = (event['title'] as String).toLowerCase(); + final description = (event['description'] as String).toLowerCase(); + final location = (event['location'] as String).toLowerCase(); + + if (!title.contains(query) && + !description.contains(query) && + !location.contains(query)) { + return false; + } + } + + // Filtre par catégorie + if (_selectedFilter != 'Tous') { + switch (_selectedFilter) { + case 'À venir': + final now = DateTime.now(); + if (!(event['startDate'] as DateTime).isAfter(now) || event['status'] == 'Annulé') { + return false; + } + break; + case 'En cours': + if (event['status'] != 'En cours') return false; + break; + case 'Terminés': + if (event['status'] != 'Terminé') return false; + break; + case 'Publics': + if (!(event['isPublic'] as bool)) return false; + break; + case 'Privés': + if (event['isPublic'] as bool) return false; + break; + } + } + + return true; + }).toList(); + + // Tri par date par défaut + filtered.sort((a, b) => (a['startDate'] as DateTime).compareTo(b['startDate'] as DateTime)); + + return filtered; + } + + /// Liste des événements avec gestion de l'état vide + Widget _buildEventsListView(List> events, String type) { + if (events.isEmpty) { + return _buildEmptyState(type); + } + + return ListView.builder( + padding: const EdgeInsets.all(12), + itemCount: events.length, + itemBuilder: (context, index) { + final event = events[index]; + return _buildSimpleEventCard(event); + }, + ); + } + + /// Carte d'événement simple alignée sur le design system + Widget _buildSimpleEventCard(Map event) { + final startDate = event['startDate'] as DateTime; + final currentParticipants = event['currentParticipants'] as int; + final maxParticipants = event['maxParticipants'] as int; + + return Container( + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: InkWell( + onTap: () => _showEventDetails(event), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header avec titre et statut + Row( + children: [ + Expanded( + child: Text( + event['title'], + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF374151), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: _getStatusColor(event['status']).withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + event['status'], + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: _getStatusColor(event['status']), + ), + ), + ), + ], + ), + + const SizedBox(height: 8), + + // Informations principales + Row( + children: [ + Icon( + Icons.calendar_today, + size: 14, + color: const Color(0xFF6B7280), + ), + const SizedBox(width: 4), + Text( + _formatDate(startDate), + style: const TextStyle( + fontSize: 12, + color: Color(0xFF6B7280), + ), + ), + const SizedBox(width: 12), + Icon( + Icons.location_on, + size: 14, + color: const Color(0xFF6B7280), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + event['location'], + style: const TextStyle( + fontSize: 12, + color: Color(0xFF6B7280), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + + const SizedBox(height: 8), + + // Footer avec type et participants + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: _getTypeColor(event['type']).withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + event['type'], + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: _getTypeColor(event['type']), + ), + ), + ), + const Spacer(), + Icon( + Icons.people, + size: 14, + color: const Color(0xFF6B7280), + ), + const SizedBox(width: 4), + Text( + '$currentParticipants/$maxParticipants', + style: const TextStyle( + fontSize: 12, + color: Color(0xFF6B7280), + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + /// État vide selon le type d'événements + Widget _buildEmptyState(String type) { + String title; + String subtitle; + IconData icon; + + switch (type) { + case 'upcoming': + title = 'Aucun événement à venir'; + subtitle = 'Aucun événement n\'est programmé prochainement'; + icon = Icons.event_available; + break; + case 'ongoing': + title = 'Aucun événement en cours'; + subtitle = 'Aucun événement n\'est actuellement en cours'; + icon = Icons.play_circle_filled; + break; + case 'past': + title = 'Aucun événement passé'; + subtitle = 'Aucun événement terminé à afficher'; + icon = Icons.event_busy; + break; + default: + title = 'Aucun événement trouvé'; + subtitle = 'Aucun événement ne correspond aux critères sélectionnés'; + icon = Icons.event_note; + } + + return Container( + height: 400, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: const Color(0xFF6C5CE7).withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon( + icon, + size: 48, + color: const Color(0xFF6C5CE7), + ), + ), + const SizedBox(height: 16), + Text( + title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Color(0xFF374151), + ), + ), + const SizedBox(height: 8), + Text( + subtitle, + style: const TextStyle( + color: Color(0xFF6B7280), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: () { + setState(() { + _searchController.clear(); + _searchQuery = ''; + _selectedFilter = 'Tous'; + }); + }, + icon: const Icon(Icons.refresh), + label: const Text('Réinitialiser les filtres'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6C5CE7), + foregroundColor: Colors.white, + ), + ), + ], + ), + ); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // MÉTHODES UTILITAIRES ET HELPERS + // ═══════════════════════════════════════════════════════════════════════════ + + /// Couleur selon le statut de l'événement + Color _getStatusColor(String status) { + switch (status) { + case 'Confirmé': + return const Color(0xFF10B981); + case 'En attente': + return const Color(0xFFF59E0B); + case 'En cours': + return const Color(0xFF3B82F6); + case 'Terminé': + return const Color(0xFF6B7280); + case 'Annulé': + return const Color(0xFFEF4444); + default: + return const Color(0xFF6B7280); + } + } + + /// Couleur selon le type d'événement + Color _getTypeColor(String type) { + switch (type) { + case 'Officiel': + return const Color(0xFF3B82F6); + case 'Loisir': + return const Color(0xFF10B981); + case 'Formation': + return const Color(0xFFF59E0B); + case 'Social': + return const Color(0xFF8B5CF6); + case 'Administratif': + return const Color(0xFFEF4444); + case 'Culturel': + return const Color(0xFF06B6D4); + case 'Sport': + return const Color(0xFF84CC16); + default: + return const Color(0xFF6B7280); + } + } + + + + /// Formatage de la date + String _formatDate(DateTime date) { + final months = [ + 'Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Jun', + 'Jul', 'Aoû', 'Sep', 'Oct', 'Nov', 'Déc' + ]; + return '${date.day} ${months[date.month - 1]} ${date.year}'; + } + + /// Formatage de l'heure + String _formatTime(DateTime time) { + return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}'; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // ACTIONS ET INTERACTIONS + // ═══════════════════════════════════════════════════════════════════════════ + + /// Afficher les détails d'un événement + void _showEventDetails(Map event) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => _buildEventDetailsSheet(event), + ); + } + + /// Sheet de détails d'un événement + Widget _buildEventDetailsSheet(Map event) { + final startDate = event['startDate'] as DateTime; + final endDate = event['endDate'] as DateTime; + final currentParticipants = event['currentParticipants'] as int; + final maxParticipants = event['maxParticipants'] as int; + + return DraggableScrollableSheet( + initialChildSize: 0.8, + minChildSize: 0.5, + maxChildSize: 0.95, + builder: (context, scrollController) { + return Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + children: [ + // Handle + Container( + margin: const EdgeInsets.symmetric(vertical: 8), + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + + // Header + Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _getTypeColor(event['type']).withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + _getEventIcon(event['type']), + color: _getTypeColor(event['type']), + size: 24, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + event['title'], + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Color(0xFF374151), + ), + ), + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: _getStatusColor(event['status']).withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + event['status'], + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: _getStatusColor(event['status']), + ), + ), + ), + ], + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + ), + + // Contenu détaillé + Expanded( + child: SingleChildScrollView( + controller: scrollController, + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Description + _buildDetailSection( + 'Description', + [ + Text( + event['description'], + style: const TextStyle( + fontSize: 16, + color: Color(0xFF374151), + height: 1.5, + ), + ), + ], + ), + + // Informations pratiques + _buildDetailSection( + 'Informations Pratiques', + [ + _buildDetailItem(Icons.calendar_today, 'Date et heure', + '${_formatDate(startDate)} de ${_formatTime(startDate)} à ${_formatTime(endDate)}'), + _buildDetailItem(Icons.location_on, 'Lieu', event['location']), + _buildDetailItem(Icons.place, 'Adresse', event['address']), + _buildDetailItem(Icons.person, 'Organisateur', event['organizer']), + if ((event['cost'] as double) > 0) + _buildDetailItem(Icons.euro, 'Coût', '${event['cost']}€'), + ], + ), + + // Participation + _buildDetailSection( + 'Participation', + [ + _buildDetailItem(Icons.people, 'Participants', + '$currentParticipants / $maxParticipants inscrits'), + _buildDetailItem(Icons.public, 'Visibilité', + (event['isPublic'] as bool) ? 'Événement public' : 'Événement privé'), + _buildDetailItem(Icons.app_registration, 'Inscription', + (event['requiresRegistration'] as bool) ? 'Inscription requise' : 'Inscription libre'), + ], + ), + + // Tags + if ((event['tags'] as List).isNotEmpty) ...[ + _buildDetailSection( + 'Tags', + [ + Wrap( + spacing: 8, + runSpacing: 8, + children: (event['tags'] as List).map((tag) => + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFF6C5CE7).withOpacity(0.1), + borderRadius: BorderRadius.circular(16), + ), + child: Text( + tag, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF6C5CE7), + ), + ), + ), + ).toList(), + ), + ], + ), + ], + + const SizedBox(height: 20), + + // Actions + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () { + Navigator.of(context).pop(); + _showEditEventDialog(event); + }, + icon: const Icon(Icons.edit), + label: const Text('Modifier'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6C5CE7), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton.icon( + onPressed: () { + Navigator.of(context).pop(); + _shareEvent(event); + }, + icon: const Icon(Icons.share), + label: const Text('Partager'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ); + }, + ); + } + + /// Section de détails + Widget _buildDetailSection(String title, List items) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Color(0xFF374151), + ), + ), + const SizedBox(height: 12), + ...items, + const SizedBox(height: 24), + ], + ); + } + + /// Item de détail + Widget _buildDetailItem(IconData icon, String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + icon, + size: 20, + color: const Color(0xFF6B7280), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 14, + color: Color(0xFF6B7280), + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + value, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Color(0xFF374151), + ), + ), + ], + ), + ), + ], + ), + ); + } + + /// Icône selon le type d'événement + IconData _getEventIcon(String type) { + switch (type) { + case 'Officiel': + return Icons.business; + case 'Loisir': + return Icons.sports_esports; + case 'Formation': + return Icons.school; + case 'Social': + return Icons.people; + case 'Administratif': + return Icons.admin_panel_settings; + case 'Culturel': + return Icons.theater_comedy; + case 'Sport': + return Icons.sports; + default: + return Icons.event; + } + } + + /// Créer un nouvel événement + void _showCreateEventDialog(BuildContext context) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Création d\'événement - Fonctionnalité à implémenter'), + backgroundColor: Color(0xFF6C5CE7), + ), + ); + } + + /// Modifier un événement + void _showEditEventDialog(Map event) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Modification de "${event['title']}" - Fonctionnalité à implémenter'), + backgroundColor: const Color(0xFF6C5CE7), + ), + ); + } + + /// Partager un événement + void _shareEvent(Map event) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Partage de "${event['title']}" - Fonctionnalité à implémenter'), + backgroundColor: const Color(0xFF10B981), + ), + ); + } + + /// Importer des événements + void _showEventImportDialog() { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Import d\'événements - Fonctionnalité à implémenter'), + backgroundColor: Color(0xFFF59E0B), + ), + ); + } + + /// Exporter des événements + void _showEventExportDialog() { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Export d\'événements - Fonctionnalité à implémenter'), + backgroundColor: Color(0xFF10B981), + ), + ); + } + + @override + void dispose() { + _searchController.dispose(); + _tabController.dispose(); + super.dispose(); + } +} diff --git a/unionflow-mobile-apps/lib/features/members/data/models/membre_model.dart b/unionflow-mobile-apps/lib/features/members/data/models/membre_model.dart new file mode 100644 index 0000000..214b9fe --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/data/models/membre_model.dart @@ -0,0 +1,69 @@ +/// Modèle de données pour un membre +class MembreModel { + final String id; + final String nom; + final String prenom; + final String email; + final String? telephone; + final String? statut; + final String? role; + final OrganisationModel? organisation; + + const MembreModel({ + required this.id, + required this.nom, + required this.prenom, + required this.email, + this.telephone, + this.statut, + this.role, + this.organisation, + }); + + factory MembreModel.fromJson(Map json) { + return MembreModel( + id: json['id'] as String, + nom: json['nom'] as String, + prenom: json['prenom'] as String, + email: json['email'] as String, + telephone: json['telephone'] as String?, + statut: json['statut'] as String?, + role: json['role'] as String?, + organisation: json['organisation'] != null + ? OrganisationModel.fromJson(json['organisation'] as Map) + : null, + ); + } + + Map toJson() { + return { + 'id': id, + 'nom': nom, + 'prenom': prenom, + 'email': email, + 'telephone': telephone, + 'statut': statut, + 'role': role, + 'organisation': organisation?.toJson(), + }; + } +} + +/// Modèle pour une organisation +class OrganisationModel { + final String? nom; + + const OrganisationModel({this.nom}); + + factory OrganisationModel.fromJson(Map json) { + return OrganisationModel( + nom: json['nom'] as String?, + ); + } + + Map toJson() { + return { + 'nom': nom, + }; + } +} diff --git a/unionflow-mobile-apps/lib/features/members/data/services/membre_search_service.dart b/unionflow-mobile-apps/lib/features/members/data/services/membre_search_service.dart new file mode 100644 index 0000000..61cc7be --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/data/services/membre_search_service.dart @@ -0,0 +1,278 @@ +import 'package:dio/dio.dart'; + +import '../../../../core/models/membre_search_criteria.dart'; +import '../../../../core/models/membre_search_result.dart'; + +/// Service pour la recherche avancée de membres +/// Gère les appels API vers l'endpoint de recherche sophistiquée +class MembreSearchService { + final Dio _dio; + + MembreSearchService(this._dio); + + /// Effectue une recherche avancée de membres + /// + /// [criteria] Critères de recherche + /// [page] Numéro de page (0-based) + /// [size] Taille de la page + /// [sortField] Champ de tri + /// [sortDirection] Direction du tri (asc/desc) + /// + /// Returns [MembreSearchResult] avec les résultats paginés + Future searchMembresAdvanced({ + required MembreSearchCriteria criteria, + int page = 0, + int size = 20, + String sortField = 'nom', + String sortDirection = 'asc', + }) async { + print('Recherche avancée de membres: ${criteria.description}'); + + try { + // Validation des critères + if (!criteria.hasAnyCriteria) { + throw Exception('Au moins un critère de recherche doit être spécifié'); + } + + if (!criteria.isValid) { + throw Exception('Critères de recherche invalides'); + } + + // Préparation des paramètres de requête + final queryParams = { + 'page': page.toString(), + 'size': size.toString(), + 'sort': sortField, + 'direction': sortDirection, + }; + + // Appel API + final response = await _dio.post( + '/api/membres/search/advanced', + data: criteria.toJson(), + queryParameters: queryParams, + ); + + // Parsing de la réponse + final result = MembreSearchResult.fromJson(response.data); + + print('Recherche terminée: ${result.totalElements} résultats en ${result.executionTimeMs}ms'); + + return result; + } on DioException catch (e) { + print('Erreur lors de la recherche avancée: $e'); + rethrow; + } catch (e) { + print('Erreur inattendue lors de la recherche: $e'); + rethrow; + } + } + + /// Recherche rapide par terme général + /// + /// [query] Terme de recherche + /// [page] Numéro de page + /// [size] Taille de la page + /// + /// Returns [MembreSearchResult] avec les résultats + Future quickSearch({ + required String query, + int page = 0, + int size = 20, + }) async { + final criteria = MembreSearchCriteria.quickSearch(query); + return searchMembresAdvanced( + criteria: criteria, + page: page, + size: size, + ); + } + + /// Recherche des membres actifs uniquement + /// + /// [page] Numéro de page + /// [size] Taille de la page + /// + /// Returns [MembreSearchResult] avec les membres actifs + Future searchActiveMembers({ + int page = 0, + int size = 20, + }) async { + return searchMembresAdvanced( + criteria: MembreSearchCriteria.activeMembers, + page: page, + size: size, + ); + } + + /// Recherche des membres du bureau + /// + /// [page] Numéro de page + /// [size] Taille de la page + /// + /// Returns [MembreSearchResult] avec les membres du bureau + Future searchBureauMembers({ + int page = 0, + int size = 20, + }) async { + return searchMembresAdvanced( + criteria: MembreSearchCriteria.bureauMembers, + page: page, + size: size, + ); + } + + /// Recherche par organisation + /// + /// [organisationIds] Liste des IDs d'organisations + /// [page] Numéro de page + /// [size] Taille de la page + /// + /// Returns [MembreSearchResult] avec les membres des organisations + Future searchByOrganisations({ + required List organisationIds, + int page = 0, + int size = 20, + }) async { + final criteria = MembreSearchCriteria( + organisationIds: organisationIds, + statut: 'ACTIF', + ); + return searchMembresAdvanced( + criteria: criteria, + page: page, + size: size, + ); + } + + /// Recherche par tranche d'âge + /// + /// [ageMin] Âge minimum + /// [ageMax] Âge maximum + /// [page] Numéro de page + /// [size] Taille de la page + /// + /// Returns [MembreSearchResult] avec les membres dans la tranche d'âge + Future searchByAgeRange({ + int? ageMin, + int? ageMax, + int page = 0, + int size = 20, + }) async { + final criteria = MembreSearchCriteria( + ageMin: ageMin, + ageMax: ageMax, + statut: 'ACTIF', + ); + return searchMembresAdvanced( + criteria: criteria, + page: page, + size: size, + ); + } + + /// Recherche par région + /// + /// [region] Nom de la région + /// [page] Numéro de page + /// [size] Taille de la page + /// + /// Returns [MembreSearchResult] avec les membres de la région + Future searchByRegion({ + required String region, + int page = 0, + int size = 20, + }) async { + final criteria = MembreSearchCriteria( + region: region, + statut: 'ACTIF', + ); + return searchMembresAdvanced( + criteria: criteria, + page: page, + size: size, + ); + } + + /// Recherche par rôles + /// + /// [roles] Liste des rôles + /// [page] Numéro de page + /// [size] Taille de la page + /// + /// Returns [MembreSearchResult] avec les membres ayant ces rôles + Future searchByRoles({ + required List roles, + int page = 0, + int size = 20, + }) async { + final criteria = MembreSearchCriteria( + roles: roles, + statut: 'ACTIF', + ); + return searchMembresAdvanced( + criteria: criteria, + page: page, + size: size, + ); + } + + /// Recherche par période d'adhésion + /// + /// [dateMin] Date minimum (ISO 8601) + /// [dateMax] Date maximum (ISO 8601) + /// [page] Numéro de page + /// [size] Taille de la page + /// + /// Returns [MembreSearchResult] avec les membres adhérés dans la période + Future searchByAdhesionPeriod({ + String? dateMin, + String? dateMax, + int page = 0, + int size = 20, + }) async { + final criteria = MembreSearchCriteria( + dateAdhesionMin: dateMin, + dateAdhesionMax: dateMax, + statut: 'ACTIF', + ); + return searchMembresAdvanced( + criteria: criteria, + page: page, + size: size, + ); + } + + /// Valide les critères de recherche avant envoi + bool validateCriteria(MembreSearchCriteria criteria) { + if (!criteria.hasAnyCriteria) { + print('Aucun critère de recherche spécifié'); + return false; + } + + if (!criteria.isValid) { + print('Critères de recherche invalides: ${criteria.description}'); + return false; + } + + return true; + } + + /// Estime le temps de recherche basé sur les critères + Duration estimateSearchTime(MembreSearchCriteria criteria) { + // Estimation basique - peut être améliorée avec des métriques réelles + int complexityScore = 0; + + if (criteria.query?.isNotEmpty == true) complexityScore += 2; + if (criteria.organisationIds?.isNotEmpty == true) complexityScore += 1; + if (criteria.roles?.isNotEmpty == true) complexityScore += 1; + if (criteria.ageMin != null || criteria.ageMax != null) complexityScore += 1; + if (criteria.dateAdhesionMin != null || criteria.dateAdhesionMax != null) complexityScore += 1; + + // Temps de base + complexité + final baseTime = 100; // 100ms de base + final additionalTime = complexityScore * 50; // 50ms par critère + + return Duration(milliseconds: baseTime + additionalTime); + } +} diff --git a/unionflow-mobile-apps/lib/features/members/presentation/pages/advanced_search_page.dart b/unionflow-mobile-apps/lib/features/members/presentation/pages/advanced_search_page.dart new file mode 100644 index 0000000..01045c3 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/presentation/pages/advanced_search_page.dart @@ -0,0 +1,579 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../core/models/membre_search_criteria.dart'; +import '../../../../core/models/membre_search_result.dart'; +import '../../../dashboard/presentation/widgets/dashboard_activity_tile.dart'; +import '../widgets/membre_search_form.dart'; +import '../widgets/membre_search_results.dart'; +import '../widgets/search_statistics_card.dart'; + +/// Page de recherche avancée des membres +/// Interface complète pour la recherche sophistiquée avec filtres multiples +class AdvancedSearchPage extends StatefulWidget { + const AdvancedSearchPage({super.key}); + + @override + State createState() => _AdvancedSearchPageState(); +} + +class _AdvancedSearchPageState extends State + with TickerProviderStateMixin { + late TabController _tabController; + MembreSearchCriteria _currentCriteria = MembreSearchCriteria.empty; + MembreSearchResult? _currentResult; + bool _isSearching = false; + String? _errorMessage; + + // Contrôleurs pour les champs de recherche + final _queryController = TextEditingController(); + final _nomController = TextEditingController(); + final _prenomController = TextEditingController(); + final _emailController = TextEditingController(); + final _telephoneController = TextEditingController(); + final _regionController = TextEditingController(); + final _villeController = TextEditingController(); + final _professionController = TextEditingController(); + + // Valeurs pour les filtres + String? _selectedStatut; + List _selectedRoles = []; + List _selectedOrganisations = []; + RangeValues _ageRange = const RangeValues(18, 65); + DateTimeRange? _adhesionDateRange; + bool _includeInactifs = false; + bool _membreBureau = false; + bool _responsable = false; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 3, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + _queryController.dispose(); + _nomController.dispose(); + _prenomController.dispose(); + _emailController.dispose(); + _telephoneController.dispose(); + _regionController.dispose(); + _villeController.dispose(); + _professionController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Recherche Avancée'), + backgroundColor: Theme.of(context).primaryColor, + foregroundColor: Colors.white, + elevation: 0, + bottom: TabBar( + controller: _tabController, + indicatorColor: Colors.white, + labelColor: Colors.white, + unselectedLabelColor: Colors.white70, + tabs: const [ + Tab(icon: Icon(Icons.search), text: 'Critères'), + Tab(icon: Icon(Icons.list), text: 'Résultats'), + Tab(icon: Icon(Icons.analytics), text: 'Statistiques'), + ], + ), + ), + body: TabBarView( + controller: _tabController, + children: [ + _buildSearchCriteriaTab(), + _buildSearchResultsTab(), + _buildStatisticsTab(), + ], + ), + floatingActionButton: _buildSearchFab(), + ); + } + + /// Onglet des critères de recherche + Widget _buildSearchCriteriaTab() { + return SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Recherche rapide + _buildQuickSearchSection(), + const SizedBox(height: 24), + + // Critères détaillés + _buildDetailedCriteriaSection(), + const SizedBox(height: 24), + + // Filtres avancés + _buildAdvancedFiltersSection(), + const SizedBox(height: 24), + + // Boutons d'action + _buildActionButtons(), + ], + ), + ); + } + + /// Section de recherche rapide + Widget _buildQuickSearchSection() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.flash_on, color: Theme.of(context).primaryColor), + const SizedBox(width: 8), + Text( + 'Recherche Rapide', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 16), + TextField( + controller: _queryController, + decoration: const InputDecoration( + labelText: 'Rechercher un membre', + hintText: 'Nom, prénom ou email...', + prefixIcon: Icon(Icons.search), + border: OutlineInputBorder(), + ), + onSubmitted: (_) => _performQuickSearch(), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + children: [ + _buildQuickFilterChip('Membres actifs', () { + _selectedStatut = 'ACTIF'; + _includeInactifs = false; + }), + _buildQuickFilterChip('Membres bureau', () { + _membreBureau = true; + _selectedStatut = 'ACTIF'; + }), + _buildQuickFilterChip('Responsables', () { + _responsable = true; + _selectedStatut = 'ACTIF'; + }), + ], + ), + ], + ), + ), + ); + } + + /// Section des critères détaillés + Widget _buildDetailedCriteriaSection() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.tune, color: Theme.of(context).primaryColor), + const SizedBox(width: 8), + Text( + 'Critères Détaillés', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextField( + controller: _nomController, + decoration: const InputDecoration( + labelText: 'Nom', + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _prenomController, + decoration: const InputDecoration( + labelText: 'Prénom', + border: OutlineInputBorder(), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + TextField( + controller: _emailController, + decoration: const InputDecoration( + labelText: 'Email', + hintText: 'exemple@unionflow.com', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextField( + controller: _telephoneController, + decoration: const InputDecoration( + labelText: 'Téléphone', + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: DropdownButtonFormField( + value: _selectedStatut, + decoration: const InputDecoration( + labelText: 'Statut', + border: OutlineInputBorder(), + ), + items: const [ + DropdownMenuItem(value: null, child: Text('Tous')), + DropdownMenuItem(value: 'ACTIF', child: Text('Actif')), + DropdownMenuItem(value: 'INACTIF', child: Text('Inactif')), + DropdownMenuItem(value: 'SUSPENDU', child: Text('Suspendu')), + DropdownMenuItem(value: 'RADIE', child: Text('Radié')), + ], + onChanged: (value) => setState(() => _selectedStatut = value), + ), + ), + ], + ), + ], + ), + ), + ); + } + + /// Section des filtres avancés + Widget _buildAdvancedFiltersSection() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.filter_alt, color: Theme.of(context).primaryColor), + const SizedBox(width: 8), + Text( + 'Filtres Avancés', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 16), + + // Tranche d'âge + Text('Tranche d\'âge: ${_ageRange.start.round()}-${_ageRange.end.round()} ans'), + RangeSlider( + values: _ageRange, + min: 18, + max: 80, + divisions: 62, + labels: RangeLabels( + '${_ageRange.start.round()}', + '${_ageRange.end.round()}', + ), + onChanged: (values) => setState(() => _ageRange = values), + ), + const SizedBox(height: 16), + + // Options booléennes + CheckboxListTile( + title: const Text('Inclure les membres inactifs'), + value: _includeInactifs, + onChanged: (value) => setState(() => _includeInactifs = value ?? false), + ), + CheckboxListTile( + title: const Text('Membres du bureau uniquement'), + value: _membreBureau, + onChanged: (value) => setState(() => _membreBureau = value ?? false), + ), + CheckboxListTile( + title: const Text('Responsables uniquement'), + value: _responsable, + onChanged: (value) => setState(() => _responsable = value ?? false), + ), + ], + ), + ), + ); + } + + /// Boutons d'action + Widget _buildActionButtons() { + return Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: _clearCriteria, + icon: const Icon(Icons.clear), + label: const Text('Effacer'), + ), + ), + const SizedBox(width: 16), + Expanded( + flex: 2, + child: ElevatedButton.icon( + onPressed: _isSearching ? null : _performAdvancedSearch, + icon: _isSearching + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.search), + label: Text(_isSearching ? 'Recherche...' : 'Rechercher'), + ), + ), + ], + ); + } + + /// Onglet des résultats + Widget _buildSearchResultsTab() { + if (_currentResult == null) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.search, size: 64, color: Colors.grey), + SizedBox(height: 16), + Text( + 'Aucune recherche effectuée', + style: TextStyle(fontSize: 18, color: Colors.grey), + ), + SizedBox(height: 8), + Text( + 'Utilisez l\'onglet Critères pour lancer une recherche', + style: TextStyle(color: Colors.grey), + ), + ], + ), + ); + } + + if (_errorMessage != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error, size: 64, color: Colors.red), + const SizedBox(height: 16), + Text( + 'Erreur de recherche', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + _errorMessage!, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.red), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => setState(() => _errorMessage = null), + child: const Text('Réessayer'), + ), + ], + ), + ); + } + + return MembreSearchResults(result: _currentResult!); + } + + /// Onglet des statistiques + Widget _buildStatisticsTab() { + if (_currentResult?.statistics == null) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.analytics, size: 64, color: Colors.grey), + SizedBox(height: 16), + Text( + 'Aucune statistique disponible', + style: TextStyle(fontSize: 18, color: Colors.grey), + ), + SizedBox(height: 8), + Text( + 'Effectuez une recherche pour voir les statistiques', + style: TextStyle(color: Colors.grey), + ), + ], + ), + ); + } + + return SearchStatisticsCard(statistics: _currentResult!.statistics!); + } + + /// FAB de recherche + Widget _buildSearchFab() { + return FloatingActionButton.extended( + onPressed: _isSearching ? null : _performAdvancedSearch, + icon: _isSearching + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : const Icon(Icons.search), + label: Text(_isSearching ? 'Recherche...' : 'Rechercher'), + ); + } + + /// Chip de filtre rapide + Widget _buildQuickFilterChip(String label, VoidCallback onTap) { + return ActionChip( + label: Text(label), + onPressed: onTap, + backgroundColor: Theme.of(context).primaryColor.withOpacity(0.1), + labelStyle: TextStyle(color: Theme.of(context).primaryColor), + ); + } + + /// Effectue une recherche rapide + void _performQuickSearch() { + if (_queryController.text.trim().isEmpty) return; + + final criteria = MembreSearchCriteria.quickSearch(_queryController.text.trim()); + _performSearch(criteria); + } + + /// Effectue une recherche avancée + void _performAdvancedSearch() { + final criteria = _buildSearchCriteria(); + _performSearch(criteria); + } + + /// Construit les critères de recherche à partir des champs + MembreSearchCriteria _buildSearchCriteria() { + return MembreSearchCriteria( + query: _queryController.text.trim().isEmpty ? null : _queryController.text.trim(), + nom: _nomController.text.trim().isEmpty ? null : _nomController.text.trim(), + prenom: _prenomController.text.trim().isEmpty ? null : _prenomController.text.trim(), + email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(), + telephone: _telephoneController.text.trim().isEmpty ? null : _telephoneController.text.trim(), + statut: _selectedStatut, + ageMin: _ageRange.start.round(), + ageMax: _ageRange.end.round(), + region: _regionController.text.trim().isEmpty ? null : _regionController.text.trim(), + ville: _villeController.text.trim().isEmpty ? null : _villeController.text.trim(), + profession: _professionController.text.trim().isEmpty ? null : _professionController.text.trim(), + organisationIds: _selectedOrganisations.isEmpty ? null : _selectedOrganisations, + roles: _selectedRoles.isEmpty ? null : _selectedRoles, + membreBureau: _membreBureau ? true : null, + responsable: _responsable ? true : null, + includeInactifs: _includeInactifs, + ); + } + + /// Effectue la recherche + void _performSearch(MembreSearchCriteria criteria) async { + if (!criteria.hasAnyCriteria) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Veuillez spécifier au moins un critère de recherche'), + backgroundColor: Colors.orange, + ), + ); + return; + } + + setState(() { + _isSearching = true; + _errorMessage = null; + _currentCriteria = criteria; + }); + + try { + // TODO: Appeler le service de recherche + // final result = await _searchService.searchMembresAdvanced(criteria: criteria); + + // Simulation pour l'instant + await Future.delayed(const Duration(seconds: 2)); + final result = MembreSearchResult.empty(criteria); + + setState(() { + _currentResult = result; + _isSearching = false; + }); + + // Basculer vers l'onglet des résultats + _tabController.animateTo(1); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(result.resultDescription), + backgroundColor: Colors.green, + ), + ); + } catch (e) { + setState(() { + _errorMessage = e.toString(); + _isSearching = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur de recherche: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + + /// Efface tous les critères + void _clearCriteria() { + setState(() { + _queryController.clear(); + _nomController.clear(); + _prenomController.clear(); + _emailController.clear(); + _telephoneController.clear(); + _regionController.clear(); + _villeController.clear(); + _professionController.clear(); + _selectedStatut = null; + _selectedRoles.clear(); + _selectedOrganisations.clear(); + _ageRange = const RangeValues(18, 65); + _adhesionDateRange = null; + _includeInactifs = false; + _membreBureau = false; + _responsable = false; + _currentResult = null; + _errorMessage = null; + }); + } +} diff --git a/unionflow-mobile-apps/lib/features/members/presentation/pages/members_page.dart b/unionflow-mobile-apps/lib/features/members/presentation/pages/members_page.dart new file mode 100644 index 0000000..e31b123 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/presentation/pages/members_page.dart @@ -0,0 +1,2039 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../core/auth/bloc/auth_bloc.dart'; +import '../../../../core/auth/models/user_role.dart'; +import '../../../../core/design_system/tokens/tokens.dart'; + +/// Page de gestion des membres - Interface sophistiquée et exhaustive +/// +/// Cette page offre une interface complète pour la gestion des membres +/// avec des fonctionnalités avancées de recherche, filtrage, statistiques +/// et actions de gestion basées sur les permissions utilisateur. +class MembersPage extends StatefulWidget { + const MembersPage({super.key}); + + @override + State createState() => _MembersPageState(); +} + +class _MembersPageState extends State with TickerProviderStateMixin { + // Controllers et état + final TextEditingController _searchController = TextEditingController(); + late TabController _tabController; + + // État de l'interface + String _searchQuery = ''; + String _selectedFilter = 'Tous'; + String _selectedSort = 'Nom'; + bool _isGridView = false; + bool _showAdvancedFilters = false; + + // Filtres avancés + List _selectedRoles = []; + List _selectedStatuses = ['Actif', 'Inactif', 'Suspendu', 'En attente']; + DateTimeRange? _dateRange; + + // Données de démonstration enrichies + final List> _allMembers = [ + { + 'id': '1', + 'name': 'Marie Dubois', + 'email': 'marie.dubois@unionflow.com', + 'role': 'Membre Actif', + 'status': 'Actif', + 'joinDate': DateTime(2023, 1, 15), + 'lastActivity': DateTime(2024, 9, 19), + 'avatar': null, + 'phone': '+33 6 12 34 56 78', + 'department': 'Ressources Humaines', + 'location': 'Paris, France', + 'permissions': 15, + 'contributionScore': 85, + 'eventsAttended': 12, + 'projectsInvolved': 5, + }, + { + 'id': '2', + 'name': 'Pierre Martin', + 'email': 'pierre.martin@unionflow.com', + 'role': 'Modérateur', + 'status': 'Actif', + 'joinDate': DateTime(2022, 11, 20), + 'lastActivity': DateTime(2024, 9, 20), + 'avatar': null, + 'phone': '+33 6 98 76 54 32', + 'department': 'IT & Développement', + 'location': 'Lyon, France', + 'permissions': 25, + 'contributionScore': 92, + 'eventsAttended': 18, + 'projectsInvolved': 8, + }, + { + 'id': '3', + 'name': 'Sophie Laurent', + 'email': 'sophie.laurent@unionflow.com', + 'role': 'Membre Simple', + 'status': 'Inactif', + 'joinDate': DateTime(2024, 2, 10), + 'lastActivity': DateTime(2024, 8, 15), + 'avatar': null, + 'phone': '+33 6 45 67 89 01', + 'department': 'Marketing', + 'location': 'Marseille, France', + 'permissions': 8, + 'contributionScore': 45, + 'eventsAttended': 3, + 'projectsInvolved': 1, + }, + { + 'id': '4', + 'name': 'Thomas Durand', + 'email': 'thomas.durand@unionflow.com', + 'role': 'Administrateur Org', + 'status': 'Actif', + 'joinDate': DateTime(2021, 6, 5), + 'lastActivity': DateTime(2024, 9, 20), + 'avatar': null, + 'phone': '+33 6 23 45 67 89', + 'department': 'Administration', + 'location': 'Toulouse, France', + 'permissions': 35, + 'contributionScore': 98, + 'eventsAttended': 25, + 'projectsInvolved': 12, + }, + { + 'id': '5', + 'name': 'Emma Rousseau', + 'email': 'emma.rousseau@unionflow.com', + 'role': 'Gestionnaire RH', + 'status': 'Actif', + 'joinDate': DateTime(2023, 3, 12), + 'lastActivity': DateTime(2024, 9, 19), + 'avatar': null, + 'phone': '+33 6 34 56 78 90', + 'department': 'Ressources Humaines', + 'location': 'Nantes, France', + 'permissions': 28, + 'contributionScore': 88, + 'eventsAttended': 15, + 'projectsInvolved': 7, + }, + { + 'id': '6', + 'name': 'Lucas Bernard', + 'email': 'lucas.bernard@unionflow.com', + 'role': 'Consultant', + 'status': 'En attente', + 'joinDate': DateTime(2024, 9, 1), + 'lastActivity': DateTime(2024, 9, 18), + 'avatar': null, + 'phone': '+33 6 56 78 90 12', + 'department': 'Consulting', + 'location': 'Bordeaux, France', + 'permissions': 12, + 'contributionScore': 0, + 'eventsAttended': 0, + 'projectsInvolved': 0, + }, + { + 'id': '7', + 'name': 'Camille Moreau', + 'email': 'camille.moreau@unionflow.com', + 'role': 'Membre Actif', + 'status': 'Suspendu', + 'joinDate': DateTime(2022, 8, 30), + 'lastActivity': DateTime(2024, 7, 10), + 'avatar': null, + 'phone': '+33 6 67 89 01 23', + 'department': 'Ventes', + 'location': 'Lille, France', + 'permissions': 15, + 'contributionScore': 65, + 'eventsAttended': 8, + 'projectsInvolved': 3, + }, + ]; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 4, vsync: this); + } + + @override + void dispose() { + _searchController.dispose(); + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state is! AuthAuthenticated) { + return Container( + color: const Color(0xFFF8F9FA), + child: const Center(child: CircularProgressIndicator()), + ); + } + + return Container( + color: const Color(0xFFF8F9FA), + child: _buildMembersContent(state), + ); + }, + ); + } + + /// Contenu principal de la page membres + Widget _buildMembersContent(AuthAuthenticated state) { + return SingleChildScrollView( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header avec titre et actions + _buildMembersHeader(state), + const SizedBox(height: 16), + + // Statistiques et métriques + _buildMembersMetrics(), + const SizedBox(height: 16), + + // Barre de recherche et filtres + _buildSearchAndFilters(), + const SizedBox(height: 16), + + // Onglets de catégories + _buildCategoryTabs(), + const SizedBox(height: 16), + + // Liste/Grille des membres + _buildMembersDisplay(), + ], + ), + ); + } + + /// Header avec titre et actions principales + Widget _buildMembersHeader(AuthAuthenticated state) { + final canManageMembers = _canManageMembers(state.effectiveRole); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF6C5CE7), Color(0xFF5A4FCF)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: const Color(0xFF6C5CE7).withOpacity(0.3), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.people, + color: Colors.white, + size: 24, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Gestion des Membres', + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + Text( + 'Interface complète de gestion des membres', + style: TextStyle( + color: Colors.white.withOpacity(0.8), + fontSize: 14, + ), + ), + ], + ), + ), + if (canManageMembers) ...[ + IconButton( + onPressed: () => _showBulkActions(), + icon: const Icon(Icons.checklist, color: Colors.white), + tooltip: 'Actions groupées', + ), + IconButton( + onPressed: () => _exportMembers(), + icon: const Icon(Icons.download, color: Colors.white), + tooltip: 'Exporter', + ), + IconButton( + onPressed: () => _showAddMemberDialog(), + icon: const Icon(Icons.person_add, color: Colors.white), + tooltip: 'Ajouter un membre', + ), + ], + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Icon( + Icons.access_time, + color: Colors.white.withOpacity(0.8), + size: 16, + ), + const SizedBox(width: 4), + Text( + 'Dernière mise à jour: ${_formatDateTime(DateTime.now())}', + style: TextStyle( + color: Colors.white.withOpacity(0.8), + fontSize: 12, + ), + ), + ], + ), + ], + ), + ); + } + + /// Section des métriques et statistiques + Widget _buildMembersMetrics() { + final totalMembers = _allMembers.length; + final activeMembers = _allMembers.where((m) => m['status'] == 'Actif').length; + final newThisMonth = _allMembers.where((m) { + final joinDate = m['joinDate'] as DateTime; + final now = DateTime.now(); + return joinDate.year == now.year && joinDate.month == now.month; + }).length; + final avgContribution = _allMembers.map((m) => m['contributionScore'] as int).reduce((a, b) => a + b) / totalMembers; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Métriques & Statistiques', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Color(0xFF6C5CE7), + fontSize: 18, + ), + ), + const SizedBox(height: 12), + + // Première ligne de métriques + Row( + children: [ + Expanded( + child: _buildMetricCard( + 'Total Membres', + totalMembers.toString(), + '+${newThisMonth} ce mois', + Icons.people, + const Color(0xFF6C5CE7), + trend: newThisMonth > 0 ? 'up' : 'stable', + ), + ), + const SizedBox(width: 8), + Expanded( + child: _buildMetricCard( + 'Membres Actifs', + activeMembers.toString(), + '${((activeMembers / totalMembers) * 100).toStringAsFixed(1)}%', + Icons.check_circle, + const Color(0xFF00B894), + trend: 'up', + ), + ), + ], + ), + const SizedBox(height: 8), + + // Deuxième ligne de métriques + Row( + children: [ + Expanded( + child: _buildMetricCard( + 'Score Moyen', + avgContribution.toStringAsFixed(0), + 'Contribution', + Icons.trending_up, + const Color(0xFF0984E3), + trend: 'up', + ), + ), + const SizedBox(width: 8), + Expanded( + child: _buildMetricCard( + 'Nouveaux', + newThisMonth.toString(), + 'Ce mois', + Icons.new_releases, + const Color(0xFFF39C12), + trend: newThisMonth > 0 ? 'up' : 'stable', + ), + ), + ], + ), + ], + ); + } + + /// Carte de métrique avec design sophistiqué + Widget _buildMetricCard( + String title, + String value, + String subtitle, + IconData icon, + Color color, { + String trend = 'stable', + }) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, color: color, size: 20), + ), + const Spacer(), + if (trend == 'up') + Icon(Icons.trending_up, color: Colors.green, size: 16) + else if (trend == 'down') + Icon(Icons.trending_down, color: Colors.red, size: 16) + else + Icon(Icons.trending_flat, color: Colors.grey, size: 16), + ], + ), + const SizedBox(height: 12), + Text( + value, + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: color, + ), + ), + const SizedBox(height: 4), + Text( + title, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF6B7280), + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: const TextStyle( + fontSize: 10, + color: Color(0xFF9CA3AF), + ), + ), + ], + ), + ); + } + + /// Barre de recherche et filtres avancés + Widget _buildSearchAndFilters() { + return Column( + children: [ + // Barre de recherche principale + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + const Icon(Icons.search, color: Color(0xFF6B7280)), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _searchController, + decoration: const InputDecoration( + hintText: 'Rechercher par nom, email, département...', + border: InputBorder.none, + hintStyle: TextStyle(color: Color(0xFF9CA3AF)), + ), + onChanged: (value) { + setState(() { + _searchQuery = value; + }); + }, + ), + ), + if (_searchQuery.isNotEmpty) + IconButton( + onPressed: () { + _searchController.clear(); + setState(() { + _searchQuery = ''; + }); + }, + icon: const Icon(Icons.clear, color: Color(0xFF6B7280)), + ), + const SizedBox(width: 8), + Container( + height: 32, + width: 1, + color: const Color(0xFFE5E7EB), + ), + const SizedBox(width: 8), + IconButton( + onPressed: () { + setState(() { + _showAdvancedFilters = !_showAdvancedFilters; + }); + }, + icon: Icon( + _showAdvancedFilters ? Icons.filter_list_off : Icons.filter_list, + color: _showAdvancedFilters ? const Color(0xFF6C5CE7) : const Color(0xFF6B7280), + ), + tooltip: 'Filtres avancés', + ), + IconButton( + onPressed: () { + setState(() { + _isGridView = !_isGridView; + }); + }, + icon: Icon( + _isGridView ? Icons.view_list : Icons.grid_view, + color: const Color(0xFF6B7280), + ), + tooltip: _isGridView ? 'Vue liste' : 'Vue grille', + ), + ], + ), + ), + + // Filtres avancés (conditionnels) + if (_showAdvancedFilters) ...[ + const SizedBox(height: 12), + _buildAdvancedFilters(), + ], + + // Barre de filtres rapides + const SizedBox(height: 12), + _buildQuickFilters(), + ], + ); + } + + /// Filtres rapides horizontaux + Widget _buildQuickFilters() { + final filters = ['Tous', 'Actifs', 'Inactifs', 'Nouveaux', 'Suspendus']; + + return SizedBox( + height: 40, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: filters.length, + itemBuilder: (context, index) { + final filter = filters[index]; + final isSelected = _selectedFilter == filter; + + return Padding( + padding: EdgeInsets.only( + left: index == 0 ? 0 : 8, + right: index == filters.length - 1 ? 0 : 0, + ), + child: FilterChip( + label: Text(filter), + selected: isSelected, + onSelected: (selected) { + setState(() { + _selectedFilter = selected ? filter : 'Tous'; + }); + }, + backgroundColor: Colors.white, + selectedColor: const Color(0xFF6C5CE7).withOpacity(0.1), + checkmarkColor: const Color(0xFF6C5CE7), + labelStyle: TextStyle( + color: isSelected ? const Color(0xFF6C5CE7) : const Color(0xFF6B7280), + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + ), + side: BorderSide( + color: isSelected ? const Color(0xFF6C5CE7) : const Color(0xFFE5E7EB), + ), + ), + ); + }, + ), + ); + } + + /// Filtres avancés extensibles + Widget _buildAdvancedFilters() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE5E7EB)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Filtres Avancés', + style: TextStyle( + fontWeight: FontWeight.w600, + color: Color(0xFF374151), + ), + ), + const SizedBox(height: 12), + + // Filtre par rôles + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + 'Membre Actif', + 'Modérateur', + 'Administrateur Org', + 'Gestionnaire RH', + 'Consultant', + 'Membre Simple', + ].map((role) { + final isSelected = _selectedRoles.contains(role); + return FilterChip( + label: Text(role), + selected: isSelected, + onSelected: (selected) { + setState(() { + if (selected) { + _selectedRoles.add(role); + } else { + _selectedRoles.remove(role); + } + }); + }, + backgroundColor: Colors.grey[50], + selectedColor: const Color(0xFF6C5CE7).withOpacity(0.1), + checkmarkColor: const Color(0xFF6C5CE7), + labelStyle: TextStyle( + color: isSelected ? const Color(0xFF6C5CE7) : const Color(0xFF6B7280), + fontSize: 12, + ), + side: BorderSide( + color: isSelected ? const Color(0xFF6C5CE7) : const Color(0xFFE5E7EB), + ), + ); + }).toList(), + ), + + const SizedBox(height: 12), + + // Actions de filtres + Row( + children: [ + TextButton.icon( + onPressed: () { + setState(() { + _selectedRoles.clear(); + _selectedStatuses = ['Actif', 'Inactif', 'Suspendu', 'En attente']; + _dateRange = null; + }); + }, + icon: const Icon(Icons.clear_all, size: 16), + label: const Text('Réinitialiser'), + style: TextButton.styleFrom( + foregroundColor: const Color(0xFF6B7280), + ), + ), + const Spacer(), + Text( + '${_getFilteredMembers().length} résultat(s)', + style: const TextStyle( + color: Color(0xFF6B7280), + fontSize: 12, + ), + ), + ], + ), + ], + ), + ); + } + + /// Onglets de catégories + Widget _buildCategoryTabs() { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: TabBar( + controller: _tabController, + tabs: const [ + Tab(text: 'Tous', icon: Icon(Icons.people, size: 18)), + Tab(text: 'Actifs', icon: Icon(Icons.check_circle, size: 18)), + Tab(text: 'Équipes', icon: Icon(Icons.groups, size: 18)), + Tab(text: 'Analytics', icon: Icon(Icons.analytics, size: 18)), + ], + labelColor: const Color(0xFF6C5CE7), + unselectedLabelColor: const Color(0xFF6B7280), + indicatorColor: const Color(0xFF6C5CE7), + indicatorWeight: 3, + labelStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + unselectedLabelStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.normal, + ), + ), + ); + } + + /// Affichage principal des membres (liste ou grille) + Widget _buildMembersDisplay() { + final filteredMembers = _getFilteredMembers(); + + if (filteredMembers.isEmpty) { + return _buildEmptyState(); + } + + return SizedBox( + height: 600, // Hauteur fixe pour éviter les problèmes de layout + child: TabBarView( + controller: _tabController, + children: [ + // Onglet "Tous" + _buildMembersList(filteredMembers), + // Onglet "Actifs" + _buildMembersList(filteredMembers.where((m) => m['status'] == 'Actif').toList()), + // Onglet "Équipes" + _buildTeamsView(filteredMembers), + // Onglet "Analytics" + _buildAnalyticsView(filteredMembers), + ], + ), + ); + } + + /// Liste des membres avec design sophistiqué + Widget _buildMembersList(List> members) { + if (_isGridView) { + return _buildMembersGrid(members); + } + + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: members.length, + itemBuilder: (context, index) { + final member = members[index]; + return _buildMemberCard(member); + }, + ); + } + + /// Grille des membres + Widget _buildMembersGrid(List> members) { + return GridView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 0.8, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemCount: members.length, + itemBuilder: (context, index) { + final member = members[index]; + return _buildMemberGridCard(member); + }, + ); + } + + /// Carte de membre sophistiquée pour la vue liste + Widget _buildMemberCard(Map member) { + final isActive = member['status'] == 'Actif'; + final joinDate = member['joinDate'] as DateTime; + final lastActivity = member['lastActivity'] as DateTime; + final contributionScore = member['contributionScore'] as int; + + return Container( + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: InkWell( + onTap: () => _showMemberDetails(member), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + // Avatar avec indicateur de statut + Stack( + children: [ + CircleAvatar( + radius: 24, + backgroundColor: _getStatusColor(member['status']).withOpacity(0.1), + child: Text( + member['name'][0].toUpperCase(), + style: TextStyle( + color: _getStatusColor(member['status']), + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), + ), + Positioned( + bottom: 0, + right: 0, + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: _getStatusColor(member['status']), + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + ), + ), + ], + ), + const SizedBox(width: 12), + + // Informations principales + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + member['name'], + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16, + color: Color(0xFF1F2937), + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: _getRoleColor(member['role']).withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + member['role'], + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: _getRoleColor(member['role']), + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + member['email'], + style: const TextStyle( + color: Color(0xFF6B7280), + fontSize: 14, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + Icon( + Icons.business, + size: 12, + color: Colors.grey[500], + ), + const SizedBox(width: 4), + Text( + member['department'], + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + const SizedBox(width: 12), + Icon( + Icons.location_on, + size: 12, + color: Colors.grey[500], + ), + const SizedBox(width: 4), + Expanded( + child: Text( + member['location'], + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + // Score de contribution + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: _getScoreColor(contributionScore).withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.star, + size: 10, + color: _getScoreColor(contributionScore), + ), + const SizedBox(width: 2), + Text( + contributionScore.toString(), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: _getScoreColor(contributionScore), + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Text( + 'Rejoint ${_formatDate(joinDate)}', + style: const TextStyle( + fontSize: 10, + color: Color(0xFF9CA3AF), + ), + ), + const Spacer(), + Text( + 'Actif ${_formatRelativeTime(lastActivity)}', + style: const TextStyle( + fontSize: 10, + color: Color(0xFF9CA3AF), + ), + ), + ], + ), + ], + ), + ), + + // Actions + PopupMenuButton( + onSelected: (value) => _handleMemberAction(value, member), + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'view', + child: Row( + children: [ + Icon(Icons.visibility, size: 16), + SizedBox(width: 8), + Text('Voir le profil'), + ], + ), + ), + const PopupMenuItem( + value: 'edit', + child: Row( + children: [ + Icon(Icons.edit, size: 16), + SizedBox(width: 8), + Text('Modifier'), + ], + ), + ), + const PopupMenuItem( + value: 'message', + child: Row( + children: [ + Icon(Icons.message, size: 16), + SizedBox(width: 8), + Text('Envoyer un message'), + ], + ), + ), + const PopupMenuItem( + value: 'delete', + child: Row( + children: [ + Icon(Icons.delete, size: 16, color: Colors.red), + SizedBox(width: 8), + Text('Supprimer', style: TextStyle(color: Colors.red)), + ], + ), + ), + ], + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.grey[50], + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.more_vert, + size: 16, + color: Color(0xFF6B7280), + ), + ), + ), + ], + ), + ), + ), + ); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // MÉTHODES UTILITAIRES ET HELPERS + // ═══════════════════════════════════════════════════════════════════════════ + + /// Filtre les membres selon les critères sélectionnés + List> _getFilteredMembers() { + return _allMembers.where((member) { + // Filtre par recherche textuelle + if (_searchQuery.isNotEmpty) { + final query = _searchQuery.toLowerCase(); + final name = member['name'].toString().toLowerCase(); + final email = member['email'].toString().toLowerCase(); + final department = member['department'].toString().toLowerCase(); + + if (!name.contains(query) && + !email.contains(query) && + !department.contains(query)) { + return false; + } + } + + // Filtre par statut rapide + if (_selectedFilter != 'Tous') { + switch (_selectedFilter) { + case 'Actifs': + if (member['status'] != 'Actif') return false; + break; + case 'Inactifs': + if (member['status'] != 'Inactif') return false; + break; + case 'Nouveaux': + final joinDate = member['joinDate'] as DateTime; + final now = DateTime.now(); + final isNewThisMonth = joinDate.year == now.year && joinDate.month == now.month; + if (!isNewThisMonth) return false; + break; + case 'Suspendus': + if (member['status'] != 'Suspendu') return false; + break; + } + } + + // Filtre par rôles sélectionnés + if (_selectedRoles.isNotEmpty && !_selectedRoles.contains(member['role'])) { + return false; + } + + return true; + }).toList(); + } + + /// Obtient la couleur selon le statut + Color _getStatusColor(String status) { + switch (status) { + case 'Actif': + return const Color(0xFF10B981); + case 'Inactif': + return const Color(0xFF6B7280); + case 'Suspendu': + return const Color(0xFFDC2626); + case 'En attente': + return const Color(0xFFF59E0B); + default: + return const Color(0xFF6B7280); + } + } + + /// Obtient la couleur selon le rôle + Color _getRoleColor(String role) { + switch (role) { + case 'Super Administrateur': + return const Color(0xFF7C3AED); + case 'Administrateur Org': + return const Color(0xFF6366F1); + case 'Gestionnaire RH': + return const Color(0xFF0EA5E9); + case 'Modérateur': + return const Color(0xFF059669); + case 'Membre Actif': + return const Color(0xFF6C5CE7); + case 'Consultant': + return const Color(0xFFF59E0B); + case 'Membre Simple': + return const Color(0xFF6B7280); + default: + return const Color(0xFF6B7280); + } + } + + /// Obtient la couleur selon le score de contribution + Color _getScoreColor(int score) { + if (score >= 90) return const Color(0xFF10B981); + if (score >= 70) return const Color(0xFF0EA5E9); + if (score >= 50) return const Color(0xFFF59E0B); + return const Color(0xFFDC2626); + } + + /// Formate une date + String _formatDate(DateTime date) { + final months = [ + 'jan', 'fév', 'mar', 'avr', 'mai', 'jun', + 'jul', 'aoû', 'sep', 'oct', 'nov', 'déc' + ]; + return '${date.day} ${months[date.month - 1]} ${date.year}'; + } + + /// Formate un temps relatif + String _formatRelativeTime(DateTime date) { + final now = DateTime.now(); + final difference = now.difference(date); + + if (difference.inDays > 30) { + return 'il y a ${(difference.inDays / 30).floor()} mois'; + } else if (difference.inDays > 0) { + return 'il y a ${difference.inDays} jour${difference.inDays > 1 ? 's' : ''}'; + } else if (difference.inHours > 0) { + return 'il y a ${difference.inHours}h'; + } else { + return 'à l\'instant'; + } + } + + /// Formate une date et heure + String _formatDateTime(DateTime dateTime) { + return '${_formatDate(dateTime)} à ${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}'; + } + + /// Vérifie si l'utilisateur peut gérer les membres + bool _canManageMembers(UserRole role) { + return [ + UserRole.superAdmin, + UserRole.orgAdmin, + UserRole.moderator, + ].contains(role); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // MÉTHODES D'ACTIONS ET DIALOGS + // ═══════════════════════════════════════════════════════════════════════════ + + /// Affiche le dialog d'ajout de membre + void _showAddMemberDialog() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Ajouter un membre'), + content: const Text('Fonctionnalité d\'ajout de membre à implémenter'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Fermer'), + ), + ], + ), + ); + } + + /// Affiche les actions groupées + void _showBulkActions() { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Actions groupées à implémenter'), + backgroundColor: Color(0xFF6C5CE7), + ), + ); + } + + /// Exporte la liste des membres + void _exportMembers() { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Export des membres en cours...'), + backgroundColor: Color(0xFF10B981), + ), + ); + } + + /// Affiche les détails d'un membre + void _showMemberDetails(Map member) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => _buildMemberDetailsSheet(member), + ); + } + + /// Gère les actions sur un membre + void _handleMemberAction(String action, Map member) { + switch (action) { + case 'view': + _showMemberDetails(member); + break; + case 'edit': + _showEditMemberDialog(member); + break; + case 'message': + _sendMessageToMember(member); + break; + case 'delete': + _showDeleteMemberDialog(member); + break; + } + } + + /// Dialog d'édition de membre + void _showEditMemberDialog(Map member) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('Modifier ${member['name']}'), + content: const Text('Fonctionnalité de modification à implémenter'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Sauvegarder'), + ), + ], + ), + ); + } + + /// Envoie un message à un membre + void _sendMessageToMember(Map member) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Message à ${member['name']} à implémenter'), + backgroundColor: const Color(0xFF0EA5E9), + ), + ); + } + + /// Dialog de suppression de membre + void _showDeleteMemberDialog(Map member) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Supprimer le membre'), + content: Text('Êtes-vous sûr de vouloir supprimer ${member['name']} ?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${member['name']} supprimé'), + backgroundColor: const Color(0xFFDC2626), + ), + ); + }, + style: ElevatedButton.styleFrom(backgroundColor: Colors.red), + child: const Text('Supprimer'), + ), + ], + ), + ); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // WIDGETS SPÉCIALISÉS ET VUES AVANCÉES + // ═══════════════════════════════════════════════════════════════════════════ + + /// Carte de membre pour la vue grille + Widget _buildMemberGridCard(Map member) { + final contributionScore = member['contributionScore'] as int; + + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: InkWell( + onTap: () => _showMemberDetails(member), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + // Avatar et statut + Stack( + children: [ + CircleAvatar( + radius: 30, + backgroundColor: _getStatusColor(member['status']).withOpacity(0.1), + child: Text( + member['name'][0].toUpperCase(), + style: TextStyle( + color: _getStatusColor(member['status']), + fontWeight: FontWeight.bold, + fontSize: 20, + ), + ), + ), + Positioned( + bottom: 0, + right: 0, + child: Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: _getStatusColor(member['status']), + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + + // Nom et rôle + Text( + member['name'], + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: _getRoleColor(member['role']).withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + member['role'], + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: _getRoleColor(member['role']), + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 8), + + // Score de contribution + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.star, + size: 14, + color: _getScoreColor(contributionScore), + ), + const SizedBox(width: 4), + Text( + contributionScore.toString(), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: _getScoreColor(contributionScore), + ), + ), + ], + ), + const Spacer(), + + // Actions rapides + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + IconButton( + onPressed: () => _showMemberDetails(member), + icon: const Icon(Icons.visibility, size: 16), + tooltip: 'Voir', + ), + IconButton( + onPressed: () => _sendMessageToMember(member), + icon: const Icon(Icons.message, size: 16), + tooltip: 'Message', + ), + ], + ), + ], + ), + ), + ), + ); + } + + /// État vide quand aucun membre ne correspond aux filtres + Widget _buildEmptyState() { + return Container( + height: 400, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: const Color(0xFF6C5CE7).withOpacity(0.1), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.people_outline, + size: 48, + color: Color(0xFF6C5CE7), + ), + ), + const SizedBox(height: 16), + const Text( + 'Aucun membre trouvé', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Color(0xFF374151), + ), + ), + const SizedBox(height: 8), + Text( + _searchQuery.isNotEmpty + ? 'Aucun membre ne correspond à votre recherche' + : 'Aucun membre ne correspond aux filtres sélectionnés', + style: const TextStyle( + color: Color(0xFF6B7280), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: () { + setState(() { + _searchController.clear(); + _searchQuery = ''; + _selectedFilter = 'Tous'; + _selectedRoles.clear(); + }); + }, + icon: const Icon(Icons.refresh), + label: const Text('Réinitialiser les filtres'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6C5CE7), + foregroundColor: Colors.white, + ), + ), + ], + ), + ); + } + + /// Vue des équipes (onglet Équipes) + Widget _buildTeamsView(List> members) { + final departments = >>{}; + + // Grouper par département + for (final member in members) { + final dept = member['department'] as String; + departments[dept] = departments[dept] ?? []; + departments[dept]!.add(member); + } + + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: departments.length, + itemBuilder: (context, index) { + final dept = departments.keys.elementAt(index); + final deptMembers = departments[dept]!; + + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: ExpansionTile( + title: Text( + dept, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + subtitle: Text('${deptMembers.length} membre(s)'), + children: deptMembers.map((member) => ListTile( + leading: CircleAvatar( + backgroundColor: _getStatusColor(member['status']).withOpacity(0.1), + child: Text( + member['name'][0].toUpperCase(), + style: TextStyle( + color: _getStatusColor(member['status']), + fontWeight: FontWeight.bold, + ), + ), + ), + title: Text(member['name']), + subtitle: Text(member['role']), + trailing: Text( + member['contributionScore'].toString(), + style: TextStyle( + fontWeight: FontWeight.bold, + color: _getScoreColor(member['contributionScore']), + ), + ), + onTap: () => _showMemberDetails(member), + )).toList(), + ), + ); + }, + ); + } + + /// Vue analytics (onglet Analytics) + Widget _buildAnalyticsView(List> members) { + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + children: [ + // Graphique de répartition par statut + _buildStatusChart(members), + const SizedBox(height: 16), + + // Graphique de répartition par rôle + _buildRoleChart(members), + const SizedBox(height: 16), + + // Top contributeurs + _buildTopContributors(members), + ], + ), + ); + } + + /// Graphique de répartition par statut + Widget _buildStatusChart(List> members) { + final statusCounts = {}; + for (final member in members) { + final status = member['status'] as String; + statusCounts[status] = (statusCounts[status] ?? 0) + 1; + } + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Répartition par Statut', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 12), + ...statusCounts.entries.map((entry) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: _getStatusColor(entry.key), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + Expanded(child: Text(entry.key)), + Text( + entry.value.toString(), + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ], + ), + )), + ], + ), + ), + ); + } + + /// Graphique de répartition par rôle + Widget _buildRoleChart(List> members) { + final roleCounts = {}; + for (final member in members) { + final role = member['role'] as String; + roleCounts[role] = (roleCounts[role] ?? 0) + 1; + } + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Répartition par Rôle', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 12), + ...roleCounts.entries.map((entry) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: _getRoleColor(entry.key), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + Expanded(child: Text(entry.key)), + Text( + entry.value.toString(), + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ], + ), + )), + ], + ), + ), + ); + } + + /// Top contributeurs + Widget _buildTopContributors(List> members) { + final sortedMembers = List>.from(members); + sortedMembers.sort((a, b) => (b['contributionScore'] as int).compareTo(a['contributionScore'] as int)); + final topMembers = sortedMembers.take(5).toList(); + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Top Contributeurs', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 12), + ...topMembers.asMap().entries.map((entry) { + final index = entry.key; + final member = entry.value; + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: index < 3 ? const Color(0xFFF59E0B) : const Color(0xFF6B7280), + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: Text( + '${index + 1}', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(width: 12), + CircleAvatar( + radius: 16, + backgroundColor: _getStatusColor(member['status']).withOpacity(0.1), + child: Text( + member['name'][0].toUpperCase(), + style: TextStyle( + color: _getStatusColor(member['status']), + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + member['name'], + style: const TextStyle(fontWeight: FontWeight.w500), + ), + Text( + member['role'], + style: const TextStyle( + fontSize: 12, + color: Color(0xFF6B7280), + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: _getScoreColor(member['contributionScore']).withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.star, + size: 12, + color: _getScoreColor(member['contributionScore']), + ), + const SizedBox(width: 2), + Text( + member['contributionScore'].toString(), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: _getScoreColor(member['contributionScore']), + ), + ), + ], + ), + ), + ], + ), + ); + }), + ], + ), + ), + ); + } + + /// Sheet de détails d'un membre + Widget _buildMemberDetailsSheet(Map member) { + return DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.5, + maxChildSize: 0.95, + builder: (context, scrollController) { + return Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + children: [ + // Handle + Container( + margin: const EdgeInsets.symmetric(vertical: 8), + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + + // Header + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + CircleAvatar( + radius: 30, + backgroundColor: _getStatusColor(member['status']).withOpacity(0.1), + child: Text( + member['name'][0].toUpperCase(), + style: TextStyle( + color: _getStatusColor(member['status']), + fontWeight: FontWeight.bold, + fontSize: 24, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + member['name'], + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + Text( + member['role'], + style: TextStyle( + color: _getRoleColor(member['role']), + fontWeight: FontWeight.w500, + ), + ), + Container( + margin: const EdgeInsets.only(top: 4), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: _getStatusColor(member['status']).withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + member['status'], + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: _getStatusColor(member['status']), + ), + ), + ), + ], + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + ), + + // Contenu détaillé + Expanded( + child: SingleChildScrollView( + controller: scrollController, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Informations de contact + _buildDetailSection( + 'Informations de Contact', + [ + _buildDetailItem(Icons.email, 'Email', member['email']), + _buildDetailItem(Icons.phone, 'Téléphone', member['phone']), + _buildDetailItem(Icons.location_on, 'Localisation', member['location']), + ], + ), + + // Informations professionnelles + _buildDetailSection( + 'Informations Professionnelles', + [ + _buildDetailItem(Icons.business, 'Département', member['department']), + _buildDetailItem(Icons.admin_panel_settings, 'Permissions', '${member['permissions']} permissions'), + _buildDetailItem(Icons.calendar_today, 'Date d\'adhésion', _formatDate(member['joinDate'])), + _buildDetailItem(Icons.access_time, 'Dernière activité', _formatRelativeTime(member['lastActivity'])), + ], + ), + + // Statistiques d'activité + _buildDetailSection( + 'Statistiques d\'Activité', + [ + _buildDetailItem(Icons.star, 'Score de contribution', '${member['contributionScore']}/100'), + _buildDetailItem(Icons.event, 'Événements participés', '${member['eventsAttended']} événements'), + _buildDetailItem(Icons.work, 'Projets impliqués', '${member['projectsInvolved']} projets'), + ], + ), + + const SizedBox(height: 20), + + // Actions + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () { + Navigator.of(context).pop(); + _showEditMemberDialog(member); + }, + icon: const Icon(Icons.edit), + label: const Text('Modifier'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6C5CE7), + foregroundColor: Colors.white, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton.icon( + onPressed: () { + Navigator.of(context).pop(); + _sendMessageToMember(member); + }, + icon: const Icon(Icons.message), + label: const Text('Message'), + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ); + }, + ); + } + + /// Section de détails + Widget _buildDetailSection(String title, List items) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF374151), + ), + ), + const SizedBox(height: 12), + ...items, + const SizedBox(height: 20), + ], + ); + } + + /// Item de détail + Widget _buildDetailItem(IconData icon, String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Icon( + icon, + size: 20, + color: const Color(0xFF6B7280), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 12, + color: Color(0xFF6B7280), + ), + ), + Text( + value, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF374151), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_search_form.dart b/unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_search_form.dart new file mode 100644 index 0000000..f78b5e7 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_search_form.dart @@ -0,0 +1,433 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/models/membre_search_criteria.dart'; + +/// Formulaire de recherche de membres +/// Widget réutilisable pour la saisie des critères de recherche +class MembreSearchForm extends StatefulWidget { + final MembreSearchCriteria initialCriteria; + final Function(MembreSearchCriteria) onCriteriaChanged; + final VoidCallback? onSearch; + final VoidCallback? onClear; + final bool isCompact; + + const MembreSearchForm({ + super.key, + this.initialCriteria = MembreSearchCriteria.empty, + required this.onCriteriaChanged, + this.onSearch, + this.onClear, + this.isCompact = false, + }); + + @override + State createState() => _MembreSearchFormState(); +} + +class _MembreSearchFormState extends State { + late TextEditingController _queryController; + late TextEditingController _nomController; + late TextEditingController _prenomController; + late TextEditingController _emailController; + late TextEditingController _telephoneController; + + String? _selectedStatut; + List _selectedRoles = []; + RangeValues _ageRange = const RangeValues(18, 65); + bool _includeInactifs = false; + bool _membreBureau = false; + bool _responsable = false; + + @override + void initState() { + super.initState(); + _initializeControllers(); + _loadInitialCriteria(); + } + + @override + void dispose() { + _queryController.dispose(); + _nomController.dispose(); + _prenomController.dispose(); + _emailController.dispose(); + _telephoneController.dispose(); + super.dispose(); + } + + void _initializeControllers() { + _queryController = TextEditingController(); + _nomController = TextEditingController(); + _prenomController = TextEditingController(); + _emailController = TextEditingController(); + _telephoneController = TextEditingController(); + + // Écouter les changements pour mettre à jour les critères + _queryController.addListener(_updateCriteria); + _nomController.addListener(_updateCriteria); + _prenomController.addListener(_updateCriteria); + _emailController.addListener(_updateCriteria); + _telephoneController.addListener(_updateCriteria); + } + + void _loadInitialCriteria() { + final criteria = widget.initialCriteria; + _queryController.text = criteria.query ?? ''; + _nomController.text = criteria.nom ?? ''; + _prenomController.text = criteria.prenom ?? ''; + _emailController.text = criteria.email ?? ''; + _telephoneController.text = criteria.telephone ?? ''; + _selectedStatut = criteria.statut; + _selectedRoles = criteria.roles ?? []; + _ageRange = RangeValues( + criteria.ageMin?.toDouble() ?? 18, + criteria.ageMax?.toDouble() ?? 65, + ); + _includeInactifs = criteria.includeInactifs; + _membreBureau = criteria.membreBureau ?? false; + _responsable = criteria.responsable ?? false; + } + + @override + Widget build(BuildContext context) { + if (widget.isCompact) { + return _buildCompactForm(); + } + return _buildFullForm(); + } + + /// Formulaire compact pour recherche rapide + Widget _buildCompactForm() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + TextField( + controller: _queryController, + decoration: InputDecoration( + labelText: 'Rechercher un membre', + hintText: 'Nom, prénom ou email...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _queryController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _queryController.clear(); + _updateCriteria(); + }, + ) + : null, + border: const OutlineInputBorder(), + ), + onSubmitted: (_) => widget.onSearch?.call(), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: DropdownButtonFormField( + value: _selectedStatut, + decoration: const InputDecoration( + labelText: 'Statut', + border: OutlineInputBorder(), + isDense: true, + ), + items: const [ + DropdownMenuItem(value: null, child: Text('Tous')), + DropdownMenuItem(value: 'ACTIF', child: Text('Actif')), + DropdownMenuItem(value: 'INACTIF', child: Text('Inactif')), + ], + onChanged: (value) { + setState(() => _selectedStatut = value); + _updateCriteria(); + }, + ), + ), + const SizedBox(width: 12), + if (widget.onSearch != null) + ElevatedButton.icon( + onPressed: widget.onSearch, + icon: const Icon(Icons.search), + label: const Text('Rechercher'), + ), + ], + ), + ], + ), + ), + ); + } + + /// Formulaire complet avec tous les critères + Widget _buildFullForm() { + return Column( + children: [ + // Recherche générale + _buildGeneralSearchSection(), + const SizedBox(height: 16), + + // Critères détaillés + _buildDetailedCriteriaSection(), + const SizedBox(height: 16), + + // Filtres avancés + _buildAdvancedFiltersSection(), + const SizedBox(height: 16), + + // Boutons d'action + _buildActionButtons(), + ], + ); + } + + /// Section de recherche générale + Widget _buildGeneralSearchSection() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Recherche Générale', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _queryController, + decoration: const InputDecoration( + labelText: 'Terme de recherche', + hintText: 'Nom, prénom, email...', + prefixIcon: Icon(Icons.search), + border: OutlineInputBorder(), + ), + ), + ], + ), + ), + ); + } + + /// Section des critères détaillés + Widget _buildDetailedCriteriaSection() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Critères Détaillés', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextField( + controller: _nomController, + decoration: const InputDecoration( + labelText: 'Nom', + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _prenomController, + decoration: const InputDecoration( + labelText: 'Prénom', + border: OutlineInputBorder(), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + TextField( + controller: _emailController, + decoration: const InputDecoration( + labelText: 'Email', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextField( + controller: _telephoneController, + decoration: const InputDecoration( + labelText: 'Téléphone', + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: DropdownButtonFormField( + value: _selectedStatut, + decoration: const InputDecoration( + labelText: 'Statut', + border: OutlineInputBorder(), + ), + items: const [ + DropdownMenuItem(value: null, child: Text('Tous')), + DropdownMenuItem(value: 'ACTIF', child: Text('Actif')), + DropdownMenuItem(value: 'INACTIF', child: Text('Inactif')), + DropdownMenuItem(value: 'SUSPENDU', child: Text('Suspendu')), + DropdownMenuItem(value: 'RADIE', child: Text('Radié')), + ], + onChanged: (value) { + setState(() => _selectedStatut = value); + _updateCriteria(); + }, + ), + ), + ], + ), + ], + ), + ), + ); + } + + /// Section des filtres avancés + Widget _buildAdvancedFiltersSection() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Filtres Avancés', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + + // Tranche d'âge + Text('Tranche d\'âge: ${_ageRange.start.round()}-${_ageRange.end.round()} ans'), + RangeSlider( + values: _ageRange, + min: 18, + max: 80, + divisions: 62, + labels: RangeLabels( + '${_ageRange.start.round()}', + '${_ageRange.end.round()}', + ), + onChanged: (values) { + setState(() => _ageRange = values); + _updateCriteria(); + }, + ), + const SizedBox(height: 16), + + // Options booléennes + CheckboxListTile( + title: const Text('Inclure les membres inactifs'), + value: _includeInactifs, + onChanged: (value) { + setState(() => _includeInactifs = value ?? false); + _updateCriteria(); + }, + ), + CheckboxListTile( + title: const Text('Membres du bureau uniquement'), + value: _membreBureau, + onChanged: (value) { + setState(() => _membreBureau = value ?? false); + _updateCriteria(); + }, + ), + CheckboxListTile( + title: const Text('Responsables uniquement'), + value: _responsable, + onChanged: (value) { + setState(() => _responsable = value ?? false); + _updateCriteria(); + }, + ), + ], + ), + ), + ); + } + + /// Boutons d'action + Widget _buildActionButtons() { + return Row( + children: [ + if (widget.onClear != null) + Expanded( + child: OutlinedButton.icon( + onPressed: () { + _clearForm(); + widget.onClear?.call(); + }, + icon: const Icon(Icons.clear), + label: const Text('Effacer'), + ), + ), + if (widget.onClear != null && widget.onSearch != null) + const SizedBox(width: 16), + if (widget.onSearch != null) + Expanded( + flex: widget.onClear != null ? 2 : 1, + child: ElevatedButton.icon( + onPressed: widget.onSearch, + icon: const Icon(Icons.search), + label: const Text('Rechercher'), + ), + ), + ], + ); + } + + /// Met à jour les critères de recherche + void _updateCriteria() { + final criteria = MembreSearchCriteria( + query: _queryController.text.trim().isEmpty ? null : _queryController.text.trim(), + nom: _nomController.text.trim().isEmpty ? null : _nomController.text.trim(), + prenom: _prenomController.text.trim().isEmpty ? null : _prenomController.text.trim(), + email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(), + telephone: _telephoneController.text.trim().isEmpty ? null : _telephoneController.text.trim(), + statut: _selectedStatut, + roles: _selectedRoles.isEmpty ? null : _selectedRoles, + ageMin: _ageRange.start.round(), + ageMax: _ageRange.end.round(), + membreBureau: _membreBureau ? true : null, + responsable: _responsable ? true : null, + includeInactifs: _includeInactifs, + ); + + widget.onCriteriaChanged(criteria); + } + + /// Efface le formulaire + void _clearForm() { + setState(() { + _queryController.clear(); + _nomController.clear(); + _prenomController.clear(); + _emailController.clear(); + _telephoneController.clear(); + _selectedStatut = null; + _selectedRoles.clear(); + _ageRange = const RangeValues(18, 65); + _includeInactifs = false; + _membreBureau = false; + _responsable = false; + }); + _updateCriteria(); + } +} diff --git a/unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_search_results.dart b/unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_search_results.dart new file mode 100644 index 0000000..9091499 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_search_results.dart @@ -0,0 +1,388 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/models/membre_search_result.dart' as search_model; +import '../../data/models/membre_model.dart' as member_model; + +/// Widget d'affichage des résultats de recherche de membres +/// Gère la pagination, le tri et l'affichage des membres trouvés +class MembreSearchResults extends StatefulWidget { + final search_model.MembreSearchResult result; + final Function(member_model.MembreModel)? onMembreSelected; + final bool showPagination; + + const MembreSearchResults({ + super.key, + required this.result, + this.onMembreSelected, + this.showPagination = true, + }); + + @override + State createState() => _MembreSearchResultsState(); +} + +class _MembreSearchResultsState extends State { + @override + Widget build(BuildContext context) { + if (widget.result.isEmpty) { + return _buildEmptyState(); + } + + return Column( + children: [ + // En-tête avec informations sur les résultats + _buildResultsHeader(), + + // Liste des membres + Expanded( + child: _buildMembersList(), + ), + + // Pagination si activée + if (widget.showPagination && widget.result.totalPages > 1) + _buildPagination(), + ], + ); + } + + /// État vide quand aucun résultat + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search_off, + size: 64, + color: Colors.grey[400], + ), + const SizedBox(height: 16), + Text( + 'Aucun membre trouvé', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: Colors.grey[600], + ), + ), + const SizedBox(height: 8), + Text( + 'Essayez de modifier vos critères de recherche', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.grey[500], + ), + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.tune), + label: const Text('Modifier les critères'), + ), + ], + ), + ); + } + + /// En-tête avec informations sur les résultats + Widget _buildResultsHeader() { + return Container( + padding: const EdgeInsets.all(16.0), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor.withOpacity(0.1), + border: Border( + bottom: BorderSide( + color: Theme.of(context).dividerColor, + width: 1, + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.search, + color: Theme.of(context).primaryColor, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + widget.result.resultDescription, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + Chip( + label: Text('${widget.result.executionTimeMs}ms'), + backgroundColor: Colors.green.withOpacity(0.1), + labelStyle: const TextStyle( + color: Colors.green, + fontSize: 12, + ), + ), + ], + ), + if (widget.result.criteria.description.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + 'Critères: ${widget.result.criteria.description}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey[600], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ); + } + + /// Liste des membres trouvés + Widget _buildMembersList() { + return ListView.builder( + itemCount: widget.result.membres.length, + itemBuilder: (context, index) { + final membre = widget.result.membres[index]; + return _buildMembreCard(membre, index); + }, + ); + } + + /// Carte d'affichage d'un membre + Widget _buildMembreCard(member_model.MembreModel membre, int index) { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: ListTile( + leading: CircleAvatar( + backgroundColor: _getStatusColor(membre.statut ?? 'ACTIF'), + child: Text( + _getInitials(membre.nom, membre.prenom), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + title: Text( + '${membre.prenom} ${membre.nom}', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (membre.email.isNotEmpty) + Row( + children: [ + const Icon(Icons.email, size: 14, color: Colors.grey), + const SizedBox(width: 4), + Expanded( + child: Text( + membre.email, + style: const TextStyle(fontSize: 12), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + if (membre.telephone?.isNotEmpty == true) + Row( + children: [ + const Icon(Icons.phone, size: 14, color: Colors.grey), + const SizedBox(width: 4), + Text( + membre.telephone!, + style: const TextStyle(fontSize: 12), + ), + ], + ), + if (membre.organisation?.nom?.isNotEmpty == true) + Row( + children: [ + const Icon(Icons.business, size: 14, color: Colors.grey), + const SizedBox(width: 4), + Expanded( + child: Text( + membre.organisation!.nom!, + style: const TextStyle(fontSize: 12), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildStatusChip(membre.statut ?? 'ACTIF'), + if (membre.role?.isNotEmpty == true) ...[ + const SizedBox(height: 4), + Text( + _formatRoles(membre.role!), + style: const TextStyle( + fontSize: 10, + color: Colors.grey, + ), + textAlign: TextAlign.center, + ), + ], + ], + ), + onTap: widget.onMembreSelected != null + ? () => widget.onMembreSelected!(membre) + : null, + ), + ); + } + + /// Pagination + Widget _buildPagination() { + return Container( + padding: const EdgeInsets.all(16.0), + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + border: Border( + top: BorderSide( + color: Theme.of(context).dividerColor, + width: 1, + ), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Bouton page précédente + ElevatedButton.icon( + onPressed: widget.result.hasPrevious ? _goToPreviousPage : null, + icon: const Icon(Icons.chevron_left), + label: const Text('Précédent'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey[100], + foregroundColor: Colors.grey[700], + ), + ), + + // Indicateur de page + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + 'Page ${widget.result.currentPage + 1} / ${widget.result.totalPages}', + style: TextStyle( + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.bold, + ), + ), + ), + + // Bouton page suivante + ElevatedButton.icon( + onPressed: widget.result.hasNext ? _goToNextPage : null, + icon: const Icon(Icons.chevron_right), + label: const Text('Suivant'), + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).primaryColor, + foregroundColor: Colors.white, + ), + ), + ], + ), + ); + } + + /// Chip de statut + Widget _buildStatusChip(String statut) { + final color = _getStatusColor(statut); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color, width: 1), + ), + child: Text( + _getStatusLabel(statut), + style: TextStyle( + color: color, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + /// Obtient la couleur du statut + Color _getStatusColor(String statut) { + switch (statut.toUpperCase()) { + case 'ACTIF': + return Colors.green; + case 'INACTIF': + return Colors.orange; + case 'SUSPENDU': + return Colors.red; + case 'RADIE': + return Colors.grey; + default: + return Colors.grey; + } + } + + /// Obtient le libellé du statut + String _getStatusLabel(String statut) { + switch (statut.toUpperCase()) { + case 'ACTIF': + return 'Actif'; + case 'INACTIF': + return 'Inactif'; + case 'SUSPENDU': + return 'Suspendu'; + case 'RADIE': + return 'Radié'; + default: + return statut; + } + } + + /// Obtient les initiales d'un membre + String _getInitials(String nom, String prenom) { + final nomInitial = nom.isNotEmpty ? nom[0].toUpperCase() : ''; + final prenomInitial = prenom.isNotEmpty ? prenom[0].toUpperCase() : ''; + return '$prenomInitial$nomInitial'; + } + + /// Formate les rôles pour l'affichage + String _formatRoles(String roles) { + final rolesList = roles.split(',').map((r) => r.trim()).toList(); + if (rolesList.length <= 2) { + return rolesList.join(', '); + } + return '${rolesList.take(2).join(', ')}...'; + } + + /// Navigation vers la page précédente + void _goToPreviousPage() { + // TODO: Implémenter la navigation vers la page précédente + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Navigation vers la page précédente'), + duration: Duration(seconds: 1), + ), + ); + } + + /// Navigation vers la page suivante + void _goToNextPage() { + // TODO: Implémenter la navigation vers la page suivante + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Navigation vers la page suivante'), + duration: Duration(seconds: 1), + ), + ); + } +} diff --git a/unionflow-mobile-apps/lib/features/members/presentation/widgets/search_statistics_card.dart b/unionflow-mobile-apps/lib/features/members/presentation/widgets/search_statistics_card.dart new file mode 100644 index 0000000..c42d2d6 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/presentation/widgets/search_statistics_card.dart @@ -0,0 +1,416 @@ +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; + +import '../../../../core/models/membre_search_result.dart'; + +/// Widget d'affichage des statistiques de recherche +/// Présente les métriques et graphiques des résultats de recherche +class SearchStatisticsCard extends StatelessWidget { + final SearchStatistics statistics; + + const SearchStatisticsCard({ + super.key, + required this.statistics, + }); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Titre + Row( + children: [ + Icon( + Icons.analytics, + color: Theme.of(context).primaryColor, + size: 24, + ), + const SizedBox(width: 8), + Text( + 'Statistiques de Recherche', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 24), + + // Métriques principales + _buildMainMetrics(context), + const SizedBox(height: 24), + + // Graphique de répartition actifs/inactifs + _buildStatusChart(context), + const SizedBox(height: 24), + + // Métriques détaillées + _buildDetailedMetrics(context), + const SizedBox(height: 24), + + // Informations complémentaires + _buildAdditionalInfo(context), + ], + ), + ); + } + + /// Métriques principales + Widget _buildMainMetrics(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Vue d\'ensemble', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _buildMetricCard( + context, + 'Total Membres', + statistics.totalMembres.toString(), + Icons.people, + Colors.blue, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildMetricCard( + context, + 'Membres Actifs', + statistics.membresActifs.toString(), + Icons.person, + Colors.green, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildMetricCard( + context, + 'Âge Moyen', + '${statistics.ageMoyen.toStringAsFixed(1)} ans', + Icons.cake, + Colors.orange, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildMetricCard( + context, + 'Ancienneté', + '${statistics.ancienneteMoyenne.toStringAsFixed(1)} ans', + Icons.schedule, + Colors.purple, + ), + ), + ], + ), + ], + ), + ), + ); + } + + /// Carte de métrique individuelle + Widget _buildMetricCard( + BuildContext context, + String title, + String value, + IconData icon, + Color color, + ) { + return Container( + padding: const EdgeInsets.all(12.0), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withOpacity(0.3)), + ), + child: Column( + children: [ + Icon(icon, color: color, size: 24), + const SizedBox(height: 8), + Text( + value, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: color, + ), + ), + Text( + title, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey[600], + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + /// Graphique de répartition des statuts + Widget _buildStatusChart(BuildContext context) { + if (statistics.totalMembres == 0) { + return const SizedBox.shrink(); + } + + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Répartition par Statut', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + SizedBox( + height: 200, + child: Row( + children: [ + // Graphique en secteurs + Expanded( + flex: 2, + child: PieChart( + PieChartData( + sections: [ + PieChartSectionData( + value: statistics.membresActifs.toDouble(), + title: '${statistics.pourcentageActifs.toStringAsFixed(1)}%', + color: Colors.green, + radius: 60, + titleStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + if (statistics.membresInactifs > 0) + PieChartSectionData( + value: statistics.membresInactifs.toDouble(), + title: '${statistics.pourcentageInactifs.toStringAsFixed(1)}%', + color: Colors.orange, + radius: 60, + titleStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ], + sectionsSpace: 2, + centerSpaceRadius: 40, + ), + ), + ), + + // Légende + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildLegendItem( + 'Actifs', + statistics.membresActifs, + Colors.green, + ), + const SizedBox(height: 8), + if (statistics.membresInactifs > 0) + _buildLegendItem( + 'Inactifs', + statistics.membresInactifs, + Colors.orange, + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + /// Item de légende + Widget _buildLegendItem(String label, int count, Color color) { + return Row( + children: [ + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + '$label ($count)', + style: const TextStyle(fontSize: 12), + ), + ), + ], + ); + } + + /// Métriques détaillées + Widget _buildDetailedMetrics(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Détails Démographiques', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + _buildDetailRow( + context, + 'Tranche d\'âge', + statistics.trancheAge, + Icons.calendar_today, + ), + _buildDetailRow( + context, + 'Organisations', + '${statistics.nombreOrganisations} représentées', + Icons.business, + ), + _buildDetailRow( + context, + 'Régions', + '${statistics.nombreRegions} représentées', + Icons.location_on, + ), + _buildDetailRow( + context, + 'Taux d\'activité', + '${statistics.pourcentageActifs.toStringAsFixed(1)}%', + Icons.trending_up, + ), + ], + ), + ), + ); + } + + /// Ligne de détail + Widget _buildDetailRow( + BuildContext context, + String label, + String value, + IconData icon, + ) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + children: [ + Icon( + icon, + size: 20, + color: Colors.grey[600], + ), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + Text( + value, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + } + + /// Informations complémentaires + Widget _buildAdditionalInfo(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.info_outline, + color: Theme.of(context).primaryColor, + ), + const SizedBox(width: 8), + Text( + 'Informations Complémentaires', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 16), + Text( + statistics.description, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.grey[700], + ), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12.0), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.blue.withOpacity(0.3)), + ), + child: Row( + children: [ + const Icon(Icons.lightbulb, color: Colors.blue), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Ces statistiques sont calculées en temps réel sur les résultats de votre recherche.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.blue[700], + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/unionflow-mobile-apps/lib/features/search/presentation/pages/advanced_search_page.dart b/unionflow-mobile-apps/lib/features/search/presentation/pages/advanced_search_page.dart new file mode 100644 index 0000000..2863d4e --- /dev/null +++ b/unionflow-mobile-apps/lib/features/search/presentation/pages/advanced_search_page.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; + +/// Page de recherche avancée des membres +class AdvancedSearchPage extends StatefulWidget { + const AdvancedSearchPage({super.key}); + + @override + State createState() => _AdvancedSearchPageState(); +} + +class _AdvancedSearchPageState extends State { + final _formKey = GlobalKey(); + final _queryController = TextEditingController(); + final _nomController = TextEditingController(); + final _prenomController = TextEditingController(); + final _emailController = TextEditingController(); + + @override + void dispose() { + _queryController.dispose(); + _nomController.dispose(); + _prenomController.dispose(); + _emailController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Recherche Avancée'), + backgroundColor: Theme.of(context).primaryColor, + foregroundColor: Colors.white, + ), + body: Form( + key: _formKey, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + TextFormField( + controller: _queryController, + decoration: const InputDecoration( + labelText: 'Recherche générale', + hintText: 'Nom, prénom, email...', + prefixIcon: Icon(Icons.search), + ), + ), + const SizedBox(height: 16), + TextFormField( + controller: _nomController, + decoration: const InputDecoration( + labelText: 'Nom', + prefixIcon: Icon(Icons.person), + ), + ), + const SizedBox(height: 16), + TextFormField( + controller: _prenomController, + decoration: const InputDecoration( + labelText: 'Prénom', + prefixIcon: Icon(Icons.person_outline), + ), + ), + const SizedBox(height: 16), + TextFormField( + controller: _emailController, + decoration: const InputDecoration( + labelText: 'Email', + prefixIcon: Icon(Icons.email), + ), + keyboardType: TextInputType.emailAddress, + ), + const SizedBox(height: 32), + Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: _performSearch, + child: const Text('Rechercher'), + ), + ), + const SizedBox(width: 16), + Expanded( + child: OutlinedButton( + onPressed: _clearForm, + child: const Text('Effacer'), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + void _performSearch() { + if (_formKey.currentState!.validate()) { + // TODO: Implémenter la recherche + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Recherche en cours...'), + ), + ); + } + } + + void _clearForm() { + _queryController.clear(); + _nomController.clear(); + _prenomController.clear(); + _emailController.clear(); + } +} diff --git a/unionflow-mobile-apps/lib/main.dart b/unionflow-mobile-apps/lib/main.dart index 49ca2ab..b481982 100644 --- a/unionflow-mobile-apps/lib/main.dart +++ b/unionflow-mobile-apps/lib/main.dart @@ -12,7 +12,7 @@ import 'core/design_system/theme/app_theme_sophisticated.dart'; import 'core/auth/bloc/auth_bloc.dart'; import 'core/cache/dashboard_cache_manager.dart'; import 'features/auth/presentation/pages/login_page.dart'; -import 'features/dashboard/presentation/pages/adaptive_dashboard_page.dart'; +import 'core/navigation/main_navigation_layout.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -85,13 +85,13 @@ class UnionFlowApp extends StatelessWidget { ), ); } else if (state is AuthAuthenticated) { - return const AdaptiveDashboardPage(); + return const MainNavigationLayout(); } else { return const LoginPage(); } }, ), - '/dashboard': (context) => const AdaptiveDashboardPage(), + '/dashboard': (context) => const MainNavigationLayout(), '/login': (context) => const LoginPage(), }, diff --git a/unionflow-mobile-apps/run_app.bat b/unionflow-mobile-apps/run_app.bat new file mode 100644 index 0000000..8933da4 --- /dev/null +++ b/unionflow-mobile-apps/run_app.bat @@ -0,0 +1,24 @@ +@echo off +echo Lancement de l'application UnionFlow Mobile... +echo. + +echo Verification des devices connectes... +flutter devices +echo. + +echo Nettoyage du projet... +flutter clean +echo. + +echo Installation des dependances... +flutter pub get +echo. + +echo Analyse du code... +flutter analyze --no-fatal-infos +echo. + +echo Lancement de l'application sur le device R58R34HT85V... +flutter run -d R58R34HT85V --verbose + +pause diff --git a/unionflow-mobile-apps/test_app.dart b/unionflow-mobile-apps/test_app.dart new file mode 100644 index 0000000..538d28e --- /dev/null +++ b/unionflow-mobile-apps/test_app.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +void main() { + runApp(const TestApp()); +} + +class TestApp extends StatelessWidget { + const TestApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Test UnionFlow', + home: Scaffold( + appBar: AppBar( + title: const Text('Test UnionFlow'), + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + ), + body: const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.check_circle, + size: 100, + color: Colors.green, + ), + SizedBox(height: 20), + Text( + 'UnionFlow Mobile App', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + SizedBox(height: 10), + Text( + 'Application lancée avec succès !', + style: TextStyle( + fontSize: 16, + color: Colors.grey, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/unionflow-server-api/src/main/java/dev/lions/unionflow/server/api/dto/membre/MembreSearchCriteria.java b/unionflow-server-api/src/main/java/dev/lions/unionflow/server/api/dto/membre/MembreSearchCriteria.java new file mode 100644 index 0000000..be40fe1 --- /dev/null +++ b/unionflow-server-api/src/main/java/dev/lions/unionflow/server/api/dto/membre/MembreSearchCriteria.java @@ -0,0 +1,229 @@ +package dev.lions.unionflow.server.api.dto.membre; + +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +/** + * DTO pour les critères de recherche avancée des membres + * Permet de filtrer les membres selon de multiples critères + * + * @author UnionFlow Team + * @version 1.0 + * @since 2025-01-19 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "Critères de recherche avancée pour les membres") +public class MembreSearchCriteria { + + /** Terme de recherche général (nom, prénom, email) */ + @Schema(description = "Terme de recherche général dans nom, prénom ou email", example = "marie") + @Size(max = 100, message = "Le terme de recherche ne peut pas dépasser 100 caractères") + private String query; + + /** Recherche par nom exact ou partiel */ + @Schema(description = "Filtre par nom (recherche partielle)", example = "Dupont") + @Size(max = 50, message = "Le nom ne peut pas dépasser 50 caractères") + private String nom; + + /** Recherche par prénom exact ou partiel */ + @Schema(description = "Filtre par prénom (recherche partielle)", example = "Marie") + @Size(max = 50, message = "Le prénom ne peut pas dépasser 50 caractères") + private String prenom; + + /** Recherche par email exact ou partiel */ + @Schema(description = "Filtre par email (recherche partielle)", example = "@unionflow.com") + @Size(max = 100, message = "L'email ne peut pas dépasser 100 caractères") + private String email; + + /** Filtre par numéro de téléphone */ + @Schema(description = "Filtre par numéro de téléphone", example = "+221") + @Size(max = 20, message = "Le téléphone ne peut pas dépasser 20 caractères") + private String telephone; + + /** Liste des IDs d'organisations */ + @Schema(description = "Liste des IDs d'organisations à inclure") + private List organisationIds; + + /** Liste des rôles à rechercher */ + @Schema(description = "Liste des rôles à rechercher", example = "[\"PRESIDENT\", \"SECRETAIRE\"]") + private List roles; + + /** Filtre par statut d'activité */ + @Schema(description = "Filtre par statut d'activité", example = "ACTIF") + @Pattern(regexp = "^(ACTIF|INACTIF|SUSPENDU|RADIE)$", message = "Statut invalide") + private String statut; + + /** Date d'adhésion minimum */ + @Schema(description = "Date d'adhésion minimum", example = "2020-01-01") + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate dateAdhesionMin; + + /** Date d'adhésion maximum */ + @Schema(description = "Date d'adhésion maximum", example = "2025-12-31") + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate dateAdhesionMax; + + /** Âge minimum */ + @Schema(description = "Âge minimum", example = "18") + @Min(value = 0, message = "L'âge minimum doit être positif") + @Max(value = 120, message = "L'âge minimum ne peut pas dépasser 120 ans") + private Integer ageMin; + + /** Âge maximum */ + @Schema(description = "Âge maximum", example = "65") + @Min(value = 0, message = "L'âge maximum doit être positif") + @Max(value = 120, message = "L'âge maximum ne peut pas dépasser 120 ans") + private Integer ageMax; + + /** Filtre par région */ + @Schema(description = "Filtre par région", example = "Dakar") + @Size(max = 50, message = "La région ne peut pas dépasser 50 caractères") + private String region; + + /** Filtre par ville */ + @Schema(description = "Filtre par ville", example = "Dakar") + @Size(max = 50, message = "La ville ne peut pas dépasser 50 caractères") + private String ville; + + /** Filtre par profession */ + @Schema(description = "Filtre par profession", example = "Ingénieur") + @Size(max = 100, message = "La profession ne peut pas dépasser 100 caractères") + private String profession; + + /** Filtre par nationalité */ + @Schema(description = "Filtre par nationalité", example = "Sénégalaise") + @Size(max = 50, message = "La nationalité ne peut pas dépasser 50 caractères") + private String nationalite; + + /** Filtre membres du bureau uniquement */ + @Schema(description = "Filtre pour les membres du bureau uniquement") + private Boolean membreBureau; + + /** Filtre responsables uniquement */ + @Schema(description = "Filtre pour les responsables uniquement") + private Boolean responsable; + + /** Inclure les membres inactifs dans la recherche */ + @Schema(description = "Inclure les membres inactifs", defaultValue = "false") + @Builder.Default + private Boolean includeInactifs = false; + + /** + * Vérifie si au moins un critère de recherche est défini + * + * @return true si au moins un critère est défini + */ + public boolean hasAnyCriteria() { + return query != null && !query.trim().isEmpty() || + nom != null && !nom.trim().isEmpty() || + prenom != null && !prenom.trim().isEmpty() || + email != null && !email.trim().isEmpty() || + telephone != null && !telephone.trim().isEmpty() || + organisationIds != null && !organisationIds.isEmpty() || + roles != null && !roles.isEmpty() || + statut != null && !statut.trim().isEmpty() || + dateAdhesionMin != null || + dateAdhesionMax != null || + ageMin != null || + ageMax != null || + region != null && !region.trim().isEmpty() || + ville != null && !ville.trim().isEmpty() || + profession != null && !profession.trim().isEmpty() || + nationalite != null && !nationalite.trim().isEmpty() || + membreBureau != null || + responsable != null; + } + + /** + * Valide la cohérence des critères de recherche + * + * @return true si les critères sont cohérents + */ + public boolean isValid() { + // Validation des dates + if (dateAdhesionMin != null && dateAdhesionMax != null) { + if (dateAdhesionMin.isAfter(dateAdhesionMax)) { + return false; + } + } + + // Validation des âges + if (ageMin != null && ageMax != null) { + if (ageMin > ageMax) { + return false; + } + } + + return true; + } + + /** + * Nettoie les chaînes de caractères (trim et null si vide) + */ + public void sanitize() { + query = sanitizeString(query); + nom = sanitizeString(nom); + prenom = sanitizeString(prenom); + email = sanitizeString(email); + telephone = sanitizeString(telephone); + statut = sanitizeString(statut); + region = sanitizeString(region); + ville = sanitizeString(ville); + profession = sanitizeString(profession); + nationalite = sanitizeString(nationalite); + } + + private String sanitizeString(String str) { + if (str == null) return null; + str = str.trim(); + return str.isEmpty() ? null : str; + } + + /** + * Retourne une description textuelle des critères actifs + * + * @return Description des critères + */ + public String getDescription() { + StringBuilder sb = new StringBuilder(); + + if (query != null) sb.append("Recherche: '").append(query).append("' "); + if (nom != null) sb.append("Nom: '").append(nom).append("' "); + if (prenom != null) sb.append("Prénom: '").append(prenom).append("' "); + if (email != null) sb.append("Email: '").append(email).append("' "); + if (statut != null) sb.append("Statut: ").append(statut).append(" "); + if (organisationIds != null && !organisationIds.isEmpty()) { + sb.append("Organisations: ").append(organisationIds.size()).append(" "); + } + if (roles != null && !roles.isEmpty()) { + sb.append("Rôles: ").append(String.join(", ", roles)).append(" "); + } + if (dateAdhesionMin != null) sb.append("Adhésion >= ").append(dateAdhesionMin).append(" "); + if (dateAdhesionMax != null) sb.append("Adhésion <= ").append(dateAdhesionMax).append(" "); + if (ageMin != null) sb.append("Âge >= ").append(ageMin).append(" "); + if (ageMax != null) sb.append("Âge <= ").append(ageMax).append(" "); + if (region != null) sb.append("Région: '").append(region).append("' "); + if (ville != null) sb.append("Ville: '").append(ville).append("' "); + if (profession != null) sb.append("Profession: '").append(profession).append("' "); + if (nationalite != null) sb.append("Nationalité: '").append(nationalite).append("' "); + if (Boolean.TRUE.equals(membreBureau)) sb.append("Membre bureau "); + if (Boolean.TRUE.equals(responsable)) sb.append("Responsable "); + + return sb.toString().trim(); + } +} diff --git a/unionflow-server-api/src/main/java/dev/lions/unionflow/server/api/dto/membre/MembreSearchResultDTO.java b/unionflow-server-api/src/main/java/dev/lions/unionflow/server/api/dto/membre/MembreSearchResultDTO.java new file mode 100644 index 0000000..8bac123 --- /dev/null +++ b/unionflow-server-api/src/main/java/dev/lions/unionflow/server/api/dto/membre/MembreSearchResultDTO.java @@ -0,0 +1,204 @@ +package dev.lions.unionflow.server.api.dto.membre; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +import java.util.List; + +/** + * DTO pour les résultats de recherche avancée des membres + * Contient les résultats paginés et les métadonnées de recherche + * + * @author UnionFlow Team + * @version 1.0 + * @since 2025-01-19 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "Résultats de recherche avancée des membres avec pagination") +public class MembreSearchResultDTO { + + /** Liste des membres trouvés */ + @Schema(description = "Liste des membres correspondant aux critères") + private List membres; + + /** Nombre total de résultats (toutes pages confondues) */ + @Schema(description = "Nombre total de résultats trouvés", example = "247") + private long totalElements; + + /** Nombre total de pages */ + @Schema(description = "Nombre total de pages", example = "13") + private int totalPages; + + /** Numéro de la page actuelle (0-based) */ + @Schema(description = "Numéro de la page actuelle", example = "0") + private int currentPage; + + /** Taille de la page */ + @Schema(description = "Nombre d'éléments par page", example = "20") + private int pageSize; + + /** Nombre d'éléments sur la page actuelle */ + @Schema(description = "Nombre d'éléments sur cette page", example = "20") + private int numberOfElements; + + /** Indique s'il y a une page suivante */ + @Schema(description = "Indique s'il y a une page suivante") + private boolean hasNext; + + /** Indique s'il y a une page précédente */ + @Schema(description = "Indique s'il y a une page précédente") + private boolean hasPrevious; + + /** Indique si c'est la première page */ + @Schema(description = "Indique si c'est la première page") + private boolean isFirst; + + /** Indique si c'est la dernière page */ + @Schema(description = "Indique si c'est la dernière page") + private boolean isLast; + + /** Critères de recherche utilisés */ + @Schema(description = "Critères de recherche qui ont été appliqués") + private MembreSearchCriteria criteria; + + /** Temps d'exécution de la recherche en millisecondes */ + @Schema(description = "Temps d'exécution de la recherche en ms", example = "45") + private long executionTimeMs; + + /** Statistiques de recherche */ + @Schema(description = "Statistiques sur les résultats de recherche") + private SearchStatistics statistics; + + /** + * Statistiques sur les résultats de recherche + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "Statistiques sur les résultats de recherche") + public static class SearchStatistics { + + /** Répartition par statut */ + @Schema(description = "Nombre de membres actifs dans les résultats") + private long membresActifs; + + @Schema(description = "Nombre de membres inactifs dans les résultats") + private long membresInactifs; + + /** Répartition par âge */ + @Schema(description = "Âge moyen des membres trouvés") + private double ageMoyen; + + @Schema(description = "Âge minimum des membres trouvés") + private int ageMin; + + @Schema(description = "Âge maximum des membres trouvés") + private int ageMax; + + /** Répartition par organisation */ + @Schema(description = "Nombre d'organisations représentées") + private long nombreOrganisations; + + /** Répartition par région */ + @Schema(description = "Nombre de régions représentées") + private long nombreRegions; + + /** Ancienneté moyenne */ + @Schema(description = "Ancienneté moyenne en années") + private double ancienneteMoyenne; + } + + /** + * Calcule et met à jour les indicateurs de pagination + */ + public void calculatePaginationFlags() { + this.isFirst = currentPage == 0; + this.isLast = currentPage >= totalPages - 1; + this.hasPrevious = currentPage > 0; + this.hasNext = currentPage < totalPages - 1; + this.numberOfElements = membres != null ? membres.size() : 0; + } + + /** + * Vérifie si les résultats sont vides + * + * @return true si aucun résultat + */ + public boolean isEmpty() { + return membres == null || membres.isEmpty(); + } + + /** + * Retourne le numéro de la page suivante (1-based pour affichage) + * + * @return Numéro de page suivante ou -1 si pas de page suivante + */ + public int getNextPageNumber() { + return hasNext ? currentPage + 2 : -1; + } + + /** + * Retourne le numéro de la page précédente (1-based pour affichage) + * + * @return Numéro de page précédente ou -1 si pas de page précédente + */ + public int getPreviousPageNumber() { + return hasPrevious ? currentPage : -1; + } + + /** + * Retourne une description textuelle des résultats + * + * @return Description des résultats + */ + public String getResultDescription() { + if (isEmpty()) { + return "Aucun membre trouvé"; + } + + if (totalElements == 1) { + return "1 membre trouvé"; + } + + if (totalPages == 1) { + return String.format("%d membres trouvés", totalElements); + } + + int startElement = currentPage * pageSize + 1; + int endElement = Math.min(startElement + numberOfElements - 1, (int) totalElements); + + return String.format("Membres %d-%d sur %d (page %d/%d)", + startElement, endElement, totalElements, + currentPage + 1, totalPages); + } + + /** + * Factory method pour créer un résultat vide + * + * @param criteria Critères de recherche + * @return Résultat vide + */ + public static MembreSearchResultDTO empty(MembreSearchCriteria criteria) { + return MembreSearchResultDTO.builder() + .membres(List.of()) + .totalElements(0) + .totalPages(0) + .currentPage(0) + .pageSize(20) + .numberOfElements(0) + .hasNext(false) + .hasPrevious(false) + .isFirst(true) + .isLast(true) + .criteria(criteria) + .executionTimeMs(0) + .build(); + } +} diff --git a/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/resource/MembreResource.java b/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/resource/MembreResource.java index a0b40ec..56dee7d 100644 --- a/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/resource/MembreResource.java +++ b/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/resource/MembreResource.java @@ -1,10 +1,13 @@ package dev.lions.unionflow.server.resource; import dev.lions.unionflow.server.api.dto.membre.MembreDTO; +import dev.lions.unionflow.server.api.dto.membre.MembreSearchCriteria; +import dev.lions.unionflow.server.api.dto.membre.MembreSearchResultDTO; import dev.lions.unionflow.server.entity.Membre; import dev.lions.unionflow.server.service.MembreService; import io.quarkus.panache.common.Page; import io.quarkus.panache.common.Sort; +import jakarta.annotation.security.RolesAllowed; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.validation.Valid; @@ -12,8 +15,15 @@ import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.media.ExampleObject; +import org.eclipse.microprofile.openapi.annotations.media.Schema; import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.parameters.RequestBody; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement; import org.eclipse.microprofile.openapi.annotations.tags.Tag; import org.jboss.logging.Logger; @@ -186,8 +196,9 @@ public class MembreResource { @GET @Path("/recherche-avancee") - @Operation(summary = "Recherche avancée de membres avec filtres multiples") + @Operation(summary = "Recherche avancée de membres avec filtres multiples (DEPRECATED)") @APIResponse(responseCode = "200", description = "Résultats de la recherche avancée") + @Deprecated public Response rechercheAvancee( @Parameter(description = "Terme de recherche") @QueryParam("q") String recherche, @Parameter(description = "Statut actif (true/false)") @QueryParam("actif") Boolean actif, @@ -198,7 +209,7 @@ public class MembreResource { @Parameter(description = "Champ de tri") @QueryParam("sort") @DefaultValue("nom") String sortField, @Parameter(description = "Direction du tri (asc/desc)") @QueryParam("direction") @DefaultValue("asc") String sortDirection) { - LOG.infof("Recherche avancée de membres - recherche: %s, actif: %s", recherche, actif); + LOG.infof("Recherche avancée de membres (DEPRECATED) - recherche: %s, actif: %s", recherche, actif); try { Sort sort = "desc".equalsIgnoreCase(sortDirection) ? @@ -222,4 +233,178 @@ public class MembreResource { .build(); } } + + /** + * Nouvelle recherche avancée avec critères complets et résultats enrichis + * Réservée aux super administrateurs pour des recherches sophistiquées + */ + @POST + @Path("/search/advanced") + @RolesAllowed({"SUPER_ADMIN", "ADMIN"}) + @Operation( + summary = "Recherche avancée de membres avec critères multiples", + description = """ + Recherche sophistiquée de membres avec de nombreux critères de filtrage : + - Recherche textuelle dans nom, prénom, email + - Filtres par organisation, rôles, statut + - Filtres par âge, région, profession + - Filtres par dates d'adhésion + - Résultats paginés avec statistiques + + Réservée aux super administrateurs et administrateurs. + """ + ) + @APIResponses({ + @APIResponse( + responseCode = "200", + description = "Recherche effectuée avec succès", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = MembreSearchResultDTO.class), + examples = @ExampleObject( + name = "Exemple de résultats", + value = """ + { + "membres": [...], + "totalElements": 247, + "totalPages": 13, + "currentPage": 0, + "pageSize": 20, + "hasNext": true, + "hasPrevious": false, + "executionTimeMs": 45, + "statistics": { + "membresActifs": 230, + "membresInactifs": 17, + "ageMoyen": 34.5, + "nombreOrganisations": 12 + } + } + """ + ) + ) + ), + @APIResponse( + responseCode = "400", + description = "Critères de recherche invalides", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + examples = @ExampleObject( + value = """ + { + "message": "Critères de recherche invalides", + "details": "La date minimum ne peut pas être postérieure à la date maximum" + } + """ + ) + ) + ), + @APIResponse( + responseCode = "403", + description = "Accès non autorisé - Rôle SUPER_ADMIN ou ADMIN requis" + ), + @APIResponse( + responseCode = "500", + description = "Erreur interne du serveur" + ) + }) + @SecurityRequirement(name = "keycloak") + public Response searchMembresAdvanced( + @RequestBody( + description = "Critères de recherche avancée", + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = MembreSearchCriteria.class), + examples = @ExampleObject( + name = "Exemple de critères", + value = """ + { + "query": "marie", + "statut": "ACTIF", + "ageMin": 25, + "ageMax": 45, + "region": "Dakar", + "roles": ["PRESIDENT", "SECRETAIRE"], + "dateAdhesionMin": "2020-01-01", + "includeInactifs": false + } + """ + ) + ) + ) + @Valid MembreSearchCriteria criteria, + + @Parameter(description = "Numéro de page (0-based)", example = "0") + @QueryParam("page") @DefaultValue("0") int page, + + @Parameter(description = "Taille de la page", example = "20") + @QueryParam("size") @DefaultValue("20") int size, + + @Parameter(description = "Champ de tri", example = "nom") + @QueryParam("sort") @DefaultValue("nom") String sortField, + + @Parameter(description = "Direction du tri (asc/desc)", example = "asc") + @QueryParam("direction") @DefaultValue("asc") String sortDirection) { + + long startTime = System.currentTimeMillis(); + + LOG.infof("Recherche avancée de membres - critères: %s, page: %d, size: %d", + criteria.getDescription(), page, size); + + try { + // Validation des critères + if (criteria == null) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("message", "Les critères de recherche sont requis")) + .build(); + } + + // Nettoyage et validation des critères + criteria.sanitize(); + + if (!criteria.hasAnyCriteria()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("message", "Au moins un critère de recherche doit être spécifié")) + .build(); + } + + if (!criteria.isValid()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of( + "message", "Critères de recherche invalides", + "details", "Vérifiez la cohérence des dates et des âges" + )) + .build(); + } + + // Construction du tri + Sort sort = "desc".equalsIgnoreCase(sortDirection) ? + Sort.by(sortField).descending() : Sort.by(sortField).ascending(); + + // Exécution de la recherche + MembreSearchResultDTO result = membreService.searchMembresAdvanced( + criteria, Page.of(page, size), sort); + + // Calcul du temps d'exécution + long executionTime = System.currentTimeMillis() - startTime; + result.setExecutionTimeMs(executionTime); + + LOG.infof("Recherche avancée terminée - %d résultats trouvés en %d ms", + result.getTotalElements(), executionTime); + + return Response.ok(result).build(); + + } catch (IllegalArgumentException e) { + LOG.warnf("Erreur de validation dans la recherche avancée: %s", e.getMessage()); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("message", "Paramètres de recherche invalides", "details", e.getMessage())) + .build(); + } catch (Exception e) { + LOG.errorf(e, "Erreur lors de la recherche avancée de membres"); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("message", "Erreur interne lors de la recherche", "error", e.getMessage())) + .build(); + } + } } diff --git a/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/service/MembreService.java b/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/service/MembreService.java index 7ee0fff..b044543 100644 --- a/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/service/MembreService.java +++ b/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/service/MembreService.java @@ -1,6 +1,8 @@ package dev.lions.unionflow.server.service; import dev.lions.unionflow.server.api.dto.membre.MembreDTO; +import dev.lions.unionflow.server.api.dto.membre.MembreSearchCriteria; +import dev.lions.unionflow.server.api.dto.membre.MembreSearchResultDTO; import dev.lions.unionflow.server.entity.Membre; import dev.lions.unionflow.server.repository.MembreRepository; import io.quarkus.panache.common.Page; @@ -12,6 +14,9 @@ import org.jboss.logging.Logger; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.Period; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -285,14 +290,240 @@ public class MembreService { } /** - * Recherche avancée de membres avec filtres multiples + * Recherche avancée de membres avec filtres multiples (DEPRECATED) */ public List rechercheAvancee(String recherche, Boolean actif, LocalDate dateAdhesionMin, LocalDate dateAdhesionMax, Page page, Sort sort) { - LOG.infof("Recherche avancée - recherche: %s, actif: %s, dateMin: %s, dateMax: %s", + LOG.infof("Recherche avancée (DEPRECATED) - recherche: %s, actif: %s, dateMin: %s, dateMax: %s", recherche, actif, dateAdhesionMin, dateAdhesionMax); return membreRepository.rechercheAvancee(recherche, actif, dateAdhesionMin, dateAdhesionMax, page, sort); } + + /** + * Nouvelle recherche avancée de membres avec critères complets + * Retourne des résultats paginés avec statistiques + * + * @param criteria Critères de recherche + * @param page Pagination + * @param sort Tri + * @return Résultats de recherche avec métadonnées + */ + public MembreSearchResultDTO searchMembresAdvanced(MembreSearchCriteria criteria, Page page, Sort sort) { + LOG.infof("Recherche avancée de membres - critères: %s", criteria.getDescription()); + + try { + // Construction de la requête dynamique + StringBuilder queryBuilder = new StringBuilder("SELECT m FROM Membre m WHERE 1=1"); + Map parameters = new HashMap<>(); + + // Ajout des critères de recherche + addSearchCriteria(queryBuilder, parameters, criteria); + + // Requête pour compter le total + String countQuery = queryBuilder.toString().replace("SELECT m FROM Membre m", "SELECT COUNT(m) FROM Membre m"); + + // Exécution de la requête de comptage + long totalElements = Membre.find(countQuery, parameters).count(); + + if (totalElements == 0) { + return MembreSearchResultDTO.empty(criteria); + } + + // Ajout du tri et pagination + String finalQuery = queryBuilder.toString(); + if (sort != null) { + finalQuery += " ORDER BY " + buildOrderByClause(sort); + } + + // Exécution de la requête principale + List membres = Membre.find(finalQuery, parameters) + .page(page) + .list(); + + // Conversion en DTOs + List membresDTO = convertToDTOList(membres); + + // Calcul des statistiques + MembreSearchResultDTO.SearchStatistics statistics = calculateSearchStatistics(membres); + + // Construction du résultat + MembreSearchResultDTO result = MembreSearchResultDTO.builder() + .membres(membresDTO) + .totalElements(totalElements) + .totalPages((int) Math.ceil((double) totalElements / page.size)) + .currentPage(page.index) + .pageSize(page.size) + .criteria(criteria) + .statistics(statistics) + .build(); + + // Calcul des indicateurs de pagination + result.calculatePaginationFlags(); + + return result; + + } catch (Exception e) { + LOG.errorf(e, "Erreur lors de la recherche avancée de membres"); + throw new RuntimeException("Erreur lors de la recherche avancée", e); + } + } + + /** + * Ajoute les critères de recherche à la requête + */ + private void addSearchCriteria(StringBuilder queryBuilder, Map parameters, MembreSearchCriteria criteria) { + + // Recherche générale dans nom, prénom, email + if (criteria.getQuery() != null) { + queryBuilder.append(" AND (LOWER(m.nom) LIKE LOWER(:query) OR LOWER(m.prenom) LIKE LOWER(:query) OR LOWER(m.email) LIKE LOWER(:query))"); + parameters.put("query", "%" + criteria.getQuery() + "%"); + } + + // Recherche par nom + if (criteria.getNom() != null) { + queryBuilder.append(" AND LOWER(m.nom) LIKE LOWER(:nom)"); + parameters.put("nom", "%" + criteria.getNom() + "%"); + } + + // Recherche par prénom + if (criteria.getPrenom() != null) { + queryBuilder.append(" AND LOWER(m.prenom) LIKE LOWER(:prenom)"); + parameters.put("prenom", "%" + criteria.getPrenom() + "%"); + } + + // Recherche par email + if (criteria.getEmail() != null) { + queryBuilder.append(" AND LOWER(m.email) LIKE LOWER(:email)"); + parameters.put("email", "%" + criteria.getEmail() + "%"); + } + + // Recherche par téléphone + if (criteria.getTelephone() != null) { + queryBuilder.append(" AND m.telephone LIKE :telephone"); + parameters.put("telephone", "%" + criteria.getTelephone() + "%"); + } + + // Filtre par statut + if (criteria.getStatut() != null) { + boolean isActif = "ACTIF".equals(criteria.getStatut()); + queryBuilder.append(" AND m.actif = :actif"); + parameters.put("actif", isActif); + } else if (!Boolean.TRUE.equals(criteria.getIncludeInactifs())) { + // Par défaut, exclure les inactifs + queryBuilder.append(" AND m.actif = true"); + } + + // Filtre par dates d'adhésion + if (criteria.getDateAdhesionMin() != null) { + queryBuilder.append(" AND m.dateAdhesion >= :dateAdhesionMin"); + parameters.put("dateAdhesionMin", criteria.getDateAdhesionMin()); + } + + if (criteria.getDateAdhesionMax() != null) { + queryBuilder.append(" AND m.dateAdhesion <= :dateAdhesionMax"); + parameters.put("dateAdhesionMax", criteria.getDateAdhesionMax()); + } + + // Filtre par âge (calculé à partir de la date de naissance) + if (criteria.getAgeMin() != null) { + LocalDate maxBirthDate = LocalDate.now().minusYears(criteria.getAgeMin()); + queryBuilder.append(" AND m.dateNaissance <= :maxBirthDateForMinAge"); + parameters.put("maxBirthDateForMinAge", maxBirthDate); + } + + if (criteria.getAgeMax() != null) { + LocalDate minBirthDate = LocalDate.now().minusYears(criteria.getAgeMax() + 1).plusDays(1); + queryBuilder.append(" AND m.dateNaissance >= :minBirthDateForMaxAge"); + parameters.put("minBirthDateForMaxAge", minBirthDate); + } + + // Filtre par organisations (si implémenté dans l'entité) + if (criteria.getOrganisationIds() != null && !criteria.getOrganisationIds().isEmpty()) { + queryBuilder.append(" AND m.organisation.id IN :organisationIds"); + parameters.put("organisationIds", criteria.getOrganisationIds()); + } + + // Filtre par rôles (recherche dans le champ roles) + if (criteria.getRoles() != null && !criteria.getRoles().isEmpty()) { + StringBuilder roleCondition = new StringBuilder(" AND ("); + for (int i = 0; i < criteria.getRoles().size(); i++) { + if (i > 0) roleCondition.append(" OR "); + roleCondition.append("m.roles LIKE :role").append(i); + parameters.put("role" + i, "%" + criteria.getRoles().get(i) + "%"); + } + roleCondition.append(")"); + queryBuilder.append(roleCondition); + } + } + + /** + * Construit la clause ORDER BY à partir du Sort + */ + private String buildOrderByClause(Sort sort) { + if (sort == null || sort.getColumns().isEmpty()) { + return "m.nom ASC"; + } + + return sort.getColumns().stream() + .map(column -> "m." + column.getName() + " " + column.getDirection().name()) + .collect(Collectors.joining(", ")); + } + + /** + * Calcule les statistiques sur les résultats de recherche + */ + private MembreSearchResultDTO.SearchStatistics calculateSearchStatistics(List membres) { + if (membres.isEmpty()) { + return MembreSearchResultDTO.SearchStatistics.builder() + .membresActifs(0) + .membresInactifs(0) + .ageMoyen(0.0) + .ageMin(0) + .ageMax(0) + .nombreOrganisations(0) + .nombreRegions(0) + .ancienneteMoyenne(0.0) + .build(); + } + + long membresActifs = membres.stream().mapToLong(m -> Boolean.TRUE.equals(m.getActif()) ? 1 : 0).sum(); + long membresInactifs = membres.size() - membresActifs; + + // Calcul des âges + List ages = membres.stream() + .filter(m -> m.getDateNaissance() != null) + .map(m -> Period.between(m.getDateNaissance(), LocalDate.now()).getYears()) + .collect(Collectors.toList()); + + double ageMoyen = ages.stream().mapToInt(Integer::intValue).average().orElse(0.0); + int ageMin = ages.stream().mapToInt(Integer::intValue).min().orElse(0); + int ageMax = ages.stream().mapToInt(Integer::intValue).max().orElse(0); + + // Calcul de l'ancienneté moyenne + double ancienneteMoyenne = membres.stream() + .filter(m -> m.getDateAdhesion() != null) + .mapToDouble(m -> Period.between(m.getDateAdhesion(), LocalDate.now()).getYears()) + .average() + .orElse(0.0); + + // Nombre d'organisations (si relation disponible) + long nombreOrganisations = membres.stream() + .filter(m -> m.getOrganisation() != null) + .map(m -> m.getOrganisation().id) + .distinct() + .count(); + + return MembreSearchResultDTO.SearchStatistics.builder() + .membresActifs(membresActifs) + .membresInactifs(membresInactifs) + .ageMoyen(ageMoyen) + .ageMin(ageMin) + .ageMax(ageMax) + .nombreOrganisations(nombreOrganisations) + .nombreRegions(0) // À implémenter si champ région disponible + .ancienneteMoyenne(ancienneteMoyenne) + .build(); + } } diff --git a/unionflow-server-impl-quarkus/src/main/resources/application.properties b/unionflow-server-impl-quarkus/src/main/resources/application.properties index c9d005b..5557b80 100644 --- a/unionflow-server-impl-quarkus/src/main/resources/application.properties +++ b/unionflow-server-impl-quarkus/src/main/resources/application.properties @@ -32,7 +32,7 @@ quarkus.flyway.baseline-on-migrate=true quarkus.flyway.baseline-version=1.0.0 # Configuration Keycloak OIDC -quarkus.oidc.auth-server-url=http://192.168.1.145:8180/realms/unionflow +quarkus.oidc.auth-server-url=http://192.168.1.11:8180/realms/unionflow quarkus.oidc.client-id=unionflow-server quarkus.oidc.credentials.secret=unionflow-secret-2025 quarkus.oidc.tls.verification=none @@ -85,7 +85,7 @@ quarkus.log.category."io.quarkus".level=INFO # Configuration Keycloak pour développement (temporairement désactivé) %dev.quarkus.oidc.tenant-enabled=false -%dev.quarkus.oidc.auth-server-url=http://192.168.1.145:8180/realms/unionflow +%dev.quarkus.oidc.auth-server-url=http://192.168.1.11:8180/realms/unionflow %dev.quarkus.oidc.client-id=unionflow-server %dev.quarkus.oidc.credentials.secret=unionflow-secret-2025 %dev.quarkus.oidc.tls.verification=none @@ -114,7 +114,7 @@ quarkus.log.category."io.quarkus".level=INFO %prod.quarkus.log.category.root.level=WARN # Configuration Keycloak pour production -%prod.quarkus.oidc.auth-server-url=${KEYCLOAK_SERVER_URL:http://192.168.1.145:8180/realms/unionflow} +%prod.quarkus.oidc.auth-server-url=${KEYCLOAK_SERVER_URL:http://192.168.1.11:8180/realms/unionflow} %prod.quarkus.oidc.client-id=${KEYCLOAK_CLIENT_ID:unionflow-server} %prod.quarkus.oidc.credentials.secret=${KEYCLOAK_CLIENT_SECRET} %prod.quarkus.oidc.tls.verification=required diff --git a/unionflow-server-impl-quarkus/src/test/java/dev/lions/unionflow/server/resource/MembreResourceAdvancedSearchTest.java b/unionflow-server-impl-quarkus/src/test/java/dev/lions/unionflow/server/resource/MembreResourceAdvancedSearchTest.java new file mode 100644 index 0000000..63102be --- /dev/null +++ b/unionflow-server-impl-quarkus/src/test/java/dev/lions/unionflow/server/resource/MembreResourceAdvancedSearchTest.java @@ -0,0 +1,320 @@ +package dev.lions.unionflow.server.resource; + +import dev.lions.unionflow.server.api.dto.membre.MembreSearchCriteria; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.*; + +import java.time.LocalDate; +import java.util.List; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.*; + +/** + * Tests d'intégration pour l'endpoint de recherche avancée des membres + * + * @author UnionFlow Team + * @version 1.0 + * @since 2025-01-19 + */ +@QuarkusTest +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class MembreResourceAdvancedSearchTest { + + private static final String ADVANCED_SEARCH_ENDPOINT = "/api/membres/search/advanced"; + + @Test + @Order(1) + @TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"}) + @DisplayName("POST /api/membres/search/advanced doit fonctionner avec critères valides") + void testAdvancedSearchWithValidCriteria() { + MembreSearchCriteria criteria = MembreSearchCriteria.builder() + .query("marie") + .statut("ACTIF") + .ageMin(20) + .ageMax(50) + .build(); + + given() + .contentType(ContentType.JSON) + .body(criteria) + .queryParam("page", 0) + .queryParam("size", 20) + .queryParam("sort", "nom") + .queryParam("direction", "asc") + .when() + .post(ADVANCED_SEARCH_ENDPOINT) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("membres", notNullValue()) + .body("totalElements", greaterThanOrEqualTo(0)) + .body("totalPages", greaterThanOrEqualTo(0)) + .body("currentPage", equalTo(0)) + .body("pageSize", equalTo(20)) + .body("hasNext", notNullValue()) + .body("hasPrevious", equalTo(false)) + .body("isFirst", equalTo(true)) + .body("executionTimeMs", greaterThan(0)) + .body("statistics", notNullValue()) + .body("statistics.membresActifs", greaterThanOrEqualTo(0)) + .body("statistics.membresInactifs", greaterThanOrEqualTo(0)) + .body("criteria.query", equalTo("marie")) + .body("criteria.statut", equalTo("ACTIF")); + } + + @Test + @Order(2) + @TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"}) + @DisplayName("POST /api/membres/search/advanced doit fonctionner avec critères multiples") + void testAdvancedSearchWithMultipleCriteria() { + MembreSearchCriteria criteria = MembreSearchCriteria.builder() + .email("@unionflow.com") + .dateAdhesionMin(LocalDate.of(2020, 1, 1)) + .dateAdhesionMax(LocalDate.of(2025, 12, 31)) + .roles(List.of("ADMIN", "SUPER_ADMIN")) + .includeInactifs(false) + .build(); + + given() + .contentType(ContentType.JSON) + .body(criteria) + .queryParam("page", 0) + .queryParam("size", 10) + .when() + .post(ADVANCED_SEARCH_ENDPOINT) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("membres", notNullValue()) + .body("totalElements", greaterThanOrEqualTo(0)) + .body("criteria.email", equalTo("@unionflow.com")) + .body("criteria.roles", hasItems("ADMIN", "SUPER_ADMIN")) + .body("criteria.includeInactifs", equalTo(false)); + } + + @Test + @Order(3) + @TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"}) + @DisplayName("POST /api/membres/search/advanced doit gérer la pagination") + void testAdvancedSearchPagination() { + MembreSearchCriteria criteria = MembreSearchCriteria.builder() + .includeInactifs(true) // Inclure tous les membres + .build(); + + given() + .contentType(ContentType.JSON) + .body(criteria) + .queryParam("page", 0) + .queryParam("size", 2) // Petite taille pour tester la pagination + .when() + .post(ADVANCED_SEARCH_ENDPOINT) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("currentPage", equalTo(0)) + .body("pageSize", equalTo(2)) + .body("isFirst", equalTo(true)) + .body("hasPrevious", equalTo(false)); + } + + @Test + @Order(4) + @TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"}) + @DisplayName("POST /api/membres/search/advanced doit gérer le tri") + void testAdvancedSearchSorting() { + MembreSearchCriteria criteria = MembreSearchCriteria.builder() + .statut("ACTIF") + .build(); + + given() + .contentType(ContentType.JSON) + .body(criteria) + .queryParam("sort", "nom") + .queryParam("direction", "desc") + .when() + .post(ADVANCED_SEARCH_ENDPOINT) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("membres", notNullValue()); + } + + @Test + @Order(5) + @TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"}) + @DisplayName("POST /api/membres/search/advanced doit retourner 400 pour critères vides") + void testAdvancedSearchWithEmptyCriteria() { + MembreSearchCriteria emptyCriteria = MembreSearchCriteria.builder().build(); + + given() + .contentType(ContentType.JSON) + .body(emptyCriteria) + .when() + .post(ADVANCED_SEARCH_ENDPOINT) + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .body("message", containsString("Au moins un critère de recherche doit être spécifié")); + } + + @Test + @Order(6) + @TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"}) + @DisplayName("POST /api/membres/search/advanced doit retourner 400 pour critères invalides") + void testAdvancedSearchWithInvalidCriteria() { + MembreSearchCriteria invalidCriteria = MembreSearchCriteria.builder() + .ageMin(50) + .ageMax(30) // Âge max < âge min + .build(); + + given() + .contentType(ContentType.JSON) + .body(invalidCriteria) + .when() + .post(ADVANCED_SEARCH_ENDPOINT) + .then() + .statusCode(400) + .contentType(ContentType.JSON) + .body("message", containsString("Critères de recherche invalides")); + } + + @Test + @Order(7) + @TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"}) + @DisplayName("POST /api/membres/search/advanced doit retourner 400 pour body null") + void testAdvancedSearchWithNullBody() { + given() + .contentType(ContentType.JSON) + .when() + .post(ADVANCED_SEARCH_ENDPOINT) + .then() + .statusCode(400); + } + + @Test + @Order(8) + @TestSecurity(user = "marie.active@unionflow.com", roles = {"MEMBRE_ACTIF"}) + @DisplayName("POST /api/membres/search/advanced doit retourner 403 pour utilisateur non autorisé") + void testAdvancedSearchUnauthorized() { + MembreSearchCriteria criteria = MembreSearchCriteria.builder() + .query("test") + .build(); + + given() + .contentType(ContentType.JSON) + .body(criteria) + .when() + .post(ADVANCED_SEARCH_ENDPOINT) + .then() + .statusCode(403); + } + + @Test + @Order(9) + @DisplayName("POST /api/membres/search/advanced doit retourner 401 pour utilisateur non authentifié") + void testAdvancedSearchUnauthenticated() { + MembreSearchCriteria criteria = MembreSearchCriteria.builder() + .query("test") + .build(); + + given() + .contentType(ContentType.JSON) + .body(criteria) + .when() + .post(ADVANCED_SEARCH_ENDPOINT) + .then() + .statusCode(401); + } + + @Test + @Order(10) + @TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"}) + @DisplayName("POST /api/membres/search/advanced doit fonctionner pour ADMIN") + void testAdvancedSearchForAdmin() { + MembreSearchCriteria criteria = MembreSearchCriteria.builder() + .statut("ACTIF") + .build(); + + given() + .contentType(ContentType.JSON) + .body(criteria) + .when() + .post(ADVANCED_SEARCH_ENDPOINT) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("membres", notNullValue()); + } + + @Test + @Order(11) + @TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"}) + @DisplayName("POST /api/membres/search/advanced doit inclure le temps d'exécution") + void testAdvancedSearchExecutionTime() { + MembreSearchCriteria criteria = MembreSearchCriteria.builder() + .query("test") + .build(); + + given() + .contentType(ContentType.JSON) + .body(criteria) + .when() + .post(ADVANCED_SEARCH_ENDPOINT) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("executionTimeMs", greaterThan(0)) + .body("executionTimeMs", lessThan(5000)); // Moins de 5 secondes + } + + @Test + @Order(12) + @TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"}) + @DisplayName("POST /api/membres/search/advanced doit retourner des statistiques complètes") + void testAdvancedSearchStatistics() { + MembreSearchCriteria criteria = MembreSearchCriteria.builder() + .includeInactifs(true) + .build(); + + given() + .contentType(ContentType.JSON) + .body(criteria) + .when() + .post(ADVANCED_SEARCH_ENDPOINT) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("statistics", notNullValue()) + .body("statistics.membresActifs", greaterThanOrEqualTo(0)) + .body("statistics.membresInactifs", greaterThanOrEqualTo(0)) + .body("statistics.ageMoyen", greaterThanOrEqualTo(0.0)) + .body("statistics.ageMin", greaterThanOrEqualTo(0)) + .body("statistics.ageMax", greaterThanOrEqualTo(0)) + .body("statistics.nombreOrganisations", greaterThanOrEqualTo(0)) + .body("statistics.ancienneteMoyenne", greaterThanOrEqualTo(0.0)); + } + + @Test + @Order(13) + @TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"}) + @DisplayName("POST /api/membres/search/advanced doit gérer les caractères spéciaux") + void testAdvancedSearchWithSpecialCharacters() { + MembreSearchCriteria criteria = MembreSearchCriteria.builder() + .query("marie-josé") + .nom("o'connor") + .build(); + + given() + .contentType(ContentType.JSON) + .body(criteria) + .when() + .post(ADVANCED_SEARCH_ENDPOINT) + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .body("membres", notNullValue()); + } +} diff --git a/unionflow-server-impl-quarkus/src/test/java/dev/lions/unionflow/server/service/MembreServiceAdvancedSearchTest.java b/unionflow-server-impl-quarkus/src/test/java/dev/lions/unionflow/server/service/MembreServiceAdvancedSearchTest.java new file mode 100644 index 0000000..c7c556f --- /dev/null +++ b/unionflow-server-impl-quarkus/src/test/java/dev/lions/unionflow/server/service/MembreServiceAdvancedSearchTest.java @@ -0,0 +1,409 @@ +package dev.lions.unionflow.server.service; + +import dev.lions.unionflow.server.api.dto.membre.MembreSearchCriteria; +import dev.lions.unionflow.server.api.dto.membre.MembreSearchResultDTO; +import dev.lions.unionflow.server.entity.Membre; +import dev.lions.unionflow.server.entity.Organisation; +import io.quarkus.panache.common.Page; +import io.quarkus.panache.common.Sort; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.*; + +/** + * Tests pour la recherche avancée de membres + * + * @author UnionFlow Team + * @version 1.0 + * @since 2025-01-19 + */ +@QuarkusTest +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class MembreServiceAdvancedSearchTest { + + @Inject + MembreService membreService; + + private static Organisation testOrganisation; + private static List testMembres; + + @BeforeAll + @Transactional + static void setupTestData() { + // Créer une organisation de test + testOrganisation = Organisation.builder() + .nom("Organisation Test") + .typeOrganisation("ASSOCIATION") + .statut("ACTIF") + .actif(true) + .dateCreation(LocalDateTime.now()) + .build(); + testOrganisation.persist(); + + // Créer des membres de test avec différents profils + testMembres = List.of( + // Membre actif jeune + Membre.builder() + .numeroMembre("UF-2025-TEST001") + .nom("Dupont") + .prenom("Marie") + .email("marie.dupont@test.com") + .telephone("+221701234567") + .dateNaissance(LocalDate.of(1995, 5, 15)) + .dateAdhesion(LocalDate.of(2023, 1, 15)) + .roles("MEMBRE,SECRETAIRE") + .actif(true) + .organisation(testOrganisation) + .dateCreation(LocalDateTime.now()) + .build(), + + // Membre actif âgé + Membre.builder() + .numeroMembre("UF-2025-TEST002") + .nom("Martin") + .prenom("Jean") + .email("jean.martin@test.com") + .telephone("+221701234568") + .dateNaissance(LocalDate.of(1970, 8, 20)) + .dateAdhesion(LocalDate.of(2020, 3, 10)) + .roles("MEMBRE,PRESIDENT") + .actif(true) + .organisation(testOrganisation) + .dateCreation(LocalDateTime.now()) + .build(), + + // Membre inactif + Membre.builder() + .numeroMembre("UF-2025-TEST003") + .nom("Diallo") + .prenom("Fatou") + .email("fatou.diallo@test.com") + .telephone("+221701234569") + .dateNaissance(LocalDate.of(1985, 12, 3)) + .dateAdhesion(LocalDate.of(2021, 6, 5)) + .roles("MEMBRE") + .actif(false) + .organisation(testOrganisation) + .dateCreation(LocalDateTime.now()) + .build(), + + // Membre avec email spécifique + Membre.builder() + .numeroMembre("UF-2025-TEST004") + .nom("Sow") + .prenom("Amadou") + .email("amadou.sow@unionflow.com") + .telephone("+221701234570") + .dateNaissance(LocalDate.of(1988, 3, 12)) + .dateAdhesion(LocalDate.of(2022, 9, 20)) + .roles("MEMBRE,TRESORIER") + .actif(true) + .organisation(testOrganisation) + .dateCreation(LocalDateTime.now()) + .build() + ); + + // Persister tous les membres + testMembres.forEach(membre -> membre.persist()); + } + + @AfterAll + @Transactional + static void cleanupTestData() { + // Nettoyer les données de test + if (testMembres != null) { + testMembres.forEach(membre -> { + if (membre.isPersistent()) { + membre.delete(); + } + }); + } + + if (testOrganisation != null && testOrganisation.isPersistent()) { + testOrganisation.delete(); + } + } + + @Test + @Order(1) + @DisplayName("Doit effectuer une recherche par terme général") + void testSearchByGeneralQuery() { + // Given + MembreSearchCriteria criteria = MembreSearchCriteria.builder() + .query("marie") + .build(); + + // When + MembreSearchResultDTO result = membreService.searchMembresAdvanced( + criteria, Page.of(0, 10), Sort.by("nom")); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getMembres()).hasSize(1); + assertThat(result.getMembres().get(0).getPrenom()).isEqualToIgnoringCase("Marie"); + assertThat(result.isFirst()).isTrue(); + assertThat(result.isLast()).isTrue(); + } + + @Test + @Order(2) + @DisplayName("Doit filtrer par statut actif") + void testSearchByActiveStatus() { + // Given + MembreSearchCriteria criteria = MembreSearchCriteria.builder() + .statut("ACTIF") + .build(); + + // When + MembreSearchResultDTO result = membreService.searchMembresAdvanced( + criteria, Page.of(0, 10), Sort.by("nom")); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getTotalElements()).isEqualTo(3); // 3 membres actifs + assertThat(result.getMembres()).hasSize(3); + assertThat(result.getMembres()).allMatch(membre -> "ACTIF".equals(membre.getStatut())); + } + + @Test + @Order(3) + @DisplayName("Doit filtrer par tranche d'âge") + void testSearchByAgeRange() { + // Given + MembreSearchCriteria criteria = MembreSearchCriteria.builder() + .ageMin(25) + .ageMax(35) + .build(); + + // When + MembreSearchResultDTO result = membreService.searchMembresAdvanced( + criteria, Page.of(0, 10), Sort.by("nom")); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getTotalElements()).isGreaterThan(0); + + // Vérifier que tous les membres sont dans la tranche d'âge + result.getMembres().forEach(membre -> { + if (membre.getDateNaissance() != null) { + int age = LocalDate.now().getYear() - membre.getDateNaissance().getYear(); + assertThat(age).isBetween(25, 35); + } + }); + } + + @Test + @Order(4) + @DisplayName("Doit filtrer par période d'adhésion") + void testSearchByAdhesionPeriod() { + // Given + MembreSearchCriteria criteria = MembreSearchCriteria.builder() + .dateAdhesionMin(LocalDate.of(2022, 1, 1)) + .dateAdhesionMax(LocalDate.of(2023, 12, 31)) + .build(); + + // When + MembreSearchResultDTO result = membreService.searchMembresAdvanced( + criteria, Page.of(0, 10), Sort.by("dateAdhesion")); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getTotalElements()).isGreaterThan(0); + + // Vérifier que toutes les dates d'adhésion sont dans la période + result.getMembres().forEach(membre -> { + if (membre.getDateAdhesion() != null) { + assertThat(membre.getDateAdhesion()) + .isAfterOrEqualTo(LocalDate.of(2022, 1, 1)) + .isBeforeOrEqualTo(LocalDate.of(2023, 12, 31)); + } + }); + } + + @Test + @Order(5) + @DisplayName("Doit rechercher par email avec domaine spécifique") + void testSearchByEmailDomain() { + // Given + MembreSearchCriteria criteria = MembreSearchCriteria.builder() + .email("@unionflow.com") + .build(); + + // When + MembreSearchResultDTO result = membreService.searchMembresAdvanced( + criteria, Page.of(0, 10), Sort.by("nom")); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getMembres()).hasSize(1); + assertThat(result.getMembres().get(0).getEmail()).contains("@unionflow.com"); + } + + @Test + @Order(6) + @DisplayName("Doit filtrer par rôles") + void testSearchByRoles() { + // Given + MembreSearchCriteria criteria = MembreSearchCriteria.builder() + .roles(List.of("PRESIDENT", "SECRETAIRE")) + .build(); + + // When + MembreSearchResultDTO result = membreService.searchMembresAdvanced( + criteria, Page.of(0, 10), Sort.by("nom")); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getTotalElements()).isGreaterThan(0); + + // Vérifier que tous les membres ont au moins un des rôles recherchés + result.getMembres().forEach(membre -> { + assertThat(membre.getRole()).satisfiesAnyOf( + role -> assertThat(role).contains("PRESIDENT"), + role -> assertThat(role).contains("SECRETAIRE") + ); + }); + } + + @Test + @Order(7) + @DisplayName("Doit gérer la pagination correctement") + void testPagination() { + // Given + MembreSearchCriteria criteria = MembreSearchCriteria.builder() + .includeInactifs(true) // Inclure tous les membres + .build(); + + // When - Première page + MembreSearchResultDTO firstPage = membreService.searchMembresAdvanced( + criteria, Page.of(0, 2), Sort.by("nom")); + + // Then + assertThat(firstPage).isNotNull(); + assertThat(firstPage.getCurrentPage()).isEqualTo(0); + assertThat(firstPage.getPageSize()).isEqualTo(2); + assertThat(firstPage.getMembres()).hasSizeLessThanOrEqualTo(2); + assertThat(firstPage.isFirst()).isTrue(); + + if (firstPage.getTotalElements() > 2) { + assertThat(firstPage.isLast()).isFalse(); + assertThat(firstPage.isHasNext()).isTrue(); + } + } + + @Test + @Order(8) + @DisplayName("Doit calculer les statistiques correctement") + void testStatisticsCalculation() { + // Given + MembreSearchCriteria criteria = MembreSearchCriteria.builder() + .includeInactifs(true) // Inclure tous les membres + .build(); + + // When + MembreSearchResultDTO result = membreService.searchMembresAdvanced( + criteria, Page.of(0, 10), Sort.by("nom")); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getStatistics()).isNotNull(); + + MembreSearchResultDTO.SearchStatistics stats = result.getStatistics(); + assertThat(stats.getMembresActifs()).isEqualTo(3); + assertThat(stats.getMembresInactifs()).isEqualTo(1); + assertThat(stats.getAgeMoyen()).isGreaterThan(0); + assertThat(stats.getAgeMin()).isGreaterThan(0); + assertThat(stats.getAgeMax()).isGreaterThan(stats.getAgeMin()); + assertThat(stats.getAncienneteMoyenne()).isGreaterThanOrEqualTo(0); + } + + @Test + @Order(9) + @DisplayName("Doit retourner un résultat vide pour critères impossibles") + void testEmptyResultForImpossibleCriteria() { + // Given + MembreSearchCriteria criteria = MembreSearchCriteria.builder() + .query("membre_inexistant_xyz") + .build(); + + // When + MembreSearchResultDTO result = membreService.searchMembresAdvanced( + criteria, Page.of(0, 10), Sort.by("nom")); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getTotalElements()).isEqualTo(0); + assertThat(result.getMembres()).isEmpty(); + assertThat(result.isEmpty()).isTrue(); + assertThat(result.getTotalPages()).isEqualTo(0); + } + + @Test + @Order(10) + @DisplayName("Doit valider la cohérence des critères") + void testCriteriaValidation() { + // Given - Critères incohérents + MembreSearchCriteria invalidCriteria = MembreSearchCriteria.builder() + .ageMin(50) + .ageMax(30) // Âge max < âge min + .build(); + + // When & Then + assertThat(invalidCriteria.isValid()).isFalse(); + } + + @Test + @Order(11) + @DisplayName("Doit avoir des performances acceptables (< 500ms)") + void testSearchPerformance() { + // Given + MembreSearchCriteria criteria = MembreSearchCriteria.builder() + .includeInactifs(true) + .build(); + + // When & Then - Mesurer le temps d'exécution + long startTime = System.currentTimeMillis(); + + MembreSearchResultDTO result = membreService.searchMembresAdvanced( + criteria, Page.of(0, 20), Sort.by("nom")); + + long executionTime = System.currentTimeMillis() - startTime; + + // Vérifications + assertThat(result).isNotNull(); + assertThat(executionTime).isLessThan(500L); // Moins de 500ms + + // Log pour monitoring + System.out.printf("Recherche avancée exécutée en %d ms pour %d résultats%n", + executionTime, result.getTotalElements()); + } + + @Test + @Order(12) + @DisplayName("Doit gérer les critères avec caractères spéciaux") + void testSearchWithSpecialCharacters() { + // Given + MembreSearchCriteria criteria = MembreSearchCriteria.builder() + .query("marie-josé") + .nom("o'connor") + .build(); + + // When + MembreSearchResultDTO result = membreService.searchMembresAdvanced( + criteria, Page.of(0, 10), Sort.by("nom")); + + // Then + assertThat(result).isNotNull(); + // La recherche ne doit pas échouer même avec des caractères spéciaux + assertThat(result.getTotalElements()).isGreaterThanOrEqualTo(0); + } +}