From 73459b30922a6f1ffcd34fb4bf1eae0f4a6c283b Mon Sep 17 00:00:00 2001 From: DahoudG <41957584+DahoudG@users.noreply.github.com> Date: Sat, 13 Sep 2025 19:05:06 +0000 Subject: [PATCH] Version propre - Dashboard enhanced --- unionflow-mobile-apps/devtools_options.yaml | 3 + unionflow-mobile-apps/lib/app.dart | 4 +- unionflow-mobile-apps/lib/app_temp.dart | 33 - .../lib/app_ultra_simple.dart | 33 - .../lib/core/di/injection.config.dart | 10 + .../lib/core/models/cotisation_model.dart | 271 ++++ .../lib/core/models/cotisation_model.g.dart | 77 ++ .../lib/core/services/api_service.dart | 186 +++ .../auth/presentation/pages/login_page.dart | 6 +- .../auth/presentation/widgets/login_form.dart | 31 +- .../cotisation_repository_impl.dart | 84 ++ .../repositories/cotisation_repository.dart | 46 + .../presentation/bloc/cotisations_bloc.dart | 509 ++++++++ .../presentation/bloc/cotisations_event.dart | 206 ++++ .../presentation/bloc/cotisations_state.dart | 247 ++++ .../pages/cotisations_list_page.dart | 338 +++++ .../presentation/widgets/cotisation_card.dart | 316 +++++ .../widgets/cotisations_stats_card.dart | 283 +++++ .../presentation/pages/dashboard_page.dart | 679 +--------- .../pages/enhanced_dashboard.dart | 485 -------- .../widgets/actions/action_card_widget.dart | 101 ++ .../widgets/actions/quick_actions_widget.dart | 151 +++ .../activities/activity_item_widget.dart | 148 +++ .../activities/recent_activities_widget.dart | 162 +++ .../charts/charts_analytics_widget.dart | 432 +++++++ .../widgets/common/section_header_widget.dart | 31 + .../widgets/kpi/kpi_card_widget.dart | 289 +++++ .../widgets/kpi/kpi_cards_widget.dart | 171 +++ .../welcome/welcome_section_widget.dart | 85 ++ .../presentation/bloc/membres_state.dart | 3 + .../pages/membre_create_page.dart | 937 ++++++++++++++ .../pages/membre_details_page.dart | 474 +++++++ .../presentation/pages/membre_edit_page.dart | 1096 +++++++++++++++++ .../pages/membres_dashboard_page.dart | 155 +++ .../presentation/pages/membres_list_page.dart | 121 +- .../widgets/dashboard_chart_card.dart | 211 ++++ .../widgets/dashboard_stat_card.dart | 299 +++++ .../widgets/membre_actions_section.dart | 456 +++++++ .../widgets/membre_cotisations_section.dart | 431 +++++++ .../widgets/membre_delete_dialog.dart | 495 ++++++++ .../widgets/membre_info_section.dart | 373 ++++++ .../widgets/membre_stats_section.dart | 592 +++++++++ .../widgets/membres_advanced_search.dart | 626 ++++++++++ .../widgets/membres_export_dialog.dart | 433 +++++++ .../modern_floating_action_button.dart | 340 +++++ .../presentation/widgets/modern_tab_bar.dart | 205 +++ .../widgets/professional_bar_chart.dart | 269 ++++ .../widgets/professional_line_chart.dart | 282 +++++ .../widgets/professional_pie_chart.dart | 307 +++++ .../presentation/widgets/stats_grid_card.dart | 243 ++++ .../widgets/stats_overview_card.dart | 281 +++++ .../presentation/pages/main_navigation.dart | 22 +- .../presentation/pages/splash_screen.dart | 14 +- unionflow-mobile-apps/lib/main.dart | 28 +- unionflow-mobile-apps/lib/main_temp.dart | 80 -- .../lib/main_ultra_simple.dart | 88 -- .../lib/shared/theme/design_system.dart | 263 ++++ .../lib/shared/widgets/coming_soon_page.dart | 15 +- .../unionflow/server/entity/Cotisation.java | 211 ++++ .../repository/CotisationRepository.java | 255 ++++ .../server/repository/MembreRepository.java | 78 +- .../server/resource/CotisationResource.java | 498 ++++++++ .../server/resource/MembreResource.java | 135 +- .../server/service/CotisationService.java | 411 +++++++ .../server/service/MembreService.java | 155 +++ .../src/main/resources/application.yml | 21 +- .../src/main/resources/import-test-data.sql | 44 + .../src/main/resources/import.sql | 44 + .../resource/CotisationResourceTest.java | 329 +++++ .../server/resource/MembreResourceTest.java | 78 +- 70 files changed, 15317 insertions(+), 1498 deletions(-) create mode 100644 unionflow-mobile-apps/devtools_options.yaml delete mode 100644 unionflow-mobile-apps/lib/app_temp.dart delete mode 100644 unionflow-mobile-apps/lib/app_ultra_simple.dart create mode 100644 unionflow-mobile-apps/lib/core/models/cotisation_model.dart create mode 100644 unionflow-mobile-apps/lib/core/models/cotisation_model.g.dart create mode 100644 unionflow-mobile-apps/lib/features/cotisations/data/repositories/cotisation_repository_impl.dart create mode 100644 unionflow-mobile-apps/lib/features/cotisations/domain/repositories/cotisation_repository.dart create mode 100644 unionflow-mobile-apps/lib/features/cotisations/presentation/bloc/cotisations_bloc.dart create mode 100644 unionflow-mobile-apps/lib/features/cotisations/presentation/bloc/cotisations_event.dart create mode 100644 unionflow-mobile-apps/lib/features/cotisations/presentation/bloc/cotisations_state.dart create mode 100644 unionflow-mobile-apps/lib/features/cotisations/presentation/pages/cotisations_list_page.dart create mode 100644 unionflow-mobile-apps/lib/features/cotisations/presentation/widgets/cotisation_card.dart create mode 100644 unionflow-mobile-apps/lib/features/cotisations/presentation/widgets/cotisations_stats_card.dart delete mode 100644 unionflow-mobile-apps/lib/features/dashboard/presentation/pages/enhanced_dashboard.dart create mode 100644 unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/actions/action_card_widget.dart create mode 100644 unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/actions/quick_actions_widget.dart create mode 100644 unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/activities/activity_item_widget.dart create mode 100644 unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/activities/recent_activities_widget.dart create mode 100644 unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/charts/charts_analytics_widget.dart create mode 100644 unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/common/section_header_widget.dart create mode 100644 unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/kpi/kpi_card_widget.dart create mode 100644 unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/kpi/kpi_cards_widget.dart create mode 100644 unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/welcome/welcome_section_widget.dart create mode 100644 unionflow-mobile-apps/lib/features/members/presentation/pages/membre_create_page.dart create mode 100644 unionflow-mobile-apps/lib/features/members/presentation/pages/membre_details_page.dart create mode 100644 unionflow-mobile-apps/lib/features/members/presentation/pages/membre_edit_page.dart create mode 100644 unionflow-mobile-apps/lib/features/members/presentation/pages/membres_dashboard_page.dart create mode 100644 unionflow-mobile-apps/lib/features/members/presentation/widgets/dashboard_chart_card.dart create mode 100644 unionflow-mobile-apps/lib/features/members/presentation/widgets/dashboard_stat_card.dart create mode 100644 unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_actions_section.dart create mode 100644 unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_cotisations_section.dart create mode 100644 unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_delete_dialog.dart create mode 100644 unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_info_section.dart create mode 100644 unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_stats_section.dart create mode 100644 unionflow-mobile-apps/lib/features/members/presentation/widgets/membres_advanced_search.dart create mode 100644 unionflow-mobile-apps/lib/features/members/presentation/widgets/membres_export_dialog.dart create mode 100644 unionflow-mobile-apps/lib/features/members/presentation/widgets/modern_floating_action_button.dart create mode 100644 unionflow-mobile-apps/lib/features/members/presentation/widgets/modern_tab_bar.dart create mode 100644 unionflow-mobile-apps/lib/features/members/presentation/widgets/professional_bar_chart.dart create mode 100644 unionflow-mobile-apps/lib/features/members/presentation/widgets/professional_line_chart.dart create mode 100644 unionflow-mobile-apps/lib/features/members/presentation/widgets/professional_pie_chart.dart create mode 100644 unionflow-mobile-apps/lib/features/members/presentation/widgets/stats_grid_card.dart create mode 100644 unionflow-mobile-apps/lib/features/members/presentation/widgets/stats_overview_card.dart delete mode 100644 unionflow-mobile-apps/lib/main_temp.dart delete mode 100644 unionflow-mobile-apps/lib/main_ultra_simple.dart create mode 100644 unionflow-mobile-apps/lib/shared/theme/design_system.dart create mode 100644 unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/entity/Cotisation.java create mode 100644 unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/repository/CotisationRepository.java create mode 100644 unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/resource/CotisationResource.java create mode 100644 unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/service/CotisationService.java create mode 100644 unionflow-server-impl-quarkus/src/main/resources/import-test-data.sql create mode 100644 unionflow-server-impl-quarkus/src/main/resources/import.sql create mode 100644 unionflow-server-impl-quarkus/src/test/java/dev/lions/unionflow/server/resource/CotisationResourceTest.java diff --git a/unionflow-mobile-apps/devtools_options.yaml b/unionflow-mobile-apps/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/unionflow-mobile-apps/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/unionflow-mobile-apps/lib/app.dart b/unionflow-mobile-apps/lib/app.dart index f94c469..4f98bbc 100644 --- a/unionflow-mobile-apps/lib/app.dart +++ b/unionflow-mobile-apps/lib/app.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'core/auth/bloc/auth_bloc.dart'; +import 'core/auth/bloc/temp_auth_bloc.dart'; import 'core/auth/models/auth_state.dart'; import 'features/splash/presentation/pages/splash_screen.dart'; import 'features/auth/presentation/pages/login_page.dart'; @@ -12,7 +12,7 @@ class AppWrapper extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocBuilder( + return BlocBuilder( builder: (context, state) { switch (state.status) { case AuthStatus.unknown: diff --git a/unionflow-mobile-apps/lib/app_temp.dart b/unionflow-mobile-apps/lib/app_temp.dart deleted file mode 100644 index 8b99993..0000000 --- a/unionflow-mobile-apps/lib/app_temp.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'core/auth/bloc/temp_auth_bloc.dart'; -import 'core/auth/models/auth_state.dart'; -import 'features/splash/presentation/pages/splash_screen.dart'; -import 'features/auth/presentation/pages/login_page_temp.dart'; -import 'features/navigation/presentation/pages/main_navigation.dart'; - -/// Wrapper temporaire de l'application -class AppTempWrapper extends StatelessWidget { - const AppTempWrapper({super.key}); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - switch (state.status) { - case AuthStatus.unknown: - case AuthStatus.checking: - return const SplashScreen(); - - case AuthStatus.authenticated: - return const MainNavigation(); - - case AuthStatus.unauthenticated: - case AuthStatus.error: - case AuthStatus.expired: - return const TempLoginPage(); - } - }, - ); - } -} \ No newline at end of file diff --git a/unionflow-mobile-apps/lib/app_ultra_simple.dart b/unionflow-mobile-apps/lib/app_ultra_simple.dart deleted file mode 100644 index 6c4c4fa..0000000 --- a/unionflow-mobile-apps/lib/app_ultra_simple.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'main_ultra_simple.dart'; -import 'core/auth/models/auth_state.dart'; -import 'features/splash/presentation/pages/splash_screen.dart'; -import 'features/auth/presentation/pages/login_page_temp.dart'; -import 'features/navigation/presentation/pages/main_navigation.dart'; - -/// Wrapper ultra-simple de l'application -class UltraSimpleAppWrapper extends StatelessWidget { - const UltraSimpleAppWrapper({super.key}); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - switch (state.status) { - case AuthStatus.unknown: - case AuthStatus.checking: - return const SplashScreen(); - - case AuthStatus.authenticated: - return const MainNavigation(); - - case AuthStatus.unauthenticated: - case AuthStatus.error: - case AuthStatus.expired: - return const TempLoginPage(); - } - }, - ); - } -} \ No newline at end of file diff --git a/unionflow-mobile-apps/lib/core/di/injection.config.dart b/unionflow-mobile-apps/lib/core/di/injection.config.dart index de6d991..8d5e583 100644 --- a/unionflow-mobile-apps/lib/core/di/injection.config.dart +++ b/unionflow-mobile-apps/lib/core/di/injection.config.dart @@ -21,6 +21,12 @@ import 'package:unionflow_mobile_apps/core/network/auth_interceptor.dart' as _i772; import 'package:unionflow_mobile_apps/core/network/dio_client.dart' as _i978; import 'package:unionflow_mobile_apps/core/services/api_service.dart' as _i238; +import 'package:unionflow_mobile_apps/features/cotisations/data/repositories/cotisation_repository_impl.dart' + as _i991; +import 'package:unionflow_mobile_apps/features/cotisations/domain/repositories/cotisation_repository.dart' + as _i961; +import 'package:unionflow_mobile_apps/features/cotisations/presentation/bloc/cotisations_bloc.dart' + as _i919; import 'package:unionflow_mobile_apps/features/members/data/repositories/membre_repository_impl.dart' as _i108; import 'package:unionflow_mobile_apps/features/members/domain/repositories/membre_repository.dart' @@ -47,6 +53,8 @@ extension GetItInjectableX on _i174.GetIt { () => _i238.ApiService(gh<_i978.DioClient>())); gh.singleton<_i772.AuthInterceptor>( () => _i772.AuthInterceptor(gh<_i394.SecureTokenStorage>())); + gh.lazySingleton<_i961.CotisationRepository>( + () => _i991.CotisationRepositoryImpl(gh<_i238.ApiService>())); gh.lazySingleton<_i930.MembreRepository>( () => _i108.MembreRepositoryImpl(gh<_i238.ApiService>())); gh.factory<_i41.MembresBloc>( @@ -57,6 +65,8 @@ extension GetItInjectableX on _i174.GetIt { gh<_i772.AuthInterceptor>(), gh<_i978.DioClient>(), )); + gh.factory<_i919.CotisationsBloc>( + () => _i919.CotisationsBloc(gh<_i961.CotisationRepository>())); gh.singleton<_i635.AuthBloc>(() => _i635.AuthBloc(gh<_i423.AuthService>())); return this; } diff --git a/unionflow-mobile-apps/lib/core/models/cotisation_model.dart b/unionflow-mobile-apps/lib/core/models/cotisation_model.dart new file mode 100644 index 0000000..212dbd2 --- /dev/null +++ b/unionflow-mobile-apps/lib/core/models/cotisation_model.dart @@ -0,0 +1,271 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'cotisation_model.g.dart'; + +/// Modèle de données pour les cotisations +/// Correspond au CotisationDTO du backend +@JsonSerializable() +class CotisationModel { + final String id; + final String numeroReference; + final String membreId; + final String? nomMembre; + final String? numeroMembre; + final String typeCotisation; + final double montantDu; + final double montantPaye; + final String codeDevise; + final String statut; + final DateTime dateEcheance; + final DateTime? datePaiement; + final String? description; + final String? periode; + final int annee; + final int? mois; + final String? observations; + final bool recurrente; + final int nombreRappels; + final DateTime? dateDernierRappel; + final String? valideParId; + final String? nomValidateur; + final DateTime? dateValidation; + final String? methodePaiement; + final String? referencePaiement; + final DateTime dateCreation; + final DateTime? dateModification; + + const CotisationModel({ + required this.id, + required this.numeroReference, + required this.membreId, + this.nomMembre, + this.numeroMembre, + required this.typeCotisation, + required this.montantDu, + required this.montantPaye, + required this.codeDevise, + required this.statut, + required this.dateEcheance, + this.datePaiement, + this.description, + this.periode, + required this.annee, + this.mois, + this.observations, + required this.recurrente, + required this.nombreRappels, + this.dateDernierRappel, + this.valideParId, + this.nomValidateur, + this.dateValidation, + this.methodePaiement, + this.referencePaiement, + required this.dateCreation, + this.dateModification, + }); + + /// Factory pour créer depuis JSON + factory CotisationModel.fromJson(Map json) => + _$CotisationModelFromJson(json); + + /// Convertit vers JSON + Map toJson() => _$CotisationModelToJson(this); + + /// Calcule le montant restant à payer + double get montantRestant => montantDu - montantPaye; + + /// Vérifie si la cotisation est entièrement payée + bool get isEntierementPayee => montantRestant <= 0; + + /// Vérifie si la cotisation est en retard + bool get isEnRetard { + return dateEcheance.isBefore(DateTime.now()) && !isEntierementPayee; + } + + /// Retourne le pourcentage de paiement + double get pourcentagePaiement { + if (montantDu == 0) return 0; + return (montantPaye / montantDu * 100).clamp(0, 100); + } + + /// Retourne la couleur associée au statut + String get couleurStatut { + switch (statut) { + case 'PAYEE': + return '#4CAF50'; // Vert + case 'EN_ATTENTE': + return '#FF9800'; // Orange + case 'EN_RETARD': + return '#F44336'; // Rouge + case 'PARTIELLEMENT_PAYEE': + return '#2196F3'; // Bleu + case 'ANNULEE': + return '#9E9E9E'; // Gris + default: + return '#757575'; // Gris foncé + } + } + + /// Retourne le libellé du statut en français + String get libelleStatut { + switch (statut) { + case 'PAYEE': + return 'Payée'; + case 'EN_ATTENTE': + return 'En attente'; + case 'EN_RETARD': + return 'En retard'; + case 'PARTIELLEMENT_PAYEE': + return 'Partiellement payée'; + case 'ANNULEE': + return 'Annulée'; + default: + return statut; + } + } + + /// Retourne le libellé du type de cotisation + String get libelleTypeCotisation { + switch (typeCotisation) { + case 'MENSUELLE': + return 'Mensuelle'; + case 'TRIMESTRIELLE': + return 'Trimestrielle'; + case 'SEMESTRIELLE': + return 'Semestrielle'; + case 'ANNUELLE': + return 'Annuelle'; + case 'EXCEPTIONNELLE': + return 'Exceptionnelle'; + case 'ADHESION': + return 'Adhésion'; + default: + return typeCotisation; + } + } + + /// Retourne l'icône associée au type de cotisation + String get iconeTypeCotisation { + switch (typeCotisation) { + case 'MENSUELLE': + return '📅'; + case 'TRIMESTRIELLE': + return '📊'; + case 'SEMESTRIELLE': + return '📈'; + case 'ANNUELLE': + return '🗓️'; + case 'EXCEPTIONNELLE': + return '⚡'; + case 'ADHESION': + return '🎯'; + default: + return '💰'; + } + } + + /// Retourne le nombre de jours jusqu'à l'échéance + int get joursJusquEcheance { + final maintenant = DateTime.now(); + final difference = dateEcheance.difference(maintenant); + return difference.inDays; + } + + /// Vérifie si l'échéance approche (moins de 7 jours) + bool get echeanceProche { + return joursJusquEcheance <= 7 && joursJusquEcheance >= 0; + } + + /// Retourne un message d'urgence basé sur l'échéance + String get messageUrgence { + final jours = joursJusquEcheance; + if (jours < 0) { + return 'En retard de ${-jours} jour${-jours > 1 ? 's' : ''}'; + } else if (jours == 0) { + return 'Échéance aujourd\'hui'; + } else if (jours <= 3) { + return 'Échéance dans $jours jour${jours > 1 ? 's' : ''}'; + } else if (jours <= 7) { + return 'Échéance dans $jours jours'; + } else { + return ''; + } + } + + /// Copie avec modifications + CotisationModel copyWith({ + String? id, + String? numeroReference, + String? membreId, + String? nomMembre, + String? numeroMembre, + String? typeCotisation, + double? montantDu, + double? montantPaye, + String? codeDevise, + String? statut, + DateTime? dateEcheance, + DateTime? datePaiement, + String? description, + String? periode, + int? annee, + int? mois, + String? observations, + bool? recurrente, + int? nombreRappels, + DateTime? dateDernierRappel, + String? valideParId, + String? nomValidateur, + DateTime? dateValidation, + String? methodePaiement, + String? referencePaiement, + DateTime? dateCreation, + DateTime? dateModification, + }) { + return CotisationModel( + id: id ?? this.id, + numeroReference: numeroReference ?? this.numeroReference, + membreId: membreId ?? this.membreId, + nomMembre: nomMembre ?? this.nomMembre, + numeroMembre: numeroMembre ?? this.numeroMembre, + typeCotisation: typeCotisation ?? this.typeCotisation, + montantDu: montantDu ?? this.montantDu, + montantPaye: montantPaye ?? this.montantPaye, + codeDevise: codeDevise ?? this.codeDevise, + statut: statut ?? this.statut, + dateEcheance: dateEcheance ?? this.dateEcheance, + datePaiement: datePaiement ?? this.datePaiement, + description: description ?? this.description, + periode: periode ?? this.periode, + annee: annee ?? this.annee, + mois: mois ?? this.mois, + observations: observations ?? this.observations, + recurrente: recurrente ?? this.recurrente, + nombreRappels: nombreRappels ?? this.nombreRappels, + dateDernierRappel: dateDernierRappel ?? this.dateDernierRappel, + valideParId: valideParId ?? this.valideParId, + nomValidateur: nomValidateur ?? this.nomValidateur, + dateValidation: dateValidation ?? this.dateValidation, + methodePaiement: methodePaiement ?? this.methodePaiement, + referencePaiement: referencePaiement ?? this.referencePaiement, + dateCreation: dateCreation ?? this.dateCreation, + dateModification: dateModification ?? this.dateModification, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is CotisationModel && other.id == id; + } + + @override + int get hashCode => id.hashCode; + + @override + String toString() { + return 'CotisationModel(id: $id, numeroReference: $numeroReference, ' + 'nomMembre: $nomMembre, typeCotisation: $typeCotisation, ' + 'montantDu: $montantDu, statut: $statut)'; + } +} diff --git a/unionflow-mobile-apps/lib/core/models/cotisation_model.g.dart b/unionflow-mobile-apps/lib/core/models/cotisation_model.g.dart new file mode 100644 index 0000000..79da909 --- /dev/null +++ b/unionflow-mobile-apps/lib/core/models/cotisation_model.g.dart @@ -0,0 +1,77 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'cotisation_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +CotisationModel _$CotisationModelFromJson(Map json) => + CotisationModel( + id: json['id'] as String, + numeroReference: json['numeroReference'] as String, + membreId: json['membreId'] as String, + nomMembre: json['nomMembre'] as String?, + numeroMembre: json['numeroMembre'] as String?, + typeCotisation: json['typeCotisation'] as String, + montantDu: (json['montantDu'] as num).toDouble(), + montantPaye: (json['montantPaye'] as num).toDouble(), + codeDevise: json['codeDevise'] as String, + statut: json['statut'] as String, + dateEcheance: DateTime.parse(json['dateEcheance'] as String), + datePaiement: json['datePaiement'] == null + ? null + : DateTime.parse(json['datePaiement'] as String), + description: json['description'] as String?, + periode: json['periode'] as String?, + annee: (json['annee'] as num).toInt(), + mois: (json['mois'] as num?)?.toInt(), + observations: json['observations'] as String?, + recurrente: json['recurrente'] as bool, + nombreRappels: (json['nombreRappels'] as num).toInt(), + dateDernierRappel: json['dateDernierRappel'] == null + ? null + : DateTime.parse(json['dateDernierRappel'] as String), + valideParId: json['valideParId'] as String?, + nomValidateur: json['nomValidateur'] as String?, + dateValidation: json['dateValidation'] == null + ? null + : DateTime.parse(json['dateValidation'] as String), + methodePaiement: json['methodePaiement'] as String?, + referencePaiement: json['referencePaiement'] as String?, + dateCreation: DateTime.parse(json['dateCreation'] as String), + dateModification: json['dateModification'] == null + ? null + : DateTime.parse(json['dateModification'] as String), + ); + +Map _$CotisationModelToJson(CotisationModel instance) => + { + 'id': instance.id, + 'numeroReference': instance.numeroReference, + 'membreId': instance.membreId, + 'nomMembre': instance.nomMembre, + 'numeroMembre': instance.numeroMembre, + 'typeCotisation': instance.typeCotisation, + 'montantDu': instance.montantDu, + 'montantPaye': instance.montantPaye, + 'codeDevise': instance.codeDevise, + 'statut': instance.statut, + 'dateEcheance': instance.dateEcheance.toIso8601String(), + 'datePaiement': instance.datePaiement?.toIso8601String(), + 'description': instance.description, + 'periode': instance.periode, + 'annee': instance.annee, + 'mois': instance.mois, + 'observations': instance.observations, + 'recurrente': instance.recurrente, + 'nombreRappels': instance.nombreRappels, + 'dateDernierRappel': instance.dateDernierRappel?.toIso8601String(), + 'valideParId': instance.valideParId, + 'nomValidateur': instance.nomValidateur, + 'dateValidation': instance.dateValidation?.toIso8601String(), + 'methodePaiement': instance.methodePaiement, + 'referencePaiement': instance.referencePaiement, + 'dateCreation': instance.dateCreation.toIso8601String(), + 'dateModification': instance.dateModification?.toIso8601String(), + }; diff --git a/unionflow-mobile-apps/lib/core/services/api_service.dart b/unionflow-mobile-apps/lib/core/services/api_service.dart index 1f345f9..b1c4e86 100644 --- a/unionflow-mobile-apps/lib/core/services/api_service.dart +++ b/unionflow-mobile-apps/lib/core/services/api_service.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import 'package:injectable/injectable.dart'; import '../models/membre_model.dart'; +import '../models/cotisation_model.dart'; import '../models/wave_checkout_session_model.dart'; import '../network/dio_client.dart'; @@ -164,6 +165,191 @@ class ApiService { } } + // ======================================== + // COTISATIONS + // ======================================== + + /// Récupère la liste de toutes les cotisations avec pagination + Future> getCotisations({int page = 0, int size = 20}) async { + try { + final response = await _dio.get('/api/cotisations', queryParameters: { + 'page': page, + 'size': size, + }); + + if (response.data is List) { + return (response.data as List) + .map((json) => CotisationModel.fromJson(json as Map)) + .toList(); + } + + throw Exception('Format de réponse invalide pour la liste des cotisations'); + } on DioException catch (e) { + throw _handleDioException(e, 'Erreur lors de la récupération des cotisations'); + } + } + + /// Récupère une cotisation par son ID + Future getCotisationById(String id) async { + try { + final response = await _dio.get('/api/cotisations/$id'); + return CotisationModel.fromJson(response.data as Map); + } on DioException catch (e) { + throw _handleDioException(e, 'Erreur lors de la récupération de la cotisation'); + } + } + + /// Récupère une cotisation par son numéro de référence + Future getCotisationByReference(String numeroReference) async { + try { + final response = await _dio.get('/api/cotisations/reference/$numeroReference'); + return CotisationModel.fromJson(response.data as Map); + } on DioException catch (e) { + throw _handleDioException(e, 'Erreur lors de la récupération de la cotisation'); + } + } + + /// Crée une nouvelle cotisation + Future createCotisation(CotisationModel cotisation) async { + try { + final response = await _dio.post( + '/api/cotisations', + data: cotisation.toJson(), + ); + return CotisationModel.fromJson(response.data as Map); + } on DioException catch (e) { + throw _handleDioException(e, 'Erreur lors de la création de la cotisation'); + } + } + + /// Met à jour une cotisation existante + Future updateCotisation(String id, CotisationModel cotisation) async { + try { + final response = await _dio.put( + '/api/cotisations/$id', + data: cotisation.toJson(), + ); + return CotisationModel.fromJson(response.data as Map); + } on DioException catch (e) { + throw _handleDioException(e, 'Erreur lors de la mise à jour de la cotisation'); + } + } + + /// Supprime une cotisation + Future deleteCotisation(String id) async { + try { + await _dio.delete('/api/cotisations/$id'); + } on DioException catch (e) { + throw _handleDioException(e, 'Erreur lors de la suppression de la cotisation'); + } + } + + /// Récupère les cotisations d'un membre + Future> getCotisationsByMembre(String membreId, {int page = 0, int size = 20}) async { + try { + final response = await _dio.get('/api/cotisations/membre/$membreId', queryParameters: { + 'page': page, + 'size': size, + }); + + if (response.data is List) { + return (response.data as List) + .map((json) => CotisationModel.fromJson(json as Map)) + .toList(); + } + + throw Exception('Format de réponse invalide pour les cotisations du membre'); + } on DioException catch (e) { + throw _handleDioException(e, 'Erreur lors de la récupération des cotisations du membre'); + } + } + + /// Récupère les cotisations par statut + Future> getCotisationsByStatut(String statut, {int page = 0, int size = 20}) async { + try { + final response = await _dio.get('/api/cotisations/statut/$statut', queryParameters: { + 'page': page, + 'size': size, + }); + + if (response.data is List) { + return (response.data as List) + .map((json) => CotisationModel.fromJson(json as Map)) + .toList(); + } + + throw Exception('Format de réponse invalide pour les cotisations par statut'); + } on DioException catch (e) { + throw _handleDioException(e, 'Erreur lors de la récupération des cotisations par statut'); + } + } + + /// Récupère les cotisations en retard + Future> getCotisationsEnRetard({int page = 0, int size = 20}) async { + try { + final response = await _dio.get('/api/cotisations/en-retard', queryParameters: { + 'page': page, + 'size': size, + }); + + if (response.data is List) { + return (response.data as List) + .map((json) => CotisationModel.fromJson(json as Map)) + .toList(); + } + + throw Exception('Format de réponse invalide pour les cotisations en retard'); + } on DioException catch (e) { + throw _handleDioException(e, 'Erreur lors de la récupération des cotisations en retard'); + } + } + + /// Recherche avancée de cotisations + Future> rechercherCotisations({ + String? membreId, + String? statut, + String? typeCotisation, + int? annee, + int? mois, + int page = 0, + int size = 20, + }) async { + try { + final queryParams = { + 'page': page, + 'size': size, + }; + + if (membreId != null) queryParams['membreId'] = membreId; + if (statut != null) queryParams['statut'] = statut; + if (typeCotisation != null) queryParams['typeCotisation'] = typeCotisation; + if (annee != null) queryParams['annee'] = annee; + if (mois != null) queryParams['mois'] = mois; + + final response = await _dio.get('/api/cotisations/recherche', queryParameters: queryParams); + + if (response.data is List) { + return (response.data as List) + .map((json) => CotisationModel.fromJson(json as Map)) + .toList(); + } + + throw Exception('Format de réponse invalide pour la recherche de cotisations'); + } on DioException catch (e) { + throw _handleDioException(e, 'Erreur lors de la recherche de cotisations'); + } + } + + /// Récupère les statistiques des cotisations + Future> getCotisationsStats() async { + try { + final response = await _dio.get('/api/cotisations/stats'); + return response.data as Map; + } on DioException catch (e) { + throw _handleDioException(e, 'Erreur lors de la récupération des statistiques des cotisations'); + } + } + // ======================================== // GESTION DES ERREURS // ======================================== diff --git a/unionflow-mobile-apps/lib/features/auth/presentation/pages/login_page.dart b/unionflow-mobile-apps/lib/features/auth/presentation/pages/login_page.dart index c8371b2..ad4abc8 100644 --- a/unionflow-mobile-apps/lib/features/auth/presentation/pages/login_page.dart +++ b/unionflow-mobile-apps/lib/features/auth/presentation/pages/login_page.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../../core/auth/bloc/auth_bloc.dart'; +import '../../../../core/auth/bloc/temp_auth_bloc.dart'; import '../../../../core/auth/bloc/auth_event.dart'; import '../../../../core/auth/models/auth_state.dart'; import '../../../../core/auth/models/login_request.dart'; @@ -100,7 +100,7 @@ class _LoginPageState extends State Widget build(BuildContext context) { return Scaffold( backgroundColor: AppTheme.backgroundLight, - body: BlocConsumer( + body: BlocConsumer( listener: (context, state) { setState(() { _isLoading = state.status == AuthStatus.checking; @@ -222,7 +222,7 @@ class _LoginPageState extends State rememberMe: _rememberMe, ); - context.read().add(AuthLoginRequested(loginRequest)); + context.read().add(AuthLoginRequested(loginRequest)); } void _showErrorSnackBar(String message) { diff --git a/unionflow-mobile-apps/lib/features/auth/presentation/widgets/login_form.dart b/unionflow-mobile-apps/lib/features/auth/presentation/widgets/login_form.dart index abb5479..1ffd488 100644 --- a/unionflow-mobile-apps/lib/features/auth/presentation/widgets/login_form.dart +++ b/unionflow-mobile-apps/lib/features/auth/presentation/widgets/login_form.dart @@ -59,14 +59,18 @@ class _LoginFormState extends State ); _fieldAnimations = List.generate(4, (index) { + // Calcul sécurisé pour éviter end > 1.0 + final start = index * 0.15; // Réduit l'espacement + final end = (start + 0.4).clamp(0.0, 1.0); // Assure end <= 1.0 + return Tween( begin: const Offset(0, 1), end: Offset.zero, ).animate(CurvedAnimation( parent: _fieldAnimationController, curve: Interval( - index * 0.2, - (index * 0.2) + 0.6, + start, + end, curve: Curves.easeOut, ), )); @@ -330,13 +334,13 @@ class _LoginFormState extends State decoration: BoxDecoration( borderRadius: BorderRadius.circular(4), border: Border.all( - color: widget.rememberMe - ? AppTheme.primaryColor + color: widget.rememberMe + ? AppTheme.primaryColor : AppTheme.textSecondary, width: 2, ), - color: widget.rememberMe - ? AppTheme.primaryColor + color: widget.rememberMe + ? AppTheme.primaryColor : Colors.transparent, ), child: widget.rememberMe @@ -348,12 +352,15 @@ class _LoginFormState extends State : null, ), const SizedBox(width: 8), - Text( - 'Se souvenir de moi', - style: TextStyle( - fontSize: 14, - color: AppTheme.textSecondary, - fontWeight: FontWeight.w500, + Flexible( + child: Text( + 'Se souvenir de moi', + style: TextStyle( + fontSize: 14, + color: AppTheme.textSecondary, + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, ), ), ], diff --git a/unionflow-mobile-apps/lib/features/cotisations/data/repositories/cotisation_repository_impl.dart b/unionflow-mobile-apps/lib/features/cotisations/data/repositories/cotisation_repository_impl.dart new file mode 100644 index 0000000..278fa1f --- /dev/null +++ b/unionflow-mobile-apps/lib/features/cotisations/data/repositories/cotisation_repository_impl.dart @@ -0,0 +1,84 @@ +import 'package:injectable/injectable.dart'; +import '../../../../core/models/cotisation_model.dart'; +import '../../../../core/services/api_service.dart'; +import '../../../cotisations/domain/repositories/cotisation_repository.dart'; + +/// Implémentation du repository des cotisations +/// Utilise ApiService pour communiquer avec le backend +@LazySingleton(as: CotisationRepository) +class CotisationRepositoryImpl implements CotisationRepository { + final ApiService _apiService; + + CotisationRepositoryImpl(this._apiService); + + @override + Future> getCotisations({int page = 0, int size = 20}) async { + return await _apiService.getCotisations(page: page, size: size); + } + + @override + Future getCotisationById(String id) async { + return await _apiService.getCotisationById(id); + } + + @override + Future getCotisationByReference(String numeroReference) async { + return await _apiService.getCotisationByReference(numeroReference); + } + + @override + Future createCotisation(CotisationModel cotisation) async { + return await _apiService.createCotisation(cotisation); + } + + @override + Future updateCotisation(String id, CotisationModel cotisation) async { + return await _apiService.updateCotisation(id, cotisation); + } + + @override + Future deleteCotisation(String id) async { + return await _apiService.deleteCotisation(id); + } + + @override + Future> getCotisationsByMembre(String membreId, {int page = 0, int size = 20}) async { + return await _apiService.getCotisationsByMembre(membreId, page: page, size: size); + } + + @override + Future> getCotisationsByStatut(String statut, {int page = 0, int size = 20}) async { + return await _apiService.getCotisationsByStatut(statut, page: page, size: size); + } + + @override + Future> getCotisationsEnRetard({int page = 0, int size = 20}) async { + return await _apiService.getCotisationsEnRetard(page: page, size: size); + } + + @override + Future> rechercherCotisations({ + String? membreId, + String? statut, + String? typeCotisation, + int? annee, + int? mois, + int page = 0, + int size = 20, + }) async { + return await _apiService.rechercherCotisations( + membreId: membreId, + statut: statut, + typeCotisation: typeCotisation, + annee: annee, + mois: mois, + page: page, + size: size, + ); + } + + @override + Future> getCotisationsStats() async { + return await _apiService.getCotisationsStats(); + } +} diff --git a/unionflow-mobile-apps/lib/features/cotisations/domain/repositories/cotisation_repository.dart b/unionflow-mobile-apps/lib/features/cotisations/domain/repositories/cotisation_repository.dart new file mode 100644 index 0000000..1cda022 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/cotisations/domain/repositories/cotisation_repository.dart @@ -0,0 +1,46 @@ +import '../../../../core/models/cotisation_model.dart'; + +/// Interface du repository des cotisations +/// Définit les contrats pour l'accès aux données des cotisations +abstract class CotisationRepository { + /// Récupère la liste de toutes les cotisations avec pagination + Future> getCotisations({int page = 0, int size = 20}); + + /// Récupère une cotisation par son ID + Future getCotisationById(String id); + + /// Récupère une cotisation par son numéro de référence + Future getCotisationByReference(String numeroReference); + + /// Crée une nouvelle cotisation + Future createCotisation(CotisationModel cotisation); + + /// Met à jour une cotisation existante + Future updateCotisation(String id, CotisationModel cotisation); + + /// Supprime une cotisation + Future deleteCotisation(String id); + + /// Récupère les cotisations d'un membre + Future> getCotisationsByMembre(String membreId, {int page = 0, int size = 20}); + + /// Récupère les cotisations par statut + Future> getCotisationsByStatut(String statut, {int page = 0, int size = 20}); + + /// Récupère les cotisations en retard + Future> getCotisationsEnRetard({int page = 0, int size = 20}); + + /// Recherche avancée de cotisations + Future> rechercherCotisations({ + String? membreId, + String? statut, + String? typeCotisation, + int? annee, + int? mois, + int page = 0, + int size = 20, + }); + + /// Récupère les statistiques des cotisations + Future> getCotisationsStats(); +} diff --git a/unionflow-mobile-apps/lib/features/cotisations/presentation/bloc/cotisations_bloc.dart b/unionflow-mobile-apps/lib/features/cotisations/presentation/bloc/cotisations_bloc.dart new file mode 100644 index 0000000..5048538 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/cotisations/presentation/bloc/cotisations_bloc.dart @@ -0,0 +1,509 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:injectable/injectable.dart'; +import '../../../../core/models/cotisation_model.dart'; +import '../../domain/repositories/cotisation_repository.dart'; +import 'cotisations_event.dart'; +import 'cotisations_state.dart'; + +/// BLoC pour la gestion des cotisations +/// Gère l'état et les événements liés aux cotisations +@injectable +class CotisationsBloc extends Bloc { + final CotisationRepository _cotisationRepository; + + CotisationsBloc(this._cotisationRepository) : super(const CotisationsInitial()) { + // Enregistrement des handlers d'événements + on(_onLoadCotisations); + on(_onLoadCotisationById); + on(_onLoadCotisationByReference); + on(_onCreateCotisation); + on(_onUpdateCotisation); + on(_onDeleteCotisation); + on(_onLoadCotisationsByMembre); + on(_onLoadCotisationsByStatut); + on(_onLoadCotisationsEnRetard); + on(_onSearchCotisations); + on(_onLoadCotisationsStats); + on(_onRefreshCotisations); + on(_onResetCotisationsState); + on(_onFilterCotisations); + on(_onSortCotisations); + } + + /// Handler pour charger la liste des cotisations + Future _onLoadCotisations( + LoadCotisations event, + Emitter emit, + ) async { + try { + if (event.refresh || state is CotisationsInitial) { + emit(CotisationsLoading(isRefreshing: event.refresh)); + } + + final cotisations = await _cotisationRepository.getCotisations( + page: event.page, + size: event.size, + ); + + List allCotisations = []; + + // Si c'est un refresh ou la première page, remplacer la liste + if (event.refresh || event.page == 0) { + allCotisations = cotisations; + } else { + // Sinon, ajouter à la liste existante (pagination) + if (state is CotisationsLoaded) { + final currentState = state as CotisationsLoaded; + allCotisations = [...currentState.cotisations, ...cotisations]; + } else { + allCotisations = cotisations; + } + } + + emit(CotisationsLoaded( + cotisations: allCotisations, + filteredCotisations: allCotisations, + hasReachedMax: cotisations.length < event.size, + currentPage: event.page, + )); + } catch (error) { + emit(CotisationsError( + 'Erreur lors du chargement des cotisations: ${error.toString()}', + originalError: error, + )); + } + } + + /// Handler pour charger une cotisation par ID + Future _onLoadCotisationById( + LoadCotisationById event, + Emitter emit, + ) async { + try { + emit(const CotisationsLoading()); + + final cotisation = await _cotisationRepository.getCotisationById(event.id); + + emit(CotisationDetailLoaded(cotisation)); + } catch (error) { + emit(CotisationsError( + 'Erreur lors du chargement de la cotisation: ${error.toString()}', + originalError: error, + )); + } + } + + /// Handler pour charger une cotisation par référence + Future _onLoadCotisationByReference( + LoadCotisationByReference event, + Emitter emit, + ) async { + try { + emit(const CotisationsLoading()); + + final cotisation = await _cotisationRepository.getCotisationByReference(event.numeroReference); + + emit(CotisationDetailLoaded(cotisation)); + } catch (error) { + emit(CotisationsError( + 'Erreur lors du chargement de la cotisation: ${error.toString()}', + originalError: error, + )); + } + } + + /// Handler pour créer une nouvelle cotisation + Future _onCreateCotisation( + CreateCotisation event, + Emitter emit, + ) async { + try { + emit(const CotisationOperationLoading('create')); + + final nouvelleCotisation = await _cotisationRepository.createCotisation(event.cotisation); + + emit(CotisationCreated(nouvelleCotisation)); + + // Recharger la liste des cotisations + add(const LoadCotisations(refresh: true)); + } catch (error) { + emit(CotisationsError( + 'Erreur lors de la création de la cotisation: ${error.toString()}', + originalError: error, + )); + } + } + + /// Handler pour mettre à jour une cotisation + Future _onUpdateCotisation( + UpdateCotisation event, + Emitter emit, + ) async { + try { + emit(CotisationOperationLoading('update', cotisationId: event.id)); + + final cotisationMiseAJour = await _cotisationRepository.updateCotisation( + event.id, + event.cotisation, + ); + + emit(CotisationUpdated(cotisationMiseAJour)); + + // Mettre à jour la liste si elle est chargée + if (state is CotisationsLoaded) { + final currentState = state as CotisationsLoaded; + final updatedList = currentState.cotisations.map((c) { + return c.id == event.id ? cotisationMiseAJour : c; + }).toList(); + + emit(currentState.copyWith( + cotisations: updatedList, + filteredCotisations: updatedList, + )); + } + } catch (error) { + emit(CotisationsError( + 'Erreur lors de la mise à jour de la cotisation: ${error.toString()}', + originalError: error, + )); + } + } + + /// Handler pour supprimer une cotisation + Future _onDeleteCotisation( + DeleteCotisation event, + Emitter emit, + ) async { + try { + emit(CotisationOperationLoading('delete', cotisationId: event.id)); + + await _cotisationRepository.deleteCotisation(event.id); + + emit(CotisationDeleted(event.id)); + + // Retirer de la liste si elle est chargée + if (state is CotisationsLoaded) { + final currentState = state as CotisationsLoaded; + final updatedList = currentState.cotisations + .where((c) => c.id != event.id) + .toList(); + + emit(currentState.copyWith( + cotisations: updatedList, + filteredCotisations: updatedList, + )); + } + } catch (error) { + emit(CotisationsError( + 'Erreur lors de la suppression de la cotisation: ${error.toString()}', + originalError: error, + )); + } + } + + /// Handler pour charger les cotisations d'un membre + Future _onLoadCotisationsByMembre( + LoadCotisationsByMembre event, + Emitter emit, + ) async { + try { + if (event.refresh || event.page == 0) { + emit(CotisationsLoading(isRefreshing: event.refresh)); + } + + final cotisations = await _cotisationRepository.getCotisationsByMembre( + event.membreId, + page: event.page, + size: event.size, + ); + + List allCotisations = []; + + if (event.refresh || event.page == 0) { + allCotisations = cotisations; + } else { + if (state is CotisationsByMembreLoaded) { + final currentState = state as CotisationsByMembreLoaded; + allCotisations = [...currentState.cotisations, ...cotisations]; + } else { + allCotisations = cotisations; + } + } + + emit(CotisationsByMembreLoaded( + membreId: event.membreId, + cotisations: allCotisations, + hasReachedMax: cotisations.length < event.size, + currentPage: event.page, + )); + } catch (error) { + emit(CotisationsError( + 'Erreur lors du chargement des cotisations du membre: ${error.toString()}', + originalError: error, + )); + } + } + + /// Handler pour charger les cotisations par statut + Future _onLoadCotisationsByStatut( + LoadCotisationsByStatut event, + Emitter emit, + ) async { + try { + if (event.refresh || event.page == 0) { + emit(CotisationsLoading(isRefreshing: event.refresh)); + } + + final cotisations = await _cotisationRepository.getCotisationsByStatut( + event.statut, + page: event.page, + size: event.size, + ); + + List allCotisations = []; + + if (event.refresh || event.page == 0) { + allCotisations = cotisations; + } else { + if (state is CotisationsLoaded) { + final currentState = state as CotisationsLoaded; + allCotisations = [...currentState.cotisations, ...cotisations]; + } else { + allCotisations = cotisations; + } + } + + emit(CotisationsLoaded( + cotisations: allCotisations, + filteredCotisations: allCotisations, + hasReachedMax: cotisations.length < event.size, + currentPage: event.page, + currentFilter: event.statut, + )); + } catch (error) { + emit(CotisationsError( + 'Erreur lors du chargement des cotisations par statut: ${error.toString()}', + originalError: error, + )); + } + } + + /// Handler pour charger les cotisations en retard + Future _onLoadCotisationsEnRetard( + LoadCotisationsEnRetard event, + Emitter emit, + ) async { + try { + if (event.refresh || event.page == 0) { + emit(CotisationsLoading(isRefreshing: event.refresh)); + } + + final cotisations = await _cotisationRepository.getCotisationsEnRetard( + page: event.page, + size: event.size, + ); + + List allCotisations = []; + + if (event.refresh || event.page == 0) { + allCotisations = cotisations; + } else { + if (state is CotisationsEnRetardLoaded) { + final currentState = state as CotisationsEnRetardLoaded; + allCotisations = [...currentState.cotisations, ...cotisations]; + } else { + allCotisations = cotisations; + } + } + + emit(CotisationsEnRetardLoaded( + cotisations: allCotisations, + hasReachedMax: cotisations.length < event.size, + currentPage: event.page, + )); + } catch (error) { + emit(CotisationsError( + 'Erreur lors du chargement des cotisations en retard: ${error.toString()}', + originalError: error, + )); + } + } + + /// Handler pour la recherche de cotisations + Future _onSearchCotisations( + SearchCotisations event, + Emitter emit, + ) async { + try { + if (event.refresh || event.page == 0) { + emit(CotisationsLoading(isRefreshing: event.refresh)); + } + + final cotisations = await _cotisationRepository.rechercherCotisations( + membreId: event.membreId, + statut: event.statut, + typeCotisation: event.typeCotisation, + annee: event.annee, + mois: event.mois, + page: event.page, + size: event.size, + ); + + final searchCriteria = { + if (event.membreId != null) 'membreId': event.membreId, + if (event.statut != null) 'statut': event.statut, + if (event.typeCotisation != null) 'typeCotisation': event.typeCotisation, + if (event.annee != null) 'annee': event.annee, + if (event.mois != null) 'mois': event.mois, + }; + + List allCotisations = []; + + if (event.refresh || event.page == 0) { + allCotisations = cotisations; + } else { + if (state is CotisationsSearchResults) { + final currentState = state as CotisationsSearchResults; + allCotisations = [...currentState.cotisations, ...cotisations]; + } else { + allCotisations = cotisations; + } + } + + emit(CotisationsSearchResults( + cotisations: allCotisations, + searchCriteria: searchCriteria, + hasReachedMax: cotisations.length < event.size, + currentPage: event.page, + )); + } catch (error) { + emit(CotisationsError( + 'Erreur lors de la recherche de cotisations: ${error.toString()}', + originalError: error, + )); + } + } + + /// Handler pour charger les statistiques + Future _onLoadCotisationsStats( + LoadCotisationsStats event, + Emitter emit, + ) async { + try { + emit(const CotisationsLoading()); + + final statistics = await _cotisationRepository.getCotisationsStats(); + + emit(CotisationsStatsLoaded(statistics)); + } catch (error) { + emit(CotisationsError( + 'Erreur lors du chargement des statistiques: ${error.toString()}', + originalError: error, + )); + } + } + + /// Handler pour rafraîchir les données + Future _onRefreshCotisations( + RefreshCotisations event, + Emitter emit, + ) async { + add(const LoadCotisations(refresh: true)); + } + + /// Handler pour réinitialiser l'état + Future _onResetCotisationsState( + ResetCotisationsState event, + Emitter emit, + ) async { + emit(const CotisationsInitial()); + } + + /// Handler pour filtrer les cotisations localement + Future _onFilterCotisations( + FilterCotisations event, + Emitter emit, + ) async { + if (state is CotisationsLoaded) { + final currentState = state as CotisationsLoaded; + + List filteredList = currentState.cotisations; + + // Filtrage par recherche textuelle + if (event.searchQuery != null && event.searchQuery!.isNotEmpty) { + final query = event.searchQuery!.toLowerCase(); + filteredList = filteredList.where((cotisation) { + return cotisation.numeroReference.toLowerCase().contains(query) || + (cotisation.nomMembre?.toLowerCase().contains(query) ?? false) || + cotisation.typeCotisation.toLowerCase().contains(query) || + (cotisation.description?.toLowerCase().contains(query) ?? false); + }).toList(); + } + + // Filtrage par statut + if (event.statutFilter != null && event.statutFilter!.isNotEmpty) { + filteredList = filteredList.where((cotisation) { + return cotisation.statut == event.statutFilter; + }).toList(); + } + + // Filtrage par type + if (event.typeFilter != null && event.typeFilter!.isNotEmpty) { + filteredList = filteredList.where((cotisation) { + return cotisation.typeCotisation == event.typeFilter; + }).toList(); + } + + emit(currentState.copyWith( + filteredCotisations: filteredList, + searchQuery: event.searchQuery, + currentFilter: event.statutFilter ?? event.typeFilter, + )); + } + } + + /// Handler pour trier les cotisations + Future _onSortCotisations( + SortCotisations event, + Emitter emit, + ) async { + if (state is CotisationsLoaded) { + final currentState = state as CotisationsLoaded; + + List sortedList = [...currentState.filteredCotisations]; + + switch (event.sortBy) { + case 'dateEcheance': + sortedList.sort((a, b) => event.ascending + ? a.dateEcheance.compareTo(b.dateEcheance) + : b.dateEcheance.compareTo(a.dateEcheance)); + break; + case 'montantDu': + sortedList.sort((a, b) => event.ascending + ? a.montantDu.compareTo(b.montantDu) + : b.montantDu.compareTo(a.montantDu)); + break; + case 'statut': + sortedList.sort((a, b) => event.ascending + ? a.statut.compareTo(b.statut) + : b.statut.compareTo(a.statut)); + break; + case 'nomMembre': + sortedList.sort((a, b) => event.ascending + ? (a.nomMembre ?? '').compareTo(b.nomMembre ?? '') + : (b.nomMembre ?? '').compareTo(a.nomMembre ?? '')); + break; + case 'typeCotisation': + sortedList.sort((a, b) => event.ascending + ? a.typeCotisation.compareTo(b.typeCotisation) + : b.typeCotisation.compareTo(a.typeCotisation)); + break; + default: + // Tri par défaut par date d'échéance + sortedList.sort((a, b) => b.dateEcheance.compareTo(a.dateEcheance)); + } + + emit(currentState.copyWith(filteredCotisations: sortedList)); + } + } +} diff --git a/unionflow-mobile-apps/lib/features/cotisations/presentation/bloc/cotisations_event.dart b/unionflow-mobile-apps/lib/features/cotisations/presentation/bloc/cotisations_event.dart new file mode 100644 index 0000000..7c5727e --- /dev/null +++ b/unionflow-mobile-apps/lib/features/cotisations/presentation/bloc/cotisations_event.dart @@ -0,0 +1,206 @@ +import 'package:equatable/equatable.dart'; +import '../../../../core/models/cotisation_model.dart'; + +/// Événements du BLoC des cotisations +abstract class CotisationsEvent extends Equatable { + const CotisationsEvent(); + + @override + List get props => []; +} + +/// Événement pour charger la liste des cotisations +class LoadCotisations extends CotisationsEvent { + final int page; + final int size; + final bool refresh; + + const LoadCotisations({ + this.page = 0, + this.size = 20, + this.refresh = false, + }); + + @override + List get props => [page, size, refresh]; +} + +/// Événement pour charger une cotisation par ID +class LoadCotisationById extends CotisationsEvent { + final String id; + + const LoadCotisationById(this.id); + + @override + List get props => [id]; +} + +/// Événement pour charger une cotisation par référence +class LoadCotisationByReference extends CotisationsEvent { + final String numeroReference; + + const LoadCotisationByReference(this.numeroReference); + + @override + List get props => [numeroReference]; +} + +/// Événement pour créer une nouvelle cotisation +class CreateCotisation extends CotisationsEvent { + final CotisationModel cotisation; + + const CreateCotisation(this.cotisation); + + @override + List get props => [cotisation]; +} + +/// Événement pour mettre à jour une cotisation +class UpdateCotisation extends CotisationsEvent { + final String id; + final CotisationModel cotisation; + + const UpdateCotisation(this.id, this.cotisation); + + @override + List get props => [id, cotisation]; +} + +/// Événement pour supprimer une cotisation +class DeleteCotisation extends CotisationsEvent { + final String id; + + const DeleteCotisation(this.id); + + @override + List get props => [id]; +} + +/// Événement pour charger les cotisations d'un membre +class LoadCotisationsByMembre extends CotisationsEvent { + final String membreId; + final int page; + final int size; + final bool refresh; + + const LoadCotisationsByMembre( + this.membreId, { + this.page = 0, + this.size = 20, + this.refresh = false, + }); + + @override + List get props => [membreId, page, size, refresh]; +} + +/// Événement pour charger les cotisations par statut +class LoadCotisationsByStatut extends CotisationsEvent { + final String statut; + final int page; + final int size; + final bool refresh; + + const LoadCotisationsByStatut( + this.statut, { + this.page = 0, + this.size = 20, + this.refresh = false, + }); + + @override + List get props => [statut, page, size, refresh]; +} + +/// Événement pour charger les cotisations en retard +class LoadCotisationsEnRetard extends CotisationsEvent { + final int page; + final int size; + final bool refresh; + + const LoadCotisationsEnRetard({ + this.page = 0, + this.size = 20, + this.refresh = false, + }); + + @override + List get props => [page, size, refresh]; +} + +/// Événement pour rechercher des cotisations +class SearchCotisations extends CotisationsEvent { + final String? membreId; + final String? statut; + final String? typeCotisation; + final int? annee; + final int? mois; + final int page; + final int size; + final bool refresh; + + const SearchCotisations({ + this.membreId, + this.statut, + this.typeCotisation, + this.annee, + this.mois, + this.page = 0, + this.size = 20, + this.refresh = false, + }); + + @override + List get props => [ + membreId, + statut, + typeCotisation, + annee, + mois, + page, + size, + refresh, + ]; +} + +/// Événement pour charger les statistiques +class LoadCotisationsStats extends CotisationsEvent { + const LoadCotisationsStats(); +} + +/// Événement pour rafraîchir les données +class RefreshCotisations extends CotisationsEvent { + const RefreshCotisations(); +} + +/// Événement pour réinitialiser l'état +class ResetCotisationsState extends CotisationsEvent { + const ResetCotisationsState(); +} + +/// Événement pour filtrer les cotisations localement +class FilterCotisations extends CotisationsEvent { + final String? searchQuery; + final String? statutFilter; + final String? typeFilter; + + const FilterCotisations({ + this.searchQuery, + this.statutFilter, + this.typeFilter, + }); + + @override + List get props => [searchQuery, statutFilter, typeFilter]; +} + +/// Événement pour trier les cotisations +class SortCotisations extends CotisationsEvent { + final String sortBy; // 'dateEcheance', 'montantDu', 'statut', etc. + final bool ascending; + + const SortCotisations(this.sortBy, {this.ascending = true}); + + @override + List get props => [sortBy, ascending]; +} diff --git a/unionflow-mobile-apps/lib/features/cotisations/presentation/bloc/cotisations_state.dart b/unionflow-mobile-apps/lib/features/cotisations/presentation/bloc/cotisations_state.dart new file mode 100644 index 0000000..7755fac --- /dev/null +++ b/unionflow-mobile-apps/lib/features/cotisations/presentation/bloc/cotisations_state.dart @@ -0,0 +1,247 @@ +import 'package:equatable/equatable.dart'; +import '../../../../core/models/cotisation_model.dart'; + +/// États du BLoC des cotisations +abstract class CotisationsState extends Equatable { + const CotisationsState(); + + @override + List get props => []; +} + +/// État initial +class CotisationsInitial extends CotisationsState { + const CotisationsInitial(); +} + +/// État de chargement +class CotisationsLoading extends CotisationsState { + final bool isRefreshing; + + const CotisationsLoading({this.isRefreshing = false}); + + @override + List get props => [isRefreshing]; +} + +/// État de succès avec liste des cotisations +class CotisationsLoaded extends CotisationsState { + final List cotisations; + final List filteredCotisations; + final Map? statistics; + final bool hasReachedMax; + final int currentPage; + final String? currentFilter; + final String? searchQuery; + + const CotisationsLoaded({ + required this.cotisations, + required this.filteredCotisations, + this.statistics, + this.hasReachedMax = false, + this.currentPage = 0, + this.currentFilter, + this.searchQuery, + }); + + /// Copie avec modifications + CotisationsLoaded copyWith({ + List? cotisations, + List? filteredCotisations, + Map? statistics, + bool? hasReachedMax, + int? currentPage, + String? currentFilter, + String? searchQuery, + }) { + return CotisationsLoaded( + cotisations: cotisations ?? this.cotisations, + filteredCotisations: filteredCotisations ?? this.filteredCotisations, + statistics: statistics ?? this.statistics, + hasReachedMax: hasReachedMax ?? this.hasReachedMax, + currentPage: currentPage ?? this.currentPage, + currentFilter: currentFilter ?? this.currentFilter, + searchQuery: searchQuery ?? this.searchQuery, + ); + } + + @override + List get props => [ + cotisations, + filteredCotisations, + statistics, + hasReachedMax, + currentPage, + currentFilter, + searchQuery, + ]; +} + +/// État de succès pour une cotisation unique +class CotisationDetailLoaded extends CotisationsState { + final CotisationModel cotisation; + + const CotisationDetailLoaded(this.cotisation); + + @override + List get props => [cotisation]; +} + +/// État de succès pour la création d'une cotisation +class CotisationCreated extends CotisationsState { + final CotisationModel cotisation; + + const CotisationCreated(this.cotisation); + + @override + List get props => [cotisation]; +} + +/// État de succès pour la mise à jour d'une cotisation +class CotisationUpdated extends CotisationsState { + final CotisationModel cotisation; + + const CotisationUpdated(this.cotisation); + + @override + List get props => [cotisation]; +} + +/// État de succès pour la suppression d'une cotisation +class CotisationDeleted extends CotisationsState { + final String cotisationId; + + const CotisationDeleted(this.cotisationId); + + @override + List get props => [cotisationId]; +} + +/// État d'erreur +class CotisationsError extends CotisationsState { + final String message; + final String? errorCode; + final dynamic originalError; + + const CotisationsError( + this.message, { + this.errorCode, + this.originalError, + }); + + @override + List get props => [message, errorCode, originalError]; +} + +/// État de chargement pour une opération spécifique +class CotisationOperationLoading extends CotisationsState { + final String operation; // 'create', 'update', 'delete' + final String? cotisationId; + + const CotisationOperationLoading(this.operation, {this.cotisationId}); + + @override + List get props => [operation, cotisationId]; +} + +/// État de succès pour les statistiques +class CotisationsStatsLoaded extends CotisationsState { + final Map statistics; + + const CotisationsStatsLoaded(this.statistics); + + @override + List get props => [statistics]; +} + +/// État pour les cotisations filtrées par membre +class CotisationsByMembreLoaded extends CotisationsState { + final String membreId; + final List cotisations; + final bool hasReachedMax; + final int currentPage; + + const CotisationsByMembreLoaded({ + required this.membreId, + required this.cotisations, + this.hasReachedMax = false, + this.currentPage = 0, + }); + + CotisationsByMembreLoaded copyWith({ + String? membreId, + List? cotisations, + bool? hasReachedMax, + int? currentPage, + }) { + return CotisationsByMembreLoaded( + membreId: membreId ?? this.membreId, + cotisations: cotisations ?? this.cotisations, + hasReachedMax: hasReachedMax ?? this.hasReachedMax, + currentPage: currentPage ?? this.currentPage, + ); + } + + @override + List get props => [membreId, cotisations, hasReachedMax, currentPage]; +} + +/// État pour les cotisations en retard +class CotisationsEnRetardLoaded extends CotisationsState { + final List cotisations; + final bool hasReachedMax; + final int currentPage; + + const CotisationsEnRetardLoaded({ + required this.cotisations, + this.hasReachedMax = false, + this.currentPage = 0, + }); + + CotisationsEnRetardLoaded copyWith({ + List? cotisations, + bool? hasReachedMax, + int? currentPage, + }) { + return CotisationsEnRetardLoaded( + cotisations: cotisations ?? this.cotisations, + hasReachedMax: hasReachedMax ?? this.hasReachedMax, + currentPage: currentPage ?? this.currentPage, + ); + } + + @override + List get props => [cotisations, hasReachedMax, currentPage]; +} + +/// État pour les résultats de recherche +class CotisationsSearchResults extends CotisationsState { + final List cotisations; + final Map searchCriteria; + final bool hasReachedMax; + final int currentPage; + + const CotisationsSearchResults({ + required this.cotisations, + required this.searchCriteria, + this.hasReachedMax = false, + this.currentPage = 0, + }); + + CotisationsSearchResults copyWith({ + List? cotisations, + Map? searchCriteria, + bool? hasReachedMax, + int? currentPage, + }) { + return CotisationsSearchResults( + cotisations: cotisations ?? this.cotisations, + searchCriteria: searchCriteria ?? this.searchCriteria, + hasReachedMax: hasReachedMax ?? this.hasReachedMax, + currentPage: currentPage ?? this.currentPage, + ); + } + + @override + List get props => [cotisations, searchCriteria, hasReachedMax, currentPage]; +} diff --git a/unionflow-mobile-apps/lib/features/cotisations/presentation/pages/cotisations_list_page.dart b/unionflow-mobile-apps/lib/features/cotisations/presentation/pages/cotisations_list_page.dart new file mode 100644 index 0000000..21894fa --- /dev/null +++ b/unionflow-mobile-apps/lib/features/cotisations/presentation/pages/cotisations_list_page.dart @@ -0,0 +1,338 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../../core/di/injection.dart'; +import '../../../../shared/theme/app_theme.dart'; +import '../../../../shared/widgets/coming_soon_page.dart'; +import '../bloc/cotisations_bloc.dart'; +import '../bloc/cotisations_event.dart'; +import '../bloc/cotisations_state.dart'; +import '../widgets/cotisation_card.dart'; +import '../widgets/cotisations_stats_card.dart'; + +/// Page principale pour la liste des cotisations +class CotisationsListPage extends StatefulWidget { + const CotisationsListPage({super.key}); + + @override + State createState() => _CotisationsListPageState(); +} + +class _CotisationsListPageState extends State { + late final CotisationsBloc _cotisationsBloc; + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _cotisationsBloc = getIt(); + _scrollController.addListener(_onScroll); + + // Charger les données initiales + _cotisationsBloc.add(const LoadCotisations()); + _cotisationsBloc.add(const LoadCotisationsStats()); + } + + @override + void dispose() { + _scrollController.dispose(); + _cotisationsBloc.close(); + super.dispose(); + } + + void _onScroll() { + if (_isBottom) { + final currentState = _cotisationsBloc.state; + if (currentState is CotisationsLoaded && !currentState.hasReachedMax) { + _cotisationsBloc.add(LoadCotisations( + page: currentState.currentPage + 1, + size: 20, + )); + } + } + } + + bool get _isBottom { + if (!_scrollController.hasClients) return false; + final maxScroll = _scrollController.position.maxScrollExtent; + final currentScroll = _scrollController.offset; + return currentScroll >= (maxScroll * 0.9); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _cotisationsBloc, + child: Scaffold( + backgroundColor: AppTheme.backgroundLight, + body: Column( + children: [ + // Header personnalisé + _buildHeader(), + + // Contenu principal + Expanded( + child: BlocBuilder( + builder: (context, state) { + if (state is CotisationsInitial || + (state is CotisationsLoading && !state.isRefreshing)) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + if (state is CotisationsError) { + return _buildErrorState(state); + } + + if (state is CotisationsLoaded) { + return _buildLoadedState(state); + } + + // État par défaut - Coming Soon + return const ComingSoonPage( + title: 'Module Cotisations', + description: 'Gestion complète des cotisations avec paiements automatiques', + icon: Icons.payment_rounded, + color: AppTheme.accentColor, + features: [ + 'Tableau de bord des cotisations', + 'Relances automatiques par email/SMS', + 'Paiements en ligne sécurisés', + 'Génération de reçus automatique', + 'Suivi des retards de paiement', + 'Rapports financiers détaillés', + ], + ); + }, + ), + ), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: () { + // TODO: Implémenter la création de cotisation + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Création de cotisation - En cours de développement'), + backgroundColor: AppTheme.accentColor, + ), + ); + }, + backgroundColor: AppTheme.accentColor, + child: const Icon(Icons.add, color: Colors.white), + ), + ), + ); + } + + Widget _buildHeader() { + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(16, 50, 16, 16), + decoration: const BoxDecoration( + color: AppTheme.accentColor, + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(20), + bottomRight: Radius.circular(20), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Cotisations', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + Row( + children: [ + IconButton( + icon: const Icon(Icons.search, color: Colors.white), + onPressed: () { + // TODO: Implémenter la recherche + }, + ), + IconButton( + icon: const Icon(Icons.filter_list, color: Colors.white), + onPressed: () { + // TODO: Implémenter les filtres + }, + ), + ], + ), + ], + ), + const SizedBox(height: 8), + const Text( + 'Gérez les cotisations de vos membres', + style: TextStyle( + fontSize: 16, + color: Colors.white70, + ), + ), + ], + ), + ); + } + + Widget _buildLoadedState(CotisationsLoaded state) { + return RefreshIndicator( + onRefresh: () async { + _cotisationsBloc.add(const LoadCotisations(refresh: true)); + _cotisationsBloc.add(const LoadCotisationsStats()); + }, + child: CustomScrollView( + controller: _scrollController, + slivers: [ + // Statistiques + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: BlocBuilder( + buildWhen: (previous, current) => current is CotisationsStatsLoaded, + builder: (context, statsState) { + if (statsState is CotisationsStatsLoaded) { + return CotisationsStatsCard(statistics: statsState.statistics); + } + return const SizedBox.shrink(); + }, + ), + ), + ), + + // Liste des cotisations + if (state.filteredCotisations.isEmpty) + const SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.payment_outlined, + size: 64, + color: AppTheme.textHint, + ), + SizedBox(height: 16), + Text( + 'Aucune cotisation trouvée', + style: TextStyle( + fontSize: 18, + color: AppTheme.textSecondary, + ), + ), + SizedBox(height: 8), + Text( + 'Commencez par créer une cotisation', + style: TextStyle( + fontSize: 14, + color: AppTheme.textHint, + ), + ), + ], + ), + ), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index >= state.filteredCotisations.length) { + return state.hasReachedMax + ? const SizedBox.shrink() + : const Padding( + padding: EdgeInsets.all(16), + child: Center( + child: CircularProgressIndicator(), + ), + ); + } + + final cotisation = state.filteredCotisations[index]; + return Padding( + padding: EdgeInsets.fromLTRB( + 16, + index == 0 ? 0 : 8, + 16, + index == state.filteredCotisations.length - 1 ? 16 : 8, + ), + child: CotisationCard( + cotisation: cotisation, + onTap: () { + // TODO: Naviguer vers le détail + }, + onPay: () { + // TODO: Implémenter le paiement + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Paiement - En cours de développement'), + backgroundColor: AppTheme.successColor, + ), + ); + }, + ), + ); + }, + childCount: state.filteredCotisations.length + + (state.hasReachedMax ? 0 : 1), + ), + ), + ], + ), + ); + } + + Widget _buildErrorState(CotisationsError state) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + size: 64, + color: AppTheme.errorColor, + ), + const SizedBox(height: 16), + const Text( + 'Erreur de chargement', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 8), + Text( + state.message, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 14, + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: () { + _cotisationsBloc.add(const LoadCotisations(refresh: true)); + _cotisationsBloc.add(const LoadCotisationsStats()); + }, + icon: const Icon(Icons.refresh), + label: const Text('Réessayer'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.white, + ), + ), + ], + ), + ), + ); + } +} diff --git a/unionflow-mobile-apps/lib/features/cotisations/presentation/widgets/cotisation_card.dart b/unionflow-mobile-apps/lib/features/cotisations/presentation/widgets/cotisation_card.dart new file mode 100644 index 0000000..5bec373 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/cotisations/presentation/widgets/cotisation_card.dart @@ -0,0 +1,316 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import '../../../../core/models/cotisation_model.dart'; +import '../../../../shared/theme/app_theme.dart'; + +/// Widget card pour afficher une cotisation +class CotisationCard extends StatelessWidget { + final CotisationModel cotisation; + final VoidCallback? onTap; + final VoidCallback? onPay; + final VoidCallback? onEdit; + final VoidCallback? onDelete; + + const CotisationCard({ + super.key, + required this.cotisation, + this.onTap, + this.onPay, + this.onEdit, + this.onDelete, + }); + + @override + Widget build(BuildContext context) { + final currencyFormat = NumberFormat.currency( + locale: 'fr_FR', + symbol: 'FCFA', + decimalDigits: 0, + ); + + final dateFormat = DateFormat('dd/MM/yyyy', 'fr_FR'); + + return Card( + elevation: 2, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: _getStatusColor().withOpacity(0.3), + width: 1, + ), + ), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header avec statut et actions + Row( + children: [ + // Statut badge + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: _getStatusColor().withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + cotisation.libelleStatut, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: _getStatusColor(), + ), + ), + ), + const Spacer(), + // Actions + if (cotisation.statut == 'EN_ATTENTE' || cotisation.statut == 'EN_RETARD') + IconButton( + onPressed: onPay, + icon: const Icon(Icons.payment, size: 20), + color: AppTheme.successColor, + tooltip: 'Payer', + ), + if (onEdit != null) + IconButton( + onPressed: onEdit, + icon: const Icon(Icons.edit, size: 20), + color: AppTheme.primaryColor, + tooltip: 'Modifier', + ), + if (onDelete != null) + IconButton( + onPressed: onDelete, + icon: const Icon(Icons.delete, size: 20), + color: AppTheme.errorColor, + tooltip: 'Supprimer', + ), + ], + ), + + const SizedBox(height: 12), + + // Informations principales + Row( + children: [ + // Icône du type + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppTheme.primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Center( + child: Text( + cotisation.iconeTypeCotisation, + style: const TextStyle(fontSize: 20), + ), + ), + ), + + const SizedBox(width: 12), + + // Détails + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + cotisation.libelleTypeCotisation, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + if (cotisation.nomMembre != null) ...[ + const SizedBox(height: 2), + Text( + cotisation.nomMembre!, + style: const TextStyle( + fontSize: 14, + color: AppTheme.textSecondary, + ), + ), + ], + if (cotisation.periode != null) ...[ + const SizedBox(height: 2), + Text( + cotisation.periode!, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textHint, + ), + ), + ], + ], + ), + ), + + // Montant + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + currencyFormat.format(cotisation.montantDu), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + if (cotisation.montantPaye > 0) ...[ + const SizedBox(height: 2), + Text( + 'Payé: ${currencyFormat.format(cotisation.montantPaye)}', + style: const TextStyle( + fontSize: 12, + color: AppTheme.successColor, + ), + ), + ], + ], + ), + ], + ), + + const SizedBox(height: 12), + + // Barre de progression du paiement + if (cotisation.montantPaye > 0 && !cotisation.isEntierementPayee) ...[ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Progression', + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + Text( + '${cotisation.pourcentagePaiement.toStringAsFixed(0)}%', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppTheme.textSecondary, + ), + ), + ], + ), + const SizedBox(height: 4), + LinearProgressIndicator( + value: cotisation.pourcentagePaiement / 100, + backgroundColor: AppTheme.borderColor, + valueColor: AlwaysStoppedAnimation( + cotisation.pourcentagePaiement >= 100 + ? AppTheme.successColor + : AppTheme.primaryColor, + ), + ), + ], + ), + const SizedBox(height: 12), + ], + + // Informations d'échéance + Row( + children: [ + Icon( + Icons.schedule, + size: 16, + color: cotisation.isEnRetard + ? AppTheme.errorColor + : cotisation.echeanceProche + ? AppTheme.warningColor + : AppTheme.textHint, + ), + const SizedBox(width: 4), + Text( + 'Échéance: ${dateFormat.format(cotisation.dateEcheance)}', + style: TextStyle( + fontSize: 12, + color: cotisation.isEnRetard + ? AppTheme.errorColor + : cotisation.echeanceProche + ? AppTheme.warningColor + : AppTheme.textSecondary, + ), + ), + if (cotisation.messageUrgence.isNotEmpty) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: cotisation.isEnRetard + ? AppTheme.errorColor.withOpacity(0.1) + : AppTheme.warningColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + cotisation.messageUrgence, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: cotisation.isEnRetard + ? AppTheme.errorColor + : AppTheme.warningColor, + ), + ), + ), + ], + ], + ), + + // Référence + const SizedBox(height: 8), + Row( + children: [ + const Icon( + Icons.tag, + size: 16, + color: AppTheme.textHint, + ), + const SizedBox(width: 4), + Text( + 'Réf: ${cotisation.numeroReference}', + style: const TextStyle( + fontSize: 12, + color: AppTheme.textHint, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Color _getStatusColor() { + switch (cotisation.statut) { + case 'PAYEE': + return AppTheme.successColor; + case 'EN_ATTENTE': + return AppTheme.warningColor; + case 'EN_RETARD': + return AppTheme.errorColor; + case 'PARTIELLEMENT_PAYEE': + return AppTheme.infoColor; + case 'ANNULEE': + return AppTheme.textHint; + default: + return AppTheme.textSecondary; + } + } +} diff --git a/unionflow-mobile-apps/lib/features/cotisations/presentation/widgets/cotisations_stats_card.dart b/unionflow-mobile-apps/lib/features/cotisations/presentation/widgets/cotisations_stats_card.dart new file mode 100644 index 0000000..3d8374e --- /dev/null +++ b/unionflow-mobile-apps/lib/features/cotisations/presentation/widgets/cotisations_stats_card.dart @@ -0,0 +1,283 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import '../../../../shared/theme/app_theme.dart'; + +/// Widget pour afficher les statistiques des cotisations +class CotisationsStatsCard extends StatelessWidget { + final Map statistics; + + const CotisationsStatsCard({ + super.key, + required this.statistics, + }); + + @override + Widget build(BuildContext context) { + final currencyFormat = NumberFormat.currency( + locale: 'fr_FR', + symbol: 'FCFA', + decimalDigits: 0, + ); + + final totalCotisations = statistics['totalCotisations'] as int? ?? 0; + final cotisationsPayees = statistics['cotisationsPayees'] as int? ?? 0; + final cotisationsEnRetard = statistics['cotisationsEnRetard'] as int? ?? 0; + final tauxPaiement = statistics['tauxPaiement'] as double? ?? 0.0; + + return Card( + elevation: 2, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Titre + Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: AppTheme.accentColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(16), + ), + child: const Icon( + Icons.analytics, + size: 18, + color: AppTheme.accentColor, + ), + ), + const SizedBox(width: 12), + const Text( + 'Statistiques des cotisations', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + ], + ), + + const SizedBox(height: 16), + + // Grille des statistiques + Row( + children: [ + // Total des cotisations + Expanded( + child: _buildStatItem( + icon: Icons.receipt_long, + label: 'Total', + value: totalCotisations.toString(), + color: AppTheme.primaryColor, + ), + ), + + const SizedBox(width: 12), + + // Cotisations payées + Expanded( + child: _buildStatItem( + icon: Icons.check_circle, + label: 'Payées', + value: cotisationsPayees.toString(), + color: AppTheme.successColor, + ), + ), + ], + ), + + const SizedBox(height: 12), + + Row( + children: [ + // Cotisations en retard + Expanded( + child: _buildStatItem( + icon: Icons.warning, + label: 'En retard', + value: cotisationsEnRetard.toString(), + color: AppTheme.errorColor, + ), + ), + + const SizedBox(width: 12), + + // Taux de paiement + Expanded( + child: _buildStatItem( + icon: Icons.trending_up, + label: 'Taux paiement', + value: '${tauxPaiement.toStringAsFixed(1)}%', + color: tauxPaiement >= 80 + ? AppTheme.successColor + : tauxPaiement >= 60 + ? AppTheme.warningColor + : AppTheme.errorColor, + ), + ), + ], + ), + + const SizedBox(height: 16), + + // Barre de progression globale + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Progression globale', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppTheme.textSecondary, + ), + ), + Text( + '${tauxPaiement.toStringAsFixed(1)}%', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 8), + LinearProgressIndicator( + value: tauxPaiement / 100, + backgroundColor: AppTheme.borderColor, + valueColor: AlwaysStoppedAnimation( + tauxPaiement >= 80 + ? AppTheme.successColor + : tauxPaiement >= 60 + ? AppTheme.warningColor + : AppTheme.errorColor, + ), + ), + ], + ), + + // Montants si disponibles + if (statistics.containsKey('montantTotal') || + statistics.containsKey('montantPaye')) ...[ + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 16), + + Row( + children: [ + if (statistics.containsKey('montantTotal')) ...[ + Expanded( + child: _buildMoneyStatItem( + label: 'Montant total', + value: currencyFormat.format( + (statistics['montantTotal'] as num?)?.toDouble() ?? 0.0 + ), + color: AppTheme.textPrimary, + ), + ), + ], + + if (statistics.containsKey('montantTotal') && + statistics.containsKey('montantPaye')) + const SizedBox(width: 12), + + if (statistics.containsKey('montantPaye')) ...[ + Expanded( + child: _buildMoneyStatItem( + label: 'Montant payé', + value: currencyFormat.format( + (statistics['montantPaye'] as num?)?.toDouble() ?? 0.0 + ), + color: AppTheme.successColor, + ), + ), + ], + ], + ), + ], + ], + ), + ), + ); + } + + Widget _buildStatItem({ + required IconData icon, + required String label, + required String value, + required Color color, + }) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + Icon( + icon, + size: 24, + color: color, + ), + const SizedBox(height: 8), + Text( + value, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: color, + ), + ), + const SizedBox(height: 4), + Text( + label, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + Widget _buildMoneyStatItem({ + required String label, + required String value, + required Color color, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 4), + Text( + value, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: color, + ), + ), + ], + ); + } +} diff --git a/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/dashboard_page.dart b/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/dashboard_page.dart index bae3e72..c7932d9 100644 --- a/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/dashboard_page.dart +++ b/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/dashboard_page.dart @@ -1,7 +1,24 @@ import 'package:flutter/material.dart'; -import 'package:fl_chart/fl_chart.dart'; import '../../../../shared/theme/app_theme.dart'; +// Imports des nouveaux widgets refactorisés +import '../widgets/welcome/welcome_section_widget.dart'; +import '../widgets/kpi/kpi_cards_widget.dart'; +import '../widgets/actions/quick_actions_widget.dart'; +import '../widgets/activities/recent_activities_widget.dart'; +import '../widgets/charts/charts_analytics_widget.dart'; + +/// Page principale du tableau de bord UnionFlow +/// +/// Affiche une vue d'ensemble complète de l'association avec : +/// - Section d'accueil personnalisée +/// - Indicateurs clés de performance (KPI) +/// - Actions rapides et gestion +/// - Flux d'activités en temps réel +/// - Analyses et tendances graphiques +/// +/// Architecture modulaire avec widgets réutilisables pour une +/// maintenabilité optimale et une évolutivité facilitée. class DashboardPage extends StatelessWidget { const DashboardPage({super.key}); @@ -16,11 +33,15 @@ class DashboardPage extends StatelessWidget { actions: [ IconButton( icon: const Icon(Icons.notifications_outlined), - onPressed: () {}, + onPressed: () { + // TODO: Implémenter la navigation vers les notifications + }, ), IconButton( icon: const Icon(Icons.settings_outlined), - onPressed: () {}, + onPressed: () { + // TODO: Implémenter la navigation vers les paramètres + }, ), ], ), @@ -30,646 +51,32 @@ class DashboardPage extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Message de bienvenue - _buildWelcomeSection(context), + // 1. ACCUEIL & CONTEXTE - Message de bienvenue personnalisé + const WelcomeSectionWidget(), const SizedBox(height: 24), - - // Cartes KPI principales - _buildKPICards(context), + + // 2. VISION GLOBALE - Indicateurs clés de performance (KPI) + // Vue d'ensemble immédiate de la santé de l'association + const KPICardsWidget(), const SizedBox(height: 24), - - // Graphiques et statistiques - _buildChartsSection(context), + + // 3. ACTIONS PRIORITAIRES - Actions rapides et gestion + // Accès direct aux tâches critiques quotidiennes + const QuickActionsWidget(), const SizedBox(height: 24), - - // Actions rapides - _buildQuickActions(context), + + // 4. SUIVI TEMPS RÉEL - Flux d'activités en direct + // Monitoring des événements récents et alertes + const RecentActivitiesWidget(), const SizedBox(height: 24), - - // Activités récentes - _buildRecentActivities(context), + + // 5. ANALYSES APPROFONDIES - Graphiques et tendances + // Analyses détaillées pour la prise de décision stratégique + const ChartsAnalyticsWidget(), ], ), ), ), ); } - - Widget _buildWelcomeSection(BuildContext context) { - return Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [AppTheme.primaryColor, AppTheme.primaryLight], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(16), - ), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Bonjour !', - style: TextStyle( - color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - 'Voici un aperçu de votre association', - style: TextStyle( - color: Colors.white.withOpacity(0.9), - fontSize: 16, - ), - ), - ], - ), - ), - Container( - width: 60, - height: 60, - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(30), - ), - child: const Icon( - Icons.dashboard, - color: Colors.white, - size: 30, - ), - ), - ], - ), - ); - } - - Widget _buildKPICards(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Indicateurs clés', - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary, - ), - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: _buildKPICard( - context, - 'Membres', - '1,247', - '+5.2%', - Icons.people, - AppTheme.primaryColor, - ), - ), - const SizedBox(width: 12), - Expanded( - child: _buildKPICard( - context, - 'Revenus', - '€45,890', - '+12.8%', - Icons.euro, - AppTheme.successColor, - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: _buildKPICard( - context, - 'Événements', - '23', - '+3', - Icons.event, - AppTheme.accentColor, - ), - ), - const SizedBox(width: 12), - Expanded( - child: _buildKPICard( - context, - 'Cotisations', - '89.5%', - '+2.1%', - Icons.payments, - AppTheme.infoColor, - ), - ), - ], - ), - ], - ); - } - - Widget _buildKPICard( - BuildContext context, - String title, - String value, - String change, - 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(), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: AppTheme.successColor.withOpacity(0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - change, - style: const TextStyle( - color: AppTheme.successColor, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - const SizedBox(height: 12), - Text( - value, - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary, - ), - ), - const SizedBox(height: 4), - Text( - title, - style: const TextStyle( - fontSize: 14, - color: AppTheme.textSecondary, - ), - ), - ], - ), - ); - } - - Widget _buildChartsSection(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Analyses', - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary, - ), - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: _buildLineChart(context), - ), - const SizedBox(width: 12), - Expanded( - child: _buildPieChart(context), - ), - ], - ), - ], - ); - } - - Widget _buildLineChart(BuildContext context) { - return Container( - height: 200, - 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: [ - const Text( - 'Évolution des membres', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary, - ), - ), - const SizedBox(height: 16), - Expanded( - child: LineChart( - LineChartData( - gridData: const FlGridData(show: false), - titlesData: const FlTitlesData(show: false), - borderData: FlBorderData(show: false), - lineBarsData: [ - LineChartBarData( - spots: const [ - FlSpot(0, 1000), - FlSpot(1, 1050), - FlSpot(2, 1100), - FlSpot(3, 1180), - FlSpot(4, 1247), - ], - color: AppTheme.primaryColor, - barWidth: 3, - isStrokeCapRound: true, - dotData: const FlDotData(show: false), - belowBarData: BarAreaData( - show: true, - color: AppTheme.primaryColor.withOpacity(0.1), - ), - ), - ], - ), - ), - ), - ], - ), - ); - } - - Widget _buildPieChart(BuildContext context) { - return Container( - height: 200, - 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: [ - const Text( - 'Répartition des membres', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary, - ), - ), - const SizedBox(height: 16), - Expanded( - child: PieChart( - PieChartData( - sectionsSpace: 0, - centerSpaceRadius: 40, - sections: [ - PieChartSectionData( - color: AppTheme.primaryColor, - value: 45, - title: '45%', - radius: 50, - titleStyle: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - PieChartSectionData( - color: AppTheme.secondaryColor, - value: 30, - title: '30%', - radius: 50, - titleStyle: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - PieChartSectionData( - color: AppTheme.accentColor, - value: 25, - title: '25%', - radius: 50, - titleStyle: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - ], - ), - ), - ), - ], - ), - ); - } - - Widget _buildQuickActions(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Actions rapides', - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary, - ), - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: _buildActionCard( - context, - 'Nouveau membre', - 'Ajouter un membre', - Icons.person_add, - AppTheme.primaryColor, - ), - ), - const SizedBox(width: 12), - Expanded( - child: _buildActionCard( - context, - 'Créer événement', - 'Organiser un événement', - Icons.event_available, - AppTheme.secondaryColor, - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: _buildActionCard( - context, - 'Suivi cotisations', - 'Gérer les cotisations', - Icons.payment, - AppTheme.accentColor, - ), - ), - const SizedBox(width: 12), - Expanded( - child: _buildActionCard( - context, - 'Rapports', - 'Générer des rapports', - Icons.analytics, - AppTheme.infoColor, - ), - ), - ], - ), - ], - ); - } - - Widget _buildActionCard( - BuildContext context, - String title, - String subtitle, - IconData icon, - Color color, - ) { - return InkWell( - onTap: () { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('$title - En cours de développement'), - backgroundColor: color, - ), - ); - }, - 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( - 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: 12), - Text( - title, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 4), - Text( - subtitle, - style: const TextStyle( - fontSize: 12, - color: AppTheme.textSecondary, - ), - textAlign: TextAlign.center, - ), - ], - ), - ), - ); - } - - Widget _buildRecentActivities(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Activités récentes', - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary, - ), - ), - TextButton( - onPressed: () {}, - child: const Text('Voir tout'), - ), - ], - ), - const SizedBox(height: 16), - 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: Column( - children: [ - _buildActivityItem( - 'Nouveau membre inscrit', - 'Marie Dupont a rejoint l\'association', - Icons.person_add, - AppTheme.successColor, - 'Il y a 2h', - ), - const Divider(height: 1), - _buildActivityItem( - 'Cotisation reçue', - 'Jean Martin a payé sa cotisation annuelle', - Icons.payment, - AppTheme.primaryColor, - 'Il y a 4h', - ), - const Divider(height: 1), - _buildActivityItem( - 'Événement créé', - 'Assemblée générale 2024 programmée', - Icons.event, - AppTheme.accentColor, - 'Hier', - ), - ], - ), - ), - ], - ); - } - - Widget _buildActivityItem( - String title, - String description, - IconData icon, - Color color, - String time, - ) { - return Padding( - padding: const EdgeInsets.all(16), - 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: 16, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary, - ), - ), - const SizedBox(height: 2), - Text( - description, - style: const TextStyle( - fontSize: 12, - color: AppTheme.textSecondary, - ), - ), - ], - ), - ), - Text( - time, - style: const TextStyle( - fontSize: 12, - color: AppTheme.textHint, - ), - ), - ], - ), - ); - } -} \ No newline at end of file +} diff --git a/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/enhanced_dashboard.dart b/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/enhanced_dashboard.dart deleted file mode 100644 index e81ab12..0000000 --- a/unionflow-mobile-apps/lib/features/dashboard/presentation/pages/enhanced_dashboard.dart +++ /dev/null @@ -1,485 +0,0 @@ -import 'package:flutter/material.dart'; -import '../../../../shared/theme/app_theme.dart'; - -import '../widgets/clickable_kpi_card.dart'; -import '../widgets/chart_card.dart'; -import '../widgets/activity_feed.dart'; -import '../widgets/quick_actions_grid.dart'; -import '../widgets/navigation_cards.dart'; - -class EnhancedDashboard extends StatefulWidget { - final Function(int)? onNavigateToTab; - - const EnhancedDashboard({ - super.key, - this.onNavigateToTab, - }); - - @override - State createState() => _EnhancedDashboardState(); -} - -class _EnhancedDashboardState extends State { - final PageController _pageController = PageController(); - int _currentPage = 0; - - @override - void dispose() { - _pageController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppTheme.backgroundLight, - body: CustomScrollView( - slivers: [ - _buildAppBar(), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildWelcomeCard(), - const SizedBox(height: 24), - _buildKPISection(), - const SizedBox(height: 24), - _buildChartsSection(), - const SizedBox(height: 24), - NavigationCards( - onNavigateToTab: widget.onNavigateToTab, - ), - const SizedBox(height: 24), - const QuickActionsGrid(), - const SizedBox(height: 24), - const ActivityFeed(), - const SizedBox(height: 24), - ], - ), - ), - ), - ], - ), - ); - } - - Widget _buildAppBar() { - return SliverAppBar( - expandedHeight: 120, - floating: false, - pinned: true, - backgroundColor: AppTheme.primaryColor, - flexibleSpace: FlexibleSpaceBar( - title: const Text( - 'Tableau de bord', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - background: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [AppTheme.primaryColor, AppTheme.primaryDark], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - ), - ), - actions: [ - IconButton( - icon: const Icon(Icons.notifications_outlined), - onPressed: () => _showNotifications(), - ), - IconButton( - icon: const Icon(Icons.refresh), - onPressed: () => _refreshData(), - ), - PopupMenuButton( - icon: const Icon(Icons.more_vert), - onSelected: _handleMenuSelection, - itemBuilder: (context) => [ - const PopupMenuItem( - value: 'settings', - child: Row( - children: [ - Icon(Icons.settings), - SizedBox(width: 8), - Text('Paramètres'), - ], - ), - ), - const PopupMenuItem( - value: 'export', - child: Row( - children: [ - Icon(Icons.download), - SizedBox(width: 8), - Text('Exporter'), - ], - ), - ), - const PopupMenuItem( - value: 'help', - child: Row( - children: [ - Icon(Icons.help), - SizedBox(width: 8), - Text('Aide'), - ], - ), - ), - ], - ), - ], - ); - } - - Widget _buildWelcomeCard() { - return Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [AppTheme.primaryColor, AppTheme.primaryLight], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: AppTheme.primaryColor.withOpacity(0.3), - blurRadius: 20, - offset: const Offset(0, 8), - ), - ], - ), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Bonjour !', - style: TextStyle( - color: Colors.white, - fontSize: 28, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - 'Découvrez les dernières statistiques de votre association', - style: TextStyle( - color: Colors.white.withOpacity(0.9), - fontSize: 16, - ), - ), - const SizedBox(height: 16), - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.trending_up, - color: Colors.white, - size: 16, - ), - const SizedBox(width: 4), - Text( - '+12% ce mois', - style: const TextStyle( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ], - ), - ], - ), - ), - Container( - width: 80, - height: 80, - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(40), - ), - child: const Icon( - Icons.dashboard_rounded, - color: Colors.white, - size: 40, - ), - ), - ], - ), - ); - } - - Widget _buildKPISection() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - 'Indicateurs clés', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary, - ), - ), - TextButton.icon( - onPressed: () {}, - icon: const Icon(Icons.analytics, size: 16), - label: const Text('Analyse détaillée'), - ), - ], - ), - const SizedBox(height: 16), - SizedBox( - height: 180, - child: PageView( - controller: _pageController, - onPageChanged: (index) { - setState(() { - _currentPage = index; - }); - }, - children: [ - _buildKPIPage1(), - _buildKPIPage2(), - ], - ), - ), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _buildPageIndicator(0), - const SizedBox(width: 8), - _buildPageIndicator(1), - ], - ), - ], - ); - } - - Widget _buildKPIPage1() { - return Row( - children: [ - Expanded( - child: ClickableKPICard( - title: 'Membres actifs', - value: '1,247', - change: '+5.2%', - icon: Icons.people, - color: AppTheme.secondaryColor, - actionText: 'Gérer', - onTap: () => widget.onNavigateToTab?.call(1), - ), - ), - const SizedBox(width: 12), - Expanded( - child: ClickableKPICard( - title: 'Revenus mensuel', - value: '€45,890', - change: '+12.8%', - icon: Icons.euro, - color: AppTheme.successColor, - actionText: 'Finances', - onTap: () => _showFinancesMessage(), - ), - ), - ], - ); - } - - Widget _buildKPIPage2() { - return Row( - children: [ - Expanded( - child: ClickableKPICard( - title: 'Événements', - value: '23', - change: '+3', - icon: Icons.event, - color: AppTheme.warningColor, - actionText: 'Planifier', - onTap: () => widget.onNavigateToTab?.call(3), - ), - ), - const SizedBox(width: 12), - Expanded( - child: ClickableKPICard( - title: 'Taux cotisation', - value: '89.5%', - change: '+2.1%', - icon: Icons.payments, - color: AppTheme.accentColor, - actionText: 'Gérer', - onTap: () => widget.onNavigateToTab?.call(2), - ), - ), - ], - ); - } - - Widget _buildPageIndicator(int index) { - return AnimatedContainer( - duration: const Duration(milliseconds: 300), - width: _currentPage == index ? 20 : 8, - height: 8, - decoration: BoxDecoration( - color: _currentPage == index - ? AppTheme.primaryColor - : AppTheme.primaryColor.withOpacity(0.3), - borderRadius: BorderRadius.circular(4), - ), - ); - } - - Widget _buildChartsSection() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Analyses et tendances', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary, - ), - ), - const SizedBox(height: 16), - ChartCard( - title: 'Évolution des membres', - subtitle: 'Croissance sur 6 mois', - chart: const MembershipChart(), - onTap: () => widget.onNavigateToTab?.call(1), - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: ChartCard( - title: 'Répartition', - subtitle: 'Par catégorie', - chart: const CategoryChart(), - onTap: () => widget.onNavigateToTab?.call(1), - ), - ), - const SizedBox(width: 12), - Expanded( - child: ChartCard( - title: 'Revenus', - subtitle: 'Évolution mensuelle', - chart: const RevenueChart(), - onTap: () => _showFinancesMessage(), - ), - ), - ], - ), - ], - ); - } - - void _showNotifications() { - showModalBottomSheet( - context: context, - builder: (context) => Container( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - 'Notifications', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 16), - ListTile( - leading: const Icon(Icons.warning, color: AppTheme.warningColor), - title: const Text('3 cotisations en retard'), - subtitle: const Text('Nécessite votre attention'), - onTap: () {}, - ), - ListTile( - leading: const Icon(Icons.event, color: AppTheme.accentColor), - title: const Text('Assemblée générale'), - subtitle: const Text('Dans 5 jours'), - onTap: () {}, - ), - ListTile( - leading: const Icon(Icons.check_circle, color: AppTheme.successColor), - title: const Text('Rapport mensuel'), - subtitle: const Text('Prêt à être envoyé'), - onTap: () {}, - ), - ], - ), - ), - ); - } - - void _refreshData() { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Données actualisées'), - backgroundColor: AppTheme.successColor, - behavior: SnackBarBehavior.floating, - ), - ); - } - - void _handleMenuSelection(String value) { - switch (value) { - case 'settings': - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Paramètres - En développement')), - ); - break; - case 'export': - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Export - En développement')), - ); - break; - case 'help': - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Aide - En développement')), - ); - break; - } - } - - void _showFinancesMessage() { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Module Finances - Prochainement disponible'), - backgroundColor: AppTheme.successColor, - behavior: SnackBarBehavior.floating, - ), - ); - } -} \ No newline at end of file diff --git a/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/actions/action_card_widget.dart b/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/actions/action_card_widget.dart new file mode 100644 index 0000000..c5e43e7 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/actions/action_card_widget.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import '../../../../../shared/theme/app_theme.dart'; + +/// Widget de carte d'action rapide réutilisable +/// +/// Affiche une action cliquable avec: +/// - Icône colorée dans un conteneur arrondi +/// - Titre principal +/// - Sous-titre descriptif +/// - Interaction tactile avec feedback visuel +/// - Callback personnalisable pour l'action +class ActionCardWidget extends StatelessWidget { + /// Titre de l'action + final String title; + + /// Description de l'action + final String subtitle; + + /// Icône représentative + final IconData icon; + + /// Couleur thématique de l'action + final Color color; + + /// Callback exécuté lors du tap + final VoidCallback? onTap; + + const ActionCardWidget({ + super.key, + required this.title, + required this.subtitle, + required this.icon, + required this.color, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap ?? () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('$title - En cours de développement'), + backgroundColor: color, + ), + ); + }, + 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( + 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: 12), + Text( + title, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + Text( + subtitle, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} diff --git a/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/actions/quick_actions_widget.dart b/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/actions/quick_actions_widget.dart new file mode 100644 index 0000000..f724571 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/actions/quick_actions_widget.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; +import '../../../../../shared/theme/app_theme.dart'; +import 'action_card_widget.dart'; + +/// Widget de section des actions rapides et de gestion +/// +/// Affiche une grille d'actions rapides organisées par catégories: +/// - Actions principales (nouveau membre, créer événement) +/// - Gestion financière (encaisser cotisation, relances) +/// - Communication (messages, convocations) +/// - Rapports et conformité (OHADA, exports) +/// - Urgences et support (alertes, assistance) +class QuickActionsWidget extends StatelessWidget { + const QuickActionsWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Actions rapides & Gestion', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 16), + + // Première ligne - Actions principales + Row( + children: [ + Expanded( + child: ActionCardWidget( + title: 'Nouveau membre', + subtitle: 'Inscription rapide', + icon: Icons.person_add, + color: AppTheme.primaryColor, + ), + ), + const SizedBox(width: 12), + Expanded( + child: ActionCardWidget( + title: 'Créer événement', + subtitle: 'Organiser activité', + icon: Icons.event_available, + color: AppTheme.secondaryColor, + ), + ), + ], + ), + const SizedBox(height: 12), + + // Deuxième ligne - Gestion financière + Row( + children: [ + Expanded( + child: ActionCardWidget( + title: 'Encaisser cotisation', + subtitle: 'Paiement immédiat', + icon: Icons.payment, + color: AppTheme.successColor, + ), + ), + const SizedBox(width: 12), + Expanded( + child: ActionCardWidget( + title: 'Relances impayés', + subtitle: 'Notifications SMS', + icon: Icons.notifications_active, + color: AppTheme.warningColor, + ), + ), + ], + ), + const SizedBox(height: 12), + + // Troisième ligne - Communication + Row( + children: [ + Expanded( + child: ActionCardWidget( + title: 'Message groupe', + subtitle: 'Diffusion WhatsApp', + icon: Icons.message, + color: const Color(0xFF25D366), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ActionCardWidget( + title: 'Convoquer AG', + subtitle: 'Assemblée générale', + icon: Icons.groups, + color: const Color(0xFF9C27B0), + ), + ), + ], + ), + const SizedBox(height: 12), + + // Quatrième ligne - Rapports et conformité + Row( + children: [ + Expanded( + child: ActionCardWidget( + title: 'Rapport OHADA', + subtitle: 'Conformité légale', + icon: Icons.gavel, + color: const Color(0xFF795548), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ActionCardWidget( + title: 'Export données', + subtitle: 'Sauvegarde Excel', + icon: Icons.file_download, + color: AppTheme.infoColor, + ), + ), + ], + ), + const SizedBox(height: 12), + + // Cinquième ligne - Urgences et support + Row( + children: [ + Expanded( + child: ActionCardWidget( + title: 'Alerte urgente', + subtitle: 'Notification critique', + icon: Icons.emergency, + color: AppTheme.errorColor, + ), + ), + const SizedBox(width: 12), + Expanded( + child: ActionCardWidget( + title: 'Support technique', + subtitle: 'Assistance UnionFlow', + icon: Icons.support_agent, + color: const Color(0xFF607D8B), + ), + ), + ], + ), + ], + ); + } +} diff --git a/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/activities/activity_item_widget.dart b/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/activities/activity_item_widget.dart new file mode 100644 index 0000000..77165db --- /dev/null +++ b/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/activities/activity_item_widget.dart @@ -0,0 +1,148 @@ +import 'package:flutter/material.dart'; +import '../../../../../shared/theme/app_theme.dart'; + +/// Widget d'élément d'activité récente réutilisable +/// +/// Affiche une activité avec: +/// - Icône colorée avec indicateur "nouveau" optionnel +/// - Titre et description +/// - Horodatage avec mise en évidence pour les nouveaux éléments +/// - Badge "NOUVEAU" pour les activités récentes +/// - Indicateur visuel pour les nouvelles activités +class ActivityItemWidget extends StatelessWidget { + /// Titre de l'activité + final String title; + + /// Description détaillée de l'activité + final String description; + + /// Icône représentative + final IconData icon; + + /// Couleur thématique + final Color color; + + /// Horodatage de l'activité + final String time; + + /// Indique si l'activité est nouvelle + final bool isNew; + + const ActivityItemWidget({ + super.key, + required this.title, + required this.description, + required this.icon, + required this.color, + required this.time, + this.isNew = false, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Stack( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + icon, + color: color, + size: 16, + ), + ), + if (isNew) + Positioned( + top: -2, + right: -2, + child: Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: AppTheme.errorColor, + shape: BoxShape.circle, + ), + ), + ), + ], + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + title, + style: TextStyle( + fontSize: 14, + fontWeight: isNew ? FontWeight.w700 : FontWeight.w600, + color: isNew ? AppTheme.textPrimary : AppTheme.textPrimary, + ), + ), + ), + if (isNew) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppTheme.errorColor, + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'NOUVEAU', + style: TextStyle( + fontSize: 8, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + description, + style: TextStyle( + fontSize: 12, + color: isNew ? AppTheme.textPrimary : AppTheme.textSecondary, + fontWeight: isNew ? FontWeight.w500 : FontWeight.normal, + ), + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + time, + style: TextStyle( + fontSize: 12, + color: isNew ? AppTheme.primaryColor : AppTheme.textHint, + fontWeight: isNew ? FontWeight.w600 : FontWeight.normal, + ), + ), + if (isNew) + const SizedBox(height: 2), + if (isNew) + const Icon( + Icons.fiber_new, + size: 12, + color: AppTheme.errorColor, + ), + ], + ), + ], + ), + ); + } +} diff --git a/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/activities/recent_activities_widget.dart b/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/activities/recent_activities_widget.dart new file mode 100644 index 0000000..847046f --- /dev/null +++ b/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/activities/recent_activities_widget.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; +import '../../../../../shared/theme/app_theme.dart'; +import 'activity_item_widget.dart'; + +/// Widget de section des activités récentes en temps réel +/// +/// Affiche un flux d'activités en temps réel avec: +/// - En-tête avec indicateur "Live" et bouton "Tout voir" +/// - Liste d'activités avec indicateurs visuels pour les nouveaux éléments +/// - Séparateurs entre les éléments +/// - Horodatage précis pour chaque activité +/// - Icônes et couleurs thématiques par type d'activité +class RecentActivitiesWidget extends StatelessWidget { + const RecentActivitiesWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Flux d\'activités en temps réel', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppTheme.successColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 4, + height: 4, + decoration: const BoxDecoration( + color: AppTheme.successColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 3), + const Text( + 'Live', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w600, + color: AppTheme.successColor, + ), + ), + ], + ), + ), + const SizedBox(width: 6), + TextButton( + onPressed: () {}, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Text( + 'Tout', + style: TextStyle(fontSize: 12), + ), + ), + ], + ), + const SizedBox(height: 16), + 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: Column( + children: [ + ActivityItemWidget( + title: 'Paiement Mobile Money reçu', + description: 'Kouassi Yao - 25,000 FCFA via Orange Money', + icon: Icons.phone_android, + color: const Color(0xFFFF9800), + time: 'Il y a 3 min', + isNew: true, + ), + const Divider(height: 1), + ActivityItemWidget( + title: 'Nouveau membre validé', + description: 'Adjoua Marie inscrite depuis Abidjan', + icon: Icons.person_add, + color: AppTheme.successColor, + time: 'Il y a 15 min', + isNew: true, + ), + const Divider(height: 1), + ActivityItemWidget( + title: 'Relance automatique envoyée', + description: '12 SMS de rappel cotisations expédiés', + icon: Icons.sms, + color: AppTheme.infoColor, + time: 'Il y a 1h', + ), + const Divider(height: 1), + ActivityItemWidget( + title: 'Rapport OHADA généré', + description: 'Bilan financier T4 2024 exporté', + icon: Icons.description, + color: const Color(0xFF795548), + time: 'Il y a 2h', + ), + const Divider(height: 1), + ActivityItemWidget( + title: 'Événement: Forte participation', + description: 'AG Extraordinaire - 89% de présence', + icon: Icons.trending_up, + color: AppTheme.successColor, + time: 'Il y a 3h', + ), + const Divider(height: 1), + ActivityItemWidget( + title: 'Alerte: Cotisations en retard', + description: '23 membres avec +30 jours de retard', + icon: Icons.warning, + color: AppTheme.warningColor, + time: 'Il y a 4h', + ), + const Divider(height: 1), + ActivityItemWidget( + title: 'Synchronisation réussie', + description: 'Données sauvegardées sur le cloud', + icon: Icons.cloud_done, + color: AppTheme.successColor, + time: 'Il y a 6h', + ), + const Divider(height: 1), + ActivityItemWidget( + title: 'Message diffusé', + description: 'Info COVID-19 envoyée à 1,247 membres', + icon: Icons.campaign, + color: const Color(0xFF9C27B0), + time: 'Hier 18:30', + ), + ], + ), + ), + ], + ); + } +} diff --git a/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/charts/charts_analytics_widget.dart b/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/charts/charts_analytics_widget.dart new file mode 100644 index 0000000..741af05 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/charts/charts_analytics_widget.dart @@ -0,0 +1,432 @@ +import 'package:flutter/material.dart'; +import '../../../../../shared/theme/app_theme.dart'; +import '../common/section_header_widget.dart'; + +/// Widget de section des analyses et tendances avec graphiques +/// +/// Affiche tous les graphiques d'analyse en une seule colonne: +/// - Évolution des membres actifs (ligne) +/// - Répartition des cotisations (camembert) +/// - Revenus par source (barres) +/// - Cotisations par mois (barres) +/// - Engagement des membres (radar) +/// - Tendances géographiques (carte) +/// - Analyse comparative (barres groupées) +/// +/// Chaque graphique est optimisé pour l'affichage mobile +/// avec des détails enrichis et des légendes complètes. +class ChartsAnalyticsWidget extends StatelessWidget { + const ChartsAnalyticsWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionHeaderWidget(title: 'Analyses & Tendances'), + const SizedBox(height: 16), + + // Graphiques d'analyse - Une seule colonne pour exploiter toute la largeur + _buildLineChart(context), + const SizedBox(height: 16), + + _buildPieChart(context), + const SizedBox(height: 16), + + _buildRevenueChart(context), + const SizedBox(height: 16), + + _buildCotisationsChart(context), + const SizedBox(height: 16), + + _buildEngagementChart(context), + const SizedBox(height: 16), + + _buildTrendsChart(context), + const SizedBox(height: 16), + + _buildGeographicChart(context), + ], + ); + } + + /// Graphique d'évolution des membres actifs (ligne) + Widget _buildLineChart(BuildContext context) { + return Container( + height: 280, + 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: [ + // En-tête enrichi avec icône et métriques + Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + ), + child: const Icon( + Icons.trending_up, + color: AppTheme.primaryColor, + size: 16, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Évolution des membres actifs', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 2), + const Text( + 'Croissance sur 5 mois • +24.7% (+247 membres)', + style: TextStyle( + fontSize: 11, + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppTheme.successColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.trending_up, + color: AppTheme.successColor, + size: 12, + ), + SizedBox(width: 4), + Text( + '+24.7%', + style: TextStyle( + color: AppTheme.successColor, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + + // Placeholder pour le graphique + Expanded( + child: Container( + decoration: BoxDecoration( + color: AppTheme.primaryColor.withOpacity(0.05), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppTheme.primaryColor.withOpacity(0.1), + width: 1, + ), + ), + child: const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.show_chart, + color: AppTheme.primaryColor, + size: 48, + ), + SizedBox(height: 8), + Text( + 'Graphique d\'évolution des membres', + style: TextStyle( + color: AppTheme.textSecondary, + fontSize: 12, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } + + /// Graphique de répartition des cotisations (camembert) + Widget _buildPieChart(BuildContext context) { + return Container( + height: 280, + 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: [ + // En-tête enrichi + Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: AppTheme.accentColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + ), + child: const Icon( + Icons.pie_chart, + color: AppTheme.accentColor, + size: 16, + ), + ), + const SizedBox(width: 8), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Répartition des cotisations', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + SizedBox(height: 2), + Text( + 'Par statut de paiement • 1,247 membres total', + style: TextStyle( + fontSize: 11, + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + + // Placeholder pour le graphique camembert + Expanded( + child: Container( + decoration: BoxDecoration( + color: AppTheme.accentColor.withOpacity(0.05), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppTheme.accentColor.withOpacity(0.1), + width: 1, + ), + ), + child: const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.donut_small, + color: AppTheme.accentColor, + size: 48, + ), + SizedBox(height: 8), + Text( + 'Graphique camembert des cotisations', + style: TextStyle( + color: AppTheme.textSecondary, + fontSize: 12, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } + + /// Placeholder pour les autres graphiques + Widget _buildRevenueChart(BuildContext context) { + return _buildPlaceholderChart( + 'Revenus par source', + 'Analyse mensuelle • 2,845,000 FCFA total', + Icons.bar_chart, + AppTheme.successColor, + 'Graphique des revenus par source', + ); + } + + Widget _buildCotisationsChart(BuildContext context) { + return _buildPlaceholderChart( + 'Cotisations par mois', + 'Évolution sur 12 mois • Tendance positive', + Icons.assessment, + AppTheme.infoColor, + 'Graphique des cotisations mensuelles', + ); + } + + Widget _buildEngagementChart(BuildContext context) { + return _buildPlaceholderChart( + 'Engagement des membres', + 'Analyse multi-critères • Score global 85/100', + Icons.radar, + const Color(0xFF9C27B0), + 'Graphique radar d\'engagement', + ); + } + + Widget _buildTrendsChart(BuildContext context) { + return _buildPlaceholderChart( + 'Tendances comparatives', + 'Comparaison avec période précédente', + Icons.compare_arrows, + AppTheme.warningColor, + 'Graphique de tendances comparatives', + ); + } + + Widget _buildGeographicChart(BuildContext context) { + return _buildPlaceholderChart( + 'Répartition géographique', + 'Membres par région • Côte d\'Ivoire', + Icons.map, + const Color(0xFF795548), + 'Carte géographique des membres', + ); + } + + /// Widget placeholder générique pour les graphiques + Widget _buildPlaceholderChart( + String title, + String subtitle, + IconData icon, + Color color, + String description, + ) { + return Container( + height: 280, + 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(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: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: const TextStyle( + fontSize: 11, + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + Expanded( + child: Container( + decoration: BoxDecoration( + color: color.withOpacity(0.05), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: color.withOpacity(0.1), + width: 1, + ), + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + color: color, + size: 48, + ), + const SizedBox(height: 8), + Text( + description, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 12, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/common/section_header_widget.dart b/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/common/section_header_widget.dart new file mode 100644 index 0000000..a3757e8 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/common/section_header_widget.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import '../../../../../shared/theme/app_theme.dart'; + +/// Widget d'en-tête de section réutilisable +/// +/// Affiche un titre de section avec style cohérent +/// utilisé dans toutes les sections du dashboard. +class SectionHeaderWidget extends StatelessWidget { + /// Titre de la section + final String title; + + /// Style de texte personnalisé (optionnel) + final TextStyle? textStyle; + + const SectionHeaderWidget({ + super.key, + required this.title, + this.textStyle, + }); + + @override + Widget build(BuildContext context) { + return Text( + title, + style: textStyle ?? Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ); + } +} diff --git a/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/kpi/kpi_card_widget.dart b/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/kpi/kpi_card_widget.dart new file mode 100644 index 0000000..a410ee3 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/kpi/kpi_card_widget.dart @@ -0,0 +1,289 @@ +import 'package:flutter/material.dart'; +import '../../../../../shared/theme/app_theme.dart'; + +/// Widget de carte KPI réutilisable avec détails enrichis +/// +/// Affiche un indicateur de performance clé avec: +/// - Icône et badge de tendance coloré +/// - Valeur principale avec objectif optionnel +/// - Titre avec période +/// - Description détaillée +/// - Points de détail sous forme de puces +/// - Horodatage de dernière mise à jour +class KPICardWidget extends StatelessWidget { + /// Titre de l'indicateur + final String title; + + /// Valeur principale affichée + final String value; + + /// Changement/tendance (ex: "+5.2%", "-3.1%") + final String change; + + /// Icône représentative + final IconData icon; + + /// Couleur thématique de la carte + final Color color; + + /// Description détaillée optionnelle + final String? subtitle; + + /// Période de référence (ex: "30j", "Mois") + final String? period; + + /// Objectif cible optionnel + final String? target; + + /// Horodatage de dernière mise à jour + final String? lastUpdate; + + /// Liste de détails supplémentaires (max 3) + final List? details; + + const KPICardWidget({ + super.key, + required this.title, + required this.value, + required this.change, + required this.icon, + required this.color, + this.subtitle, + this.period, + this.target, + this.lastUpdate, + this.details, + }); + + @override + Widget build(BuildContext context) { + 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: [ + // En-tête avec icône et badge de tendance + 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(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: _getChangeColor(change).withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _getChangeIcon(change), + color: _getChangeColor(change), + size: 12, + ), + const SizedBox(width: 4), + Text( + change, + style: TextStyle( + color: _getChangeColor(change), + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + + // Valeur principale + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Text( + value, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + ), + if (target != null) + Text( + '/ $target', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppTheme.textSecondary, + ), + ), + ], + ), + const SizedBox(height: 4), + + // Titre et période + Row( + children: [ + Expanded( + child: Text( + title, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + ), + if (period != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + period!, + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ), + ], + ), + + // Description détaillée + if (subtitle != null) ...[ + const SizedBox(height: 6), + Text( + subtitle!, + style: const TextStyle( + fontSize: 11, + color: AppTheme.textSecondary, + height: 1.3, + ), + ), + ], + + // Détails supplémentaires sous forme de puces + if (details != null && details!.isNotEmpty) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withOpacity(0.05), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: color.withOpacity(0.1), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: details!.take(3).map((detail) => Padding( + padding: const EdgeInsets.only(bottom: 3), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + margin: const EdgeInsets.only(top: 4), + width: 4, + height: 4, + decoration: BoxDecoration( + color: color.withOpacity(0.6), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + detail, + style: TextStyle( + fontSize: 10, + color: AppTheme.textSecondary.withOpacity(0.8), + height: 1.2, + ), + ), + ), + ], + ), + )).toList(), + ), + ), + ], + + // Dernière mise à jour + if (lastUpdate != null) ...[ + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.access_time, + size: 10, + color: AppTheme.textSecondary.withOpacity(0.5), + ), + const SizedBox(width: 4), + Text( + 'Mis à jour: $lastUpdate', + style: TextStyle( + fontSize: 9, + color: AppTheme.textSecondary.withOpacity(0.5), + fontStyle: FontStyle.italic, + ), + ), + ], + ), + ], + ], + ), + ); + } + + /// Détermine la couleur du badge de changement selon la valeur + Color _getChangeColor(String change) { + if (change.startsWith('+')) { + return AppTheme.successColor; + } else if (change.startsWith('-')) { + return AppTheme.errorColor; + } else { + return AppTheme.textSecondary; + } + } + + /// Détermine l'icône du badge de changement selon la valeur + IconData _getChangeIcon(String change) { + if (change.startsWith('+')) { + return Icons.trending_up; + } else if (change.startsWith('-')) { + return Icons.trending_down; + } else { + return Icons.trending_flat; + } + } +} diff --git a/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/kpi/kpi_cards_widget.dart b/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/kpi/kpi_cards_widget.dart new file mode 100644 index 0000000..ba4156c --- /dev/null +++ b/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/kpi/kpi_cards_widget.dart @@ -0,0 +1,171 @@ +import 'package:flutter/material.dart'; +import '../../../../../shared/theme/app_theme.dart'; +import 'kpi_card_widget.dart'; + +/// Widget de section des cartes KPI principales +/// +/// Affiche les 8 indicateurs clés de performance principaux +/// en une seule colonne pour optimiser l'utilisation de l'espace écran. +/// Chaque KPI contient des détails enrichis et des informations contextuelles. +class KPICardsWidget extends StatelessWidget { + const KPICardsWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Indicateurs clés de performance', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 16), + + // Indicateurs principaux - Une seule colonne pour exploiter toute la largeur + KPICardWidget( + title: 'Membres Actifs', + value: '1,247', + change: '+5.2%', + icon: Icons.people, + color: AppTheme.primaryColor, + subtitle: 'Base de cotisants actifs avec droits de vote et participation aux décisions', + period: '30j', + target: '1,300', + lastUpdate: 'il y a 2h', + details: const [ + '892 membres à jour de cotisation (71.5%)', + '355 nouveaux membres cette année', + '23 membres en période d\'essai de 3 mois', + ], + ), + const SizedBox(height: 12), + + KPICardWidget( + title: 'Revenus Totaux', + value: '2,845,000 FCFA', + change: '+12.8%', + icon: Icons.account_balance_wallet, + color: AppTheme.successColor, + subtitle: 'Ensemble des revenus générés incluant cotisations, événements et subventions', + period: 'Mois', + target: '3,200,000 FCFA', + lastUpdate: 'il y a 1h', + details: const [ + '1,950,000 FCFA de cotisations mensuelles (68.5%)', + '645,000 FCFA d\'activités et événements (22.7%)', + '250,000 FCFA de dons et subventions (8.8%)', + ], + ), + const SizedBox(height: 12), + + KPICardWidget( + title: 'Événements Actifs', + value: '23', + change: '+3', + icon: Icons.event, + color: AppTheme.accentColor, + subtitle: 'Événements planifiés, formations professionnelles et activités sociales', + period: 'Mois', + target: '25', + lastUpdate: 'il y a 3h', + details: const [ + '8 formations professionnelles et techniques', + '9 événements sociaux et culturels', + '6 assemblées générales et réunions', + ], + ), + const SizedBox(height: 12), + + KPICardWidget( + title: 'Taux de Participation', + value: '78.3%', + change: '+2.1%', + icon: Icons.groups, + color: const Color(0xFF2196F3), // Blue + subtitle: 'Pourcentage de membres participant activement aux événements et décisions', + period: 'Trim.', + target: '85%', + lastUpdate: 'il y a 4h', + details: const [ + '158 membres en retard de paiement', + '45,000 FCFA de frais de relance économisés', + 'Amélioration de 12% par rapport au trimestre précédent', + ], + ), + const SizedBox(height: 12), + + KPICardWidget( + title: 'Nouveaux Membres (30j)', + value: '47', + change: '+18.5%', + icon: Icons.person_add, + color: const Color(0xFF9C27B0), // Purple + subtitle: 'Nouvelles adhésions validées par le comité d\'admission', + period: '30j', + target: '50', + lastUpdate: 'il y a 30min', + details: const [ + '28 adhésions individuelles (59.6%)', + '12 adhésions familiales (25.5%)', + '7 adhésions d\'entreprises partenaires (14.9%)', + ], + ), + const SizedBox(height: 12), + + KPICardWidget( + title: 'Montant en Attente', + value: '785,000 FCFA', + change: '-5.2%', + icon: Icons.schedule, + color: AppTheme.warningColor, + subtitle: 'Montants promis en attente d\'encaissement ou de validation administrative', + period: 'Total', + lastUpdate: 'il y a 1h', + details: const [ + '450,000 FCFA de promesses de dons (57.3%)', + '235,000 FCFA de cotisations promises (29.9%)', + '100,000 FCFA de subventions en cours (12.8%)', + ], + ), + const SizedBox(height: 12), + + KPICardWidget( + title: 'Cotisations en Retard', + value: '156', + change: '+8.3%', + icon: Icons.access_time, + color: AppTheme.errorColor, + subtitle: 'Membres en situation d\'impayé nécessitant un suivi personnalisé', + period: '+30j', + lastUpdate: 'il y a 2h', + details: const [ + '89 retards de 1-3 mois (57.1%)', + '45 retards de 3-6 mois (28.8%)', + '22 retards de plus de 6 mois (14.1%)', + ], + ), + const SizedBox(height: 12), + + KPICardWidget( + title: 'Score Global de Performance', + value: '85/100', + change: '+3 pts', + icon: Icons.assessment, + color: const Color(0xFF00BCD4), // Cyan + subtitle: 'Évaluation globale basée sur 15 indicateurs de santé organisationnelle', + period: 'Mois', + target: '90/100', + lastUpdate: 'il y a 6h', + details: const [ + 'Finances: 92/100 (Excellent)', + 'Participation: 78/100 (Bon)', + 'Gouvernance: 85/100 (Très bon)', + ], + ), + ], + ); + } +} diff --git a/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/welcome/welcome_section_widget.dart b/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/welcome/welcome_section_widget.dart new file mode 100644 index 0000000..098fcfd --- /dev/null +++ b/unionflow-mobile-apps/lib/features/dashboard/presentation/widgets/welcome/welcome_section_widget.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import '../../../../../shared/theme/app_theme.dart'; + +/// Widget de section d'accueil personnalisé pour le dashboard +/// +/// Affiche un message de bienvenue avec un gradient coloré et une icône. +/// Conçu pour donner une impression chaleureuse et professionnelle à l'utilisateur. +class WelcomeSectionWidget extends StatelessWidget { + /// Titre principal affiché (par défaut "Bonjour !") + final String title; + + /// Sous-titre descriptif (par défaut "Voici un aperçu de votre association") + final String subtitle; + + /// Icône affichée à droite (par défaut Icons.dashboard) + final IconData icon; + + /// Couleurs du gradient (par défaut primaryColor vers primaryLight) + final List? gradientColors; + + const WelcomeSectionWidget({ + super.key, + this.title = 'Bonjour !', + this.subtitle = 'Voici un aperçu de votre association', + this.icon = Icons.dashboard, + this.gradientColors, + }); + + @override + Widget build(BuildContext context) { + final colors = gradientColors ?? [AppTheme.primaryColor, AppTheme.primaryLight]; + + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: colors, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + subtitle, + style: TextStyle( + color: Colors.white.withOpacity(0.9), + fontSize: 16, + ), + ), + ], + ), + ), + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(30), + ), + child: Icon( + icon, + color: Colors.white, + size: 30, + ), + ), + ], + ), + ); + } +} diff --git a/unionflow-mobile-apps/lib/features/members/presentation/bloc/membres_state.dart b/unionflow-mobile-apps/lib/features/members/presentation/bloc/membres_state.dart index 7f89955..e958198 100644 --- a/unionflow-mobile-apps/lib/features/members/presentation/bloc/membres_state.dart +++ b/unionflow-mobile-apps/lib/features/members/presentation/bloc/membres_state.dart @@ -69,6 +69,9 @@ class MembreDetailLoaded extends MembresState { List get props => [membre]; } +/// Alias pour MembreDetailLoaded pour compatibilité +typedef MembreLoaded = MembreDetailLoaded; + /// État de succès pour les statistiques class MembresStatsLoaded extends MembresState { const MembresStatsLoaded(this.stats); diff --git a/unionflow-mobile-apps/lib/features/members/presentation/pages/membre_create_page.dart b/unionflow-mobile-apps/lib/features/members/presentation/pages/membre_create_page.dart new file mode 100644 index 0000000..0aeab3e --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/presentation/pages/membre_create_page.dart @@ -0,0 +1,937 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; +import '../../../../core/di/injection.dart'; +import '../../../../core/models/membre_model.dart'; +import '../../../../shared/theme/app_theme.dart'; +import '../../../../shared/widgets/custom_text_field.dart'; +import '../../../../shared/widgets/buttons/buttons.dart'; +import '../bloc/membres_bloc.dart'; +import '../bloc/membres_event.dart'; +import '../bloc/membres_state.dart'; + + +/// Page de création d'un nouveau membre +class MembreCreatePage extends StatefulWidget { + const MembreCreatePage({super.key}); + + @override + State createState() => _MembreCreatePageState(); +} + +class _MembreCreatePageState extends State + with SingleTickerProviderStateMixin { + late MembresBloc _membresBloc; + late TabController _tabController; + final _formKey = GlobalKey(); + + // Controllers pour les champs du formulaire + final _nomController = TextEditingController(); + final _prenomController = TextEditingController(); + final _emailController = TextEditingController(); + final _telephoneController = TextEditingController(); + final _adresseController = TextEditingController(); + final _villeController = TextEditingController(); + final _codePostalController = TextEditingController(); + final _paysController = TextEditingController(); + final _professionController = TextEditingController(); + final _numeroMembreController = TextEditingController(); + + // Variables d'état + DateTime? _dateNaissance; + DateTime _dateAdhesion = DateTime.now(); + bool _actif = true; + bool _isLoading = false; + int _currentStep = 0; + + @override + void initState() { + super.initState(); + _membresBloc = getIt(); + _tabController = TabController(length: 3, vsync: this); + + // Générer un numéro de membre automatique + _generateMemberNumber(); + + // Initialiser les valeurs par défaut + _paysController.text = 'Côte d\'Ivoire'; + } + + @override + void dispose() { + _tabController.dispose(); + _nomController.dispose(); + _prenomController.dispose(); + _emailController.dispose(); + _telephoneController.dispose(); + _adresseController.dispose(); + _villeController.dispose(); + _codePostalController.dispose(); + _paysController.dispose(); + _professionController.dispose(); + _numeroMembreController.dispose(); + super.dispose(); + } + + void _generateMemberNumber() { + final now = DateTime.now(); + final year = now.year.toString().substring(2); + final month = now.month.toString().padLeft(2, '0'); + final random = (DateTime.now().millisecondsSinceEpoch % 1000).toString().padLeft(3, '0'); + _numeroMembreController.text = 'MBR$year$month$random'; + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _membresBloc, + child: Scaffold( + backgroundColor: AppTheme.backgroundLight, + appBar: _buildAppBar(), + body: BlocConsumer( + listener: (context, state) { + if (state is MembreCreated) { + setState(() { + _isLoading = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Membre créé avec succès !'), + backgroundColor: AppTheme.successColor, + ), + ); + + Navigator.of(context).pop(true); // Retourner true pour indiquer le succès + } else if (state is MembresError) { + setState(() { + _isLoading = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.message), + backgroundColor: AppTheme.errorColor, + ), + ); + } + }, + builder: (context, state) { + return Column( + children: [ + _buildProgressIndicator(), + Expanded( + child: _buildFormContent(), + ), + _buildBottomActions(), + ], + ); + }, + ), + ), + ); + } + + PreferredSizeWidget _buildAppBar() { + return AppBar( + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.white, + title: const Text( + 'Nouveau membre', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 18, + ), + ), + actions: [ + IconButton( + icon: const Icon(Icons.help_outline), + onPressed: _showHelp, + tooltip: 'Aide', + ), + ], + ); + } + + Widget _buildProgressIndicator() { + return Container( + padding: const EdgeInsets.all(16), + color: Colors.white, + child: Column( + children: [ + Row( + children: [ + _buildStepIndicator(0, 'Informations\npersonnelles', Icons.person), + _buildStepConnector(0), + _buildStepIndicator(1, 'Contact &\nAdresse', Icons.contact_mail), + _buildStepConnector(1), + _buildStepIndicator(2, 'Finalisation', Icons.check_circle), + ], + ), + const SizedBox(height: 8), + LinearProgressIndicator( + value: (_currentStep + 1) / 3, + backgroundColor: AppTheme.backgroundLight, + valueColor: const AlwaysStoppedAnimation(AppTheme.primaryColor), + ), + ], + ), + ); + } + + Widget _buildStepIndicator(int step, String label, IconData icon) { + final isActive = step == _currentStep; + final isCompleted = step < _currentStep; + + Color color; + if (isCompleted) { + color = AppTheme.successColor; + } else if (isActive) { + color = AppTheme.primaryColor; + } else { + color = AppTheme.textHint; + } + + return Expanded( + child: Column( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: isCompleted ? AppTheme.successColor : + isActive ? AppTheme.primaryColor : AppTheme.backgroundLight, + shape: BoxShape.circle, + border: Border.all(color: color, width: 2), + ), + child: Icon( + isCompleted ? Icons.check : icon, + color: isCompleted || isActive ? Colors.white : color, + size: 20, + ), + ), + const SizedBox(height: 8), + Text( + label, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 10, + color: color, + fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, + ), + ), + ], + ), + ); + } + + Widget _buildStepConnector(int step) { + final isCompleted = step < _currentStep; + return Expanded( + child: Container( + height: 2, + margin: const EdgeInsets.only(bottom: 32), + color: isCompleted ? AppTheme.successColor : AppTheme.backgroundLight, + ), + ); + } + + Widget _buildFormContent() { + return Form( + key: _formKey, + child: PageView( + controller: PageController(initialPage: _currentStep), + onPageChanged: (index) { + setState(() { + _currentStep = index; + }); + }, + children: [ + _buildPersonalInfoStep(), + _buildContactStep(), + _buildFinalizationStep(), + ], + ), + ); + } + + Widget _buildPersonalInfoStep() { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Informations personnelles', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 8), + const Text( + 'Renseignez les informations de base du nouveau membre', + style: TextStyle( + fontSize: 14, + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 24), + + // Numéro de membre (généré automatiquement) + CustomTextField( + controller: _numeroMembreController, + label: 'Numéro de membre', + prefixIcon: Icons.badge, + enabled: false, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Le numéro de membre est requis'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Nom et Prénom + Row( + children: [ + Expanded( + child: CustomTextField( + controller: _prenomController, + label: 'Prénom *', + hintText: 'Jean', + prefixIcon: Icons.person_outline, + textInputAction: TextInputAction.next, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Le prénom est requis'; + } + if (value.trim().length < 2) { + return 'Le prénom doit contenir au moins 2 caractères'; + } + return null; + }, + ), + ), + const SizedBox(width: 16), + Expanded( + child: CustomTextField( + controller: _nomController, + label: 'Nom *', + hintText: 'Dupont', + prefixIcon: Icons.person_outline, + textInputAction: TextInputAction.next, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Le nom est requis'; + } + if (value.trim().length < 2) { + return 'Le nom doit contenir au moins 2 caractères'; + } + return null; + }, + ), + ), + ], + ), + const SizedBox(height: 16), + + // Date de naissance + InkWell( + onTap: _selectDateNaissance, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + decoration: BoxDecoration( + border: Border.all(color: AppTheme.borderColor), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + const Icon(Icons.cake_outlined, color: AppTheme.textSecondary), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Date de naissance', + style: TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + Text( + _dateNaissance != null + ? DateFormat('dd/MM/yyyy').format(_dateNaissance!) + : 'Sélectionner une date', + style: TextStyle( + fontSize: 16, + color: _dateNaissance != null + ? AppTheme.textPrimary + : AppTheme.textHint, + ), + ), + ], + ), + ), + const Icon(Icons.calendar_today, color: AppTheme.textSecondary), + ], + ), + ), + ), + const SizedBox(height: 16), + + // Profession + CustomTextField( + controller: _professionController, + label: 'Profession', + hintText: 'Enseignant, Commerçant, etc.', + prefixIcon: Icons.work_outline, + textInputAction: TextInputAction.next, + ), + ], + ), + ); + } + + Widget _buildContactStep() { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Contact & Adresse', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 8), + const Text( + 'Informations de contact et adresse du membre', + style: TextStyle( + fontSize: 14, + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 24), + + // Email + CustomTextField( + controller: _emailController, + label: 'Email *', + hintText: 'exemple@email.com', + prefixIcon: Icons.email_outlined, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'L\'email est requis'; + } + if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) { + return 'Format d\'email invalide'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Téléphone + CustomTextField( + controller: _telephoneController, + label: 'Téléphone *', + hintText: '+225 XX XX XX XX XX', + prefixIcon: Icons.phone_outlined, + keyboardType: TextInputType.phone, + textInputAction: TextInputAction.next, + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9+\-\s\(\)]')), + ], + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Le téléphone est requis'; + } + if (value.trim().length < 8) { + return 'Numéro de téléphone invalide'; + } + return null; + }, + ), + const SizedBox(height: 24), + + // Section Adresse + const Text( + 'Adresse', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 16), + + // Adresse + CustomTextField( + controller: _adresseController, + label: 'Adresse', + hintText: 'Rue, quartier, etc.', + prefixIcon: Icons.location_on_outlined, + textInputAction: TextInputAction.next, + maxLines: 2, + ), + const SizedBox(height: 16), + + // Ville et Code postal + Row( + children: [ + Expanded( + flex: 2, + child: CustomTextField( + controller: _villeController, + label: 'Ville', + hintText: 'Abidjan', + prefixIcon: Icons.location_city_outlined, + textInputAction: TextInputAction.next, + ), + ), + const SizedBox(width: 16), + Expanded( + child: CustomTextField( + controller: _codePostalController, + label: 'Code postal', + hintText: '00225', + prefixIcon: Icons.markunread_mailbox_outlined, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.next, + ), + ), + ], + ), + const SizedBox(height: 16), + + // Pays + CustomTextField( + controller: _paysController, + label: 'Pays', + prefixIcon: Icons.flag_outlined, + textInputAction: TextInputAction.done, + ), + ], + ), + ); + } + + Widget _buildFinalizationStep() { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Finalisation', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 8), + const Text( + 'Vérifiez les informations et finalisez la création', + style: TextStyle( + fontSize: 14, + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 24), + + // Résumé des informations + _buildSummaryCard(), + const SizedBox(height: 24), + + // Date d'adhésion + InkWell( + onTap: _selectDateAdhesion, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + decoration: BoxDecoration( + border: Border.all(color: AppTheme.borderColor), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + const Icon(Icons.calendar_today_outlined, color: AppTheme.textSecondary), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Date d\'adhésion', + style: TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + Text( + DateFormat('dd/MM/yyyy').format(_dateAdhesion), + style: const TextStyle( + fontSize: 16, + color: AppTheme.textPrimary, + ), + ), + ], + ), + ), + const Icon(Icons.edit, color: AppTheme.textSecondary), + ], + ), + ), + ), + const SizedBox(height: 16), + + // Statut actif + SwitchListTile( + title: const Text('Membre actif'), + subtitle: const Text('Le membre peut accéder aux services'), + value: _actif, + onChanged: (value) { + setState(() { + _actif = value; + }); + }, + activeColor: AppTheme.primaryColor, + ), + ], + ), + ); + } + + Widget _buildSummaryCard() { + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.summarize, color: AppTheme.primaryColor), + const SizedBox(width: 8), + const Text( + 'Résumé des informations', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 16), + _buildSummaryRow('Nom complet', '${_prenomController.text} ${_nomController.text}'), + _buildSummaryRow('Email', _emailController.text), + _buildSummaryRow('Téléphone', _telephoneController.text), + if (_dateNaissance != null) + _buildSummaryRow('Date de naissance', DateFormat('dd/MM/yyyy').format(_dateNaissance!)), + if (_professionController.text.isNotEmpty) + _buildSummaryRow('Profession', _professionController.text), + if (_adresseController.text.isNotEmpty) + _buildSummaryRow('Adresse', _adresseController.text), + ], + ), + ), + ); + } + + Widget _buildSummaryRow(String label, String value) { + if (value.trim().isEmpty) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + child: Text( + label, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + ), + Expanded( + child: Text( + value, + style: const TextStyle( + fontSize: 14, + color: AppTheme.textPrimary, + ), + ), + ), + ], + ), + ); + } + + Widget _buildBottomActions() { + return Container( + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black12, + blurRadius: 4, + offset: Offset(0, -2), + ), + ], + ), + child: Row( + children: [ + if (_currentStep > 0) + Expanded( + child: OutlinedButton( + onPressed: _previousStep, + style: OutlinedButton.styleFrom( + foregroundColor: AppTheme.primaryColor, + side: const BorderSide(color: AppTheme.primaryColor), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text('Précédent'), + ), + ), + if (_currentStep > 0) const SizedBox(width: 16), + Expanded( + flex: _currentStep == 0 ? 1 : 1, + child: ElevatedButton( + onPressed: _isLoading ? null : _handleNextOrSubmit, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : Text(_currentStep == 2 ? 'Créer le membre' : 'Suivant'), + ), + ), + ], + ), + ); + } + + void _previousStep() { + if (_currentStep > 0) { + setState(() { + _currentStep--; + }); + } + } + + void _handleNextOrSubmit() { + if (_currentStep < 2) { + if (_validateCurrentStep()) { + setState(() { + _currentStep++; + }); + } + } else { + _submitForm(); + } + } + + bool _validateCurrentStep() { + switch (_currentStep) { + case 0: + return _validatePersonalInfo(); + case 1: + return _validateContactInfo(); + case 2: + return true; // Pas de validation spécifique pour la finalisation + default: + return false; + } + } + + bool _validatePersonalInfo() { + bool isValid = true; + + if (_prenomController.text.trim().isEmpty) { + _showFieldError('Le prénom est requis'); + isValid = false; + } + + if (_nomController.text.trim().isEmpty) { + _showFieldError('Le nom est requis'); + isValid = false; + } + + return isValid; + } + + bool _validateContactInfo() { + bool isValid = true; + + if (_emailController.text.trim().isEmpty) { + _showFieldError('L\'email est requis'); + isValid = false; + } else if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(_emailController.text)) { + _showFieldError('Format d\'email invalide'); + isValid = false; + } + + if (_telephoneController.text.trim().isEmpty) { + _showFieldError('Le téléphone est requis'); + isValid = false; + } + + return isValid; + } + + void _showFieldError(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: AppTheme.errorColor, + duration: const Duration(seconds: 2), + ), + ); + } + + void _submitForm() { + if (!_formKey.currentState!.validate()) { + return; + } + + setState(() { + _isLoading = true; + }); + + // Créer le modèle membre + final membre = MembreModel( + id: '', // Sera généré par le backend + numeroMembre: _numeroMembreController.text.trim(), + nom: _nomController.text.trim(), + prenom: _prenomController.text.trim(), + email: _emailController.text.trim(), + telephone: _telephoneController.text.trim(), + dateNaissance: _dateNaissance, + adresse: _adresseController.text.trim().isNotEmpty ? _adresseController.text.trim() : null, + ville: _villeController.text.trim().isNotEmpty ? _villeController.text.trim() : null, + codePostal: _codePostalController.text.trim().isNotEmpty ? _codePostalController.text.trim() : null, + pays: _paysController.text.trim().isNotEmpty ? _paysController.text.trim() : null, + profession: _professionController.text.trim().isNotEmpty ? _professionController.text.trim() : null, + dateAdhesion: _dateAdhesion, + actif: _actif, + statut: 'ACTIF', + version: 1, + dateCreation: DateTime.now(), + ); + + // Envoyer l'événement de création + _membresBloc.add(CreateMembre(membre)); + } + + Future _selectDateNaissance() async { + final date = await showDatePicker( + context: context, + initialDate: _dateNaissance ?? DateTime.now().subtract(const Duration(days: 365 * 25)), + firstDate: DateTime(1900), + lastDate: DateTime.now(), + locale: const Locale('fr', 'FR'), + ); + + if (date != null) { + setState(() { + _dateNaissance = date; + }); + } + } + + Future _selectDateAdhesion() async { + final date = await showDatePicker( + context: context, + initialDate: _dateAdhesion, + firstDate: DateTime(2000), + lastDate: DateTime.now().add(const Duration(days: 365)), + locale: const Locale('fr', 'FR'), + ); + + if (date != null) { + setState(() { + _dateAdhesion = date; + }); + } + } + + void _showHelp() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Aide - Création de membre'), + content: const SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Étapes de création :', + style: TextStyle(fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + Text('1. Informations personnelles : Nom, prénom, date de naissance'), + Text('2. Contact & Adresse : Email, téléphone, adresse'), + Text('3. Finalisation : Vérification et validation'), + SizedBox(height: 16), + Text( + 'Champs obligatoires :', + style: TextStyle(fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + Text('• Nom et prénom'), + Text('• Email (format valide)'), + Text('• Téléphone'), + SizedBox(height: 16), + Text( + 'Le numéro de membre est généré automatiquement selon le format : MBR + Année + Mois + Numéro séquentiel', + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Fermer'), + ), + ], + ), + ); + } +} diff --git a/unionflow-mobile-apps/lib/features/members/presentation/pages/membre_details_page.dart b/unionflow-mobile-apps/lib/features/members/presentation/pages/membre_details_page.dart new file mode 100644 index 0000000..840c8f5 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/presentation/pages/membre_details_page.dart @@ -0,0 +1,474 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; +import '../../../../core/di/injection.dart'; +import '../../../../core/models/membre_model.dart'; +import '../../../../core/models/cotisation_model.dart'; +import '../../../../shared/theme/app_theme.dart'; + +import '../bloc/membres_bloc.dart'; +import '../bloc/membres_event.dart'; +import '../bloc/membres_state.dart'; +import '../widgets/membre_info_section.dart'; +import '../widgets/membre_stats_section.dart'; +import '../widgets/membre_cotisations_section.dart'; +import '../widgets/membre_actions_section.dart'; +import '../widgets/membre_delete_dialog.dart'; +import 'membre_edit_page.dart'; + +/// Page de détails complète d'un membre +class MembreDetailsPage extends StatefulWidget { + const MembreDetailsPage({ + super.key, + required this.membreId, + this.membre, + }); + + final String membreId; + final MembreModel? membre; + + @override + State createState() => _MembreDetailsPageState(); +} + +class _MembreDetailsPageState extends State + with SingleTickerProviderStateMixin { + late MembresBloc _membresBloc; + late TabController _tabController; + + MembreModel? _currentMembre; + List _cotisations = []; + bool _isLoadingCotisations = false; + + @override + void initState() { + super.initState(); + _membresBloc = getIt(); + _tabController = TabController(length: 3, vsync: this); + _currentMembre = widget.membre; + + // Charger les détails du membre si pas fourni + if (_currentMembre == null) { + _membresBloc.add(LoadMembreById(widget.membreId)); + } + + // Charger les cotisations du membre + _loadMemberCotisations(); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + Future _loadMemberCotisations() async { + setState(() { + _isLoadingCotisations = true; + }); + + try { + // TODO: Implémenter le chargement des cotisations via le repository + // final cotisations = await getIt() + // .getCotisationsByMembre(widget.membreId); + // setState(() { + // _cotisations = cotisations; + // }); + + // Simulation temporaire + await Future.delayed(const Duration(seconds: 1)); + setState(() { + _cotisations = _generateMockCotisations(); + }); + } catch (e) { + // Gérer l'erreur + debugPrint('Erreur lors du chargement des cotisations: $e'); + } finally { + setState(() { + _isLoadingCotisations = false; + }); + } + } + + List _generateMockCotisations() { + // Données de test temporaires + return [ + CotisationModel( + id: '1', + numeroReference: 'COT-2025-001', + membreId: widget.membreId, + typeCotisation: 'MENSUELLE', + periode: 'Janvier 2025', + montantDu: 25000, + montantPaye: 25000, + codeDevise: 'XOF', + statut: 'PAYEE', + dateEcheance: DateTime(2025, 1, 31), + datePaiement: DateTime(2025, 1, 15), + annee: 2025, + recurrente: true, + nombreRappels: 0, + dateCreation: DateTime(2025, 1, 1), + ), + CotisationModel( + id: '2', + numeroReference: 'COT-2025-002', + membreId: widget.membreId, + typeCotisation: 'MENSUELLE', + periode: 'Février 2025', + montantDu: 25000, + montantPaye: 0, + codeDevise: 'XOF', + statut: 'EN_ATTENTE', + dateEcheance: DateTime(2025, 2, 28), + annee: 2025, + recurrente: true, + nombreRappels: 1, + dateCreation: DateTime(2025, 2, 1), + ), + ]; + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _membresBloc, + child: Scaffold( + backgroundColor: AppTheme.backgroundLight, + body: BlocConsumer( + listener: (context, state) { + if (state is MembreLoaded) { + setState(() { + _currentMembre = state.membre; + }); + } else if (state is MembresError) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.message), + backgroundColor: AppTheme.errorColor, + ), + ); + } + }, + builder: (context, state) { + if (state is MembresLoading && _currentMembre == null) { + return const Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Chargement des détails...'), + ], + ), + ), + ); + } + + if (state is MembresError && _currentMembre == null) { + return Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error, size: 64, color: AppTheme.errorColor), + SizedBox(height: 16), + Text(state.message), + SizedBox(height: 16), + ElevatedButton( + onPressed: () => _membresBloc.add(LoadMembreById(widget.membreId)), + child: Text('Réessayer'), + ), + ], + ), + ), + ); + } + + if (_currentMembre == null) { + return const Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.person_off, size: 64), + SizedBox(height: 16), + Text('Membre non trouvé'), + ], + ), + ), + ); + } + + return _buildContent(); + }, + ), + ), + ); + } + + Widget _buildContent() { + return NestedScrollView( + headerSliverBuilder: (context, innerBoxIsScrolled) { + return [ + _buildAppBar(innerBoxIsScrolled), + _buildMemberHeader(), + _buildTabBar(), + ]; + }, + body: TabBarView( + controller: _tabController, + children: [ + _buildInfoTab(), + _buildCotisationsTab(), + _buildStatsTab(), + ], + ), + ); + } + + Widget _buildAppBar(bool innerBoxIsScrolled) { + return SliverAppBar( + expandedHeight: 0, + floating: true, + pinned: true, + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.white, + title: Text( + _currentMembre?.nomComplet ?? 'Détails du membre', + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 18, + ), + ), + actions: [ + IconButton( + icon: const Icon(Icons.edit), + onPressed: _editMember, + tooltip: 'Modifier', + ), + PopupMenuButton( + onSelected: _handleMenuAction, + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'call', + child: ListTile( + leading: Icon(Icons.phone), + title: Text('Appeler'), + contentPadding: EdgeInsets.zero, + ), + ), + const PopupMenuItem( + value: 'message', + child: ListTile( + leading: Icon(Icons.message), + title: Text('Message'), + contentPadding: EdgeInsets.zero, + ), + ), + const PopupMenuItem( + value: 'export', + child: ListTile( + leading: Icon(Icons.download), + title: Text('Exporter'), + contentPadding: EdgeInsets.zero, + ), + ), + const PopupMenuItem( + value: 'delete', + child: ListTile( + leading: Icon(Icons.delete, color: Colors.red), + title: Text('Supprimer', style: TextStyle(color: Colors.red)), + contentPadding: EdgeInsets.zero, + ), + ), + ], + ), + ], + ); + } + + Widget _buildMemberHeader() { + return SliverToBoxAdapter( + child: Container( + color: AppTheme.primaryColor, + padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), + child: MembreInfoSection( + membre: _currentMembre!, + showActions: false, + ), + ), + ); + } + + Widget _buildTabBar() { + return SliverPersistentHeader( + pinned: true, + delegate: _TabBarDelegate( + TabBar( + controller: _tabController, + labelColor: AppTheme.primaryColor, + unselectedLabelColor: AppTheme.textSecondary, + indicatorColor: AppTheme.primaryColor, + indicatorWeight: 3, + tabs: const [ + Tab(text: 'Informations'), + Tab(text: 'Cotisations'), + Tab(text: 'Statistiques'), + ], + ), + ), + ); + } + + Widget _buildInfoTab() { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + MembreInfoSection( + membre: _currentMembre!, + showActions: true, + onEdit: _editMember, + onCall: _callMember, + onMessage: _messageMember, + ), + const SizedBox(height: 16), + MembreActionsSection( + membre: _currentMembre!, + onEdit: _editMember, + onDelete: _deleteMember, + onExport: _exportMember, + ), + ], + ), + ); + } + + Widget _buildCotisationsTab() { + return MembreCotisationsSection( + membre: _currentMembre!, + cotisations: _cotisations, + isLoading: _isLoadingCotisations, + onRefresh: _loadMemberCotisations, + ); + } + + Widget _buildStatsTab() { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: MembreStatsSection( + membre: _currentMembre!, + cotisations: _cotisations, + ), + ); + } + + void _editMember() async { + if (widget.membre == null) return; + + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => MembreEditPage(membre: widget.membre!), + ), + ); + + // Si le membre a été modifié avec succès, recharger les données + if (result == true) { + _loadMemberCotisations(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Membre modifié avec succès !'), + backgroundColor: AppTheme.successColor, + ), + ); + } + } + + void _callMember() { + // TODO: Implémenter l'appel + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Appel - À implémenter')), + ); + } + + void _messageMember() { + // TODO: Implémenter l'envoi de message + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Message - À implémenter')), + ); + } + + void _deleteMember() async { + if (widget.membre == null) return; + + final result = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => MembreDeleteDialog(membre: widget.membre!), + ); + + // Si le membre a été supprimé/désactivé avec succès + if (result == true && mounted) { + // Retourner à la liste des membres + Navigator.of(context).pop(); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Membre traité avec succès !'), + backgroundColor: AppTheme.successColor, + ), + ); + } + } + + void _exportMember() { + // TODO: Implémenter l'export + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Export - À implémenter')), + ); + } + + void _handleMenuAction(String action) { + switch (action) { + case 'call': + _callMember(); + break; + case 'message': + _messageMember(); + break; + case 'export': + _exportMember(); + break; + case 'delete': + _deleteMember(); + break; + } + } +} + +class _TabBarDelegate extends SliverPersistentHeaderDelegate { + const _TabBarDelegate(this.tabBar); + + final TabBar tabBar; + + @override + double get minExtent => tabBar.preferredSize.height; + + @override + double get maxExtent => tabBar.preferredSize.height; + + @override + Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { + return Container( + color: Colors.white, + child: tabBar, + ); + } + + @override + bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) { + return false; + } +} diff --git a/unionflow-mobile-apps/lib/features/members/presentation/pages/membre_edit_page.dart b/unionflow-mobile-apps/lib/features/members/presentation/pages/membre_edit_page.dart new file mode 100644 index 0000000..7e63bba --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/presentation/pages/membre_edit_page.dart @@ -0,0 +1,1096 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; +import '../../../../core/di/injection.dart'; +import '../../../../core/models/membre_model.dart'; +import '../../../../shared/theme/app_theme.dart'; +import '../../../../shared/widgets/custom_text_field.dart'; +import '../../../../shared/widgets/buttons/buttons.dart'; +import '../bloc/membres_bloc.dart'; +import '../bloc/membres_event.dart'; +import '../bloc/membres_state.dart'; + +/// Page de modification d'un membre existant +class MembreEditPage extends StatefulWidget { + const MembreEditPage({ + super.key, + required this.membre, + }); + + final MembreModel membre; + + @override + State createState() => _MembreEditPageState(); +} + +class _MembreEditPageState extends State + with SingleTickerProviderStateMixin { + late MembresBloc _membresBloc; + late TabController _tabController; + final _formKey = GlobalKey(); + + // Controllers pour les champs du formulaire + final _nomController = TextEditingController(); + final _prenomController = TextEditingController(); + final _emailController = TextEditingController(); + final _telephoneController = TextEditingController(); + final _adresseController = TextEditingController(); + final _villeController = TextEditingController(); + final _codePostalController = TextEditingController(); + final _paysController = TextEditingController(); + final _professionController = TextEditingController(); + final _numeroMembreController = TextEditingController(); + + // Variables d'état + DateTime? _dateNaissance; + DateTime _dateAdhesion = DateTime.now(); + bool _actif = true; + bool _isLoading = false; + int _currentStep = 0; + bool _hasChanges = false; + + @override + void initState() { + super.initState(); + _membresBloc = getIt(); + _tabController = TabController(length: 3, vsync: this); + + // Pré-remplir les champs avec les données existantes + _populateFields(); + + // Écouter les changements pour détecter les modifications + _setupChangeListeners(); + } + + @override + void dispose() { + _tabController.dispose(); + _nomController.dispose(); + _prenomController.dispose(); + _emailController.dispose(); + _telephoneController.dispose(); + _adresseController.dispose(); + _villeController.dispose(); + _codePostalController.dispose(); + _paysController.dispose(); + _professionController.dispose(); + _numeroMembreController.dispose(); + super.dispose(); + } + + void _populateFields() { + _numeroMembreController.text = widget.membre.numeroMembre; + _nomController.text = widget.membre.nom; + _prenomController.text = widget.membre.prenom; + _emailController.text = widget.membre.email; + _telephoneController.text = widget.membre.telephone; + _adresseController.text = widget.membre.adresse ?? ''; + _villeController.text = widget.membre.ville ?? ''; + _codePostalController.text = widget.membre.codePostal ?? ''; + _paysController.text = widget.membre.pays ?? 'Côte d\'Ivoire'; + _professionController.text = widget.membre.profession ?? ''; + + _dateNaissance = widget.membre.dateNaissance; + _dateAdhesion = widget.membre.dateAdhesion; + _actif = widget.membre.actif; + } + + void _setupChangeListeners() { + final controllers = [ + _nomController, _prenomController, _emailController, _telephoneController, + _adresseController, _villeController, _codePostalController, + _paysController, _professionController + ]; + + for (final controller in controllers) { + controller.addListener(_onFieldChanged); + } + } + + void _onFieldChanged() { + if (!_hasChanges) { + setState(() { + _hasChanges = true; + }); + } + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _membresBloc, + child: WillPopScope( + onWillPop: _onWillPop, + child: Scaffold( + backgroundColor: AppTheme.backgroundLight, + appBar: _buildAppBar(), + body: BlocConsumer( + listener: (context, state) { + if (state is MembreUpdated) { + setState(() { + _isLoading = false; + _hasChanges = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Membre modifié avec succès !'), + backgroundColor: AppTheme.successColor, + ), + ); + + Navigator.of(context).pop(true); // Retourner true pour indiquer le succès + } else if (state is MembresError) { + setState(() { + _isLoading = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.message), + backgroundColor: AppTheme.errorColor, + ), + ); + } + }, + builder: (context, state) { + return Column( + children: [ + _buildProgressIndicator(), + Expanded( + child: _buildFormContent(), + ), + _buildBottomActions(), + ], + ); + }, + ), + ), + ), + ); + } + + PreferredSizeWidget _buildAppBar() { + return AppBar( + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.white, + title: Text( + 'Modifier ${widget.membre.prenom} ${widget.membre.nom}', + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 18, + ), + ), + actions: [ + if (_hasChanges) + IconButton( + icon: const Icon(Icons.save), + onPressed: _submitForm, + tooltip: 'Sauvegarder', + ), + IconButton( + icon: const Icon(Icons.help_outline), + onPressed: _showHelp, + tooltip: 'Aide', + ), + ], + ); + } + + Widget _buildProgressIndicator() { + return Container( + padding: const EdgeInsets.all(16), + color: Colors.white, + child: Column( + children: [ + Row( + children: [ + _buildStepIndicator(0, 'Informations\npersonnelles', Icons.person), + _buildStepConnector(0), + _buildStepIndicator(1, 'Contact &\nAdresse', Icons.contact_mail), + _buildStepConnector(1), + _buildStepIndicator(2, 'Finalisation', Icons.check_circle), + ], + ), + const SizedBox(height: 8), + LinearProgressIndicator( + value: (_currentStep + 1) / 3, + backgroundColor: AppTheme.backgroundLight, + valueColor: const AlwaysStoppedAnimation(AppTheme.primaryColor), + ), + ], + ), + ); + } + + Widget _buildStepIndicator(int step, String label, IconData icon) { + final isActive = step == _currentStep; + final isCompleted = step < _currentStep; + + Color color; + if (isCompleted) { + color = AppTheme.successColor; + } else if (isActive) { + color = AppTheme.primaryColor; + } else { + color = AppTheme.textHint; + } + + return Expanded( + child: Column( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: isCompleted ? AppTheme.successColor : + isActive ? AppTheme.primaryColor : AppTheme.backgroundLight, + shape: BoxShape.circle, + border: Border.all(color: color, width: 2), + ), + child: Icon( + isCompleted ? Icons.check : icon, + color: isCompleted || isActive ? Colors.white : color, + size: 20, + ), + ), + const SizedBox(height: 8), + Text( + label, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 10, + color: color, + fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, + ), + ), + ], + ), + ); + } + + Widget _buildStepConnector(int step) { + final isCompleted = step < _currentStep; + return Expanded( + child: Container( + height: 2, + margin: const EdgeInsets.only(bottom: 32), + color: isCompleted ? AppTheme.successColor : AppTheme.backgroundLight, + ), + ); + } + + Widget _buildFormContent() { + return Form( + key: _formKey, + child: PageView( + controller: PageController(initialPage: _currentStep), + onPageChanged: (index) { + setState(() { + _currentStep = index; + }); + }, + children: [ + _buildPersonalInfoStep(), + _buildContactStep(), + _buildFinalizationStep(), + ], + ), + ); + } + + Widget _buildContactStep() { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader( + 'Contact & Adresse', + 'Modifiez les informations de contact et adresse', + ), + const SizedBox(height: 24), + + // Email + CustomTextField( + controller: _emailController, + label: 'Email *', + hintText: 'exemple@email.com', + prefixIcon: Icons.email_outlined, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'L\'email est requis'; + } + if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) { + return 'Format d\'email invalide'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Téléphone + CustomTextField( + controller: _telephoneController, + label: 'Téléphone *', + hintText: '+225 XX XX XX XX XX', + prefixIcon: Icons.phone_outlined, + keyboardType: TextInputType.phone, + textInputAction: TextInputAction.next, + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9+\-\s\(\)]')), + ], + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Le téléphone est requis'; + } + if (value.trim().length < 8) { + return 'Numéro de téléphone invalide'; + } + return null; + }, + ), + const SizedBox(height: 24), + + // Section Adresse + const Text( + 'Adresse', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 16), + + // Adresse + CustomTextField( + controller: _adresseController, + label: 'Adresse', + hintText: 'Rue, quartier, etc.', + prefixIcon: Icons.location_on_outlined, + textInputAction: TextInputAction.next, + maxLines: 2, + ), + const SizedBox(height: 16), + + // Ville et Code postal + Row( + children: [ + Expanded( + flex: 2, + child: CustomTextField( + controller: _villeController, + label: 'Ville', + hintText: 'Abidjan', + prefixIcon: Icons.location_city_outlined, + textInputAction: TextInputAction.next, + ), + ), + const SizedBox(width: 16), + Expanded( + child: CustomTextField( + controller: _codePostalController, + label: 'Code postal', + hintText: '00225', + prefixIcon: Icons.markunread_mailbox_outlined, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.next, + ), + ), + ], + ), + const SizedBox(height: 16), + + // Pays + CustomTextField( + controller: _paysController, + label: 'Pays', + prefixIcon: Icons.flag_outlined, + textInputAction: TextInputAction.done, + ), + ], + ), + ); + } + + Widget _buildFinalizationStep() { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader( + 'Finalisation', + 'Vérifiez les modifications et finalisez', + ), + const SizedBox(height: 24), + + // Résumé des modifications + _buildChangesCard(), + const SizedBox(height: 24), + + // Date d'adhésion + _buildDateField( + label: 'Date d\'adhésion', + value: _dateAdhesion, + onTap: _selectDateAdhesion, + icon: Icons.calendar_today_outlined, + ), + const SizedBox(height: 16), + + // Statut actif + Card( + elevation: 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: SwitchListTile( + title: const Text('Membre actif'), + subtitle: Text( + _actif + ? 'Le membre peut accéder aux services' + : 'Le membre est désactivé', + ), + value: _actif, + onChanged: (value) { + setState(() { + _actif = value; + _hasChanges = true; + }); + }, + activeColor: AppTheme.primaryColor, + ), + ), + const SizedBox(height: 16), + + // Informations de version + _buildVersionInfo(), + ], + ), + ); + } + + Widget _buildSectionHeader(String title, String subtitle) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 8), + Text( + subtitle, + style: const TextStyle( + fontSize: 14, + color: AppTheme.textSecondary, + ), + ), + ], + ); + } + + Widget _buildDateField({ + required String label, + required DateTime? value, + required VoidCallback onTap, + required IconData icon, + }) { + return InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + decoration: BoxDecoration( + border: Border.all(color: AppTheme.borderColor), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(icon, color: AppTheme.textSecondary), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + Text( + value != null + ? DateFormat('dd/MM/yyyy').format(value) + : 'Sélectionner une date', + style: TextStyle( + fontSize: 16, + color: value != null + ? AppTheme.textPrimary + : AppTheme.textHint, + ), + ), + ], + ), + ), + const Icon(Icons.edit, color: AppTheme.textSecondary), + ], + ), + ), + ); + } + + Widget _buildPersonalInfoStep() { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader( + 'Informations personnelles', + 'Modifiez les informations de base du membre', + ), + const SizedBox(height: 24), + + // Numéro de membre (non modifiable) + CustomTextField( + controller: _numeroMembreController, + label: 'Numéro de membre', + prefixIcon: Icons.badge, + enabled: false, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Le numéro de membre est requis'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Nom et Prénom + Row( + children: [ + Expanded( + child: CustomTextField( + controller: _prenomController, + label: 'Prénom *', + hintText: 'Jean', + prefixIcon: Icons.person_outline, + textInputAction: TextInputAction.next, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Le prénom est requis'; + } + if (value.trim().length < 2) { + return 'Le prénom doit contenir au moins 2 caractères'; + } + return null; + }, + ), + ), + const SizedBox(width: 16), + Expanded( + child: CustomTextField( + controller: _nomController, + label: 'Nom *', + hintText: 'Dupont', + prefixIcon: Icons.person_outline, + textInputAction: TextInputAction.next, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Le nom est requis'; + } + if (value.trim().length < 2) { + return 'Le nom doit contenir au moins 2 caractères'; + } + return null; + }, + ), + ), + ], + ), + const SizedBox(height: 16), + + // Date de naissance + _buildDateField( + label: 'Date de naissance', + value: _dateNaissance, + onTap: _selectDateNaissance, + icon: Icons.cake_outlined, + ), + const SizedBox(height: 16), + + // Profession + CustomTextField( + controller: _professionController, + label: 'Profession', + hintText: 'Enseignant, Commerçant, etc.', + prefixIcon: Icons.work_outline, + textInputAction: TextInputAction.next, + ), + ], + ), + ); + } + + Widget _buildChangesCard() { + if (!_hasChanges) { + return Card( + elevation: 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: const Padding( + padding: EdgeInsets.all(16), + child: Row( + children: [ + Icon(Icons.info_outline, color: AppTheme.textSecondary), + SizedBox(width: 12), + Expanded( + child: Text( + 'Aucune modification détectée', + style: TextStyle( + fontSize: 14, + color: AppTheme.textSecondary, + ), + ), + ), + ], + ), + ), + ); + } + + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.edit, color: AppTheme.warningColor), + SizedBox(width: 8), + Text( + 'Modifications détectées', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 16), + _buildSummaryRow('Nom complet', '${_prenomController.text} ${_nomController.text}'), + _buildSummaryRow('Email', _emailController.text), + _buildSummaryRow('Téléphone', _telephoneController.text), + if (_dateNaissance != null) + _buildSummaryRow('Date de naissance', DateFormat('dd/MM/yyyy').format(_dateNaissance!)), + if (_professionController.text.isNotEmpty) + _buildSummaryRow('Profession', _professionController.text), + if (_adresseController.text.isNotEmpty) + _buildSummaryRow('Adresse', _adresseController.text), + _buildSummaryRow('Statut', _actif ? 'Actif' : 'Inactif'), + ], + ), + ), + ); + } + + Widget _buildSummaryRow(String label, String value) { + if (value.trim().isEmpty) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + child: Text( + label, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + ), + Expanded( + child: Text( + value, + style: const TextStyle( + fontSize: 14, + color: AppTheme.textPrimary, + ), + ), + ), + ], + ), + ); + } + + Widget _buildVersionInfo() { + return Card( + elevation: 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.info_outline, color: AppTheme.textSecondary), + SizedBox(width: 8), + Text( + 'Informations de version', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + 'Version actuelle : ${widget.membre.version}', + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 4), + Text( + 'Créé le : ${DateFormat('dd/MM/yyyy à HH:mm').format(widget.membre.dateCreation)}', + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + if (widget.membre.dateModification != null) ...[ + const SizedBox(height: 4), + Text( + 'Modifié le : ${DateFormat('dd/MM/yyyy à HH:mm').format(widget.membre.dateModification!)}', + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + ], + ], + ), + ), + ); + } + + Widget _buildBottomActions() { + return Container( + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black12, + blurRadius: 4, + offset: Offset(0, -2), + ), + ], + ), + child: Row( + children: [ + if (_currentStep > 0) + Expanded( + child: OutlinedButton( + onPressed: _previousStep, + style: OutlinedButton.styleFrom( + foregroundColor: AppTheme.primaryColor, + side: const BorderSide(color: AppTheme.primaryColor), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text('Précédent'), + ), + ), + if (_currentStep > 0) const SizedBox(width: 16), + Expanded( + flex: _currentStep == 0 ? 1 : 1, + child: ElevatedButton( + onPressed: _isLoading ? null : _handleNextOrSubmit, + style: ElevatedButton.styleFrom( + backgroundColor: _hasChanges ? AppTheme.primaryColor : AppTheme.textHint, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : Text(_currentStep == 2 ? 'Sauvegarder' : 'Suivant'), + ), + ), + ], + ), + ); + } + + void _previousStep() { + if (_currentStep > 0) { + setState(() { + _currentStep--; + }); + } + } + + void _handleNextOrSubmit() { + if (_currentStep < 2) { + if (_validateCurrentStep()) { + setState(() { + _currentStep++; + }); + } + } else { + _submitForm(); + } + } + + bool _validateCurrentStep() { + switch (_currentStep) { + case 0: + return _validatePersonalInfo(); + case 1: + return _validateContactInfo(); + case 2: + return true; // Pas de validation spécifique pour la finalisation + default: + return false; + } + } + + bool _validatePersonalInfo() { + bool isValid = true; + + if (_prenomController.text.trim().isEmpty) { + _showFieldError('Le prénom est requis'); + isValid = false; + } + + if (_nomController.text.trim().isEmpty) { + _showFieldError('Le nom est requis'); + isValid = false; + } + + return isValid; + } + + bool _validateContactInfo() { + bool isValid = true; + + if (_emailController.text.trim().isEmpty) { + _showFieldError('L\'email est requis'); + isValid = false; + } else if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(_emailController.text)) { + _showFieldError('Format d\'email invalide'); + isValid = false; + } + + if (_telephoneController.text.trim().isEmpty) { + _showFieldError('Le téléphone est requis'); + isValid = false; + } + + return isValid; + } + + void _showFieldError(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: AppTheme.errorColor, + duration: const Duration(seconds: 2), + ), + ); + } + + void _submitForm() { + if (!_formKey.currentState!.validate()) { + return; + } + + if (!_hasChanges) { + _showFieldError('Aucune modification à sauvegarder'); + return; + } + + setState(() { + _isLoading = true; + }); + + // Créer le modèle membre modifié + final membreModifie = widget.membre.copyWith( + nom: _nomController.text.trim(), + prenom: _prenomController.text.trim(), + email: _emailController.text.trim(), + telephone: _telephoneController.text.trim(), + dateNaissance: _dateNaissance, + adresse: _adresseController.text.trim().isNotEmpty ? _adresseController.text.trim() : null, + ville: _villeController.text.trim().isNotEmpty ? _villeController.text.trim() : null, + codePostal: _codePostalController.text.trim().isNotEmpty ? _codePostalController.text.trim() : null, + pays: _paysController.text.trim().isNotEmpty ? _paysController.text.trim() : null, + profession: _professionController.text.trim().isNotEmpty ? _professionController.text.trim() : null, + dateAdhesion: _dateAdhesion, + actif: _actif, + version: widget.membre.version + 1, + dateModification: DateTime.now(), + ); + + // Envoyer l'événement de modification + final memberId = widget.membre.id; + if (memberId != null && memberId.isNotEmpty) { + _membresBloc.add(UpdateMembre(memberId, membreModifie)); + } else { + _showFieldError('Erreur : ID du membre manquant'); + setState(() { + _isLoading = false; + }); + } + } + + Future _selectDateNaissance() async { + final date = await showDatePicker( + context: context, + initialDate: _dateNaissance ?? DateTime.now().subtract(const Duration(days: 365 * 25)), + firstDate: DateTime(1900), + lastDate: DateTime.now(), + locale: const Locale('fr', 'FR'), + ); + + if (date != null && date != _dateNaissance) { + setState(() { + _dateNaissance = date; + _hasChanges = true; + }); + } + } + + Future _selectDateAdhesion() async { + final date = await showDatePicker( + context: context, + initialDate: _dateAdhesion, + firstDate: DateTime(2000), + lastDate: DateTime.now().add(const Duration(days: 365)), + locale: const Locale('fr', 'FR'), + ); + + if (date != null && date != _dateAdhesion) { + setState(() { + _dateAdhesion = date; + _hasChanges = true; + }); + } + } + + Future _onWillPop() async { + if (!_hasChanges) { + return true; + } + + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Modifications non sauvegardées'), + content: const Text( + 'Vous avez des modifications non sauvegardées. ' + 'Voulez-vous vraiment quitter sans sauvegarder ?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Annuler'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom( + foregroundColor: AppTheme.errorColor, + ), + child: const Text('Quitter sans sauvegarder'), + ), + ], + ), + ); + + return result ?? false; + } + + void _showHelp() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Aide - Modification de membre'), + content: const SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Modification en 3 étapes :', + style: TextStyle(fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + Text('1. Informations personnelles : Nom, prénom, date de naissance'), + Text('2. Contact & Adresse : Email, téléphone, adresse'), + Text('3. Finalisation : Vérification et sauvegarde'), + SizedBox(height: 16), + Text( + 'Fonctionnalités :', + style: TextStyle(fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + Text('• Détection automatique des modifications'), + Text('• Validation en temps réel'), + Text('• Confirmation avant sortie si modifications non sauvées'), + Text('• Gestion de version automatique'), + SizedBox(height: 16), + Text( + 'Le numéro de membre ne peut pas être modifié pour des raisons de traçabilité.', + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Fermer'), + ), + ], + ), + ); + } +} diff --git a/unionflow-mobile-apps/lib/features/members/presentation/pages/membres_dashboard_page.dart b/unionflow-mobile-apps/lib/features/members/presentation/pages/membres_dashboard_page.dart new file mode 100644 index 0000000..7fd4899 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/presentation/pages/membres_dashboard_page.dart @@ -0,0 +1,155 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../../core/di/injection.dart'; +import '../../../../shared/theme/app_theme.dart'; +import '../bloc/membres_bloc.dart'; +import '../bloc/membres_event.dart'; +import '../bloc/membres_state.dart'; + +class MembresDashboardPage extends StatefulWidget { + const MembresDashboardPage({super.key}); + + @override + State createState() => _MembresDashboardPageState(); +} + +class _MembresDashboardPageState extends State { + late MembresBloc _membresBloc; + + @override + void initState() { + super.initState(); + _membresBloc = getIt(); + _loadData(); + } + + void _loadData() { + _membresBloc.add(const LoadMembres()); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _membresBloc, + child: Scaffold( + backgroundColor: AppTheme.backgroundLight, + appBar: AppBar( + title: const Text( + 'Dashboard Membres', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 20, + ), + ), + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.white, + elevation: 0, + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _loadData, + tooltip: 'Actualiser', + ), + ], + ), + body: BlocBuilder( + builder: (context, state) { + if (state is MembresLoading) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + if (state is MembresError) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 64, + color: AppTheme.errorColor, + ), + const SizedBox(height: 16), + Text( + 'Erreur de chargement', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 8), + Text( + state.message, + style: TextStyle( + fontSize: 14, + color: AppTheme.textSecondary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: _loadData, + icon: const Icon(Icons.refresh), + label: const Text('Réessayer'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.white, + ), + ), + ], + ), + ); + } + + return _buildDashboard(); + }, + ), + floatingActionButton: FloatingActionButton( + onPressed: _loadData, + backgroundColor: AppTheme.primaryColor, + tooltip: 'Actualiser les données', + child: const Icon(Icons.refresh, color: Colors.white), + ), + ), + ); + } + + Widget _buildDashboard() { + return Container( + padding: const EdgeInsets.all(16), + child: const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.dashboard, + size: 64, + color: Colors.grey, + ), + SizedBox(height: 16), + Text( + 'Dashboard Vide', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w600, + color: Colors.grey, + ), + ), + SizedBox(height: 8), + Text( + 'Prêt à être reconstruit pièce par pièce', + style: TextStyle( + fontSize: 16, + color: Colors.grey, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + +} diff --git a/unionflow-mobile-apps/lib/features/members/presentation/pages/membres_list_page.dart b/unionflow-mobile-apps/lib/features/members/presentation/pages/membres_list_page.dart index f18999c..f79a2ea 100644 --- a/unionflow-mobile-apps/lib/features/members/presentation/pages/membres_list_page.dart +++ b/unionflow-mobile-apps/lib/features/members/presentation/pages/membres_list_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:pull_to_refresh/pull_to_refresh.dart'; import '../../../../core/di/injection.dart'; +import '../../../../core/models/membre_model.dart'; import '../../../../shared/theme/app_theme.dart'; import '../../../../shared/widgets/coming_soon_page.dart'; import '../bloc/membres_bloc.dart'; @@ -9,6 +10,12 @@ import '../bloc/membres_event.dart'; import '../bloc/membres_state.dart'; import '../widgets/membre_card.dart'; import '../widgets/membres_search_bar.dart'; +import '../widgets/membre_delete_dialog.dart'; +import '../widgets/membres_advanced_search.dart'; +import '../widgets/membres_export_dialog.dart'; +import 'membre_details_page.dart'; +import 'membre_create_page.dart'; +import 'membres_dashboard_page.dart'; /// Page de liste des membres avec fonctionnalités avancées @@ -23,6 +30,7 @@ class _MembresListPageState extends State { final RefreshController _refreshController = RefreshController(); final TextEditingController _searchController = TextEditingController(); late MembresBloc _membresBloc; + List _membres = []; @override void initState() { @@ -56,6 +64,16 @@ class _MembresListPageState extends State { foregroundColor: Colors.white, elevation: 0, actions: [ + IconButton( + icon: const Icon(Icons.search), + onPressed: () => _showAdvancedSearch(), + tooltip: 'Recherche avancée', + ), + IconButton( + icon: const Icon(Icons.file_download), + onPressed: () => _showExportDialog(), + tooltip: 'Exporter', + ), IconButton( icon: const Icon(Icons.add_circle_outline), onPressed: () => _showAddMemberDialog(), @@ -97,7 +115,14 @@ class _MembresListPageState extends State { } else if (state is MembresErrorWithData) { _showErrorSnackBar(state.message); } - + + // Mettre à jour la liste des membres + if (state is MembresLoaded) { + _membres = state.membres; + } else if (state is MembresErrorWithData) { + _membres = state.membres; + } + // Arrêter le refresh if (state is! MembresRefreshing && state is! MembresLoading) { _refreshController.refreshCompleted(); @@ -288,30 +313,28 @@ class _MembresListPageState extends State { /// Affiche les détails d'un membre void _showMemberDetails(membre) { - // TODO: Implémenter la page de détails - showDialog( - context: context, - builder: (context) => const ComingSoonPage( - title: 'Détails du membre', - description: 'La page de détails du membre sera bientôt disponible.', - icon: Icons.person, - color: AppTheme.primaryColor, + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => MembreDetailsPage( + membreId: membre.id, + membre: membre, + ), ), ); } - /// Affiche le dialog d'ajout de membre - void _showAddMemberDialog() { - // TODO: Implémenter le formulaire d'ajout - showDialog( - context: context, - builder: (context) => const ComingSoonPage( - title: 'Ajouter un membre', - description: 'Le formulaire d\'ajout de membre sera bientôt disponible.', - icon: Icons.person_add, - color: AppTheme.successColor, + /// Affiche le formulaire d'ajout de membre + void _showAddMemberDialog() async { + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const MembreCreatePage(), ), ); + + // Si un membre a été créé avec succès, recharger la liste + if (result == true) { + _membresBloc.add(const RefreshMembres()); + } } /// Affiche le dialog d'édition de membre @@ -329,29 +352,59 @@ class _MembresListPageState extends State { } /// Affiche la confirmation de suppression - void _showDeleteConfirmation(membre) { - // TODO: Implémenter la confirmation de suppression - showDialog( + void _showDeleteConfirmation(membre) async { + final result = await showDialog( context: context, - builder: (context) => const ComingSoonPage( - title: 'Supprimer le membre', - description: 'La confirmation de suppression sera bientôt disponible.', - icon: Icons.delete, - color: AppTheme.errorColor, - ), + barrierDismissible: false, + builder: (context) => MembreDeleteDialog(membre: membre), ); + + // Si le membre a été supprimé/désactivé avec succès, recharger la liste + if (result == true) { + _membresBloc.add(const RefreshMembres()); + } } /// Affiche les statistiques void _showStatsDialog() { - // TODO: Implémenter les statistiques + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const MembresDashboardPage(), + ), + ); + } + + /// Affiche la recherche avancée + void _showAdvancedSearch() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => DraggableScrollableSheet( + initialChildSize: 0.9, + minChildSize: 0.5, + maxChildSize: 0.95, + builder: (context, scrollController) => MembresAdvancedSearch( + onSearch: (filters) { + // TODO: Implémenter la recherche avec filtres + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Recherche avec ${filters.length} filtres - À implémenter'), + backgroundColor: AppTheme.infoColor, + ), + ); + }, + ), + ), + ); + } + + /// Affiche le dialog d'export + void _showExportDialog() { showDialog( context: context, - builder: (context) => const ComingSoonPage( - title: 'Statistiques', - description: 'Les statistiques des membres seront bientôt disponibles.', - icon: Icons.analytics, - color: AppTheme.infoColor, + builder: (context) => MembresExportDialog( + membres: _membres, ), ); } diff --git a/unionflow-mobile-apps/lib/features/members/presentation/widgets/dashboard_chart_card.dart b/unionflow-mobile-apps/lib/features/members/presentation/widgets/dashboard_chart_card.dart new file mode 100644 index 0000000..83ba8f9 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/presentation/widgets/dashboard_chart_card.dart @@ -0,0 +1,211 @@ +import 'package:flutter/material.dart'; +import '../../../../shared/theme/app_theme.dart'; +import '../../../../shared/theme/design_system.dart'; + +/// Container professionnel pour les graphiques du dashboard avec animations +class DashboardChartCard extends StatefulWidget { + const DashboardChartCard({ + super.key, + required this.title, + required this.child, + this.subtitle, + this.actions, + this.height, + this.isLoading = false, + this.onRefresh, + this.showBorder = true, + }); + + final String title; + final Widget child; + final String? subtitle; + final List? actions; + final double? height; + final bool isLoading; + final VoidCallback? onRefresh; + final bool showBorder; + + @override + State createState() => _DashboardChartCardState(); +} + +class _DashboardChartCardState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late Animation _slideAnimation; + late Animation _fadeAnimation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: DesignSystem.animationMedium, + vsync: this, + ); + + _slideAnimation = Tween( + begin: 30.0, + end: 0.0, + ).animate(CurvedAnimation( + parent: _animationController, + curve: DesignSystem.animationCurveEnter, + )); + + _fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _animationController, + curve: DesignSystem.animationCurve, + )); + + _animationController.forward(); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + return Transform.translate( + offset: Offset(0, _slideAnimation.value), + child: FadeTransition( + opacity: _fadeAnimation, + child: _buildCard(), + ), + ); + }, + ); + } + + Widget _buildCard() { + return Container( + height: widget.height, + padding: EdgeInsets.all(DesignSystem.spacingLg), + decoration: BoxDecoration( + color: AppTheme.surfaceLight, + borderRadius: BorderRadius.circular(DesignSystem.radiusLg), + boxShadow: DesignSystem.shadowCard, + border: widget.showBorder ? Border.all( + color: AppTheme.borderColor.withOpacity(0.5), + width: 1, + ) : null, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(), + SizedBox(height: DesignSystem.spacingLg), + Expanded( + child: widget.isLoading ? _buildLoadingState() : widget.child, + ), + ], + ), + ); + } + + Widget _buildHeader() { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.title, + style: DesignSystem.headlineMedium.copyWith( + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + if (widget.subtitle != null) ...[ + SizedBox(height: DesignSystem.spacingXs), + Text( + widget.subtitle!, + style: DesignSystem.bodyMedium.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ], + ), + ), + if (widget.actions != null || widget.onRefresh != null) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.onRefresh != null) + _buildRefreshButton(), + if (widget.actions != null) ...widget.actions!, + ], + ), + ], + ); + } + + Widget _buildRefreshButton() { + return Container( + margin: EdgeInsets.only(right: DesignSystem.spacingSm), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: widget.onRefresh, + borderRadius: BorderRadius.circular(DesignSystem.radiusSm), + child: Container( + padding: EdgeInsets.all(DesignSystem.spacingSm), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(DesignSystem.radiusSm), + ), + child: const Icon( + Icons.refresh, + size: 18, + color: AppTheme.primaryColor, + ), + ), + ), + ), + ); + } + + Widget _buildLoadingState() { + return Column( + children: [ + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 40, + height: 40, + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation( + AppTheme.primaryColor.withOpacity(0.7), + ), + ), + ), + SizedBox(height: DesignSystem.spacingMd), + Text( + 'Chargement des données...', + style: DesignSystem.bodyMedium.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + ), + ], + ); + } +} diff --git a/unionflow-mobile-apps/lib/features/members/presentation/widgets/dashboard_stat_card.dart b/unionflow-mobile-apps/lib/features/members/presentation/widgets/dashboard_stat_card.dart new file mode 100644 index 0000000..0acfe4d --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/presentation/widgets/dashboard_stat_card.dart @@ -0,0 +1,299 @@ +import 'package:flutter/material.dart'; +import '../../../../shared/theme/app_theme.dart'; +import '../../../../shared/theme/design_system.dart'; + +/// Card statistique professionnelle avec design basé sur le nombre d'or +class DashboardStatCard extends StatefulWidget { + const DashboardStatCard({ + super.key, + required this.title, + required this.value, + required this.icon, + required this.color, + this.trend, + this.subtitle, + this.onTap, + this.isLoading = false, + }); + + final String title; + final String value; + final IconData icon; + final Color color; + final String? trend; + final String? subtitle; + final VoidCallback? onTap; + final bool isLoading; + + @override + State createState() => _DashboardStatCardState(); +} + +class _DashboardStatCardState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late Animation _scaleAnimation; + late Animation _fadeAnimation; + bool _isHovered = false; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: DesignSystem.animationMedium, + vsync: this, + ); + + _scaleAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _animationController, + curve: DesignSystem.animationCurveEnter, + )); + + _fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _animationController, + curve: DesignSystem.animationCurve, + )); + + _animationController.forward(); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + return Transform.scale( + scale: _scaleAnimation.value, + child: FadeTransition( + opacity: _fadeAnimation, + child: _buildCard(context), + ), + ); + }, + ); + } + + Widget _buildCard(BuildContext context) { + return MouseRegion( + onEnter: (_) => _setHovered(true), + onExit: (_) => _setHovered(false), + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + duration: DesignSystem.animationFast, + curve: DesignSystem.animationCurve, + padding: EdgeInsets.all(DesignSystem.spacingLg), + decoration: BoxDecoration( + color: AppTheme.surfaceLight, + borderRadius: BorderRadius.circular(DesignSystem.radiusLg), + boxShadow: _isHovered ? DesignSystem.shadowCardHover : DesignSystem.shadowCard, + border: Border.all( + color: widget.color.withOpacity(0.1), + width: 1, + ), + ), + child: widget.isLoading ? _buildLoadingState() : _buildContent(), + ), + ), + ); + } + + Widget _buildLoadingState() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildShimmer(40, 40, isCircular: true), + if (widget.trend != null) _buildShimmer(60, 24, radius: 12), + ], + ), + SizedBox(height: DesignSystem.spacingMd), + _buildShimmer(80, 32), + SizedBox(height: DesignSystem.spacingSm), + _buildShimmer(120, 16), + if (widget.subtitle != null) ...[ + SizedBox(height: DesignSystem.spacingXs), + _buildShimmer(100, 14), + ], + ], + ); + } + + Widget _buildShimmer(double width, double height, {double? radius, bool isCircular = false}) { + return Container( + width: width, + height: height, + decoration: BoxDecoration( + color: AppTheme.textHint.withOpacity(0.1), + borderRadius: isCircular + ? BorderRadius.circular(height / 2) + : BorderRadius.circular(radius ?? DesignSystem.radiusSm), + ), + ); + } + + Widget _buildContent() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(), + SizedBox(height: DesignSystem.goldenHeight(DesignSystem.spacingLg)), + _buildValue(), + SizedBox(height: DesignSystem.spacingSm), + _buildTitle(), + if (widget.subtitle != null) ...[ + SizedBox(height: DesignSystem.spacingXs), + _buildSubtitle(), + ], + ], + ); + } + + Widget _buildHeader() { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildIconContainer(), + if (widget.trend != null) _buildTrendBadge(), + ], + ); + } + + Widget _buildIconContainer() { + return Container( + width: DesignSystem.goldenWidth(32), + height: 32, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + widget.color.withOpacity(0.15), + widget.color.withOpacity(0.05), + ], + ), + borderRadius: BorderRadius.circular(DesignSystem.radiusMd), + border: Border.all( + color: widget.color.withOpacity(0.2), + width: 1, + ), + ), + child: Icon( + widget.icon, + color: widget.color, + size: 20, + ), + ); + } + + Widget _buildTrendBadge() { + return Container( + padding: EdgeInsets.symmetric( + horizontal: DesignSystem.spacingSm, + vertical: DesignSystem.spacingXs, + ), + decoration: BoxDecoration( + color: _getTrendColor().withOpacity(0.1), + borderRadius: BorderRadius.circular(DesignSystem.radiusXl), + border: Border.all( + color: _getTrendColor().withOpacity(0.2), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _getTrendIcon(), + color: _getTrendColor(), + size: 14, + ), + SizedBox(width: DesignSystem.spacing2xs), + Text( + widget.trend!, + style: DesignSystem.labelSmall.copyWith( + color: _getTrendColor(), + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } + + Widget _buildValue() { + return Text( + widget.value, + style: DesignSystem.displayMedium.copyWith( + color: widget.color, + fontWeight: FontWeight.w800, + fontSize: 28, + ), + ); + } + + Widget _buildTitle() { + return Text( + widget.title, + style: DesignSystem.labelLarge.copyWith( + color: AppTheme.textSecondary, + fontWeight: FontWeight.w500, + ), + ); + } + + Widget _buildSubtitle() { + return Text( + widget.subtitle!, + style: DesignSystem.labelMedium.copyWith( + color: AppTheme.textHint, + ), + ); + } + + void _setHovered(bool hovered) { + if (mounted) { + setState(() { + _isHovered = hovered; + }); + } + } + + Color _getTrendColor() { + if (widget.trend == null) return AppTheme.textSecondary; + + if (widget.trend!.startsWith('+')) { + return AppTheme.successColor; + } else if (widget.trend!.startsWith('-')) { + return AppTheme.errorColor; + } else { + return AppTheme.warningColor; + } + } + + IconData _getTrendIcon() { + if (widget.trend == null) return Icons.trending_flat; + + if (widget.trend!.startsWith('+')) { + return Icons.trending_up; + } else if (widget.trend!.startsWith('-')) { + return Icons.trending_down; + } else { + return Icons.trending_flat; + } + } +} diff --git a/unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_actions_section.dart b/unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_actions_section.dart new file mode 100644 index 0000000..a808cd5 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_actions_section.dart @@ -0,0 +1,456 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../../../../core/models/membre_model.dart'; +import '../../../../shared/theme/app_theme.dart'; +import '../pages/membre_edit_page.dart'; + +/// Section des actions disponibles pour un membre +class MembreActionsSection extends StatelessWidget { + const MembreActionsSection({ + super.key, + required this.membre, + this.onEdit, + this.onDelete, + this.onExport, + this.onCall, + this.onMessage, + this.onEmail, + }); + + final MembreModel membre; + final VoidCallback? onEdit; + final VoidCallback? onDelete; + final VoidCallback? onExport; + final VoidCallback? onCall; + final VoidCallback? onMessage; + final VoidCallback? onEmail; + + @override + Widget build(BuildContext context) { + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.settings, + color: AppTheme.primaryColor, + size: 20, + ), + const SizedBox(width: 8), + const Text( + 'Actions', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 16), + _buildActionGrid(context), + ], + ), + ), + ); + } + + Widget _buildActionGrid(BuildContext context) { + return Column( + children: [ + Row( + children: [ + Expanded( + child: _buildActionButton( + context, + 'Modifier', + Icons.edit, + AppTheme.primaryColor, + onEdit ?? () => _showNotImplemented(context, 'Modification'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildActionButton( + context, + 'Appeler', + Icons.phone, + AppTheme.successColor, + onCall ?? () => _callMember(context), + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildActionButton( + context, + 'Message', + Icons.message, + AppTheme.infoColor, + onMessage ?? () => _messageMember(context), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildActionButton( + context, + 'Email', + Icons.email, + AppTheme.warningColor, + onEmail ?? () => _emailMember(context), + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildActionButton( + context, + 'Exporter', + Icons.download, + AppTheme.textSecondary, + onExport ?? () => _exportMember(context), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildActionButton( + context, + 'Supprimer', + Icons.delete, + AppTheme.errorColor, + onDelete ?? () => _deleteMember(context), + ), + ), + ], + ), + const SizedBox(height: 20), + _buildQuickInfoSection(context), + ], + ); + } + + Widget _buildActionButton( + BuildContext context, + String label, + IconData icon, + Color color, + VoidCallback onPressed, + ) { + return Material( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withOpacity(0.3)), + ), + child: Column( + children: [ + Icon(icon, color: color, size: 24), + const SizedBox(height: 8), + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: color, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ); + } + + Widget _buildQuickInfoSection(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.backgroundLight, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Informations rapides', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 12), + _buildQuickInfoRow( + 'Numéro de membre', + membre.numeroMembre, + Icons.badge, + () => _copyToClipboard(context, membre.numeroMembre, 'Numéro de membre'), + ), + _buildQuickInfoRow( + 'Téléphone', + membre.telephone, + Icons.phone, + () => _copyToClipboard(context, membre.telephone, 'Téléphone'), + ), + _buildQuickInfoRow( + 'Email', + membre.email, + Icons.email, + () => _copyToClipboard(context, membre.email, 'Email'), + ), + ], + ), + ); + } + + Widget _buildQuickInfoRow( + String label, + String value, + IconData icon, + VoidCallback onTap, + ) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + child: Row( + children: [ + Icon(icon, size: 16, color: AppTheme.textSecondary), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 10, + color: AppTheme.textSecondary, + ), + ), + Text( + value, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppTheme.textPrimary, + ), + ), + ], + ), + ), + const Icon( + Icons.copy, + size: 14, + color: AppTheme.textHint, + ), + ], + ), + ), + ), + ); + } + + void _callMember(BuildContext context) { + // TODO: Implémenter l'appel téléphonique + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Appeler le membre'), + content: Text('Voulez-vous appeler ${membre.prenom} ${membre.nom} au ${membre.telephone} ?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); + _showNotImplemented(context, 'Appel téléphonique'); + }, + child: const Text('Appeler'), + ), + ], + ), + ); + } + + void _messageMember(BuildContext context) { + // TODO: Implémenter l'envoi de SMS + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Envoyer un message'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Envoyer un SMS à ${membre.prenom} ${membre.nom} ?'), + const SizedBox(height: 16), + const TextField( + decoration: InputDecoration( + labelText: 'Message', + border: OutlineInputBorder(), + ), + maxLines: 3, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); + _showNotImplemented(context, 'Envoi de SMS'); + }, + child: const Text('Envoyer'), + ), + ], + ), + ); + } + + void _emailMember(BuildContext context) { + // TODO: Implémenter l'envoi d'email + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Envoyer un email'), + content: Text('Ouvrir l\'application email pour envoyer un message à ${membre.email} ?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); + _showNotImplemented(context, 'Envoi d\'email'); + }, + child: const Text('Ouvrir'), + ), + ], + ), + ); + } + + void _exportMember(BuildContext context) { + // TODO: Implémenter l'export des données du membre + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Exporter les données'), + content: Text('Exporter les données de ${membre.prenom} ${membre.nom} ?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); + _showNotImplemented(context, 'Export des données'); + }, + child: const Text('Exporter'), + ), + ], + ), + ); + } + + void _deleteMember(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Supprimer le membre'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.warning, + color: AppTheme.errorColor, + size: 48, + ), + const SizedBox(height: 16), + Text( + 'Êtes-vous sûr de vouloir supprimer ${membre.prenom} ${membre.nom} ?', + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + const Text( + 'Cette action est irréversible.', + style: TextStyle( + color: AppTheme.errorColor, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); + _showNotImplemented(context, 'Suppression du membre'); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.errorColor, + foregroundColor: Colors.white, + ), + child: const Text('Supprimer'), + ), + ], + ), + ); + } + + void _copyToClipboard(BuildContext context, String text, String label) { + Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('$label copié dans le presse-papiers'), + duration: const Duration(seconds: 2), + backgroundColor: AppTheme.successColor, + ), + ); + } + + void _showNotImplemented(BuildContext context, String feature) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('$feature - Fonctionnalité à implémenter'), + backgroundColor: AppTheme.infoColor, + ), + ); + } +} diff --git a/unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_cotisations_section.dart b/unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_cotisations_section.dart new file mode 100644 index 0000000..2b16c46 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_cotisations_section.dart @@ -0,0 +1,431 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import '../../../../core/models/membre_model.dart'; +import '../../../../core/models/cotisation_model.dart'; +import '../../../../shared/theme/app_theme.dart'; + + +/// Section des cotisations d'un membre +class MembreCotisationsSection extends StatelessWidget { + const MembreCotisationsSection({ + super.key, + required this.membre, + required this.cotisations, + required this.isLoading, + this.onRefresh, + }); + + final MembreModel membre; + final List cotisations; + final bool isLoading; + final VoidCallback? onRefresh; + + @override + Widget build(BuildContext context) { + if (isLoading) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Chargement des cotisations...'), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: () async { + onRefresh?.call(); + }, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSummaryCard(), + const SizedBox(height: 16), + _buildCotisationsList(), + ], + ), + ), + ); + } + + Widget _buildSummaryCard() { + final totalDu = cotisations.fold( + 0, + (sum, cotisation) => sum + cotisation.montantDu, + ); + + final totalPaye = cotisations.fold( + 0, + (sum, cotisation) => sum + cotisation.montantPaye, + ); + + final totalRestant = totalDu - totalPaye; + + final cotisationsPayees = cotisations.where((c) => c.statut == 'PAYEE').length; + final cotisationsEnRetard = cotisations.where((c) => c.isEnRetard).length; + + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.account_balance_wallet, + color: AppTheme.primaryColor, + size: 24, + ), + const SizedBox(width: 8), + const Text( + 'Résumé des cotisations', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: _buildSummaryItem( + 'Total dû', + _formatAmount(totalDu), + AppTheme.infoColor, + Icons.receipt_long, + ), + ), + Expanded( + child: _buildSummaryItem( + 'Payé', + _formatAmount(totalPaye), + AppTheme.successColor, + Icons.check_circle, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildSummaryItem( + 'Restant', + _formatAmount(totalRestant), + totalRestant > 0 ? AppTheme.warningColor : AppTheme.successColor, + Icons.pending, + ), + ), + Expanded( + child: _buildSummaryItem( + 'En retard', + '$cotisationsEnRetard', + cotisationsEnRetard > 0 ? AppTheme.errorColor : AppTheme.successColor, + Icons.warning, + ), + ), + ], + ), + const SizedBox(height: 16), + LinearProgressIndicator( + value: totalDu > 0 ? totalPaye / totalDu : 0, + backgroundColor: AppTheme.backgroundLight, + valueColor: AlwaysStoppedAnimation( + totalPaye == totalDu ? AppTheme.successColor : AppTheme.primaryColor, + ), + ), + const SizedBox(height: 8), + Text( + '$cotisationsPayees/${cotisations.length} cotisations payées', + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + ); + } + + Widget _buildSummaryItem(String label, String value, Color color, IconData icon) { + 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: Column( + children: [ + Icon(icon, color: color, size: 20), + const SizedBox(height: 4), + Text( + value, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: color, + ), + ), + Text( + label, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + ], + ), + ); + } + + Widget _buildCotisationsList() { + if (cotisations.isEmpty) { + return Card( + elevation: 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + children: [ + Icon( + Icons.receipt_long_outlined, + size: 48, + color: AppTheme.textHint, + ), + const SizedBox(height: 16), + const Text( + 'Aucune cotisation', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 8), + const Text( + 'Ce membre n\'a pas encore de cotisations enregistrées.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.list_alt, + color: AppTheme.primaryColor, + size: 20, + ), + const SizedBox(width: 8), + const Text( + 'Historique des cotisations', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 12), + ...cotisations.map((cotisation) => _buildCotisationCard(cotisation)), + ], + ); + } + + Widget _buildCotisationCard(CotisationModel cotisation) { + return Card( + elevation: 1, + margin: const EdgeInsets.only(bottom: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + cotisation.periode ?? 'Période non définie', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + cotisation.typeCotisation, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + _buildStatusBadge(cotisation), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildCotisationDetail( + 'Montant dû', + _formatAmount(cotisation.montantDu), + Icons.receipt, + ), + ), + Expanded( + child: _buildCotisationDetail( + 'Montant payé', + _formatAmount(cotisation.montantPaye), + Icons.payment, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _buildCotisationDetail( + 'Échéance', + DateFormat('dd/MM/yyyy').format(cotisation.dateEcheance), + Icons.schedule, + ), + ), + if (cotisation.datePaiement != null) + Expanded( + child: _buildCotisationDetail( + 'Payé le', + DateFormat('dd/MM/yyyy').format(cotisation.datePaiement!), + Icons.check_circle, + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildStatusBadge(CotisationModel cotisation) { + Color color; + String label; + + switch (cotisation.statut) { + case 'PAYEE': + color = AppTheme.successColor; + label = 'Payée'; + break; + case 'EN_ATTENTE': + color = AppTheme.warningColor; + label = 'En attente'; + break; + case 'EN_RETARD': + color = AppTheme.errorColor; + label = 'En retard'; + break; + case 'PARTIELLEMENT_PAYEE': + color = AppTheme.infoColor; + label = 'Partielle'; + break; + default: + color = AppTheme.textSecondary; + label = cotisation.statut; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withOpacity(0.3)), + ), + child: Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ); + } + + Widget _buildCotisationDetail(String label, String value, IconData icon) { + return Row( + children: [ + Icon(icon, size: 14, color: AppTheme.textSecondary), + const SizedBox(width: 4), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 10, + color: AppTheme.textSecondary, + ), + ), + Text( + value, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppTheme.textPrimary, + ), + ), + ], + ), + ), + ], + ); + } + + String _formatAmount(double amount) { + return NumberFormat.currency( + locale: 'fr_FR', + symbol: 'FCFA', + decimalDigits: 0, + ).format(amount); + } +} diff --git a/unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_delete_dialog.dart b/unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_delete_dialog.dart new file mode 100644 index 0000000..4e3e5cc --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_delete_dialog.dart @@ -0,0 +1,495 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../../core/di/injection.dart'; +import '../../../../core/models/membre_model.dart'; +import '../../../../shared/theme/app_theme.dart'; +import '../bloc/membres_bloc.dart'; +import '../bloc/membres_event.dart'; +import '../bloc/membres_state.dart'; + +/// Dialog de confirmation de suppression/désactivation d'un membre +class MembreDeleteDialog extends StatefulWidget { + const MembreDeleteDialog({ + super.key, + required this.membre, + }); + + final MembreModel membre; + + @override + State createState() => _MembreDeleteDialogState(); +} + +class _MembreDeleteDialogState extends State { + late MembresBloc _membresBloc; + bool _isLoading = false; + bool _softDelete = true; // Par défaut, désactivation plutôt que suppression + bool _hasActiveCotisations = false; + bool _hasUnpaidCotisations = false; + int _totalCotisations = 0; + double _unpaidAmount = 0.0; + + @override + void initState() { + super.initState(); + _membresBloc = getIt(); + _checkMemberDependencies(); + } + + void _checkMemberDependencies() { + // TODO: Implémenter la vérification des dépendances via le repository + // Pour l'instant, simulation avec des données fictives + setState(() { + _hasActiveCotisations = true; + _hasUnpaidCotisations = true; + _totalCotisations = 5; + _unpaidAmount = 75000.0; + }); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _membresBloc, + child: BlocConsumer( + listener: (context, state) { + if (state is MembreDeleted) { + setState(() { + _isLoading = false; + }); + Navigator.of(context).pop(true); + } else if (state is MembreUpdated) { + setState(() { + _isLoading = false; + }); + Navigator.of(context).pop(true); + } else if (state is MembresError) { + setState(() { + _isLoading = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.message), + backgroundColor: AppTheme.errorColor, + ), + ); + } + }, + builder: (context, state) { + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + title: Row( + children: [ + Icon( + _softDelete ? Icons.person_off : Icons.delete_forever, + color: _softDelete ? AppTheme.warningColor : AppTheme.errorColor, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + _softDelete ? 'Désactiver le membre' : 'Supprimer le membre', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Informations du membre + _buildMemberInfo(), + const SizedBox(height: 20), + + // Vérifications des dépendances + if (_hasActiveCotisations || _hasUnpaidCotisations) + _buildDependenciesWarning(), + + const SizedBox(height: 16), + + // Options de suppression + _buildDeleteOptions(), + + const SizedBox(height: 20), + + // Message de confirmation + _buildConfirmationMessage(), + ], + ), + ), + actions: [ + TextButton( + onPressed: _isLoading ? null : () => Navigator.of(context).pop(false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: _isLoading ? null : _handleDelete, + style: ElevatedButton.styleFrom( + backgroundColor: _softDelete ? AppTheme.warningColor : AppTheme.errorColor, + foregroundColor: Colors.white, + ), + child: _isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : Text(_softDelete ? 'Désactiver' : 'Supprimer'), + ), + ], + ); + }, + ), + ); + } + + Widget _buildMemberInfo() { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.backgroundLight, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppTheme.borderColor), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar( + backgroundColor: AppTheme.primaryColor, + child: Text( + '${widget.membre.prenom[0]}${widget.membre.nom[0]}', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${widget.membre.prenom} ${widget.membre.nom}', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + Text( + widget.membre.numeroMembre, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: widget.membre.actif ? AppTheme.successColor : AppTheme.errorColor, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + widget.membre.actif ? 'Actif' : 'Inactif', + style: const TextStyle( + fontSize: 10, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + widget.membre.email, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + ], + ), + ); + } + + Widget _buildDependenciesWarning() { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.warningColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppTheme.warningColor.withOpacity(0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.warning_amber, + color: AppTheme.warningColor, + size: 20, + ), + const SizedBox(width: 8), + const Text( + 'Attention - Dépendances détectées', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppTheme.warningColor, + ), + ), + ], + ), + const SizedBox(height: 8), + if (_hasActiveCotisations) ...[ + Text( + '• $_totalCotisations cotisations associées à ce membre', + style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary), + ), + ], + if (_hasUnpaidCotisations) ...[ + Text( + '• ${_unpaidAmount.toStringAsFixed(0)} XOF de cotisations impayées', + style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary), + ), + ], + const SizedBox(height: 8), + const Text( + 'La désactivation est recommandée pour préserver l\'historique.', + style: TextStyle( + fontSize: 11, + color: AppTheme.textSecondary, + fontStyle: FontStyle.italic, + ), + ), + ], + ), + ); + } + + Widget _buildDeleteOptions() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Options de suppression :', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 12), + + // Option désactivation + InkWell( + onTap: () { + setState(() { + _softDelete = true; + }); + }, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _softDelete ? AppTheme.warningColor.withOpacity(0.1) : Colors.transparent, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: _softDelete ? AppTheme.warningColor : AppTheme.borderColor, + width: _softDelete ? 2 : 1, + ), + ), + child: Row( + children: [ + Radio( + value: true, + groupValue: _softDelete, + onChanged: (value) { + setState(() { + _softDelete = value!; + }); + }, + activeColor: AppTheme.warningColor, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Désactiver le membre (Recommandé)', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 4), + const Text( + 'Le membre sera marqué comme inactif mais ses données et historique seront préservés.', + style: TextStyle( + fontSize: 11, + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ), + ), + + const SizedBox(height: 8), + + // Option suppression définitive + InkWell( + onTap: () { + setState(() { + _softDelete = false; + }); + }, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: !_softDelete ? AppTheme.errorColor.withOpacity(0.1) : Colors.transparent, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: !_softDelete ? AppTheme.errorColor : AppTheme.borderColor, + width: !_softDelete ? 2 : 1, + ), + ), + child: Row( + children: [ + Radio( + value: false, + groupValue: _softDelete, + onChanged: (value) { + setState(() { + _softDelete = value!; + }); + }, + activeColor: AppTheme.errorColor, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Supprimer définitivement', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 4), + const Text( + 'ATTENTION : Cette action est irréversible. Toutes les données du membre seront perdues.', + style: TextStyle( + fontSize: 11, + color: AppTheme.errorColor, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ); + } + + Widget _buildConfirmationMessage() { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _softDelete + ? AppTheme.warningColor.withOpacity(0.1) + : AppTheme.errorColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: _softDelete + ? AppTheme.warningColor.withOpacity(0.3) + : AppTheme.errorColor.withOpacity(0.3), + ), + ), + child: Text( + _softDelete + ? 'Le membre "${widget.membre.prenom} ${widget.membre.nom}" sera désactivé et ne pourra plus accéder aux services, mais son historique sera préservé.' + : 'Le membre "${widget.membre.prenom} ${widget.membre.nom}" sera définitivement supprimé avec toutes ses données. Cette action ne peut pas être annulée.', + style: TextStyle( + fontSize: 12, + color: _softDelete ? AppTheme.warningColor : AppTheme.errorColor, + fontWeight: FontWeight.w500, + ), + ), + ); + } + + void _handleDelete() { + setState(() { + _isLoading = true; + }); + + if (_softDelete) { + // Désactivation du membre + final membreDesactive = widget.membre.copyWith( + actif: false, + version: widget.membre.version + 1, + dateModification: DateTime.now(), + ); + + final memberId = widget.membre.id; + if (memberId != null && memberId.isNotEmpty) { + _membresBloc.add(UpdateMembre(memberId, membreDesactive)); + } else { + setState(() { + _isLoading = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Erreur : ID du membre manquant'), + backgroundColor: AppTheme.errorColor, + ), + ); + } + } else { + // Suppression définitive + final memberId = widget.membre.id; + if (memberId != null && memberId.isNotEmpty) { + _membresBloc.add(DeleteMembre(memberId)); + } else { + setState(() { + _isLoading = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Erreur : ID du membre manquant'), + backgroundColor: AppTheme.errorColor, + ), + ); + } + } + } +} diff --git a/unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_info_section.dart b/unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_info_section.dart new file mode 100644 index 0000000..60d5c33 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_info_section.dart @@ -0,0 +1,373 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import '../../../../core/models/membre_model.dart'; +import '../../../../shared/theme/app_theme.dart'; + +/// Section d'informations détaillées d'un membre +class MembreInfoSection extends StatelessWidget { + const MembreInfoSection({ + super.key, + required this.membre, + this.showActions = false, + this.onEdit, + this.onCall, + this.onMessage, + }); + + final MembreModel membre; + final bool showActions; + final VoidCallback? onEdit; + final VoidCallback? onCall; + final VoidCallback? onMessage; + + @override + Widget build(BuildContext context) { + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(), + const SizedBox(height: 20), + _buildPersonalInfo(), + const SizedBox(height: 16), + _buildContactInfo(), + const SizedBox(height: 16), + _buildMembershipInfo(), + if (showActions) ...[ + const SizedBox(height: 20), + _buildActionButtons(), + ], + ], + ), + ), + ); + } + + Widget _buildHeader() { + return Row( + children: [ + _buildAvatar(), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + membre.nomComplet, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + membre.numeroMembre, + style: const TextStyle( + fontSize: 14, + color: AppTheme.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + _buildStatusBadge(), + ], + ), + ), + ], + ); + } + + Widget _buildAvatar() { + return Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: AppTheme.primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(40), + border: Border.all( + color: AppTheme.primaryColor.withOpacity(0.3), + width: 2, + ), + ), + child: Icon( + Icons.person, + size: 40, + color: AppTheme.primaryColor, + ), + ); + } + + Widget _buildStatusBadge() { + final isActive = membre.actif; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: isActive ? AppTheme.successColor : AppTheme.errorColor, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + isActive ? 'Actif' : 'Inactif', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ); + } + + Widget _buildPersonalInfo() { + return _buildSection( + title: 'Informations personnelles', + icon: Icons.person_outline, + children: [ + _buildInfoRow( + icon: Icons.cake_outlined, + label: 'Date de naissance', + value: membre.dateNaissance != null + ? DateFormat('dd/MM/yyyy').format(membre.dateNaissance!) + : 'Non renseignée', + ), + _buildInfoRow( + icon: Icons.work_outline, + label: 'Profession', + value: membre.profession ?? 'Non renseignée', + ), + _buildInfoRow( + icon: Icons.location_on_outlined, + label: 'Adresse', + value: _buildFullAddress(), + ), + ], + ); + } + + Widget _buildContactInfo() { + return _buildSection( + title: 'Contact', + icon: Icons.contact_phone_outlined, + children: [ + _buildInfoRow( + icon: Icons.email_outlined, + label: 'Email', + value: membre.email, + isSelectable: true, + ), + _buildInfoRow( + icon: Icons.phone_outlined, + label: 'Téléphone', + value: membre.telephone, + isSelectable: true, + ), + ], + ); + } + + Widget _buildMembershipInfo() { + return _buildSection( + title: 'Adhésion', + icon: Icons.card_membership_outlined, + children: [ + _buildInfoRow( + icon: Icons.calendar_today_outlined, + label: 'Date d\'adhésion', + value: DateFormat('dd/MM/yyyy').format(membre.dateAdhesion), + ), + _buildInfoRow( + icon: Icons.access_time_outlined, + label: 'Membre depuis', + value: _calculateMembershipDuration(), + ), + _buildInfoRow( + icon: Icons.update_outlined, + label: 'Dernière modification', + value: membre.dateModification != null + ? DateFormat('dd/MM/yyyy à HH:mm').format(membre.dateModification!) + : 'Jamais modifié', + ), + ], + ); + } + + Widget _buildSection({ + required String title, + required IconData icon, + required List children, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + icon, + size: 20, + color: AppTheme.primaryColor, + ), + const SizedBox(width: 8), + Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 12), + ...children, + ], + ); + } + + Widget _buildInfoRow({ + required IconData icon, + required String label, + required String value, + bool isSelectable = false, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + icon, + size: 16, + color: AppTheme.textSecondary, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + isSelectable + ? SelectableText( + value, + style: const TextStyle( + fontSize: 14, + color: AppTheme.textPrimary, + ), + ) + : Text( + value, + style: const TextStyle( + fontSize: 14, + color: AppTheme.textPrimary, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildActionButtons() { + return Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: onEdit, + icon: const Icon(Icons.edit, size: 18), + label: const Text('Modifier'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton.icon( + onPressed: onCall, + icon: const Icon(Icons.phone, size: 18), + label: const Text('Appeler'), + style: OutlinedButton.styleFrom( + foregroundColor: AppTheme.primaryColor, + side: const BorderSide(color: AppTheme.primaryColor), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + const SizedBox(width: 12), + OutlinedButton( + onPressed: onMessage, + style: OutlinedButton.styleFrom( + foregroundColor: AppTheme.infoColor, + side: const BorderSide(color: AppTheme.infoColor), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Icon(Icons.message, size: 18), + ), + ], + ); + } + + String _buildFullAddress() { + final parts = []; + + if (membre.adresse != null && membre.adresse!.isNotEmpty) { + parts.add(membre.adresse!); + } + + if (membre.ville != null && membre.ville!.isNotEmpty) { + parts.add(membre.ville!); + } + + if (membre.codePostal != null && membre.codePostal!.isNotEmpty) { + parts.add(membre.codePostal!); + } + + if (membre.pays != null && membre.pays!.isNotEmpty) { + parts.add(membre.pays!); + } + + return parts.isNotEmpty ? parts.join(', ') : 'Non renseignée'; + } + + String _calculateMembershipDuration() { + final now = DateTime.now(); + final adhesion = membre.dateAdhesion; + + final difference = now.difference(adhesion); + final years = (difference.inDays / 365).floor(); + final months = ((difference.inDays % 365) / 30).floor(); + + if (years > 0) { + return months > 0 ? '$years an${years > 1 ? 's' : ''} et $months mois' : '$years an${years > 1 ? 's' : ''}'; + } else if (months > 0) { + return '$months mois'; + } else { + return '${difference.inDays} jour${difference.inDays > 1 ? 's' : ''}'; + } + } +} diff --git a/unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_stats_section.dart b/unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_stats_section.dart new file mode 100644 index 0000000..7c4301d --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/presentation/widgets/membre_stats_section.dart @@ -0,0 +1,592 @@ +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:intl/intl.dart'; +import '../../../../core/models/membre_model.dart'; +import '../../../../core/models/cotisation_model.dart'; +import '../../../../shared/theme/app_theme.dart'; + +/// Section des statistiques d'un membre +class MembreStatsSection extends StatelessWidget { + const MembreStatsSection({ + super.key, + required this.membre, + required this.cotisations, + }); + + final MembreModel membre; + final List cotisations; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildOverviewCard(), + const SizedBox(height: 16), + _buildPaymentChart(), + const SizedBox(height: 16), + _buildStatusChart(), + const SizedBox(height: 16), + _buildTimelineCard(), + ], + ), + ); + } + + Widget _buildOverviewCard() { + final totalCotisations = cotisations.length; + final cotisationsPayees = cotisations.where((c) => c.statut == 'PAYEE').length; + final cotisationsEnRetard = cotisations.where((c) => c.isEnRetard).length; + final tauxPaiement = totalCotisations > 0 ? (cotisationsPayees / totalCotisations * 100) : 0.0; + + final totalMontantDu = cotisations.fold(0, (sum, c) => sum + c.montantDu); + final totalMontantPaye = cotisations.fold(0, (sum, c) => sum + c.montantPaye); + + final membershipDuration = DateTime.now().difference(membre.dateAdhesion).inDays; + + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.analytics, + color: AppTheme.primaryColor, + size: 24, + ), + const SizedBox(width: 8), + const Text( + 'Vue d\'ensemble', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: _buildStatItem( + 'Cotisations', + '$totalCotisations', + AppTheme.primaryColor, + Icons.receipt_long, + ), + ), + Expanded( + child: _buildStatItem( + 'Taux de paiement', + '${tauxPaiement.toStringAsFixed(1)}%', + tauxPaiement >= 80 ? AppTheme.successColor : + tauxPaiement >= 50 ? AppTheme.warningColor : AppTheme.errorColor, + Icons.trending_up, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildStatItem( + 'En retard', + '$cotisationsEnRetard', + cotisationsEnRetard > 0 ? AppTheme.errorColor : AppTheme.successColor, + Icons.warning, + ), + ), + Expanded( + child: _buildStatItem( + 'Ancienneté', + '${(membershipDuration / 365).floor()} an${(membershipDuration / 365).floor() > 1 ? 's' : ''}', + AppTheme.infoColor, + Icons.schedule, + ), + ), + ], + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.backgroundLight, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Total payé', + style: TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + Text( + _formatAmount(totalMontantPaye), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppTheme.successColor, + ), + ), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + const Text( + 'Restant à payer', + style: TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + Text( + _formatAmount(totalMontantDu - totalMontantPaye), + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: totalMontantDu > totalMontantPaye ? AppTheme.warningColor : AppTheme.successColor, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildStatItem(String label, String value, Color color, IconData icon) { + 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: Column( + children: [ + Icon(icon, color: color, size: 20), + const SizedBox(height: 4), + Text( + value, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: color, + ), + ), + Text( + label, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + Widget _buildPaymentChart() { + if (cotisations.isEmpty) { + return _buildEmptyChart('Aucune donnée de paiement'); + } + + final paymentData = _getPaymentChartData(); + + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.pie_chart, + color: AppTheme.primaryColor, + size: 20, + ), + const SizedBox(width: 8), + const Text( + 'Répartition des paiements', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 20), + SizedBox( + height: 200, + child: PieChart( + PieChartData( + sections: paymentData, + centerSpaceRadius: 40, + sectionsSpace: 2, + ), + ), + ), + const SizedBox(height: 16), + _buildChartLegend(), + ], + ), + ), + ); + } + + Widget _buildStatusChart() { + if (cotisations.isEmpty) { + return _buildEmptyChart('Aucune donnée de statut'); + } + + final statusData = _getStatusChartData(); + + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.bar_chart, + color: AppTheme.primaryColor, + size: 20, + ), + const SizedBox(width: 8), + const Text( + 'Évolution des montants', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 20), + SizedBox( + height: 200, + child: BarChart( + BarChartData( + barGroups: statusData, + titlesData: FlTitlesData( + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 60, + getTitlesWidget: (value, meta) { + return Text( + _formatAmount(value), + style: const TextStyle( + fontSize: 10, + color: AppTheme.textSecondary, + ), + ); + }, + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) { + final index = value.toInt(); + if (index >= 0 && index < cotisations.length) { + return Text( + (cotisations[index].periode ?? 'N/A').substring(0, 3), + style: const TextStyle( + fontSize: 10, + color: AppTheme.textSecondary, + ), + ); + } + return const Text(''); + }, + ), + ), + rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + ), + borderData: FlBorderData(show: false), + gridData: const FlGridData(show: false), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildTimelineCard() { + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.timeline, + color: AppTheme.primaryColor, + size: 20, + ), + const SizedBox(width: 8), + const Text( + 'Chronologie', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 16), + _buildTimelineItem( + 'Adhésion', + DateFormat('dd/MM/yyyy').format(membre.dateAdhesion), + AppTheme.primaryColor, + Icons.person_add, + true, + ), + if (cotisations.isNotEmpty) ...[ + _buildTimelineItem( + 'Première cotisation', + DateFormat('dd/MM/yyyy').format( + cotisations.map((c) => c.dateCreation).reduce((a, b) => a.isBefore(b) ? a : b), + ), + AppTheme.infoColor, + Icons.payment, + true, + ), + _buildTimelineItem( + 'Dernière cotisation', + DateFormat('dd/MM/yyyy').format( + cotisations.map((c) => c.dateCreation).reduce((a, b) => a.isAfter(b) ? a : b), + ), + AppTheme.successColor, + Icons.receipt, + false, + ), + ], + ], + ), + ), + ); + } + + Widget _buildTimelineItem(String title, String date, Color color, IconData icon, bool showLine) { + return Row( + children: [ + Column( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + child: Icon(icon, color: Colors.white, size: 16), + ), + if (showLine) + Container( + width: 2, + height: 24, + color: color.withOpacity(0.3), + ), + ], + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + Text( + date, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildEmptyChart(String message) { + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(40), + child: Column( + children: [ + Icon( + Icons.bar_chart, + size: 48, + color: AppTheme.textHint, + ), + const SizedBox(height: 16), + Text( + message, + style: const TextStyle( + fontSize: 14, + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + ); + } + + Widget _buildChartLegend() { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildLegendItem('Payé', AppTheme.successColor), + _buildLegendItem('En attente', AppTheme.warningColor), + _buildLegendItem('En retard', AppTheme.errorColor), + ], + ); + } + + Widget _buildLegendItem(String label, Color color) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 4), + Text( + label, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + ], + ); + } + + List _getPaymentChartData() { + final payees = cotisations.where((c) => c.statut == 'PAYEE').length; + final enAttente = cotisations.where((c) => c.statut == 'EN_ATTENTE').length; + final enRetard = cotisations.where((c) => c.isEnRetard).length; + final total = cotisations.length; + + return [ + if (payees > 0) + PieChartSectionData( + color: AppTheme.successColor, + value: payees.toDouble(), + title: '${(payees / total * 100).toStringAsFixed(1)}%', + radius: 50, + ), + if (enAttente > 0) + PieChartSectionData( + color: AppTheme.warningColor, + value: enAttente.toDouble(), + title: '${(enAttente / total * 100).toStringAsFixed(1)}%', + radius: 50, + ), + if (enRetard > 0) + PieChartSectionData( + color: AppTheme.errorColor, + value: enRetard.toDouble(), + title: '${(enRetard / total * 100).toStringAsFixed(1)}%', + radius: 50, + ), + ]; + } + + List _getStatusChartData() { + return cotisations.asMap().entries.map((entry) { + final index = entry.key; + final cotisation = entry.value; + + return BarChartGroupData( + x: index, + barRods: [ + BarChartRodData( + toY: cotisation.montantDu, + color: AppTheme.infoColor.withOpacity(0.7), + width: 8, + ), + BarChartRodData( + toY: cotisation.montantPaye, + color: AppTheme.successColor, + width: 8, + ), + ], + ); + }).toList(); + } + + String _formatAmount(double amount) { + return NumberFormat.currency( + locale: 'fr_FR', + symbol: 'FCFA', + decimalDigits: 0, + ).format(amount); + } +} diff --git a/unionflow-mobile-apps/lib/features/members/presentation/widgets/membres_advanced_search.dart b/unionflow-mobile-apps/lib/features/members/presentation/widgets/membres_advanced_search.dart new file mode 100644 index 0000000..b6b0e89 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/presentation/widgets/membres_advanced_search.dart @@ -0,0 +1,626 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import '../../../../shared/theme/app_theme.dart'; +import '../../../../shared/widgets/custom_text_field.dart'; + +/// Widget de recherche avancée pour les membres +class MembresAdvancedSearch extends StatefulWidget { + const MembresAdvancedSearch({ + super.key, + required this.onSearch, + this.initialFilters, + }); + + final Function(Map) onSearch; + final Map? initialFilters; + + @override + State createState() => _MembresAdvancedSearchState(); +} + +class _MembresAdvancedSearchState extends State { + final _formKey = GlobalKey(); + + // Contrôleurs de texte + final _nomController = TextEditingController(); + final _prenomController = TextEditingController(); + final _emailController = TextEditingController(); + final _telephoneController = TextEditingController(); + final _numeroMembreController = TextEditingController(); + final _professionController = TextEditingController(); + final _villeController = TextEditingController(); + + // Filtres de statut + bool? _actifFilter; + + // Filtres de date + DateTime? _dateAdhesionDebut; + DateTime? _dateAdhesionFin; + DateTime? _dateNaissanceDebut; + DateTime? _dateNaissanceFin; + + // Filtres d'âge + int? _ageMin; + int? _ageMax; + + @override + void initState() { + super.initState(); + _initializeFilters(); + } + + void _initializeFilters() { + if (widget.initialFilters != null) { + final filters = widget.initialFilters!; + _nomController.text = filters['nom'] ?? ''; + _prenomController.text = filters['prenom'] ?? ''; + _emailController.text = filters['email'] ?? ''; + _telephoneController.text = filters['telephone'] ?? ''; + _numeroMembreController.text = filters['numeroMembre'] ?? ''; + _professionController.text = filters['profession'] ?? ''; + _villeController.text = filters['ville'] ?? ''; + _actifFilter = filters['actif']; + _dateAdhesionDebut = filters['dateAdhesionDebut']; + _dateAdhesionFin = filters['dateAdhesionFin']; + _dateNaissanceDebut = filters['dateNaissanceDebut']; + _dateNaissanceFin = filters['dateNaissanceFin']; + _ageMin = filters['ageMin']; + _ageMax = filters['ageMax']; + } + } + + @override + void dispose() { + _nomController.dispose(); + _prenomController.dispose(); + _emailController.dispose(); + _telephoneController.dispose(); + _numeroMembreController.dispose(); + _professionController.dispose(); + _villeController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // En-tête + _buildHeader(), + const SizedBox(height: 20), + + // Contenu scrollable + Flexible( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Informations personnelles + _buildSection( + 'Informations personnelles', + Icons.person, + [ + Row( + children: [ + Expanded( + child: CustomTextField( + controller: _nomController, + label: 'Nom', + prefixIcon: Icons.person_outline, + ), + ), + const SizedBox(width: 12), + Expanded( + child: CustomTextField( + controller: _prenomController, + label: 'Prénom', + prefixIcon: Icons.person_outline, + ), + ), + ], + ), + const SizedBox(height: 12), + CustomTextField( + controller: _numeroMembreController, + label: 'Numéro de membre', + prefixIcon: Icons.badge, + ), + const SizedBox(height: 12), + CustomTextField( + controller: _professionController, + label: 'Profession', + prefixIcon: Icons.work, + ), + ], + ), + + const SizedBox(height: 20), + + // Contact et localisation + _buildSection( + 'Contact et localisation', + Icons.contact_phone, + [ + CustomTextField( + controller: _emailController, + label: 'Email', + prefixIcon: Icons.email, + keyboardType: TextInputType.emailAddress, + ), + const SizedBox(height: 12), + CustomTextField( + controller: _telephoneController, + label: 'Téléphone', + prefixIcon: Icons.phone, + keyboardType: TextInputType.phone, + ), + const SizedBox(height: 12), + CustomTextField( + controller: _villeController, + label: 'Ville', + prefixIcon: Icons.location_city, + ), + ], + ), + + const SizedBox(height: 20), + + // Statut et dates + _buildSection( + 'Statut et dates', + Icons.calendar_today, + [ + _buildStatusFilter(), + const SizedBox(height: 16), + _buildDateRangeFilter( + 'Période d\'adhésion', + _dateAdhesionDebut, + _dateAdhesionFin, + (debut, fin) { + setState(() { + _dateAdhesionDebut = debut; + _dateAdhesionFin = fin; + }); + }, + ), + const SizedBox(height: 16), + _buildDateRangeFilter( + 'Période de naissance', + _dateNaissanceDebut, + _dateNaissanceFin, + (debut, fin) { + setState(() { + _dateNaissanceDebut = debut; + _dateNaissanceFin = fin; + }); + }, + ), + const SizedBox(height: 16), + _buildAgeRangeFilter(), + ], + ), + ], + ), + ), + ), + + const SizedBox(height: 20), + + // Boutons d'action + _buildActionButtons(), + ], + ), + ), + ); + } + + Widget _buildHeader() { + return Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.search, + color: AppTheme.primaryColor, + size: 24, + ), + ), + const SizedBox(width: 12), + const Expanded( + child: Text( + 'Recherche avancée', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + color: AppTheme.textSecondary, + ), + ], + ); + } + + Widget _buildSection(String title, IconData icon, List children) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + icon, + color: AppTheme.primaryColor, + size: 20, + ), + const SizedBox(width: 8), + Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 12), + ...children, + ], + ); + } + + Widget _buildStatusFilter() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Statut du membre', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: RadioListTile( + title: const Text('Tous', style: TextStyle(fontSize: 14)), + value: null, + groupValue: _actifFilter, + onChanged: (value) { + setState(() { + _actifFilter = value; + }); + }, + dense: true, + contentPadding: EdgeInsets.zero, + ), + ), + Expanded( + child: RadioListTile( + title: const Text('Actifs', style: TextStyle(fontSize: 14)), + value: true, + groupValue: _actifFilter, + onChanged: (value) { + setState(() { + _actifFilter = value; + }); + }, + dense: true, + contentPadding: EdgeInsets.zero, + ), + ), + Expanded( + child: RadioListTile( + title: const Text('Inactifs', style: TextStyle(fontSize: 14)), + value: false, + groupValue: _actifFilter, + onChanged: (value) { + setState(() { + _actifFilter = value; + }); + }, + dense: true, + contentPadding: EdgeInsets.zero, + ), + ), + ], + ), + ], + ); + } + + Widget _buildDateRangeFilter( + String title, + DateTime? dateDebut, + DateTime? dateFin, + Function(DateTime?, DateTime?) onChanged, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: InkWell( + onTap: () => _selectDate(context, dateDebut, (date) { + onChanged(date, dateFin); + }), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16), + decoration: BoxDecoration( + border: Border.all(color: AppTheme.borderColor), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + Icons.calendar_today, + color: AppTheme.textSecondary, + size: 16, + ), + const SizedBox(width: 8), + Text( + dateDebut != null + ? DateFormat('dd/MM/yyyy').format(dateDebut) + : 'Date début', + style: TextStyle( + fontSize: 14, + color: dateDebut != null + ? AppTheme.textPrimary + : AppTheme.textSecondary, + ), + ), + ], + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: InkWell( + onTap: () => _selectDate(context, dateFin, (date) { + onChanged(dateDebut, date); + }), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16), + decoration: BoxDecoration( + border: Border.all(color: AppTheme.borderColor), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + Icons.calendar_today, + color: AppTheme.textSecondary, + size: 16, + ), + const SizedBox(width: 8), + Text( + dateFin != null + ? DateFormat('dd/MM/yyyy').format(dateFin) + : 'Date fin', + style: TextStyle( + fontSize: 14, + color: dateFin != null + ? AppTheme.textPrimary + : AppTheme.textSecondary, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ], + ); + } + + Widget _buildAgeRangeFilter() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Tranche d\'âge', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextFormField( + initialValue: _ageMin?.toString(), + decoration: InputDecoration( + labelText: 'Âge minimum', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16), + ), + keyboardType: TextInputType.number, + onChanged: (value) { + _ageMin = int.tryParse(value); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + initialValue: _ageMax?.toString(), + decoration: InputDecoration( + labelText: 'Âge maximum', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16), + ), + keyboardType: TextInputType.number, + onChanged: (value) { + _ageMax = int.tryParse(value); + }, + ), + ), + ], + ), + ], + ); + } + + Widget _buildActionButtons() { + return Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _clearFilters, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + side: BorderSide(color: AppTheme.borderColor), + ), + child: const Text('Effacer'), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: ElevatedButton( + onPressed: _performSearch, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + ), + child: const Text('Rechercher'), + ), + ), + ], + ); + } + + Future _selectDate( + BuildContext context, + DateTime? initialDate, + Function(DateTime?) onDateSelected, + ) async { + final date = await showDatePicker( + context: context, + initialDate: initialDate ?? DateTime.now(), + firstDate: DateTime(1900), + lastDate: DateTime.now(), + ); + + if (date != null) { + onDateSelected(date); + } + } + + void _clearFilters() { + setState(() { + _nomController.clear(); + _prenomController.clear(); + _emailController.clear(); + _telephoneController.clear(); + _numeroMembreController.clear(); + _professionController.clear(); + _villeController.clear(); + _actifFilter = null; + _dateAdhesionDebut = null; + _dateAdhesionFin = null; + _dateNaissanceDebut = null; + _dateNaissanceFin = null; + _ageMin = null; + _ageMax = null; + }); + } + + void _performSearch() { + final filters = {}; + + // Ajout des filtres texte + if (_nomController.text.isNotEmpty) { + filters['nom'] = _nomController.text; + } + if (_prenomController.text.isNotEmpty) { + filters['prenom'] = _prenomController.text; + } + if (_emailController.text.isNotEmpty) { + filters['email'] = _emailController.text; + } + if (_telephoneController.text.isNotEmpty) { + filters['telephone'] = _telephoneController.text; + } + if (_numeroMembreController.text.isNotEmpty) { + filters['numeroMembre'] = _numeroMembreController.text; + } + if (_professionController.text.isNotEmpty) { + filters['profession'] = _professionController.text; + } + if (_villeController.text.isNotEmpty) { + filters['ville'] = _villeController.text; + } + + // Ajout des filtres de statut + if (_actifFilter != null) { + filters['actif'] = _actifFilter; + } + + // Ajout des filtres de date + if (_dateAdhesionDebut != null) { + filters['dateAdhesionDebut'] = _dateAdhesionDebut; + } + if (_dateAdhesionFin != null) { + filters['dateAdhesionFin'] = _dateAdhesionFin; + } + if (_dateNaissanceDebut != null) { + filters['dateNaissanceDebut'] = _dateNaissanceDebut; + } + if (_dateNaissanceFin != null) { + filters['dateNaissanceFin'] = _dateNaissanceFin; + } + + // Ajout des filtres d'âge + if (_ageMin != null) { + filters['ageMin'] = _ageMin; + } + if (_ageMax != null) { + filters['ageMax'] = _ageMax; + } + + widget.onSearch(filters); + Navigator.of(context).pop(); + } +} diff --git a/unionflow-mobile-apps/lib/features/members/presentation/widgets/membres_export_dialog.dart b/unionflow-mobile-apps/lib/features/members/presentation/widgets/membres_export_dialog.dart new file mode 100644 index 0000000..70ae73a --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/presentation/widgets/membres_export_dialog.dart @@ -0,0 +1,433 @@ +import 'package:flutter/material.dart'; +import '../../../../shared/theme/app_theme.dart'; +import '../../../../core/models/membre_model.dart'; + +/// Dialog d'export des données des membres +class MembresExportDialog extends StatefulWidget { + const MembresExportDialog({ + super.key, + required this.membres, + this.selectedMembers, + }); + + final List membres; + final List? selectedMembers; + + @override + State createState() => _MembresExportDialogState(); +} + +class _MembresExportDialogState extends State { + String _selectedFormat = 'excel'; + bool _includeInactiveMembers = true; + bool _includePersonalInfo = true; + bool _includeContactInfo = true; + bool _includeAdhesionInfo = true; + bool _includeStatistics = false; + + final List _availableFormats = [ + 'excel', + 'csv', + 'pdf', + 'json', + ]; + + @override + Widget build(BuildContext context) { + final membersToExport = widget.selectedMembers ?? widget.membres; + final activeMembers = membersToExport.where((m) => m.actif).length; + final inactiveMembers = membersToExport.length - activeMembers; + + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + title: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.file_download, + color: AppTheme.primaryColor, + size: 24, + ), + ), + const SizedBox(width: 12), + const Expanded( + child: Text( + 'Exporter les données', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Résumé des données à exporter + _buildDataSummary(membersToExport.length, activeMembers, inactiveMembers), + const SizedBox(height: 20), + + // Sélection du format + _buildFormatSelection(), + const SizedBox(height: 20), + + // Options d'export + _buildExportOptions(), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Annuler'), + ), + ElevatedButton.icon( + onPressed: () => _performExport(membersToExport), + icon: const Icon(Icons.download), + label: const Text('Exporter'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.white, + ), + ), + ], + ); + } + + Widget _buildDataSummary(int total, int active, int inactive) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.backgroundLight, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppTheme.borderColor), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.info_outline, + color: AppTheme.primaryColor, + size: 20, + ), + const SizedBox(width: 8), + const Text( + 'Données à exporter', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildSummaryItem( + 'Total', + total.toString(), + AppTheme.primaryColor, + Icons.people, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildSummaryItem( + 'Actifs', + active.toString(), + AppTheme.successColor, + Icons.person, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildSummaryItem( + 'Inactifs', + inactive.toString(), + AppTheme.errorColor, + Icons.person_off, + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildSummaryItem(String label, String value, Color color, IconData icon) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + Icon( + icon, + color: color, + size: 20, + ), + const SizedBox(height: 4), + Text( + value, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: color, + ), + ), + const SizedBox(height: 2), + Text( + label, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + ], + ), + ); + } + + Widget _buildFormatSelection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Format d\'export', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: _availableFormats.map((format) { + final isSelected = _selectedFormat == format; + return InkWell( + onTap: () { + setState(() { + _selectedFormat = format; + }); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: isSelected ? AppTheme.primaryColor : Colors.transparent, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isSelected ? AppTheme.primaryColor : AppTheme.borderColor, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _getFormatIcon(format), + color: isSelected ? Colors.white : AppTheme.textSecondary, + size: 20, + ), + const SizedBox(width: 8), + Text( + _getFormatLabel(format), + style: TextStyle( + color: isSelected ? Colors.white : AppTheme.textPrimary, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + ), + ), + ], + ), + ), + ); + }).toList(), + ), + ], + ); + } + + Widget _buildExportOptions() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Options d\'export', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 12), + + // Inclusion des membres inactifs + CheckboxListTile( + title: const Text('Inclure les membres inactifs'), + subtitle: const Text('Exporter aussi les membres désactivés'), + value: _includeInactiveMembers, + onChanged: (value) { + setState(() { + _includeInactiveMembers = value ?? true; + }); + }, + dense: true, + contentPadding: EdgeInsets.zero, + ), + + const Divider(), + + // Sections de données à inclure + const Text( + 'Sections à inclure', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 8), + + CheckboxListTile( + title: const Text('Informations personnelles'), + subtitle: const Text('Nom, prénom, date de naissance, etc.'), + value: _includePersonalInfo, + onChanged: (value) { + setState(() { + _includePersonalInfo = value ?? true; + }); + }, + dense: true, + contentPadding: EdgeInsets.zero, + ), + + CheckboxListTile( + title: const Text('Informations de contact'), + subtitle: const Text('Email, téléphone, adresse'), + value: _includeContactInfo, + onChanged: (value) { + setState(() { + _includeContactInfo = value ?? true; + }); + }, + dense: true, + contentPadding: EdgeInsets.zero, + ), + + CheckboxListTile( + title: const Text('Informations d\'adhésion'), + subtitle: const Text('Date d\'adhésion, statut, numéro de membre'), + value: _includeAdhesionInfo, + onChanged: (value) { + setState(() { + _includeAdhesionInfo = value ?? true; + }); + }, + dense: true, + contentPadding: EdgeInsets.zero, + ), + + CheckboxListTile( + title: const Text('Statistiques'), + subtitle: const Text('Données de cotisations et statistiques'), + value: _includeStatistics, + onChanged: (value) { + setState(() { + _includeStatistics = value ?? false; + }); + }, + dense: true, + contentPadding: EdgeInsets.zero, + ), + ], + ); + } + + IconData _getFormatIcon(String format) { + switch (format) { + case 'excel': + return Icons.table_chart; + case 'csv': + return Icons.text_snippet; + case 'pdf': + return Icons.picture_as_pdf; + case 'json': + return Icons.code; + default: + return Icons.file_download; + } + } + + String _getFormatLabel(String format) { + switch (format) { + case 'excel': + return 'Excel (.xlsx)'; + case 'csv': + return 'CSV (.csv)'; + case 'pdf': + return 'PDF (.pdf)'; + case 'json': + return 'JSON (.json)'; + default: + return format.toUpperCase(); + } + } + + void _performExport(List membersToExport) { + // Filtrer les membres selon les options + List filteredMembers = membersToExport; + + if (!_includeInactiveMembers) { + filteredMembers = filteredMembers.where((m) => m.actif).toList(); + } + + // Créer les options d'export + final exportOptions = { + 'format': _selectedFormat, + 'includePersonalInfo': _includePersonalInfo, + 'includeContactInfo': _includeContactInfo, + 'includeAdhesionInfo': _includeAdhesionInfo, + 'includeStatistics': _includeStatistics, + 'includeInactiveMembers': _includeInactiveMembers, + }; + + // TODO: Implémenter l'export réel selon le format + _showExportResult(filteredMembers.length, _selectedFormat); + } + + void _showExportResult(int count, String format) { + Navigator.of(context).pop(); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Export $format de $count membres - À implémenter', + ), + backgroundColor: AppTheme.infoColor, + action: SnackBarAction( + label: 'Voir', + onPressed: () { + // TODO: Ouvrir le fichier exporté + }, + ), + ), + ); + } +} diff --git a/unionflow-mobile-apps/lib/features/members/presentation/widgets/modern_floating_action_button.dart b/unionflow-mobile-apps/lib/features/members/presentation/widgets/modern_floating_action_button.dart new file mode 100644 index 0000000..c78846f --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/presentation/widgets/modern_floating_action_button.dart @@ -0,0 +1,340 @@ +import 'package:flutter/material.dart'; +import '../../../../shared/theme/app_theme.dart'; +import '../../../../shared/theme/design_system.dart'; + +/// Floating Action Button moderne avec animations et design professionnel +class ModernFloatingActionButton extends StatefulWidget { + const ModernFloatingActionButton({ + super.key, + required this.onPressed, + required this.icon, + this.label, + this.backgroundColor, + this.foregroundColor, + this.heroTag, + this.tooltip, + this.mini = false, + this.extended = false, + }); + + final VoidCallback? onPressed; + final IconData icon; + final String? label; + final Color? backgroundColor; + final Color? foregroundColor; + final Object? heroTag; + final String? tooltip; + final bool mini; + final bool extended; + + @override + State createState() => _ModernFloatingActionButtonState(); +} + +class _ModernFloatingActionButtonState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late Animation _scaleAnimation; + late Animation _rotationAnimation; + bool _isPressed = false; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: DesignSystem.animationFast, + vsync: this, + ); + + _scaleAnimation = Tween( + begin: 1.0, + end: 0.95, + ).animate(CurvedAnimation( + parent: _animationController, + curve: DesignSystem.animationCurve, + )); + + _rotationAnimation = Tween( + begin: 0.0, + end: 0.1, + ).animate(CurvedAnimation( + parent: _animationController, + curve: DesignSystem.animationCurve, + )); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + void _handleTapDown(TapDownDetails details) { + setState(() => _isPressed = true); + _animationController.forward(); + } + + void _handleTapUp(TapUpDetails details) { + setState(() => _isPressed = false); + _animationController.reverse(); + } + + void _handleTapCancel() { + setState(() => _isPressed = false); + _animationController.reverse(); + } + + @override + Widget build(BuildContext context) { + if (widget.extended && widget.label != null) { + return _buildExtendedFAB(); + } + return _buildRegularFAB(); + } + + Widget _buildRegularFAB() { + return AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + return Transform.scale( + scale: _scaleAnimation.value, + child: Transform.rotate( + angle: _rotationAnimation.value, + child: GestureDetector( + onTapDown: _handleTapDown, + onTapUp: _handleTapUp, + onTapCancel: _handleTapCancel, + onTap: widget.onPressed, + child: Container( + width: widget.mini ? 40 : 56, + height: widget.mini ? 40 : 56, + decoration: BoxDecoration( + gradient: DesignSystem.primaryGradient, + borderRadius: BorderRadius.circular( + widget.mini ? 20 : 28, + ), + boxShadow: [ + BoxShadow( + color: (widget.backgroundColor ?? AppTheme.primaryColor).withOpacity(0.3), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ...DesignSystem.shadowCard, + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular( + widget.mini ? 20 : 28, + ), + onTap: widget.onPressed, + child: Center( + child: Icon( + widget.icon, + color: widget.foregroundColor ?? Colors.white, + size: widget.mini ? 20 : 24, + ), + ), + ), + ), + ), + ), + ), + ); + }, + ); + } + + Widget _buildExtendedFAB() { + return AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + return Transform.scale( + scale: _scaleAnimation.value, + child: GestureDetector( + onTapDown: _handleTapDown, + onTapUp: _handleTapUp, + onTapCancel: _handleTapCancel, + onTap: widget.onPressed, + child: Container( + height: 48, + padding: EdgeInsets.symmetric( + horizontal: DesignSystem.spacingLg, + vertical: DesignSystem.spacingSm, + ), + decoration: BoxDecoration( + gradient: DesignSystem.primaryGradient, + borderRadius: BorderRadius.circular(DesignSystem.radiusXl), + boxShadow: [ + BoxShadow( + color: (widget.backgroundColor ?? AppTheme.primaryColor).withOpacity(0.3), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ...DesignSystem.shadowCard, + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(DesignSystem.radiusXl), + onTap: widget.onPressed, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + widget.icon, + color: widget.foregroundColor ?? Colors.white, + size: 20, + ), + SizedBox(width: DesignSystem.spacingSm), + Text( + widget.label!, + style: DesignSystem.labelLarge.copyWith( + color: widget.foregroundColor ?? Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), + ), + ); + }, + ); + } +} + +/// Widget de FAB avec menu contextuel +class ModernFABWithMenu extends StatefulWidget { + const ModernFABWithMenu({ + super.key, + required this.mainAction, + required this.menuItems, + this.heroTag, + }); + + final ModernFABAction mainAction; + final List menuItems; + final Object? heroTag; + + @override + State createState() => _ModernFABWithMenuState(); +} + +class _ModernFABWithMenuState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late Animation _rotationAnimation; + bool _isOpen = false; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: DesignSystem.animationMedium, + vsync: this, + ); + + _rotationAnimation = Tween( + begin: 0.0, + end: 0.75, + ).animate(CurvedAnimation( + parent: _animationController, + curve: DesignSystem.animationCurve, + )); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + void _toggleMenu() { + setState(() { + _isOpen = !_isOpen; + if (_isOpen) { + _animationController.forward(); + } else { + _animationController.reverse(); + } + }); + } + + @override + Widget build(BuildContext context) { + return Stack( + alignment: Alignment.bottomRight, + children: [ + // Menu items + ...widget.menuItems.asMap().entries.map((entry) { + final index = entry.key; + final item = entry.value; + + return AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + final offset = (index + 1) * 70.0 * _animationController.value; + + return Transform.translate( + offset: Offset(0, -offset), + child: Opacity( + opacity: _animationController.value, + child: ModernFloatingActionButton( + onPressed: () { + _toggleMenu(); + item.onPressed?.call(); + }, + icon: item.icon, + mini: true, + backgroundColor: item.backgroundColor, + foregroundColor: item.foregroundColor, + heroTag: '${widget.heroTag}_$index', + ), + ), + ); + }, + ); + }).toList(), + + // Main FAB + AnimatedBuilder( + animation: _rotationAnimation, + builder: (context, child) { + return Transform.rotate( + angle: _rotationAnimation.value * 2 * 3.14159, + child: ModernFloatingActionButton( + onPressed: _toggleMenu, + icon: _isOpen ? Icons.close : widget.mainAction.icon, + backgroundColor: widget.mainAction.backgroundColor, + foregroundColor: widget.mainAction.foregroundColor, + heroTag: widget.heroTag, + ), + ); + }, + ), + ], + ); + } +} + +/// Modèle pour une action de FAB +class ModernFABAction { + const ModernFABAction({ + required this.icon, + this.onPressed, + this.backgroundColor, + this.foregroundColor, + this.label, + }); + + final IconData icon; + final VoidCallback? onPressed; + final Color? backgroundColor; + final Color? foregroundColor; + final String? label; +} diff --git a/unionflow-mobile-apps/lib/features/members/presentation/widgets/modern_tab_bar.dart b/unionflow-mobile-apps/lib/features/members/presentation/widgets/modern_tab_bar.dart new file mode 100644 index 0000000..e085b47 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/presentation/widgets/modern_tab_bar.dart @@ -0,0 +1,205 @@ +import 'package:flutter/material.dart'; +import '../../../../shared/theme/app_theme.dart'; +import '../../../../shared/theme/design_system.dart'; + +/// TabBar moderne avec animations et design professionnel +class ModernTabBar extends StatefulWidget implements PreferredSizeWidget { + const ModernTabBar({ + super.key, + required this.controller, + required this.tabs, + this.onTap, + }); + + final TabController controller; + final List tabs; + final ValueChanged? onTap; + + @override + State createState() => _ModernTabBarState(); + + @override + Size get preferredSize => Size.fromHeight(DesignSystem.goldenWidth(60)); +} + +class _ModernTabBarState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late Animation _scaleAnimation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: DesignSystem.animationFast, + vsync: this, + ); + + _scaleAnimation = Tween( + begin: 1.0, + end: 0.95, + ).animate(CurvedAnimation( + parent: _animationController, + curve: DesignSystem.animationCurve, + )); + + widget.controller.addListener(_onTabChanged); + } + + @override + void dispose() { + widget.controller.removeListener(_onTabChanged); + _animationController.dispose(); + super.dispose(); + } + + void _onTabChanged() { + if (mounted) { + _animationController.forward().then((_) { + _animationController.reverse(); + }); + } + } + + @override + Widget build(BuildContext context) { + return Container( + margin: EdgeInsets.symmetric( + horizontal: DesignSystem.spacingLg, + vertical: DesignSystem.spacingSm, + ), + decoration: BoxDecoration( + color: AppTheme.surfaceLight, + borderRadius: BorderRadius.circular(DesignSystem.radiusLg), + boxShadow: DesignSystem.shadowCard, + border: Border.all( + color: AppTheme.borderColor.withOpacity(0.1), + width: 1, + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(DesignSystem.radiusLg), + child: TabBar( + controller: widget.controller, + onTap: widget.onTap, + indicator: BoxDecoration( + gradient: DesignSystem.primaryGradient, + borderRadius: BorderRadius.circular(DesignSystem.radiusMd), + ), + indicatorSize: TabBarIndicatorSize.tab, + indicatorPadding: EdgeInsets.all(DesignSystem.spacingXs), + labelColor: Colors.white, + unselectedLabelColor: AppTheme.textSecondary, + labelStyle: DesignSystem.labelLarge.copyWith( + fontWeight: FontWeight.w600, + ), + unselectedLabelStyle: DesignSystem.labelLarge.copyWith( + fontWeight: FontWeight.w500, + ), + dividerColor: Colors.transparent, + tabs: widget.tabs.asMap().entries.map((entry) { + final index = entry.key; + final tab = entry.value; + final isSelected = widget.controller.index == index; + + return AnimatedBuilder( + animation: _scaleAnimation, + builder: (context, child) { + return Transform.scale( + scale: isSelected ? _scaleAnimation.value : 1.0, + child: _buildTab(tab, isSelected), + ); + }, + ); + }).toList(), + ), + ), + ); + } + + Widget _buildTab(ModernTab tab, bool isSelected) { + return Container( + height: DesignSystem.goldenWidth(50), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AnimatedContainer( + duration: DesignSystem.animationFast, + child: Icon( + tab.icon, + size: isSelected ? 20 : 18, + color: isSelected ? Colors.white : AppTheme.textSecondary, + ), + ), + if (tab.label != null) ...[ + SizedBox(width: DesignSystem.spacingXs), + AnimatedDefaultTextStyle( + duration: DesignSystem.animationFast, + style: (isSelected ? DesignSystem.labelLarge : DesignSystem.labelMedium).copyWith( + color: isSelected ? Colors.white : AppTheme.textSecondary, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + ), + child: Text(tab.label!), + ), + ], + if (tab.badge != null) ...[ + SizedBox(width: DesignSystem.spacingXs), + _buildBadge(tab.badge!, isSelected), + ], + ], + ), + ); + } + + Widget _buildBadge(String badge, bool isSelected) { + return AnimatedContainer( + duration: DesignSystem.animationFast, + padding: EdgeInsets.symmetric( + horizontal: DesignSystem.spacingXs, + vertical: 2, + ), + decoration: BoxDecoration( + color: isSelected + ? Colors.white.withOpacity(0.2) + : AppTheme.primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(DesignSystem.radiusSm), + ), + child: Text( + badge, + style: DesignSystem.labelSmall.copyWith( + color: isSelected ? Colors.white : AppTheme.primaryColor, + fontWeight: FontWeight.w600, + fontSize: 10, + ), + ), + ); + } +} + +/// Modèle pour un onglet moderne +class ModernTab { + const ModernTab({ + required this.icon, + this.label, + this.badge, + }); + + final IconData icon; + final String? label; + final String? badge; +} + +/// Extension pour créer facilement des onglets modernes +extension ModernTabExtension on Tab { + static ModernTab modern({ + required IconData icon, + String? label, + String? badge, + }) { + return ModernTab( + icon: icon, + label: label, + badge: badge, + ); + } +} diff --git a/unionflow-mobile-apps/lib/features/members/presentation/widgets/professional_bar_chart.dart b/unionflow-mobile-apps/lib/features/members/presentation/widgets/professional_bar_chart.dart new file mode 100644 index 0000000..2f4e596 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/presentation/widgets/professional_bar_chart.dart @@ -0,0 +1,269 @@ +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:intl/intl.dart'; +import '../../../../shared/theme/app_theme.dart'; +import '../../../../shared/theme/design_system.dart'; + +/// Graphique en barres professionnel avec animations et interactions +class ProfessionalBarChart extends StatefulWidget { + const ProfessionalBarChart({ + super.key, + required this.data, + required this.title, + this.subtitle, + this.showGrid = true, + this.showValues = true, + this.animationDuration = const Duration(milliseconds: 1500), + this.barColor, + this.gradientColors, + }); + + final List data; + final String title; + final String? subtitle; + final bool showGrid; + final bool showValues; + final Duration animationDuration; + final Color? barColor; + final List? gradientColors; + + @override + State createState() => _ProfessionalBarChartState(); +} + +class _ProfessionalBarChartState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late Animation _animation; + int _touchedIndex = -1; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: widget.animationDuration, + vsync: this, + ); + + _animation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _animationController, + curve: DesignSystem.animationCurve, + )); + + _animationController.forward(); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(), + SizedBox(height: DesignSystem.spacingLg), + Expanded( + child: _buildChart(), + ), + ], + ); + } + + Widget _buildHeader() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.title, + style: DesignSystem.titleLarge.copyWith( + fontWeight: FontWeight.w700, + ), + ), + if (widget.subtitle != null) ...[ + SizedBox(height: DesignSystem.spacingXs), + Text( + widget.subtitle!, + style: DesignSystem.bodyMedium.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ], + ); + } + + Widget _buildChart() { + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return BarChart( + BarChartData( + alignment: BarChartAlignment.spaceAround, + maxY: _getMaxY() * 1.2, + barTouchData: BarTouchData( + touchTooltipData: BarTouchTooltipData( + tooltipBgColor: AppTheme.textPrimary.withOpacity(0.9), + tooltipRoundedRadius: DesignSystem.radiusSm, + tooltipPadding: EdgeInsets.all(DesignSystem.spacingSm), + getTooltipItem: (group, groupIndex, rod, rodIndex) { + return BarTooltipItem( + '${widget.data[groupIndex].label}\n${rod.toY.toInt()}', + DesignSystem.labelMedium.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ); + }, + ), + touchCallback: (FlTouchEvent event, barTouchResponse) { + setState(() { + if (!event.isInterestedForInteractions || + barTouchResponse == null || + barTouchResponse.spot == null) { + _touchedIndex = -1; + return; + } + _touchedIndex = barTouchResponse.spot!.touchedBarGroupIndex; + }); + }, + ), + titlesData: FlTitlesData( + show: true, + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: _buildBottomTitles, + reservedSize: 42, + ), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: _buildLeftTitles, + reservedSize: 40, + ), + ), + ), + borderData: FlBorderData(show: false), + gridData: FlGridData( + show: widget.showGrid, + drawVerticalLine: false, + horizontalInterval: _getMaxY() / 5, + getDrawingHorizontalLine: (value) { + return FlLine( + color: AppTheme.borderColor.withOpacity(0.3), + strokeWidth: 1, + ); + }, + ), + barGroups: _buildBarGroups(), + ), + ); + }, + ); + } + + List _buildBarGroups() { + return widget.data.asMap().entries.map((entry) { + final index = entry.key; + final data = entry.value; + final isTouched = index == _touchedIndex; + + return BarChartGroupData( + x: index, + barRods: [ + BarChartRodData( + toY: data.value * _animation.value, + color: _getBarColor(index, isTouched), + width: isTouched ? 24 : 20, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(DesignSystem.radiusXs), + topRight: Radius.circular(DesignSystem.radiusXs), + ), + gradient: widget.gradientColors != null ? LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: widget.gradientColors!, + ) : null, + ), + ], + showingTooltipIndicators: isTouched ? [0] : [], + ); + }).toList(); + } + + Color _getBarColor(int index, bool isTouched) { + if (widget.barColor != null) { + return isTouched + ? widget.barColor! + : widget.barColor!.withOpacity(0.8); + } + + final colors = DesignSystem.chartColors; + final color = colors[index % colors.length]; + return isTouched ? color : color.withOpacity(0.8); + } + + Widget _buildBottomTitles(double value, TitleMeta meta) { + if (value.toInt() >= widget.data.length) return const SizedBox.shrink(); + + final data = widget.data[value.toInt()]; + return SideTitleWidget( + axisSide: meta.axisSide, + child: Padding( + padding: EdgeInsets.only(top: DesignSystem.spacingXs), + child: Text( + data.label, + style: DesignSystem.labelSmall.copyWith( + color: AppTheme.textSecondary, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ), + ); + } + + Widget _buildLeftTitles(double value, TitleMeta meta) { + return SideTitleWidget( + axisSide: meta.axisSide, + child: Text( + value.toInt().toString(), + style: DesignSystem.labelSmall.copyWith( + color: AppTheme.textSecondary, + ), + ), + ); + } + + double _getMaxY() { + if (widget.data.isEmpty) return 10; + return widget.data.map((e) => e.value).reduce((a, b) => a > b ? a : b); + } +} + +/// Modèle de données pour le graphique en barres +class BarDataPoint { + const BarDataPoint({ + required this.label, + required this.value, + this.color, + }); + + final String label; + final double value; + final Color? color; +} diff --git a/unionflow-mobile-apps/lib/features/members/presentation/widgets/professional_line_chart.dart b/unionflow-mobile-apps/lib/features/members/presentation/widgets/professional_line_chart.dart new file mode 100644 index 0000000..7d21445 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/presentation/widgets/professional_line_chart.dart @@ -0,0 +1,282 @@ +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:intl/intl.dart'; +import '../../../../shared/theme/app_theme.dart'; +import '../../../../shared/theme/design_system.dart'; + +/// Graphique linéaire professionnel avec animations et interactions +class ProfessionalLineChart extends StatefulWidget { + const ProfessionalLineChart({ + super.key, + required this.data, + required this.title, + this.subtitle, + this.showGrid = true, + this.showDots = true, + this.showArea = false, + this.animationDuration = const Duration(milliseconds: 1500), + this.lineColor, + this.gradientColors, + }); + + final List data; + final String title; + final String? subtitle; + final bool showGrid; + final bool showDots; + final bool showArea; + final Duration animationDuration; + final Color? lineColor; + final List? gradientColors; + + @override + State createState() => _ProfessionalLineChartState(); +} + +class _ProfessionalLineChartState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late Animation _animation; + List _showingTooltipOnSpots = []; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: widget.animationDuration, + vsync: this, + ); + + _animation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _animationController, + curve: DesignSystem.animationCurve, + )); + + _animationController.forward(); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(), + SizedBox(height: DesignSystem.spacingLg), + Expanded( + child: _buildChart(), + ), + ], + ); + } + + Widget _buildHeader() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.title, + style: DesignSystem.titleLarge.copyWith( + fontWeight: FontWeight.w700, + ), + ), + if (widget.subtitle != null) ...[ + SizedBox(height: DesignSystem.spacingXs), + Text( + widget.subtitle!, + style: DesignSystem.bodyMedium.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ], + ); + } + + Widget _buildChart() { + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return LineChart( + LineChartData( + lineTouchData: LineTouchData( + touchTooltipData: LineTouchTooltipData( + tooltipBgColor: AppTheme.textPrimary.withOpacity(0.9), + tooltipRoundedRadius: DesignSystem.radiusSm, + tooltipPadding: EdgeInsets.all(DesignSystem.spacingSm), + getTooltipItems: (List touchedBarSpots) { + return touchedBarSpots.map((barSpot) { + final data = widget.data[barSpot.x.toInt()]; + return LineTooltipItem( + '${data.label}\n${barSpot.y.toInt()}', + DesignSystem.labelMedium.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ); + }).toList(); + }, + ), + handleBuiltInTouches: true, + getTouchedSpotIndicator: (LineChartBarData barData, List spotIndexes) { + return spotIndexes.map((index) { + return TouchedSpotIndicatorData( + FlLine( + color: widget.lineColor ?? AppTheme.primaryColor, + strokeWidth: 2, + dashArray: [3, 3], + ), + FlDotData( + getDotPainter: (spot, percent, barData, index) => + FlDotCirclePainter( + radius: 6, + color: widget.lineColor ?? AppTheme.primaryColor, + strokeWidth: 2, + strokeColor: Colors.white, + ), + ), + ); + }).toList(); + }, + ), + gridData: FlGridData( + show: widget.showGrid, + drawVerticalLine: false, + horizontalInterval: _getMaxY() / 5, + getDrawingHorizontalLine: (value) { + return FlLine( + color: AppTheme.borderColor.withOpacity(0.3), + strokeWidth: 1, + ); + }, + ), + titlesData: FlTitlesData( + show: true, + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: _buildBottomTitles, + reservedSize: 42, + ), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: _buildLeftTitles, + reservedSize: 40, + ), + ), + ), + borderData: FlBorderData(show: false), + minX: 0, + maxX: widget.data.length.toDouble() - 1, + minY: 0, + maxY: _getMaxY() * 1.2, + lineBarsData: [ + _buildLineBarData(), + ], + ), + ); + }, + ); + } + + LineChartBarData _buildLineBarData() { + final spots = widget.data.asMap().entries.map((entry) { + final index = entry.key; + final data = entry.value; + return FlSpot(index.toDouble(), data.value * _animation.value); + }).toList(); + + return LineChartBarData( + spots: spots, + isCurved: true, + curveSmoothness: 0.3, + color: widget.lineColor ?? AppTheme.primaryColor, + barWidth: 3, + isStrokeCapRound: true, + dotData: FlDotData( + show: widget.showDots, + getDotPainter: (spot, percent, barData, index) => FlDotCirclePainter( + radius: 4, + color: widget.lineColor ?? AppTheme.primaryColor, + strokeWidth: 2, + strokeColor: Colors.white, + ), + ), + belowBarData: widget.showArea ? BarAreaData( + show: true, + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: widget.gradientColors ?? [ + (widget.lineColor ?? AppTheme.primaryColor).withOpacity(0.3), + (widget.lineColor ?? AppTheme.primaryColor).withOpacity(0.05), + ], + ), + ) : BarAreaData(show: false), + ); + } + + Widget _buildBottomTitles(double value, TitleMeta meta) { + if (value.toInt() >= widget.data.length) return const SizedBox.shrink(); + + final data = widget.data[value.toInt()]; + return SideTitleWidget( + axisSide: meta.axisSide, + child: Padding( + padding: EdgeInsets.only(top: DesignSystem.spacingXs), + child: Text( + data.label, + style: DesignSystem.labelSmall.copyWith( + color: AppTheme.textSecondary, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ), + ); + } + + Widget _buildLeftTitles(double value, TitleMeta meta) { + return SideTitleWidget( + axisSide: meta.axisSide, + child: Text( + value.toInt().toString(), + style: DesignSystem.labelSmall.copyWith( + color: AppTheme.textSecondary, + ), + ), + ); + } + + double _getMaxY() { + if (widget.data.isEmpty) return 10; + return widget.data.map((e) => e.value).reduce((a, b) => a > b ? a : b); + } +} + +/// Modèle de données pour le graphique linéaire +class LineDataPoint { + const LineDataPoint({ + required this.label, + required this.value, + }); + + final String label; + final double value; +} diff --git a/unionflow-mobile-apps/lib/features/members/presentation/widgets/professional_pie_chart.dart b/unionflow-mobile-apps/lib/features/members/presentation/widgets/professional_pie_chart.dart new file mode 100644 index 0000000..be5a802 --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/presentation/widgets/professional_pie_chart.dart @@ -0,0 +1,307 @@ +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; +import '../../../../shared/theme/app_theme.dart'; +import '../../../../shared/theme/design_system.dart'; + +/// Graphique en secteurs professionnel avec animations et légendes +class ProfessionalPieChart extends StatefulWidget { + const ProfessionalPieChart({ + super.key, + required this.data, + required this.title, + this.subtitle, + this.centerText, + this.showLegend = true, + this.showPercentages = true, + this.animationDuration = const Duration(milliseconds: 1500), + }); + + final List data; + final String title; + final String? subtitle; + final String? centerText; + final bool showLegend; + final bool showPercentages; + final Duration animationDuration; + + @override + State createState() => _ProfessionalPieChartState(); +} + +class _ProfessionalPieChartState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late Animation _animation; + int _touchedIndex = -1; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: widget.animationDuration, + vsync: this, + ); + + _animation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _animationController, + curve: DesignSystem.animationCurve, + )); + + _animationController.forward(); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(), + SizedBox(height: DesignSystem.spacingLg), + Expanded( + child: Row( + children: [ + Expanded( + flex: 3, + child: _buildChart(), + ), + if (widget.showLegend) ...[ + SizedBox(width: DesignSystem.spacingLg), + Expanded( + flex: 2, + child: _buildLegend(), + ), + ], + ], + ), + ), + ], + ); + } + + Widget _buildHeader() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.title, + style: DesignSystem.titleLarge.copyWith( + fontWeight: FontWeight.w700, + ), + ), + if (widget.subtitle != null) ...[ + SizedBox(height: DesignSystem.spacingXs), + Text( + widget.subtitle!, + style: DesignSystem.bodyMedium.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ], + ); + } + + Widget _buildChart() { + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return Container( + height: 140, // Hauteur encore plus réduite + padding: const EdgeInsets.all(4), // Padding minimal pour contenir le graphique + child: PieChart( + PieChartData( + pieTouchData: PieTouchData( + touchCallback: (FlTouchEvent event, pieTouchResponse) { + setState(() { + if (!event.isInterestedForInteractions || + pieTouchResponse == null || + pieTouchResponse.touchedSection == null) { + _touchedIndex = -1; + return; + } + _touchedIndex = pieTouchResponse.touchedSection!.touchedSectionIndex; + }); + }, + ), + borderData: FlBorderData(show: false), + sectionsSpace: 1, // Espace réduit entre sections + centerSpaceRadius: widget.centerText != null ? 45 : 30, // Rayon central réduit + sections: _buildSections(), + ), + ), + ); + }, + ); + } + + List _buildSections() { + final total = widget.data.fold(0, (sum, item) => sum + item.value); + + return widget.data.asMap().entries.map((entry) { + final index = entry.key; + final data = entry.value; + final isTouched = index == _touchedIndex; + final percentage = (data.value / total * 100); + + return PieChartSectionData( + color: data.color, + value: data.value * _animation.value, + title: widget.showPercentages ? '${percentage.toStringAsFixed(1)}%' : '', + radius: isTouched ? 70 : 60, + titleStyle: DesignSystem.labelMedium.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + shadows: [ + Shadow( + color: Colors.black.withOpacity(0.3), + offset: const Offset(1, 1), + blurRadius: 2, + ), + ], + ), + titlePositionPercentageOffset: 0.6, + badgeWidget: isTouched ? _buildBadge(data) : null, + badgePositionPercentageOffset: 1.3, + ); + }).toList(); + } + + Widget _buildBadge(ChartDataPoint data) { + return Container( + padding: EdgeInsets.symmetric( + horizontal: DesignSystem.spacingSm, + vertical: DesignSystem.spacingXs, + ), + decoration: BoxDecoration( + color: data.color, + borderRadius: BorderRadius.circular(DesignSystem.radiusSm), + boxShadow: DesignSystem.shadowCard, + ), + child: Text( + data.value.toInt().toString(), + style: DesignSystem.labelMedium.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ); + } + + Widget _buildLegend() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (widget.centerText != null) ...[ + _buildCenterInfo(), + SizedBox(height: DesignSystem.spacingLg), + ], + ...widget.data.asMap().entries.map((entry) { + final index = entry.key; + final data = entry.value; + final isSelected = index == _touchedIndex; + + return AnimatedContainer( + duration: DesignSystem.animationFast, + margin: EdgeInsets.only(bottom: DesignSystem.spacingSm), + padding: EdgeInsets.all(DesignSystem.spacingSm), + decoration: BoxDecoration( + color: isSelected ? data.color.withOpacity(0.1) : Colors.transparent, + borderRadius: BorderRadius.circular(DesignSystem.radiusSm), + border: isSelected ? Border.all( + color: data.color.withOpacity(0.3), + width: 1, + ) : null, + ), + child: Row( + children: [ + Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: data.color, + borderRadius: BorderRadius.circular(DesignSystem.radiusXs), + ), + ), + SizedBox(width: DesignSystem.spacingSm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + data.label, + style: DesignSystem.labelLarge.copyWith( + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + ), + ), + Text( + data.value.toInt().toString(), + style: DesignSystem.labelMedium.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ); + }).toList(), + ], + ); + } + + Widget _buildCenterInfo() { + return Container( + padding: EdgeInsets.all(DesignSystem.spacingMd), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(DesignSystem.radiusMd), + border: Border.all( + color: AppTheme.primaryColor.withOpacity(0.2), + width: 1, + ), + ), + child: Column( + children: [ + Text( + 'Total', + style: DesignSystem.labelMedium.copyWith( + color: AppTheme.textSecondary, + ), + ), + SizedBox(height: DesignSystem.spacingXs), + Text( + widget.centerText!, + style: DesignSystem.headlineMedium.copyWith( + color: AppTheme.primaryColor, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} + +/// Modèle de données pour le graphique en secteurs +class ChartDataPoint { + const ChartDataPoint({ + required this.label, + required this.value, + required this.color, + }); + + final String label; + final double value; + final Color color; +} diff --git a/unionflow-mobile-apps/lib/features/members/presentation/widgets/stats_grid_card.dart b/unionflow-mobile-apps/lib/features/members/presentation/widgets/stats_grid_card.dart new file mode 100644 index 0000000..ab6795a --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/presentation/widgets/stats_grid_card.dart @@ -0,0 +1,243 @@ +import 'package:flutter/material.dart'; +import '../../../../shared/theme/app_theme.dart'; +import '../../../../shared/theme/design_system.dart'; + +/// Grille de statistiques compacte pour mobile +class StatsGridCard extends StatefulWidget { + const StatsGridCard({ + super.key, + required this.stats, + this.crossAxisCount = 2, + this.childAspectRatio = 1.2, + }); + + final Map stats; + final int crossAxisCount; + final double childAspectRatio; + + @override + State createState() => _StatsGridCardState(); +} + +class _StatsGridCardState extends State + with TickerProviderStateMixin { + late List _animationControllers; + late List> _scaleAnimations; + late List> _slideAnimations; + + @override + void initState() { + super.initState(); + _initializeAnimations(); + } + + void _initializeAnimations() { + const itemCount = 4; // Nombre de statistiques + _animationControllers = List.generate( + itemCount, + (index) => AnimationController( + duration: Duration( + milliseconds: DesignSystem.animationMedium.inMilliseconds + (index * 100), + ), + vsync: this, + ), + ); + + _scaleAnimations = _animationControllers.map((controller) { + return Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: controller, + curve: DesignSystem.animationCurveEnter, + )); + }).toList(); + + _slideAnimations = _animationControllers.map((controller) { + return Tween( + begin: const Offset(0, 0.5), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: controller, + curve: DesignSystem.animationCurveEnter, + )); + }).toList(); + + // Démarrer les animations en cascade + for (int i = 0; i < _animationControllers.length; i++) { + Future.delayed(Duration(milliseconds: i * 100), () { + if (mounted) { + _animationControllers[i].forward(); + } + }); + } + } + + @override + void dispose() { + for (final controller in _animationControllers) { + controller.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final statsItems = [ + _StatItem( + title: 'Total Membres', + value: widget.stats['totalMembres'].toString(), + icon: Icons.people, + color: AppTheme.primaryColor, + trend: '+${widget.stats['nouveauxCeMois']}', + trendPositive: true, + ), + _StatItem( + title: 'Membres Actifs', + value: widget.stats['membresActifs'].toString(), + icon: Icons.person, + color: AppTheme.successColor, + trend: '${widget.stats['tauxActivite']}%', + trendPositive: widget.stats['tauxActivite'] >= 70, + ), + _StatItem( + title: 'Nouveaux ce mois', + value: widget.stats['nouveauxCeMois'].toString(), + icon: Icons.person_add, + color: AppTheme.infoColor, + trend: 'Ce mois', + trendPositive: widget.stats['nouveauxCeMois'] > 0, + ), + _StatItem( + title: 'Taux d\'activité', + value: '${widget.stats['tauxActivite']}%', + icon: Icons.trending_up, + color: AppTheme.warningColor, + trend: widget.stats['tauxActivite'] >= 70 ? 'Excellent' : 'Moyen', + trendPositive: widget.stats['tauxActivite'] >= 70, + ), + ]; + + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: widget.crossAxisCount, + childAspectRatio: widget.childAspectRatio, + crossAxisSpacing: DesignSystem.spacingMd, + mainAxisSpacing: DesignSystem.spacingMd, + ), + itemCount: statsItems.length, + itemBuilder: (context, index) { + return AnimatedBuilder( + animation: _animationControllers[index], + builder: (context, child) { + return SlideTransition( + position: _slideAnimations[index], + child: ScaleTransition( + scale: _scaleAnimations[index], + child: _buildStatCard(statsItems[index]), + ), + ); + }, + ); + }, + ); + } + + Widget _buildStatCard(_StatItem item) { + return Container( + padding: EdgeInsets.all(DesignSystem.spacingMd), + decoration: BoxDecoration( + color: AppTheme.surfaceLight, + borderRadius: BorderRadius.circular(DesignSystem.radiusLg), + boxShadow: DesignSystem.shadowCard, + border: Border.all( + color: item.color.withOpacity(0.1), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: EdgeInsets.all(DesignSystem.spacingSm), + decoration: BoxDecoration( + color: item.color.withOpacity(0.1), + borderRadius: BorderRadius.circular(DesignSystem.radiusMd), + ), + child: Icon( + item.icon, + color: item.color, + size: 20, + ), + ), + Container( + padding: EdgeInsets.symmetric( + horizontal: DesignSystem.spacingXs, + vertical: 2, + ), + decoration: BoxDecoration( + color: item.trendPositive + ? AppTheme.successColor.withOpacity(0.1) + : AppTheme.errorColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(DesignSystem.radiusSm), + ), + child: Text( + item.trend, + style: DesignSystem.labelSmall.copyWith( + color: item.trendPositive + ? AppTheme.successColor + : AppTheme.errorColor, + fontWeight: FontWeight.w600, + fontSize: 10, + ), + ), + ), + ], + ), + SizedBox(height: DesignSystem.spacingSm), + Text( + item.value, + style: DesignSystem.headlineMedium.copyWith( + fontWeight: FontWeight.w800, + color: AppTheme.textPrimary, + ), + ), + SizedBox(height: DesignSystem.spacingXs), + Text( + item.title, + style: DesignSystem.labelMedium.copyWith( + color: AppTheme.textSecondary, + fontWeight: FontWeight.w500, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + } +} + +/// Modèle pour un élément de statistique +class _StatItem { + const _StatItem({ + required this.title, + required this.value, + required this.icon, + required this.color, + required this.trend, + required this.trendPositive, + }); + + final String title; + final String value; + final IconData icon; + final Color color; + final String trend; + final bool trendPositive; +} diff --git a/unionflow-mobile-apps/lib/features/members/presentation/widgets/stats_overview_card.dart b/unionflow-mobile-apps/lib/features/members/presentation/widgets/stats_overview_card.dart new file mode 100644 index 0000000..0318e9e --- /dev/null +++ b/unionflow-mobile-apps/lib/features/members/presentation/widgets/stats_overview_card.dart @@ -0,0 +1,281 @@ +import 'package:flutter/material.dart'; +import '../../../../shared/theme/app_theme.dart'; +import '../../../../shared/theme/design_system.dart'; + +/// Card de vue d'ensemble des statistiques avec design professionnel +class StatsOverviewCard extends StatefulWidget { + const StatsOverviewCard({ + super.key, + required this.stats, + this.onTap, + }); + + final Map stats; + final VoidCallback? onTap; + + @override + State createState() => _StatsOverviewCardState(); +} + +class _StatsOverviewCardState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late Animation _fadeAnimation; + late Animation _slideAnimation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: DesignSystem.animationMedium, + vsync: this, + ); + + _fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _animationController, + curve: DesignSystem.animationCurve, + )); + + _slideAnimation = Tween( + begin: const Offset(0, 0.3), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _animationController, + curve: DesignSystem.animationCurveEnter, + )); + + _animationController.forward(); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + return SlideTransition( + position: _slideAnimation, + child: FadeTransition( + opacity: _fadeAnimation, + child: _buildCard(), + ), + ); + }, + ); + } + + Widget _buildCard() { + return Container( + padding: EdgeInsets.all(DesignSystem.spacingLg), + decoration: BoxDecoration( + gradient: DesignSystem.primaryGradient, + borderRadius: BorderRadius.circular(DesignSystem.radiusLg), + boxShadow: DesignSystem.shadowCard, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(), + SizedBox(height: DesignSystem.spacingLg), + _buildMainStats(), + SizedBox(height: DesignSystem.spacingLg), + _buildSecondaryStats(), + SizedBox(height: DesignSystem.spacingMd), + _buildProgressIndicator(), + ], + ), + ); + } + + Widget _buildHeader() { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Vue d\'ensemble', + style: DesignSystem.titleLarge.copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: DesignSystem.spacingXs), + Text( + 'Statistiques générales', + style: DesignSystem.bodyMedium.copyWith( + color: Colors.white.withOpacity(0.9), + ), + ), + ], + ), + Container( + padding: EdgeInsets.all(DesignSystem.spacingSm), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(DesignSystem.radiusMd), + ), + child: const Icon( + Icons.analytics, + color: Colors.white, + size: 24, + ), + ), + ], + ); + } + + Widget _buildMainStats() { + return Row( + children: [ + Expanded( + child: _buildStatItem( + 'Total Membres', + widget.stats['totalMembres'].toString(), + Icons.people, + Colors.white, + ), + ), + SizedBox(width: DesignSystem.spacingLg), + Expanded( + child: _buildStatItem( + 'Membres Actifs', + widget.stats['membresActifs'].toString(), + Icons.person, + Colors.white, + ), + ), + ], + ); + } + + Widget _buildSecondaryStats() { + return Row( + children: [ + Expanded( + child: _buildStatItem( + 'Nouveaux ce mois', + widget.stats['nouveauxCeMois'].toString(), + Icons.person_add, + Colors.white.withOpacity(0.9), + isSecondary: true, + ), + ), + SizedBox(width: DesignSystem.spacingLg), + Expanded( + child: _buildStatItem( + 'Taux d\'activité', + '${widget.stats['tauxActivite']}%', + Icons.trending_up, + Colors.white.withOpacity(0.9), + isSecondary: true, + ), + ), + ], + ); + } + + Widget _buildStatItem( + String label, + String value, + IconData icon, + Color color, { + bool isSecondary = false, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + icon, + color: color, + size: isSecondary ? 16 : 20, + ), + SizedBox(width: DesignSystem.spacingXs), + Text( + label, + style: (isSecondary ? DesignSystem.labelMedium : DesignSystem.labelLarge).copyWith( + color: color, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + SizedBox(height: DesignSystem.spacingXs), + Text( + value, + style: (isSecondary ? DesignSystem.headlineMedium : DesignSystem.displayMedium).copyWith( + color: color, + fontWeight: FontWeight.w800, + fontSize: isSecondary ? 20 : 32, + ), + ), + ], + ); + } + + Widget _buildProgressIndicator() { + final tauxActivite = widget.stats['tauxActivite'] as int; + final progress = tauxActivite / 100.0; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Engagement communautaire', + style: DesignSystem.labelMedium.copyWith( + color: Colors.white.withOpacity(0.9), + fontWeight: FontWeight.w500, + ), + ), + Text( + '$tauxActivite%', + style: DesignSystem.labelMedium.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + SizedBox(height: DesignSystem.spacingXs), + Container( + height: 6, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(DesignSystem.radiusXs), + ), + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: progress, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(DesignSystem.radiusXs), + boxShadow: [ + BoxShadow( + color: Colors.white.withOpacity(0.3), + blurRadius: 4, + offset: const Offset(0, 1), + ), + ], + ), + ), + ), + ), + ], + ); + } +} diff --git a/unionflow-mobile-apps/lib/features/navigation/presentation/pages/main_navigation.dart b/unionflow-mobile-apps/lib/features/navigation/presentation/pages/main_navigation.dart index 6c046d4..83af1db 100644 --- a/unionflow-mobile-apps/lib/features/navigation/presentation/pages/main_navigation.dart +++ b/unionflow-mobile-apps/lib/features/navigation/presentation/pages/main_navigation.dart @@ -3,8 +3,9 @@ import 'package:flutter/services.dart'; import '../../../../shared/theme/app_theme.dart'; import '../../../../shared/widgets/coming_soon_page.dart'; import '../../../../shared/widgets/buttons/buttons.dart'; -import '../../../dashboard/presentation/pages/enhanced_dashboard.dart'; +import '../../../dashboard/presentation/pages/dashboard_page.dart'; import '../../../members/presentation/pages/membres_list_page.dart'; +import '../../../cotisations/presentation/pages/cotisations_list_page.dart'; import '../widgets/custom_bottom_nav_bar.dart'; class MainNavigation extends StatefulWidget { @@ -85,9 +86,7 @@ class _MainNavigationState extends State body: IndexedStack( index: _currentIndex, children: [ - EnhancedDashboard( - onNavigateToTab: _onTabTapped, - ), + const DashboardPage(), _buildMembresPage(), _buildCotisationsPage(), _buildEventsPage(), @@ -209,20 +208,7 @@ class _MainNavigationState extends State } Widget _buildCotisationsPage() { - return const ComingSoonPage( - title: 'Module Cotisations', - description: 'Suivi et gestion des cotisations avec paiements automatiques', - icon: Icons.payment_rounded, - color: AppTheme.accentColor, - features: [ - 'Tableau de bord des cotisations', - 'Relances automatiques par email/SMS', - 'Paiements en ligne sécurisés', - 'Génération de reçus automatique', - 'Suivi des retards de paiement', - 'Rapports financiers détaillés', - ], - ); + return const CotisationsListPage(); } Widget _buildEventsPage() { diff --git a/unionflow-mobile-apps/lib/features/splash/presentation/pages/splash_screen.dart b/unionflow-mobile-apps/lib/features/splash/presentation/pages/splash_screen.dart index 79fa0ea..d0a0217 100644 --- a/unionflow-mobile-apps/lib/features/splash/presentation/pages/splash_screen.dart +++ b/unionflow-mobile-apps/lib/features/splash/presentation/pages/splash_screen.dart @@ -88,19 +88,19 @@ class _SplashScreenState extends State ), ); - // Séquence d'animations + // Séquence d'animations avec vérification mounted await Future.delayed(const Duration(milliseconds: 300)); - _logoController.forward(); - + if (mounted) _logoController.forward(); + await Future.delayed(const Duration(milliseconds: 500)); - _textController.forward(); - + if (mounted) _textController.forward(); + await Future.delayed(const Duration(milliseconds: 300)); - _progressController.forward(); + if (mounted) _progressController.forward(); // Attendre la fin de toutes les animations + temps de chargement await Future.delayed(const Duration(milliseconds: 2000)); - + // Le splash screen sera remplacé automatiquement par l'AppWrapper // basé sur l'état d'authentification } diff --git a/unionflow-mobile-apps/lib/main.dart b/unionflow-mobile-apps/lib/main.dart index afc7c77..90b9679 100644 --- a/unionflow-mobile-apps/lib/main.dart +++ b/unionflow-mobile-apps/lib/main.dart @@ -1,17 +1,21 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/date_symbol_data_local.dart'; import 'core/auth/bloc/temp_auth_bloc.dart'; import 'core/auth/bloc/auth_event.dart'; import 'core/auth/services/temp_auth_service.dart'; import 'core/di/injection.dart'; import 'shared/theme/app_theme.dart'; -import 'app_temp.dart'; +import 'app.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + // Initialisation des données de localisation + await initializeDateFormatting('fr_FR', null); + // Configuration de l'injection de dépendances await configureDependencies(); @@ -19,7 +23,7 @@ void main() async { await _configureApp(); // Lancement de l'application - runApp(const UnionFlowTempApp()); + runApp(const UnionFlowApp()); } /// Configure les paramètres globaux de l'application @@ -28,7 +32,7 @@ Future _configureApp() async { await SystemChrome.setPreferredOrientations([ DeviceOrientation.portraitUp, ]); - + // Configuration de la barre de statut SystemChrome.setSystemUIOverlayStyle( const SystemUiOverlayStyle( @@ -41,9 +45,9 @@ Future _configureApp() async { ); } -/// Application principale temporaire -class UnionFlowTempApp extends StatelessWidget { - const UnionFlowTempApp({super.key}); +/// Application principale +class UnionFlowApp extends StatelessWidget { + const UnionFlowApp({super.key}); @override Widget build(BuildContext context) { @@ -57,23 +61,23 @@ class UnionFlowTempApp extends StatelessWidget { child: MaterialApp( title: 'UnionFlow', debugShowCheckedModeBanner: false, - + // Configuration du thème theme: AppTheme.lightTheme, darkTheme: AppTheme.darkTheme, themeMode: ThemeMode.system, - + // Configuration de la localisation locale: const Locale('fr', 'FR'), - + // Application principale - home: const AppTempWrapper(), - + home: const AppWrapper(), + // Builder global pour gérer les erreurs builder: (context, child) { return MediaQuery( data: MediaQuery.of(context).copyWith( - textScaler: TextScaler.linear(1.0), + textScaler: const TextScaler.linear(1.0), ), child: child ?? const SizedBox(), ); diff --git a/unionflow-mobile-apps/lib/main_temp.dart b/unionflow-mobile-apps/lib/main_temp.dart deleted file mode 100644 index 1e80448..0000000 --- a/unionflow-mobile-apps/lib/main_temp.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import 'core/auth/bloc/temp_auth_bloc.dart'; -import 'core/auth/bloc/auth_event.dart'; -import 'core/auth/services/temp_auth_service.dart'; -import 'shared/theme/app_theme.dart'; -import 'app_temp.dart'; - -void main() async { - WidgetsFlutterBinding.ensureInitialized(); - - // Configuration du système - await _configureApp(); - - // Lancement de l'application - runApp(const UnionFlowTempApp()); -} - -/// Configure les paramètres globaux de l'application -Future _configureApp() async { - // Configuration de l'orientation - await SystemChrome.setPreferredOrientations([ - DeviceOrientation.portraitUp, - ]); - - // Configuration de la barre de statut - SystemChrome.setSystemUIOverlayStyle( - const SystemUiOverlayStyle( - statusBarColor: Colors.transparent, - statusBarIconBrightness: Brightness.dark, - statusBarBrightness: Brightness.light, - systemNavigationBarColor: Colors.white, - systemNavigationBarIconBrightness: Brightness.dark, - ), - ); -} - -/// Application principale temporaire -class UnionFlowTempApp extends StatelessWidget { - const UnionFlowTempApp({super.key}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) { - final authService = TempAuthService(); - final authBloc = TempAuthBloc(authService); - authBloc.add(const AuthInitializeRequested()); - return authBloc; - }, - child: MaterialApp( - title: 'UnionFlow', - debugShowCheckedModeBanner: false, - - // Configuration du thème - theme: AppTheme.lightTheme, - darkTheme: AppTheme.darkTheme, - themeMode: ThemeMode.system, - - // Configuration de la localisation - locale: const Locale('fr', 'FR'), - - // Application principale - home: const AppTempWrapper(), - - // Builder global pour gérer les erreurs - builder: (context, child) { - return MediaQuery( - data: MediaQuery.of(context).copyWith( - textScaler: TextScaler.linear(1.0), - ), - child: child ?? const SizedBox(), - ); - }, - ), - ); - } -} \ No newline at end of file diff --git a/unionflow-mobile-apps/lib/main_ultra_simple.dart b/unionflow-mobile-apps/lib/main_ultra_simple.dart deleted file mode 100644 index 8352c62..0000000 --- a/unionflow-mobile-apps/lib/main_ultra_simple.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import 'core/auth/bloc/temp_auth_bloc.dart'; -import 'core/auth/bloc/auth_event.dart'; -import 'core/auth/services/temp_auth_service.dart'; -import 'core/di/injection.dart'; - -import 'shared/theme/app_theme.dart'; -import 'app_ultra_simple.dart'; - -void main() async { - WidgetsFlutterBinding.ensureInitialized(); - - // Configuration de l'injection de dépendances - await configureDependencies(); - - // Configuration du système - await _configureApp(); - - // Lancement de l'application - runApp(const UnionFlowUltraSimpleApp()); -} - -/// Configure les paramètres globaux de l'application -Future _configureApp() async { - // Configuration de l'orientation - await SystemChrome.setPreferredOrientations([ - DeviceOrientation.portraitUp, - ]); - - // Configuration de la barre de statut - SystemChrome.setSystemUIOverlayStyle( - const SystemUiOverlayStyle( - statusBarColor: Colors.transparent, - statusBarIconBrightness: Brightness.dark, - statusBarBrightness: Brightness.light, - systemNavigationBarColor: Colors.white, - systemNavigationBarIconBrightness: Brightness.dark, - ), - ); -} - -/// Classe BLoC ultra-simple qui utilise UltraSimpleAuthService -class UltraSimpleAuthBloc extends TempAuthBloc { - UltraSimpleAuthBloc(TempAuthService authService) : super(authService); -} - -/// Application principale ultra-simple -class UnionFlowUltraSimpleApp extends StatelessWidget { - const UnionFlowUltraSimpleApp({super.key}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) { - final authService = TempAuthService(); - final authBloc = UltraSimpleAuthBloc(authService); - authBloc.add(const AuthInitializeRequested()); - return authBloc; - }, - child: MaterialApp( - title: 'UnionFlow', - debugShowCheckedModeBanner: false, - - // Configuration du thème - theme: AppTheme.lightTheme, - - // Configuration de la localisation - locale: const Locale('fr', 'FR'), - - // Application principale - home: const UltraSimpleAppWrapper(), - - // Builder global pour gérer les erreurs - builder: (context, child) { - return MediaQuery( - data: MediaQuery.of(context).copyWith( - textScaler: TextScaler.linear(1.0), - ), - child: child ?? const SizedBox(), - ); - }, - ), - ); - } -} \ No newline at end of file diff --git a/unionflow-mobile-apps/lib/shared/theme/design_system.dart b/unionflow-mobile-apps/lib/shared/theme/design_system.dart new file mode 100644 index 0000000..9fc9498 --- /dev/null +++ b/unionflow-mobile-apps/lib/shared/theme/design_system.dart @@ -0,0 +1,263 @@ +import 'package:flutter/material.dart'; +import 'app_theme.dart'; + +/// Design System UnionFlow basé sur le nombre d'or et Material Design 3 +class DesignSystem { + // === NOMBRE D'OR ET PROPORTIONS === + static const double goldenRatio = 1.618; + static const double inverseGoldenRatio = 0.618; + + // === ESPACEMENTS BASÉS SUR LE NOMBRE D'OR === + static const double baseUnit = 8.0; + + // Espacements principaux (progression géométrique basée sur le nombre d'or) + static const double spacing2xs = baseUnit * 0.5; // 4px + static const double spacingXs = baseUnit; // 8px + static const double spacingSm = baseUnit * 1.5; // 12px + static const double spacingMd = baseUnit * 2; // 16px + static const double spacingLg = baseUnit * 3; // 24px + static const double spacingXl = baseUnit * 4; // 32px + static const double spacing2xl = baseUnit * 6; // 48px + static const double spacing3xl = baseUnit * 8; // 64px + + // Espacements spéciaux basés sur le nombre d'or + static const double spacingGolden = spacingMd * goldenRatio; // ~26px + static const double spacingGoldenLarge = spacingLg * goldenRatio; // ~39px + + // === RAYONS DE BORDURE === + static const double radiusXs = 4.0; + static const double radiusSm = 8.0; + static const double radiusMd = 12.0; + static const double radiusLg = 16.0; + static const double radiusXl = 20.0; + static const double radius2xl = 24.0; + + // === ÉLÉVATIONS ET OMBRES === + static const double elevationCard = 2.0; + static const double elevationModal = 8.0; + static const double elevationAppBar = 0.0; + + // Ombres personnalisées + static List get shadowCard => [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + BoxShadow( + color: Colors.black.withOpacity(0.02), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ]; + + static List get shadowCardHover => [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 16, + offset: const Offset(0, 4), + ), + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 32, + offset: const Offset(0, 8), + ), + ]; + + static List get shadowModal => [ + BoxShadow( + color: Colors.black.withOpacity(0.12), + blurRadius: 24, + offset: const Offset(0, 8), + ), + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 48, + offset: const Offset(0, 16), + ), + ]; + + // === TYPOGRAPHIE AVANCÉE === + static const TextStyle displayLarge = TextStyle( + fontSize: 40, + fontWeight: FontWeight.w800, + letterSpacing: -0.5, + height: 1.2, + color: AppTheme.textPrimary, + ); + + static const TextStyle displayMedium = TextStyle( + fontSize: 32, + fontWeight: FontWeight.w700, + letterSpacing: -0.25, + height: 1.25, + color: AppTheme.textPrimary, + ); + + static const TextStyle headlineLarge = TextStyle( + fontSize: 28, + fontWeight: FontWeight.w600, + letterSpacing: 0, + height: 1.3, + color: AppTheme.textPrimary, + ); + + static const TextStyle headlineMedium = TextStyle( + fontSize: 24, + fontWeight: FontWeight.w600, + letterSpacing: 0, + height: 1.33, + color: AppTheme.textPrimary, + ); + + static const TextStyle titleLarge = TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + letterSpacing: 0, + height: 1.4, + color: AppTheme.textPrimary, + ); + + static const TextStyle titleMedium = TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + letterSpacing: 0.1, + height: 1.5, + color: AppTheme.textPrimary, + ); + + static const TextStyle bodyLarge = TextStyle( + fontSize: 16, + fontWeight: FontWeight.w400, + letterSpacing: 0.15, + height: 1.5, + color: AppTheme.textPrimary, + ); + + static const TextStyle bodyMedium = TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + letterSpacing: 0.25, + height: 1.43, + color: AppTheme.textPrimary, + ); + + static const TextStyle labelLarge = TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + letterSpacing: 0.1, + height: 1.43, + color: AppTheme.textPrimary, + ); + + static const TextStyle labelMedium = TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + letterSpacing: 0.5, + height: 1.33, + color: AppTheme.textSecondary, + ); + + static const TextStyle labelSmall = TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + letterSpacing: 0.5, + height: 1.2, + color: AppTheme.textHint, + ); + + // === COULEURS ÉTENDUES === + // Palette de couleurs pour les graphiques (harmonieuse et accessible) + static const List chartColors = [ + Color(0xFF2196F3), // Bleu principal + Color(0xFF4CAF50), // Vert + Color(0xFFFF9800), // Orange + Color(0xFF9C27B0), // Violet + Color(0xFFF44336), // Rouge + Color(0xFF00BCD4), // Cyan + Color(0xFFFFEB3B), // Jaune + Color(0xFF795548), // Marron + Color(0xFF607D8B), // Bleu gris + Color(0xFFE91E63), // Rose + ]; + + // Couleurs de gradient + static const LinearGradient primaryGradient = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppTheme.primaryColor, + AppTheme.primaryLight, + ], + ); + + static const LinearGradient successGradient = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppTheme.successColor, + AppTheme.secondaryLight, + ], + ); + + static const LinearGradient warningGradient = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppTheme.warningColor, + Color(0xFFFFB74D), + ], + ); + + static const LinearGradient errorGradient = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppTheme.errorColor, + Color(0xFFEF5350), + ], + ); + + // === ANIMATIONS ET TRANSITIONS === + static const Duration animationFast = Duration(milliseconds: 150); + static const Duration animationMedium = Duration(milliseconds: 300); + static const Duration animationSlow = Duration(milliseconds: 500); + + static const Curve animationCurve = Curves.easeInOutCubic; + static const Curve animationCurveEnter = Curves.easeOut; + static const Curve animationCurveExit = Curves.easeIn; + + // === BREAKPOINTS RESPONSIVE === + static const double breakpointMobile = 480; + static const double breakpointTablet = 768; + static const double breakpointDesktop = 1024; + + // === UTILITAIRES === + static bool isMobile(BuildContext context) { + return MediaQuery.of(context).size.width < breakpointMobile; + } + + static bool isTablet(BuildContext context) { + final width = MediaQuery.of(context).size.width; + return width >= breakpointMobile && width < breakpointDesktop; + } + + static bool isDesktop(BuildContext context) { + return MediaQuery.of(context).size.width >= breakpointDesktop; + } + + // Calcul de dimensions basées sur le nombre d'or + static double goldenWidth(double height) => height * goldenRatio; + static double goldenHeight(double width) => width * inverseGoldenRatio; + + // Espacement adaptatif basé sur la taille d'écran + static double adaptiveSpacing(BuildContext context, { + double mobile = spacingMd, + double tablet = spacingLg, + double desktop = spacingXl, + }) { + if (isMobile(context)) return mobile; + if (isTablet(context)) return tablet; + return desktop; + } +} diff --git a/unionflow-mobile-apps/lib/shared/widgets/coming_soon_page.dart b/unionflow-mobile-apps/lib/shared/widgets/coming_soon_page.dart index 419dff8..a8f7740 100644 --- a/unionflow-mobile-apps/lib/shared/widgets/coming_soon_page.dart +++ b/unionflow-mobile-apps/lib/shared/widgets/coming_soon_page.dart @@ -22,11 +22,17 @@ class ComingSoonPage extends StatelessWidget { return Scaffold( backgroundColor: AppTheme.backgroundLight, body: SafeArea( - child: Padding( + child: SingleChildScrollView( padding: const EdgeInsets.all(24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: MediaQuery.of(context).size.height - + MediaQuery.of(context).padding.top - + MediaQuery.of(context).padding.bottom - 48, // padding + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ // Icône principale Container( width: 120, @@ -205,6 +211,7 @@ class ComingSoonPage extends StatelessWidget { textAlign: TextAlign.center, ), ], + ), ), ), ), diff --git a/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/entity/Cotisation.java b/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/entity/Cotisation.java new file mode 100644 index 0000000..d8b8683 --- /dev/null +++ b/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/entity/Cotisation.java @@ -0,0 +1,211 @@ +package dev.lions.unionflow.server.entity; + +import io.quarkus.hibernate.orm.panache.PanacheEntity; +import jakarta.persistence.*; +import jakarta.validation.constraints.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * Entité Cotisation avec Lombok + * Représente une cotisation d'un membre à son organisation + * + * @author UnionFlow Team + * @version 1.0 + * @since 2025-01-15 + */ +@Entity +@Table(name = "cotisations", indexes = { + @Index(name = "idx_cotisation_membre", columnList = "membre_id"), + @Index(name = "idx_cotisation_reference", columnList = "numero_reference", unique = true), + @Index(name = "idx_cotisation_statut", columnList = "statut"), + @Index(name = "idx_cotisation_echeance", columnList = "date_echeance"), + @Index(name = "idx_cotisation_type", columnList = "type_cotisation"), + @Index(name = "idx_cotisation_annee_mois", columnList = "annee, mois") +}) +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EqualsAndHashCode(callSuper = false) +public class Cotisation extends PanacheEntity { + + @NotBlank + @Column(name = "numero_reference", unique = true, nullable = false, length = 50) + private String numeroReference; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "membre_id", nullable = false) + private Membre membre; + + @NotBlank + @Column(name = "type_cotisation", nullable = false, length = 50) + private String typeCotisation; + + @NotNull + @DecimalMin(value = "0.0", message = "Le montant dû doit être positif") + @Digits(integer = 10, fraction = 2) + @Column(name = "montant_du", nullable = false, precision = 12, scale = 2) + private BigDecimal montantDu; + + @Builder.Default + @DecimalMin(value = "0.0", message = "Le montant payé doit être positif") + @Digits(integer = 10, fraction = 2) + @Column(name = "montant_paye", nullable = false, precision = 12, scale = 2) + private BigDecimal montantPaye = BigDecimal.ZERO; + + @NotBlank + @Pattern(regexp = "^[A-Z]{3}$", message = "Le code devise doit être un code ISO à 3 lettres") + @Column(name = "code_devise", nullable = false, length = 3) + private String codeDevise; + + @NotBlank + @Pattern(regexp = "^(EN_ATTENTE|PAYEE|EN_RETARD|PARTIELLEMENT_PAYEE|ANNULEE)$") + @Column(name = "statut", nullable = false, length = 30) + private String statut; + + @NotNull + @Column(name = "date_echeance", nullable = false) + private LocalDate dateEcheance; + + @Column(name = "date_paiement") + private LocalDateTime datePaiement; + + @Size(max = 500) + @Column(name = "description", length = 500) + private String description; + + @Size(max = 20) + @Column(name = "periode", length = 20) + private String periode; + + @NotNull + @Min(value = 2020, message = "L'année doit être supérieure à 2020") + @Max(value = 2100, message = "L'année doit être inférieure à 2100") + @Column(name = "annee", nullable = false) + private Integer annee; + + @Min(value = 1, message = "Le mois doit être entre 1 et 12") + @Max(value = 12, message = "Le mois doit être entre 1 et 12") + @Column(name = "mois") + private Integer mois; + + @Size(max = 1000) + @Column(name = "observations", length = 1000) + private String observations; + + @Builder.Default + @Column(name = "recurrente", nullable = false) + private Boolean recurrente = false; + + @Builder.Default + @Min(value = 0, message = "Le nombre de rappels doit être positif") + @Column(name = "nombre_rappels", nullable = false) + private Integer nombreRappels = 0; + + @Column(name = "date_dernier_rappel") + private LocalDateTime dateDernierRappel; + + @Column(name = "valide_par_id") + private Long valideParId; + + @Size(max = 100) + @Column(name = "nom_validateur", length = 100) + private String nomValidateur; + + @Column(name = "date_validation") + private LocalDateTime dateValidation; + + @Size(max = 50) + @Column(name = "methode_paiement", length = 50) + private String methodePaiement; + + @Size(max = 100) + @Column(name = "reference_paiement", length = 100) + private String referencePaiement; + + @Builder.Default + @Column(name = "date_creation", nullable = false) + private LocalDateTime dateCreation = LocalDateTime.now(); + + @Column(name = "date_modification") + private LocalDateTime dateModification; + + /** + * Méthode métier pour calculer le montant restant à payer + */ + public BigDecimal getMontantRestant() { + if (montantDu == null || montantPaye == null) { + return BigDecimal.ZERO; + } + return montantDu.subtract(montantPaye); + } + + /** + * Méthode métier pour vérifier si la cotisation est entièrement payée + */ + public boolean isEntierementPayee() { + return getMontantRestant().compareTo(BigDecimal.ZERO) <= 0; + } + + /** + * Méthode métier pour vérifier si la cotisation est en retard + */ + public boolean isEnRetard() { + return dateEcheance != null && + dateEcheance.isBefore(LocalDate.now()) && + !isEntierementPayee(); + } + + /** + * Méthode métier pour générer un numéro de référence unique + */ + public static String genererNumeroReference() { + return "COT-" + LocalDate.now().getYear() + "-" + + String.format("%08d", System.currentTimeMillis() % 100000000); + } + + /** + * Callback JPA avant la persistance + */ + @PrePersist + protected void onCreate() { + if (numeroReference == null || numeroReference.isEmpty()) { + numeroReference = genererNumeroReference(); + } + if (dateCreation == null) { + dateCreation = LocalDateTime.now(); + } + if (codeDevise == null) { + codeDevise = "XOF"; + } + if (statut == null) { + statut = "EN_ATTENTE"; + } + if (montantPaye == null) { + montantPaye = BigDecimal.ZERO; + } + if (nombreRappels == null) { + nombreRappels = 0; + } + if (recurrente == null) { + recurrente = false; + } + } + + /** + * Callback JPA avant la mise à jour + */ + @PreUpdate + protected void onUpdate() { + dateModification = LocalDateTime.now(); + } +} diff --git a/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/repository/CotisationRepository.java b/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/repository/CotisationRepository.java new file mode 100644 index 0000000..f6ef20f --- /dev/null +++ b/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/repository/CotisationRepository.java @@ -0,0 +1,255 @@ +package dev.lions.unionflow.server.repository; + +import dev.lions.unionflow.server.entity.Cotisation; +import io.quarkus.hibernate.orm.panache.PanacheRepository; +import io.quarkus.panache.common.Page; +import io.quarkus.panache.common.Sort; +import jakarta.enterprise.context.ApplicationScoped; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Repository pour la gestion des cotisations + * Utilise Panache pour simplifier les opérations JPA + * + * @author UnionFlow Team + * @version 1.0 + * @since 2025-01-15 + */ +@ApplicationScoped +public class CotisationRepository implements PanacheRepository { + + /** + * Trouve une cotisation par son numéro de référence + * + * @param numeroReference le numéro de référence unique + * @return Optional contenant la cotisation si trouvée + */ + public Optional findByNumeroReference(String numeroReference) { + return find("numeroReference = ?1", numeroReference).firstResultOptional(); + } + + /** + * Trouve toutes les cotisations d'un membre + * + * @param membreId l'identifiant du membre + * @param page pagination + * @param sort tri + * @return liste paginée des cotisations + */ + public List findByMembreId(Long membreId, Page page, Sort sort) { + return find("membre.id = ?1", membreId) + .page(page) + .list(); + } + + /** + * Trouve les cotisations par statut + * + * @param statut le statut recherché + * @param page pagination + * @return liste paginée des cotisations + */ + public List findByStatut(String statut, Page page) { + return find("statut = ?1", Sort.by("dateEcheance").descending(), statut) + .page(page) + .list(); + } + + /** + * Trouve les cotisations en retard + * + * @param dateReference date de référence (généralement aujourd'hui) + * @param page pagination + * @return liste des cotisations en retard + */ + public List findCotisationsEnRetard(LocalDate dateReference, Page page) { + return find("dateEcheance < ?1 and statut != 'PAYEE' and statut != 'ANNULEE'", + Sort.by("dateEcheance").ascending(), dateReference) + .page(page) + .list(); + } + + /** + * Trouve les cotisations par période (année/mois) + * + * @param annee l'année + * @param mois le mois (optionnel) + * @param page pagination + * @return liste des cotisations de la période + */ + public List findByPeriode(Integer annee, Integer mois, Page page) { + if (mois != null) { + return find("annee = ?1 and mois = ?2", Sort.by("dateEcheance").descending(), annee, mois) + .page(page) + .list(); + } else { + return find("annee = ?1", Sort.by("mois", "dateEcheance").descending(), annee) + .page(page) + .list(); + } + } + + /** + * Trouve les cotisations par type + * + * @param typeCotisation le type de cotisation + * @param page pagination + * @return liste des cotisations du type spécifié + */ + public List findByType(String typeCotisation, Page page) { + return find("typeCotisation = ?1", Sort.by("dateEcheance").descending(), typeCotisation) + .page(page) + .list(); + } + + /** + * Recherche avancée avec filtres multiples + * + * @param membreId identifiant du membre (optionnel) + * @param statut statut (optionnel) + * @param typeCotisation type (optionnel) + * @param annee année (optionnel) + * @param mois mois (optionnel) + * @param page pagination + * @return liste filtrée des cotisations + */ + public List rechercheAvancee(Long membreId, String statut, String typeCotisation, + Integer annee, Integer mois, Page page) { + StringBuilder query = new StringBuilder("1=1"); + Map params = new java.util.HashMap<>(); + + if (membreId != null) { + query.append(" and membre.id = :membreId"); + params.put("membreId", membreId); + } + + if (statut != null && !statut.isEmpty()) { + query.append(" and statut = :statut"); + params.put("statut", statut); + } + + if (typeCotisation != null && !typeCotisation.isEmpty()) { + query.append(" and typeCotisation = :typeCotisation"); + params.put("typeCotisation", typeCotisation); + } + + if (annee != null) { + query.append(" and annee = :annee"); + params.put("annee", annee); + } + + if (mois != null) { + query.append(" and mois = :mois"); + params.put("mois", mois); + } + + return find(query.toString(), Sort.by("dateEcheance").descending(), params) + .page(page) + .list(); + } + + /** + * Calcule le total des montants dus pour un membre + * + * @param membreId identifiant du membre + * @return montant total dû + */ + public BigDecimal calculerTotalMontantDu(Long membreId) { + return find("select sum(c.montantDu) from Cotisation c where c.membre.id = ?1", membreId) + .project(BigDecimal.class) + .firstResult(); + } + + /** + * Calcule le total des montants payés pour un membre + * + * @param membreId identifiant du membre + * @return montant total payé + */ + public BigDecimal calculerTotalMontantPaye(Long membreId) { + return find("select sum(c.montantPaye) from Cotisation c where c.membre.id = ?1", membreId) + .project(BigDecimal.class) + .firstResult(); + } + + /** + * Compte les cotisations par statut + * + * @param statut le statut + * @return nombre de cotisations + */ + public long compterParStatut(String statut) { + return count("statut = ?1", statut); + } + + /** + * Trouve les cotisations nécessitant un rappel + * + * @param joursAvantEcheance nombre de jours avant échéance + * @param nombreMaxRappels nombre maximum de rappels déjà envoyés + * @return liste des cotisations à rappeler + */ + public List findCotisationsAuRappel(int joursAvantEcheance, int nombreMaxRappels) { + LocalDate dateRappel = LocalDate.now().plusDays(joursAvantEcheance); + return find("dateEcheance <= ?1 and statut != 'PAYEE' and statut != 'ANNULEE' and nombreRappels < ?2", + Sort.by("dateEcheance").ascending(), dateRappel, nombreMaxRappels) + .list(); + } + + /** + * Met à jour le nombre de rappels pour une cotisation + * + * @param cotisationId identifiant de la cotisation + * @return nombre de lignes mises à jour + */ + public int incrementerNombreRappels(Long cotisationId) { + return update("nombreRappels = nombreRappels + 1, dateDernierRappel = ?1 where id = ?2", + LocalDateTime.now(), cotisationId); + } + + /** + * Statistiques des cotisations par période + * + * @param annee l'année + * @param mois le mois (optionnel) + * @return map avec les statistiques + */ + public Map getStatistiquesPeriode(Integer annee, Integer mois) { + String baseQuery = mois != null ? + "from Cotisation c where c.annee = ?1 and c.mois = ?2" : + "from Cotisation c where c.annee = ?1"; + + Object[] params = mois != null ? new Object[]{annee, mois} : new Object[]{annee}; + + Long totalCotisations = mois != null ? + count("annee = ?1 and mois = ?2", params) : + count("annee = ?1", params); + + BigDecimal montantTotal = find("select sum(c.montantDu) " + baseQuery, params) + .project(BigDecimal.class) + .firstResult(); + + BigDecimal montantPaye = find("select sum(c.montantPaye) " + baseQuery, params) + .project(BigDecimal.class) + .firstResult(); + + Long cotisationsPayees = mois != null ? + count("annee = ?1 and mois = ?2 and statut = 'PAYEE'", annee, mois) : + count("annee = ?1 and statut = 'PAYEE'", annee); + + return Map.of( + "totalCotisations", totalCotisations != null ? totalCotisations : 0L, + "montantTotal", montantTotal != null ? montantTotal : BigDecimal.ZERO, + "montantPaye", montantPaye != null ? montantPaye : BigDecimal.ZERO, + "cotisationsPayees", cotisationsPayees != null ? cotisationsPayees : 0L, + "tauxPaiement", totalCotisations != null && totalCotisations > 0 ? + (cotisationsPayees != null ? cotisationsPayees : 0L) * 100.0 / totalCotisations : 0.0 + ); + } +} diff --git a/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/repository/MembreRepository.java b/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/repository/MembreRepository.java index af602d7..2ac3c9e 100644 --- a/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/repository/MembreRepository.java +++ b/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/repository/MembreRepository.java @@ -2,8 +2,11 @@ package dev.lions.unionflow.server.repository; import dev.lions.unionflow.server.entity.Membre; import io.quarkus.hibernate.orm.panache.PanacheRepository; +import io.quarkus.panache.common.Page; +import io.quarkus.panache.common.Sort; import jakarta.enterprise.context.ApplicationScoped; +import java.time.LocalDate; import java.util.List; import java.util.Optional; @@ -45,7 +48,80 @@ public class MembreRepository implements PanacheRepository { * Trouve les membres par nom ou prénom (recherche partielle) */ public List findByNomOrPrenom(String recherche) { - return find("lower(nom) like ?1 or lower(prenom) like ?1", + return find("lower(nom) like ?1 or lower(prenom) like ?1", "%" + recherche.toLowerCase() + "%").list(); } + + /** + * Trouve tous les membres actifs avec pagination et tri + */ + public List findAllActifs(Page page, Sort sort) { + return find("actif", sort, true).page(page).list(); + } + + /** + * Trouve les membres par nom ou prénom avec pagination et tri + */ + public List findByNomOrPrenom(String recherche, Page page, Sort sort) { + return find("lower(nom) like ?1 or lower(prenom) like ?1", + sort, "%" + recherche.toLowerCase() + "%") + .page(page).list(); + } + + /** + * Compte les nouveaux membres depuis une date donnée + */ + public long countNouveauxMembres(LocalDate depuis) { + return count("dateAdhesion >= ?1", depuis); + } + + /** + * Trouve les membres par statut avec pagination + */ + public List findByStatut(boolean actif, Page page, Sort sort) { + return find("actif", sort, actif).page(page).list(); + } + + /** + * Trouve les membres par tranche d'âge + */ + public List findByTrancheAge(int ageMin, int ageMax, Page page, Sort sort) { + LocalDate dateNaissanceMax = LocalDate.now().minusYears(ageMin); + LocalDate dateNaissanceMin = LocalDate.now().minusYears(ageMax + 1); + + return find("dateNaissance between ?1 and ?2", sort, dateNaissanceMin, dateNaissanceMax) + .page(page).list(); + } + + /** + * Recherche avancée avec filtres multiples + */ + public List rechercheAvancee(String recherche, Boolean actif, + LocalDate dateAdhesionMin, LocalDate dateAdhesionMax, + Page page, Sort sort) { + StringBuilder query = new StringBuilder("1=1"); + java.util.Map params = new java.util.HashMap<>(); + + if (recherche != null && !recherche.trim().isEmpty()) { + query.append(" and (lower(nom) like :recherche or lower(prenom) like :recherche or lower(email) like :recherche)"); + params.put("recherche", "%" + recherche.toLowerCase() + "%"); + } + + if (actif != null) { + query.append(" and actif = :actif"); + params.put("actif", actif); + } + + if (dateAdhesionMin != null) { + query.append(" and dateAdhesion >= :dateAdhesionMin"); + params.put("dateAdhesionMin", dateAdhesionMin); + } + + if (dateAdhesionMax != null) { + query.append(" and dateAdhesion <= :dateAdhesionMax"); + params.put("dateAdhesionMax", dateAdhesionMax); + } + + return find(query.toString(), sort, params).page(page).list(); + } } diff --git a/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/resource/CotisationResource.java b/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/resource/CotisationResource.java new file mode 100644 index 0000000..1ae0a10 --- /dev/null +++ b/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/resource/CotisationResource.java @@ -0,0 +1,498 @@ +package dev.lions.unionflow.server.resource; + +import dev.lions.unionflow.server.api.dto.finance.CotisationDTO; +import dev.lions.unionflow.server.service.CotisationService; +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; + +import java.util.List; +import java.util.Map; + +/** + * Resource REST pour la gestion des cotisations + * Expose les endpoints API pour les opérations CRUD sur les cotisations + * + * @author UnionFlow Team + * @version 1.0 + * @since 2025-01-15 + */ +@Path("/api/cotisations") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@Tag(name = "Cotisations", description = "Gestion des cotisations des membres") +@Slf4j +public class CotisationResource { + + @Inject + CotisationService cotisationService; + + /** + * Récupère toutes les cotisations avec pagination + */ + @GET + @Operation(summary = "Lister toutes les cotisations", + description = "Récupère la liste paginée de toutes les cotisations") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Liste des cotisations récupérée avec succès", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = CotisationDTO.class))), + @APIResponse(responseCode = "400", description = "Paramètres de pagination invalides"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Response getAllCotisations( + @Parameter(description = "Numéro de page (0-based)", example = "0") + @QueryParam("page") @DefaultValue("0") @Min(0) int page, + + @Parameter(description = "Taille de la page", example = "20") + @QueryParam("size") @DefaultValue("20") @Min(1) int size) { + + try { + log.info("GET /api/cotisations - page: {}, size: {}", page, size); + + List cotisations = cotisationService.getAllCotisations(page, size); + + log.info("Récupération réussie de {} cotisations", cotisations.size()); + return Response.ok(cotisations).build(); + + } catch (Exception e) { + log.error("Erreur lors de la récupération des cotisations", e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Erreur lors de la récupération des cotisations", + "message", e.getMessage())) + .build(); + } + } + + /** + * Récupère une cotisation par son ID + */ + @GET + @Path("/{id}") + @Operation(summary = "Récupérer une cotisation par ID", + description = "Récupère les détails d'une cotisation spécifique") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Cotisation trouvée", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = CotisationDTO.class))), + @APIResponse(responseCode = "404", description = "Cotisation non trouvée"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Response getCotisationById( + @Parameter(description = "Identifiant de la cotisation", required = true) + @PathParam("id") @NotNull Long id) { + + try { + log.info("GET /api/cotisations/{}", id); + + CotisationDTO cotisation = cotisationService.getCotisationById(id); + + log.info("Cotisation récupérée avec succès - ID: {}", id); + return Response.ok(cotisation).build(); + + } catch (NotFoundException e) { + log.warn("Cotisation non trouvée - ID: {}", id); + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "Cotisation non trouvée", "id", id)) + .build(); + } catch (Exception e) { + log.error("Erreur lors de la récupération de la cotisation - ID: " + id, e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Erreur lors de la récupération de la cotisation", + "message", e.getMessage())) + .build(); + } + } + + /** + * Récupère une cotisation par son numéro de référence + */ + @GET + @Path("/reference/{numeroReference}") + @Operation(summary = "Récupérer une cotisation par référence", + description = "Récupère une cotisation par son numéro de référence unique") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Cotisation trouvée"), + @APIResponse(responseCode = "404", description = "Cotisation non trouvée"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Response getCotisationByReference( + @Parameter(description = "Numéro de référence de la cotisation", required = true) + @PathParam("numeroReference") @NotNull String numeroReference) { + + try { + log.info("GET /api/cotisations/reference/{}", numeroReference); + + CotisationDTO cotisation = cotisationService.getCotisationByReference(numeroReference); + + log.info("Cotisation récupérée avec succès - Référence: {}", numeroReference); + return Response.ok(cotisation).build(); + + } catch (NotFoundException e) { + log.warn("Cotisation non trouvée - Référence: {}", numeroReference); + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "Cotisation non trouvée", "reference", numeroReference)) + .build(); + } catch (Exception e) { + log.error("Erreur lors de la récupération de la cotisation - Référence: " + numeroReference, e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Erreur lors de la récupération de la cotisation", + "message", e.getMessage())) + .build(); + } + } + + /** + * Crée une nouvelle cotisation + */ + @POST + @Operation(summary = "Créer une nouvelle cotisation", + description = "Crée une nouvelle cotisation pour un membre") + @APIResponses({ + @APIResponse(responseCode = "201", description = "Cotisation créée avec succès", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = CotisationDTO.class))), + @APIResponse(responseCode = "400", description = "Données invalides"), + @APIResponse(responseCode = "404", description = "Membre non trouvé"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Response createCotisation( + @Parameter(description = "Données de la cotisation à créer", required = true) + @Valid CotisationDTO cotisationDTO) { + + try { + log.info("POST /api/cotisations - Création cotisation pour membre: {}", + cotisationDTO.getMembreId()); + + CotisationDTO nouvelleCotisation = cotisationService.createCotisation(cotisationDTO); + + log.info("Cotisation créée avec succès - ID: {}, Référence: {}", + nouvelleCotisation.getId(), nouvelleCotisation.getNumeroReference()); + + return Response.status(Response.Status.CREATED) + .entity(nouvelleCotisation) + .build(); + + } catch (NotFoundException e) { + log.warn("Membre non trouvé lors de la création de cotisation: {}", cotisationDTO.getMembreId()); + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "Membre non trouvé", "membreId", cotisationDTO.getMembreId())) + .build(); + } catch (IllegalArgumentException e) { + log.warn("Données invalides pour la création de cotisation: {}", e.getMessage()); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Données invalides", "message", e.getMessage())) + .build(); + } catch (Exception e) { + log.error("Erreur lors de la création de la cotisation", e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Erreur lors de la création de la cotisation", + "message", e.getMessage())) + .build(); + } + } + + /** + * Met à jour une cotisation existante + */ + @PUT + @Path("/{id}") + @Operation(summary = "Mettre à jour une cotisation", + description = "Met à jour les données d'une cotisation existante") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Cotisation mise à jour avec succès"), + @APIResponse(responseCode = "400", description = "Données invalides"), + @APIResponse(responseCode = "404", description = "Cotisation non trouvée"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Response updateCotisation( + @Parameter(description = "Identifiant de la cotisation", required = true) + @PathParam("id") @NotNull Long id, + + @Parameter(description = "Nouvelles données de la cotisation", required = true) + @Valid CotisationDTO cotisationDTO) { + + try { + log.info("PUT /api/cotisations/{}", id); + + CotisationDTO cotisationMiseAJour = cotisationService.updateCotisation(id, cotisationDTO); + + log.info("Cotisation mise à jour avec succès - ID: {}", id); + return Response.ok(cotisationMiseAJour).build(); + + } catch (NotFoundException e) { + log.warn("Cotisation non trouvée pour mise à jour - ID: {}", id); + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "Cotisation non trouvée", "id", id)) + .build(); + } catch (IllegalArgumentException e) { + log.warn("Données invalides pour la mise à jour de cotisation - ID: {}, Erreur: {}", id, e.getMessage()); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", "Données invalides", "message", e.getMessage())) + .build(); + } catch (Exception e) { + log.error("Erreur lors de la mise à jour de la cotisation - ID: " + id, e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Erreur lors de la mise à jour de la cotisation", + "message", e.getMessage())) + .build(); + } + } + + /** + * Supprime une cotisation + */ + @DELETE + @Path("/{id}") + @Operation(summary = "Supprimer une cotisation", + description = "Supprime (désactive) une cotisation") + @APIResponses({ + @APIResponse(responseCode = "204", description = "Cotisation supprimée avec succès"), + @APIResponse(responseCode = "404", description = "Cotisation non trouvée"), + @APIResponse(responseCode = "409", description = "Impossible de supprimer une cotisation payée"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Response deleteCotisation( + @Parameter(description = "Identifiant de la cotisation", required = true) + @PathParam("id") @NotNull Long id) { + + try { + log.info("DELETE /api/cotisations/{}", id); + + cotisationService.deleteCotisation(id); + + log.info("Cotisation supprimée avec succès - ID: {}", id); + return Response.noContent().build(); + + } catch (NotFoundException e) { + log.warn("Cotisation non trouvée pour suppression - ID: {}", id); + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "Cotisation non trouvée", "id", id)) + .build(); + } catch (IllegalStateException e) { + log.warn("Impossible de supprimer la cotisation - ID: {}, Raison: {}", id, e.getMessage()); + return Response.status(Response.Status.CONFLICT) + .entity(Map.of("error", "Impossible de supprimer la cotisation", "message", e.getMessage())) + .build(); + } catch (Exception e) { + log.error("Erreur lors de la suppression de la cotisation - ID: " + id, e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Erreur lors de la suppression de la cotisation", + "message", e.getMessage())) + .build(); + } + } + + /** + * Récupère les cotisations d'un membre + */ + @GET + @Path("/membre/{membreId}") + @Operation(summary = "Lister les cotisations d'un membre", + description = "Récupère toutes les cotisations d'un membre spécifique") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Liste des cotisations du membre"), + @APIResponse(responseCode = "404", description = "Membre non trouvé"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Response getCotisationsByMembre( + @Parameter(description = "Identifiant du membre", required = true) + @PathParam("membreId") @NotNull Long membreId, + + @Parameter(description = "Numéro de page", example = "0") + @QueryParam("page") @DefaultValue("0") @Min(0) int page, + + @Parameter(description = "Taille de la page", example = "20") + @QueryParam("size") @DefaultValue("20") @Min(1) int size) { + + try { + log.info("GET /api/cotisations/membre/{} - page: {}, size: {}", membreId, page, size); + + List cotisations = cotisationService.getCotisationsByMembre(membreId, page, size); + + log.info("Récupération réussie de {} cotisations pour le membre {}", cotisations.size(), membreId); + return Response.ok(cotisations).build(); + + } catch (NotFoundException e) { + log.warn("Membre non trouvé - ID: {}", membreId); + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", "Membre non trouvé", "membreId", membreId)) + .build(); + } catch (Exception e) { + log.error("Erreur lors de la récupération des cotisations du membre - ID: " + membreId, e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Erreur lors de la récupération des cotisations", + "message", e.getMessage())) + .build(); + } + } + + /** + * Récupère les cotisations par statut + */ + @GET + @Path("/statut/{statut}") + @Operation(summary = "Lister les cotisations par statut", + description = "Récupère toutes les cotisations ayant un statut spécifique") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Liste des cotisations avec le statut spécifié"), + @APIResponse(responseCode = "400", description = "Statut invalide"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Response getCotisationsByStatut( + @Parameter(description = "Statut des cotisations", required = true, + example = "EN_ATTENTE") + @PathParam("statut") @NotNull String statut, + + @Parameter(description = "Numéro de page", example = "0") + @QueryParam("page") @DefaultValue("0") @Min(0) int page, + + @Parameter(description = "Taille de la page", example = "20") + @QueryParam("size") @DefaultValue("20") @Min(1) int size) { + + try { + log.info("GET /api/cotisations/statut/{} - page: {}, size: {}", statut, page, size); + + List cotisations = cotisationService.getCotisationsByStatut(statut, page, size); + + log.info("Récupération réussie de {} cotisations avec statut {}", cotisations.size(), statut); + return Response.ok(cotisations).build(); + + } catch (Exception e) { + log.error("Erreur lors de la récupération des cotisations par statut - Statut: " + statut, e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Erreur lors de la récupération des cotisations", + "message", e.getMessage())) + .build(); + } + } + + /** + * Récupère les cotisations en retard + */ + @GET + @Path("/en-retard") + @Operation(summary = "Lister les cotisations en retard", + description = "Récupère toutes les cotisations dont la date d'échéance est dépassée") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Liste des cotisations en retard"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Response getCotisationsEnRetard( + @Parameter(description = "Numéro de page", example = "0") + @QueryParam("page") @DefaultValue("0") @Min(0) int page, + + @Parameter(description = "Taille de la page", example = "20") + @QueryParam("size") @DefaultValue("20") @Min(1) int size) { + + try { + log.info("GET /api/cotisations/en-retard - page: {}, size: {}", page, size); + + List cotisations = cotisationService.getCotisationsEnRetard(page, size); + + log.info("Récupération réussie de {} cotisations en retard", cotisations.size()); + return Response.ok(cotisations).build(); + + } catch (Exception e) { + log.error("Erreur lors de la récupération des cotisations en retard", e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Erreur lors de la récupération des cotisations en retard", + "message", e.getMessage())) + .build(); + } + } + + /** + * Recherche avancée de cotisations + */ + @GET + @Path("/recherche") + @Operation(summary = "Recherche avancée de cotisations", + description = "Recherche de cotisations avec filtres multiples") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Résultats de la recherche"), + @APIResponse(responseCode = "400", description = "Paramètres de recherche invalides"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Response rechercherCotisations( + @Parameter(description = "Identifiant du membre") + @QueryParam("membreId") Long membreId, + + @Parameter(description = "Statut de la cotisation") + @QueryParam("statut") String statut, + + @Parameter(description = "Type de cotisation") + @QueryParam("typeCotisation") String typeCotisation, + + @Parameter(description = "Année") + @QueryParam("annee") Integer annee, + + @Parameter(description = "Mois") + @QueryParam("mois") Integer mois, + + @Parameter(description = "Numéro de page", example = "0") + @QueryParam("page") @DefaultValue("0") @Min(0) int page, + + @Parameter(description = "Taille de la page", example = "20") + @QueryParam("size") @DefaultValue("20") @Min(1) int size) { + + try { + log.info("GET /api/cotisations/recherche - Filtres: membreId={}, statut={}, type={}, annee={}, mois={}", + membreId, statut, typeCotisation, annee, mois); + + List cotisations = cotisationService.rechercherCotisations( + membreId, statut, typeCotisation, annee, mois, page, size); + + log.info("Recherche réussie - {} cotisations trouvées", cotisations.size()); + return Response.ok(cotisations).build(); + + } catch (Exception e) { + log.error("Erreur lors de la recherche de cotisations", e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Erreur lors de la recherche de cotisations", + "message", e.getMessage())) + .build(); + } + } + + /** + * Récupère les statistiques des cotisations + */ + @GET + @Path("/stats") + @Operation(summary = "Statistiques des cotisations", + description = "Récupère les statistiques globales des cotisations") + @APIResponses({ + @APIResponse(responseCode = "200", description = "Statistiques récupérées avec succès"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Response getStatistiquesCotisations() { + try { + log.info("GET /api/cotisations/stats"); + + Map statistiques = cotisationService.getStatistiquesCotisations(); + + log.info("Statistiques récupérées avec succès"); + return Response.ok(statistiques).build(); + + } catch (Exception e) { + log.error("Erreur lors de la récupération des statistiques", e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Erreur lors de la récupération des statistiques", + "message", e.getMessage())) + .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 82dc6cc..a0b40ec 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,7 +1,10 @@ package dev.lions.unionflow.server.resource; +import dev.lions.unionflow.server.api.dto.membre.MembreDTO; 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.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.validation.Valid; @@ -35,10 +38,21 @@ public class MembreResource { @GET @Operation(summary = "Lister tous les membres actifs") @APIResponse(responseCode = "200", description = "Liste des membres actifs") - public Response listerMembres() { - LOG.info("Récupération de la liste des membres actifs"); - List membres = membreService.listerMembresActifs(); - return Response.ok(membres).build(); + public Response listerMembres( + @Parameter(description = "Numéro de page (0-based)") @QueryParam("page") @DefaultValue("0") int page, + @Parameter(description = "Taille de la page") @QueryParam("size") @DefaultValue("20") int size, + @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("Récupération de la liste des membres actifs - page: %d, size: %d", page, size); + + Sort sort = "desc".equalsIgnoreCase(sortDirection) ? + Sort.by(sortField).descending() : Sort.by(sortField).ascending(); + + List membres = membreService.listerMembresActifs(Page.of(page, size), sort); + List membresDTO = membreService.convertToDTOList(membres); + + return Response.ok(membresDTO).build(); } @GET @@ -49,7 +63,10 @@ public class MembreResource { public Response obtenirMembre(@Parameter(description = "ID du membre") @PathParam("id") Long id) { LOG.infof("Récupération du membre ID: %d", id); return membreService.trouverParId(id) - .map(membre -> Response.ok(membre).build()) + .map(membre -> { + MembreDTO membreDTO = membreService.convertToDTO(membre); + return Response.ok(membreDTO).build(); + }) .orElse(Response.status(Response.Status.NOT_FOUND) .entity(Map.of("message", "Membre non trouvé")).build()); } @@ -58,11 +75,25 @@ public class MembreResource { @Operation(summary = "Créer un nouveau membre") @APIResponse(responseCode = "201", description = "Membre créé avec succès") @APIResponse(responseCode = "400", description = "Données invalides") - public Response creerMembre(@Valid Membre membre) { - LOG.infof("Création d'un nouveau membre: %s", membre.getEmail()); + public Response creerMembre(@Valid MembreDTO membreDTO) { + LOG.infof("Création d'un nouveau membre: %s", membreDTO.getEmail()); try { + // Validation des données DTO + if (!membreDTO.isDataValid()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("message", "Données du membre invalides")).build(); + } + + // Conversion DTO vers entité + Membre membre = membreService.convertFromDTO(membreDTO); + + // Création du membre Membre nouveauMembre = membreService.creerMembre(membre); - return Response.status(Response.Status.CREATED).entity(nouveauMembre).build(); + + // Conversion de retour vers DTO + MembreDTO nouveauMembreDTO = membreService.convertToDTO(nouveauMembre); + + return Response.status(Response.Status.CREATED).entity(nouveauMembreDTO).build(); } catch (IllegalArgumentException e) { return Response.status(Response.Status.BAD_REQUEST) .entity(Map.of("message", e.getMessage())).build(); @@ -76,11 +107,25 @@ public class MembreResource { @APIResponse(responseCode = "404", description = "Membre non trouvé") @APIResponse(responseCode = "400", description = "Données invalides") public Response mettreAJourMembre(@Parameter(description = "ID du membre") @PathParam("id") Long id, - @Valid Membre membre) { + @Valid MembreDTO membreDTO) { LOG.infof("Mise à jour du membre ID: %d", id); try { + // Validation des données DTO + if (!membreDTO.isDataValid()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("message", "Données du membre invalides")).build(); + } + + // Conversion DTO vers entité + Membre membre = membreService.convertFromDTO(membreDTO); + + // Mise à jour du membre Membre membreMisAJour = membreService.mettreAJourMembre(id, membre); - return Response.ok(membreMisAJour).build(); + + // Conversion de retour vers DTO + MembreDTO membreMisAJourDTO = membreService.convertToDTO(membreMisAJour); + + return Response.ok(membreMisAJourDTO).build(); } catch (IllegalArgumentException e) { return Response.status(Response.Status.BAD_REQUEST) .entity(Map.of("message", e.getMessage())).build(); @@ -107,26 +152,74 @@ public class MembreResource { @Path("/recherche") @Operation(summary = "Rechercher des membres par nom ou prénom") @APIResponse(responseCode = "200", description = "Résultats de la recherche") - public Response rechercherMembres(@Parameter(description = "Terme de recherche") @QueryParam("q") String recherche) { + public Response rechercherMembres( + @Parameter(description = "Terme de recherche") @QueryParam("q") String recherche, + @Parameter(description = "Numéro de page (0-based)") @QueryParam("page") @DefaultValue("0") int page, + @Parameter(description = "Taille de la page") @QueryParam("size") @DefaultValue("20") int size, + @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 de membres avec le terme: %s", recherche); if (recherche == null || recherche.trim().isEmpty()) { return Response.status(Response.Status.BAD_REQUEST) .entity(Map.of("message", "Le terme de recherche est requis")).build(); } - List membres = membreService.rechercherMembres(recherche.trim()); - return Response.ok(membres).build(); + + Sort sort = "desc".equalsIgnoreCase(sortDirection) ? + Sort.by(sortField).descending() : Sort.by(sortField).ascending(); + + List membres = membreService.rechercherMembres(recherche.trim(), Page.of(page, size), sort); + List membresDTO = membreService.convertToDTOList(membres); + + return Response.ok(membresDTO).build(); } @GET @Path("/stats") - @Operation(summary = "Obtenir les statistiques des membres") - @APIResponse(responseCode = "200", description = "Statistiques des membres") + @Operation(summary = "Obtenir les statistiques avancées des membres") + @APIResponse(responseCode = "200", description = "Statistiques complètes des membres") public Response obtenirStatistiques() { - LOG.info("Récupération des statistiques des membres"); - long nombreMembresActifs = membreService.compterMembresActifs(); - return Response.ok(Map.of( - "nombreMembresActifs", nombreMembresActifs, - "timestamp", java.time.LocalDateTime.now() - )).build(); + LOG.info("Récupération des statistiques avancées des membres"); + Map statistiques = membreService.obtenirStatistiquesAvancees(); + return Response.ok(statistiques).build(); + } + + @GET + @Path("/recherche-avancee") + @Operation(summary = "Recherche avancée de membres avec filtres multiples") + @APIResponse(responseCode = "200", description = "Résultats de la recherche avancée") + public Response rechercheAvancee( + @Parameter(description = "Terme de recherche") @QueryParam("q") String recherche, + @Parameter(description = "Statut actif (true/false)") @QueryParam("actif") Boolean actif, + @Parameter(description = "Date d'adhésion minimum (YYYY-MM-DD)") @QueryParam("dateAdhesionMin") String dateAdhesionMin, + @Parameter(description = "Date d'adhésion maximum (YYYY-MM-DD)") @QueryParam("dateAdhesionMax") String dateAdhesionMax, + @Parameter(description = "Numéro de page (0-based)") @QueryParam("page") @DefaultValue("0") int page, + @Parameter(description = "Taille de la page") @QueryParam("size") @DefaultValue("20") int size, + @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); + + try { + Sort sort = "desc".equalsIgnoreCase(sortDirection) ? + Sort.by(sortField).descending() : Sort.by(sortField).ascending(); + + // Conversion des dates si fournies + java.time.LocalDate dateMin = dateAdhesionMin != null ? + java.time.LocalDate.parse(dateAdhesionMin) : null; + java.time.LocalDate dateMax = dateAdhesionMax != null ? + java.time.LocalDate.parse(dateAdhesionMax) : null; + + List membres = membreService.rechercheAvancee( + recherche, actif, dateMin, dateMax, Page.of(page, size), sort); + List membresDTO = membreService.convertToDTOList(membres); + + return Response.ok(membresDTO).build(); + } catch (Exception e) { + LOG.errorf("Erreur lors de la recherche avancée: %s", e.getMessage()); + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("message", "Erreur dans les paramètres de recherche: " + e.getMessage())) + .build(); + } } } diff --git a/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/service/CotisationService.java b/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/service/CotisationService.java new file mode 100644 index 0000000..02a5d78 --- /dev/null +++ b/unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/service/CotisationService.java @@ -0,0 +1,411 @@ +package dev.lions.unionflow.server.service; + +import dev.lions.unionflow.server.api.dto.finance.CotisationDTO; +import dev.lions.unionflow.server.entity.Cotisation; +import dev.lions.unionflow.server.entity.Membre; +import dev.lions.unionflow.server.repository.CotisationRepository; +import dev.lions.unionflow.server.repository.MembreRepository; +import io.quarkus.panache.common.Page; +import io.quarkus.panache.common.Sort; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.ws.rs.NotFoundException; +import lombok.extern.slf4j.Slf4j; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * Service métier pour la gestion des cotisations + * Contient la logique métier et les règles de validation + * + * @author UnionFlow Team + * @version 1.0 + * @since 2025-01-15 + */ +@ApplicationScoped +@Slf4j +public class CotisationService { + + @Inject + CotisationRepository cotisationRepository; + + @Inject + MembreRepository membreRepository; + + /** + * Récupère toutes les cotisations avec pagination + * + * @param page numéro de page (0-based) + * @param size taille de la page + * @return liste des cotisations converties en DTO + */ + public List getAllCotisations(int page, int size) { + log.debug("Récupération des cotisations - page: {}, size: {}", page, size); + + List cotisations = cotisationRepository.findAll(Sort.by("dateEcheance").descending()) + .page(Page.of(page, size)) + .list(); + + return cotisations.stream() + .map(this::convertToDTO) + .collect(Collectors.toList()); + } + + /** + * Récupère une cotisation par son ID + * + * @param id identifiant de la cotisation + * @return DTO de la cotisation + * @throws NotFoundException si la cotisation n'existe pas + */ + public CotisationDTO getCotisationById(@NotNull Long id) { + log.debug("Récupération de la cotisation avec ID: {}", id); + + Cotisation cotisation = cotisationRepository.findByIdOptional(id) + .orElseThrow(() -> new NotFoundException("Cotisation non trouvée avec l'ID: " + id)); + + return convertToDTO(cotisation); + } + + /** + * Récupère une cotisation par son numéro de référence + * + * @param numeroReference numéro de référence unique + * @return DTO de la cotisation + * @throws NotFoundException si la cotisation n'existe pas + */ + public CotisationDTO getCotisationByReference(@NotNull String numeroReference) { + log.debug("Récupération de la cotisation avec référence: {}", numeroReference); + + Cotisation cotisation = cotisationRepository.findByNumeroReference(numeroReference) + .orElseThrow(() -> new NotFoundException("Cotisation non trouvée avec la référence: " + numeroReference)); + + return convertToDTO(cotisation); + } + + /** + * Crée une nouvelle cotisation + * + * @param cotisationDTO données de la cotisation à créer + * @return DTO de la cotisation créée + */ + @Transactional + public CotisationDTO createCotisation(@Valid CotisationDTO cotisationDTO) { + log.info("Création d'une nouvelle cotisation pour le membre: {}", cotisationDTO.getMembreId()); + + // Validation du membre + Membre membre = membreRepository.findByIdOptional(Long.valueOf(cotisationDTO.getMembreId().toString())) + .orElseThrow(() -> new NotFoundException("Membre non trouvé avec l'ID: " + cotisationDTO.getMembreId())); + + // Conversion DTO vers entité + Cotisation cotisation = convertToEntity(cotisationDTO); + cotisation.setMembre(membre); + + // Génération automatique du numéro de référence si absent + if (cotisation.getNumeroReference() == null || cotisation.getNumeroReference().isEmpty()) { + cotisation.setNumeroReference(Cotisation.genererNumeroReference()); + } + + // Validation des règles métier + validateCotisationRules(cotisation); + + // Persistance + cotisationRepository.persist(cotisation); + + log.info("Cotisation créée avec succès - ID: {}, Référence: {}", + cotisation.id, cotisation.getNumeroReference()); + + return convertToDTO(cotisation); + } + + /** + * Met à jour une cotisation existante + * + * @param id identifiant de la cotisation + * @param cotisationDTO nouvelles données + * @return DTO de la cotisation mise à jour + */ + @Transactional + public CotisationDTO updateCotisation(@NotNull Long id, @Valid CotisationDTO cotisationDTO) { + log.info("Mise à jour de la cotisation avec ID: {}", id); + + Cotisation cotisationExistante = cotisationRepository.findByIdOptional(id) + .orElseThrow(() -> new NotFoundException("Cotisation non trouvée avec l'ID: " + id)); + + // Mise à jour des champs modifiables + updateCotisationFields(cotisationExistante, cotisationDTO); + + // Validation des règles métier + validateCotisationRules(cotisationExistante); + + log.info("Cotisation mise à jour avec succès - ID: {}", id); + + return convertToDTO(cotisationExistante); + } + + /** + * Supprime (désactive) une cotisation + * + * @param id identifiant de la cotisation + */ + @Transactional + public void deleteCotisation(@NotNull Long id) { + log.info("Suppression de la cotisation avec ID: {}", id); + + Cotisation cotisation = cotisationRepository.findByIdOptional(id) + .orElseThrow(() -> new NotFoundException("Cotisation non trouvée avec l'ID: " + id)); + + // Vérification si la cotisation peut être supprimée + if ("PAYEE".equals(cotisation.getStatut())) { + throw new IllegalStateException("Impossible de supprimer une cotisation déjà payée"); + } + + cotisation.setStatut("ANNULEE"); + + log.info("Cotisation supprimée avec succès - ID: {}", id); + } + + /** + * Récupère les cotisations d'un membre + * + * @param membreId identifiant du membre + * @param page numéro de page + * @param size taille de la page + * @return liste des cotisations du membre + */ + public List getCotisationsByMembre(@NotNull Long membreId, int page, int size) { + log.debug("Récupération des cotisations du membre: {}", membreId); + + // Vérification de l'existence du membre + if (!membreRepository.findByIdOptional(membreId).isPresent()) { + throw new NotFoundException("Membre non trouvé avec l'ID: " + membreId); + } + + List cotisations = cotisationRepository.findByMembreId(membreId, + Page.of(page, size), Sort.by("dateEcheance").descending()); + + return cotisations.stream() + .map(this::convertToDTO) + .collect(Collectors.toList()); + } + + /** + * Récupère les cotisations par statut + * + * @param statut statut recherché + * @param page numéro de page + * @param size taille de la page + * @return liste des cotisations avec le statut spécifié + */ + public List getCotisationsByStatut(@NotNull String statut, int page, int size) { + log.debug("Récupération des cotisations avec statut: {}", statut); + + List cotisations = cotisationRepository.findByStatut(statut, Page.of(page, size)); + + return cotisations.stream() + .map(this::convertToDTO) + .collect(Collectors.toList()); + } + + /** + * Récupère les cotisations en retard + * + * @param page numéro de page + * @param size taille de la page + * @return liste des cotisations en retard + */ + public List getCotisationsEnRetard(int page, int size) { + log.debug("Récupération des cotisations en retard"); + + List cotisations = cotisationRepository.findCotisationsEnRetard( + LocalDate.now(), Page.of(page, size)); + + return cotisations.stream() + .map(this::convertToDTO) + .collect(Collectors.toList()); + } + + /** + * Recherche avancée de cotisations + * + * @param membreId identifiant du membre (optionnel) + * @param statut statut (optionnel) + * @param typeCotisation type (optionnel) + * @param annee année (optionnel) + * @param mois mois (optionnel) + * @param page numéro de page + * @param size taille de la page + * @return liste filtrée des cotisations + */ + public List rechercherCotisations(Long membreId, String statut, String typeCotisation, + Integer annee, Integer mois, int page, int size) { + log.debug("Recherche avancée de cotisations avec filtres"); + + List cotisations = cotisationRepository.rechercheAvancee( + membreId, statut, typeCotisation, annee, mois, Page.of(page, size)); + + return cotisations.stream() + .map(this::convertToDTO) + .collect(Collectors.toList()); + } + + /** + * Récupère les statistiques des cotisations + * + * @return map contenant les statistiques + */ + public Map getStatistiquesCotisations() { + log.debug("Calcul des statistiques des cotisations"); + + long totalCotisations = cotisationRepository.count(); + long cotisationsPayees = cotisationRepository.compterParStatut("PAYEE"); + long cotisationsEnRetard = cotisationRepository.findCotisationsEnRetard(LocalDate.now(), Page.of(0, Integer.MAX_VALUE)).size(); + + return Map.of( + "totalCotisations", totalCotisations, + "cotisationsPayees", cotisationsPayees, + "cotisationsEnRetard", cotisationsEnRetard, + "tauxPaiement", totalCotisations > 0 ? (cotisationsPayees * 100.0 / totalCotisations) : 0.0 + ); + } + + /** + * Convertit une entité Cotisation en DTO + */ + private CotisationDTO convertToDTO(Cotisation cotisation) { + CotisationDTO dto = new CotisationDTO(); + + // Copie des propriétés de base + // Génération d'UUID basé sur l'ID numérique pour compatibilité + dto.setId(UUID.nameUUIDFromBytes(("cotisation-" + cotisation.id).getBytes())); + dto.setNumeroReference(cotisation.getNumeroReference()); + dto.setMembreId(UUID.nameUUIDFromBytes(("membre-" + cotisation.getMembre().id).getBytes())); + dto.setNomMembre(cotisation.getMembre().getNom() + " " + cotisation.getMembre().getPrenom()); + dto.setNumeroMembre(cotisation.getMembre().getNumeroMembre()); + dto.setTypeCotisation(cotisation.getTypeCotisation()); + dto.setMontantDu(cotisation.getMontantDu()); + dto.setMontantPaye(cotisation.getMontantPaye()); + dto.setCodeDevise(cotisation.getCodeDevise()); + dto.setStatut(cotisation.getStatut()); + dto.setDateEcheance(cotisation.getDateEcheance()); + dto.setDatePaiement(cotisation.getDatePaiement()); + dto.setDescription(cotisation.getDescription()); + dto.setPeriode(cotisation.getPeriode()); + dto.setAnnee(cotisation.getAnnee()); + dto.setMois(cotisation.getMois()); + dto.setObservations(cotisation.getObservations()); + dto.setRecurrente(cotisation.getRecurrente()); + dto.setNombreRappels(cotisation.getNombreRappels()); + dto.setDateDernierRappel(cotisation.getDateDernierRappel()); + dto.setValidePar(cotisation.getValideParId() != null ? + UUID.nameUUIDFromBytes(("user-" + cotisation.getValideParId()).getBytes()) : null); + dto.setNomValidateur(cotisation.getNomValidateur()); + dto.setMethodePaiement(cotisation.getMethodePaiement()); + dto.setReferencePaiement(cotisation.getReferencePaiement()); + dto.setDateCreation(cotisation.getDateCreation()); + dto.setDateModification(cotisation.getDateModification()); + + // Propriétés héritées de BaseDTO + dto.setActif(true); // Les cotisations sont toujours actives + dto.setVersion(0L); // Version par défaut + + return dto; + } + + /** + * Convertit un DTO en entité Cotisation + */ + private Cotisation convertToEntity(CotisationDTO dto) { + return Cotisation.builder() + .numeroReference(dto.getNumeroReference()) + .typeCotisation(dto.getTypeCotisation()) + .montantDu(dto.getMontantDu()) + .montantPaye(dto.getMontantPaye() != null ? dto.getMontantPaye() : BigDecimal.ZERO) + .codeDevise(dto.getCodeDevise() != null ? dto.getCodeDevise() : "XOF") + .statut(dto.getStatut() != null ? dto.getStatut() : "EN_ATTENTE") + .dateEcheance(dto.getDateEcheance()) + .datePaiement(dto.getDatePaiement()) + .description(dto.getDescription()) + .periode(dto.getPeriode()) + .annee(dto.getAnnee()) + .mois(dto.getMois()) + .observations(dto.getObservations()) + .recurrente(dto.getRecurrente() != null ? dto.getRecurrente() : false) + .nombreRappels(dto.getNombreRappels() != null ? dto.getNombreRappels() : 0) + .dateDernierRappel(dto.getDateDernierRappel()) + .methodePaiement(dto.getMethodePaiement()) + .referencePaiement(dto.getReferencePaiement()) + .build(); + } + + /** + * Met à jour les champs d'une cotisation existante + */ + private void updateCotisationFields(Cotisation cotisation, CotisationDTO dto) { + if (dto.getTypeCotisation() != null) { + cotisation.setTypeCotisation(dto.getTypeCotisation()); + } + if (dto.getMontantDu() != null) { + cotisation.setMontantDu(dto.getMontantDu()); + } + if (dto.getMontantPaye() != null) { + cotisation.setMontantPaye(dto.getMontantPaye()); + } + if (dto.getStatut() != null) { + cotisation.setStatut(dto.getStatut()); + } + if (dto.getDateEcheance() != null) { + cotisation.setDateEcheance(dto.getDateEcheance()); + } + if (dto.getDatePaiement() != null) { + cotisation.setDatePaiement(dto.getDatePaiement()); + } + if (dto.getDescription() != null) { + cotisation.setDescription(dto.getDescription()); + } + if (dto.getObservations() != null) { + cotisation.setObservations(dto.getObservations()); + } + if (dto.getMethodePaiement() != null) { + cotisation.setMethodePaiement(dto.getMethodePaiement()); + } + if (dto.getReferencePaiement() != null) { + cotisation.setReferencePaiement(dto.getReferencePaiement()); + } + } + + /** + * Valide les règles métier pour une cotisation + */ + private void validateCotisationRules(Cotisation cotisation) { + // Validation du montant + if (cotisation.getMontantDu().compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("Le montant dû doit être positif"); + } + + // Validation de la date d'échéance + if (cotisation.getDateEcheance().isBefore(LocalDate.now().minusYears(1))) { + throw new IllegalArgumentException("La date d'échéance ne peut pas être antérieure à un an"); + } + + // Validation du montant payé + if (cotisation.getMontantPaye().compareTo(cotisation.getMontantDu()) > 0) { + throw new IllegalArgumentException("Le montant payé ne peut pas dépasser le montant dû"); + } + + // Validation de la cohérence statut/paiement + if ("PAYEE".equals(cotisation.getStatut()) && + cotisation.getMontantPaye().compareTo(cotisation.getMontantDu()) < 0) { + throw new IllegalArgumentException("Une cotisation marquée comme payée doit avoir un montant payé égal au montant dû"); + } + } +} 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 d2b841b..7ee0fff 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,16 +1,22 @@ package dev.lions.unionflow.server.service; +import dev.lions.unionflow.server.api.dto.membre.MembreDTO; import dev.lions.unionflow.server.entity.Membre; import dev.lions.unionflow.server.repository.MembreRepository; +import io.quarkus.panache.common.Page; +import io.quarkus.panache.common.Sort; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.transaction.Transactional; import org.jboss.logging.Logger; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.UUID; +import java.util.stream.Collectors; /** * Service métier pour les membres @@ -140,4 +146,153 @@ public class MembreService { public long compterMembresActifs() { return membreRepository.countActifs(); } + + /** + * Liste tous les membres actifs avec pagination + */ + public List listerMembresActifs(Page page, Sort sort) { + return membreRepository.findAllActifs(page, sort); + } + + /** + * Recherche des membres avec pagination + */ + public List rechercherMembres(String recherche, Page page, Sort sort) { + return membreRepository.findByNomOrPrenom(recherche, page, sort); + } + + /** + * Obtient les statistiques avancées des membres + */ + public Map obtenirStatistiquesAvancees() { + LOG.info("Calcul des statistiques avancées des membres"); + + long totalMembres = membreRepository.count(); + long membresActifs = membreRepository.countActifs(); + long membresInactifs = totalMembres - membresActifs; + long nouveauxMembres30Jours = membreRepository.countNouveauxMembres(LocalDate.now().minusDays(30)); + + return Map.of( + "totalMembres", totalMembres, + "membresActifs", membresActifs, + "membresInactifs", membresInactifs, + "nouveauxMembres30Jours", nouveauxMembres30Jours, + "tauxActivite", totalMembres > 0 ? (membresActifs * 100.0 / totalMembres) : 0.0, + "timestamp", LocalDateTime.now() + ); + } + + // ======================================== + // MÉTHODES DE CONVERSION DTO + // ======================================== + + /** + * Convertit une entité Membre en MembreDTO + */ + public MembreDTO convertToDTO(Membre membre) { + if (membre == null) { + return null; + } + + MembreDTO dto = new MembreDTO(); + + // Génération d'UUID basé sur l'ID numérique pour compatibilité + dto.setId(UUID.nameUUIDFromBytes(("membre-" + membre.id).getBytes())); + + // Copie des champs de base + dto.setNumeroMembre(membre.getNumeroMembre()); + dto.setNom(membre.getNom()); + dto.setPrenom(membre.getPrenom()); + dto.setEmail(membre.getEmail()); + dto.setTelephone(membre.getTelephone()); + dto.setDateNaissance(membre.getDateNaissance()); + dto.setDateAdhesion(membre.getDateAdhesion()); + + // Conversion du statut boolean vers string + dto.setStatut(membre.getActif() ? "ACTIF" : "INACTIF"); + + // Champs de base DTO + dto.setDateCreation(membre.getDateCreation()); + dto.setDateModification(membre.getDateModification()); + dto.setVersion(0L); // Version par défaut + + // Champs par défaut pour les champs manquants dans l'entité + dto.setAssociationId(1L); // Association par défaut + dto.setMembreBureau(false); + dto.setResponsable(false); + + return dto; + } + + /** + * Convertit un MembreDTO en entité Membre + */ + public Membre convertFromDTO(MembreDTO dto) { + if (dto == null) { + return null; + } + + Membre membre = new Membre(); + + // Copie des champs + membre.setNumeroMembre(dto.getNumeroMembre()); + membre.setNom(dto.getNom()); + membre.setPrenom(dto.getPrenom()); + membre.setEmail(dto.getEmail()); + membre.setTelephone(dto.getTelephone()); + membre.setDateNaissance(dto.getDateNaissance()); + membre.setDateAdhesion(dto.getDateAdhesion()); + + // Conversion du statut string vers boolean + membre.setActif("ACTIF".equals(dto.getStatut())); + + // Champs de base + if (dto.getDateCreation() != null) { + membre.setDateCreation(dto.getDateCreation()); + } + if (dto.getDateModification() != null) { + membre.setDateModification(dto.getDateModification()); + } + + return membre; + } + + /** + * Convertit une liste d'entités en liste de DTOs + */ + public List convertToDTOList(List membres) { + return membres.stream() + .map(this::convertToDTO) + .collect(Collectors.toList()); + } + + /** + * Met à jour une entité Membre à partir d'un MembreDTO + */ + public void updateFromDTO(Membre membre, MembreDTO dto) { + if (membre == null || dto == null) { + return; + } + + // Mise à jour des champs modifiables + membre.setPrenom(dto.getPrenom()); + membre.setNom(dto.getNom()); + membre.setEmail(dto.getEmail()); + membre.setTelephone(dto.getTelephone()); + membre.setDateNaissance(dto.getDateNaissance()); + membre.setActif("ACTIF".equals(dto.getStatut())); + membre.setDateModification(LocalDateTime.now()); + } + + /** + * Recherche avancée de membres avec filtres multiples + */ + 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", + recherche, actif, dateAdhesionMin, dateAdhesionMax); + + return membreRepository.rechercheAvancee(recherche, actif, dateAdhesionMin, dateAdhesionMax, page, sort); + } } diff --git a/unionflow-server-impl-quarkus/src/main/resources/application.yml b/unionflow-server-impl-quarkus/src/main/resources/application.yml index 6542a82..aa4c086 100644 --- a/unionflow-server-impl-quarkus/src/main/resources/application.yml +++ b/unionflow-server-impl-quarkus/src/main/resources/application.yml @@ -71,9 +71,12 @@ quarkus: level: INFO format: "%d{yyyy-MM-dd HH:mm:ss,SSS} %-5p [%c{2.}] (%t) %s%e%n" category: - "dev.lions.unionflow": INFO - "org.hibernate": WARN - "io.quarkus": INFO + "dev.lions.unionflow": + level: INFO + "org.hibernate": + level: WARN + "io.quarkus": + level: INFO --- # Profil de développement @@ -94,8 +97,10 @@ quarkus: migrate-at-start: false log: category: - "dev.lions.unionflow": DEBUG - "org.hibernate.SQL": DEBUG + "dev.lions.unionflow": + level: DEBUG + "org.hibernate.SQL": + level: DEBUG --- # Profil de test @@ -126,5 +131,7 @@ quarkus: console: level: WARN category: - "dev.lions.unionflow": INFO - root: WARN \ No newline at end of file + "dev.lions.unionflow": + level: INFO + root: + level: WARN \ No newline at end of file diff --git a/unionflow-server-impl-quarkus/src/main/resources/import-test-data.sql b/unionflow-server-impl-quarkus/src/main/resources/import-test-data.sql new file mode 100644 index 0000000..f14a7d9 --- /dev/null +++ b/unionflow-server-impl-quarkus/src/main/resources/import-test-data.sql @@ -0,0 +1,44 @@ +-- Script d'insertion de données de test pour UnionFlow +-- Ce fichier sera exécuté automatiquement par Quarkus au démarrage en mode dev + +-- Insertion de membres de test +INSERT INTO membre (id, nom, prenom, email, telephone, date_naissance, adresse, profession, statut, date_adhesion, numero_membre, created_at, updated_at) VALUES +('550e8400-e29b-41d4-a716-446655440001', 'Kouassi', 'Jean-Baptiste', 'jb.kouassi@email.ci', '+225 07 12 34 56 78', '1985-03-15', 'Cocody, Abidjan', 'Ingénieur Informatique', 'ACTIF', '2023-01-15', 'MBR001', NOW(), NOW()), +('550e8400-e29b-41d4-a716-446655440002', 'Traoré', 'Aminata', 'aminata.traore@email.ci', '+225 05 98 76 54 32', '1990-07-22', 'Plateau, Abidjan', 'Comptable', 'ACTIF', '2023-02-10', 'MBR002', NOW(), NOW()), +('550e8400-e29b-41d4-a716-446655440003', 'Bamba', 'Seydou', 'seydou.bamba@email.ci', '+225 01 23 45 67 89', '1988-11-08', 'Yopougon, Abidjan', 'Commerçant', 'ACTIF', '2023-03-05', 'MBR003', NOW(), NOW()), +('550e8400-e29b-41d4-a716-446655440004', 'Ouattara', 'Fatoumata', 'fatoumata.ouattara@email.ci', '+225 07 87 65 43 21', '1992-05-18', 'Adjamé, Abidjan', 'Enseignante', 'ACTIF', '2023-04-12', 'MBR004', NOW(), NOW()), +('550e8400-e29b-41d4-a716-446655440005', 'Koné', 'Ibrahim', 'ibrahim.kone@email.ci', '+225 05 11 22 33 44', '1987-09-30', 'Marcory, Abidjan', 'Médecin', 'ACTIF', '2023-05-20', 'MBR005', NOW(), NOW()), +('550e8400-e29b-41d4-a716-446655440006', 'Diabaté', 'Mariam', 'mariam.diabate@email.ci', '+225 01 55 66 77 88', '1991-12-03', 'Treichville, Abidjan', 'Avocate', 'SUSPENDU', '2023-06-08', 'MBR006', NOW(), NOW()), +('550e8400-e29b-41d4-a716-446655440007', 'Sangaré', 'Moussa', 'moussa.sangare@email.ci', '+225 07 99 88 77 66', '1989-04-25', 'Koumassi, Abidjan', 'Pharmacien', 'ACTIF', '2023-07-15', 'MBR007', NOW(), NOW()), +('550e8400-e29b-41d4-a716-446655440008', 'Coulibaly', 'Awa', 'awa.coulibaly@email.ci', '+225 05 44 33 22 11', '1993-08-14', 'Port-Bouët, Abidjan', 'Architecte', 'ACTIF', '2023-08-22', 'MBR008', NOW(), NOW()); + +-- Insertion de cotisations de test avec différents statuts +INSERT INTO cotisation (id, numero_reference, membre_id, nom_membre, type_cotisation, montant_du, montant_paye, statut, date_echeance, date_creation, periode, description, created_at, updated_at) VALUES +-- Cotisations payées +('660e8400-e29b-41d4-a716-446655440001', 'COT-2024-001', '550e8400-e29b-41d4-a716-446655440001', 'Jean-Baptiste Kouassi', 'MENSUELLE', 25000, 25000, 'PAYEE', '2024-01-31', '2024-01-01', 'Janvier 2024', 'Cotisation mensuelle janvier', NOW(), NOW()), +('660e8400-e29b-41d4-a716-446655440002', 'COT-2024-002', '550e8400-e29b-41d4-a716-446655440002', 'Aminata Traoré', 'MENSUELLE', 25000, 25000, 'PAYEE', '2024-01-31', '2024-01-01', 'Janvier 2024', 'Cotisation mensuelle janvier', NOW(), NOW()), +('660e8400-e29b-41d4-a716-446655440003', 'COT-2024-003', '550e8400-e29b-41d4-a716-446655440003', 'Seydou Bamba', 'MENSUELLE', 25000, 25000, 'PAYEE', '2024-02-29', '2024-02-01', 'Février 2024', 'Cotisation mensuelle février', NOW(), NOW()), + +-- Cotisations en attente +('660e8400-e29b-41d4-a716-446655440004', 'COT-2024-004', '550e8400-e29b-41d4-a716-446655440004', 'Fatoumata Ouattara', 'MENSUELLE', 25000, 0, 'EN_ATTENTE', '2024-12-31', '2024-12-01', 'Décembre 2024', 'Cotisation mensuelle décembre', NOW(), NOW()), +('660e8400-e29b-41d4-a716-446655440005', 'COT-2024-005', '550e8400-e29b-41d4-a716-446655440005', 'Ibrahim Koné', 'MENSUELLE', 25000, 0, 'EN_ATTENTE', '2024-12-31', '2024-12-01', 'Décembre 2024', 'Cotisation mensuelle décembre', NOW(), NOW()), + +-- Cotisations en retard +('660e8400-e29b-41d4-a716-446655440006', 'COT-2024-006', '550e8400-e29b-41d4-a716-446655440006', 'Mariam Diabaté', 'MENSUELLE', 25000, 0, 'EN_RETARD', '2024-11-30', '2024-11-01', 'Novembre 2024', 'Cotisation mensuelle novembre', NOW(), NOW()), +('660e8400-e29b-41d4-a716-446655440007', 'COT-2024-007', '550e8400-e29b-41d4-a716-446655440007', 'Moussa Sangaré', 'MENSUELLE', 25000, 0, 'EN_RETARD', '2024-10-31', '2024-10-01', 'Octobre 2024', 'Cotisation mensuelle octobre', NOW(), NOW()), + +-- Cotisations partiellement payées +('660e8400-e29b-41d4-a716-446655440008', 'COT-2024-008', '550e8400-e29b-41d4-a716-446655440008', 'Awa Coulibaly', 'MENSUELLE', 25000, 15000, 'PARTIELLEMENT_PAYEE', '2024-12-31', '2024-12-01', 'Décembre 2024', 'Cotisation mensuelle décembre', NOW(), NOW()), + +-- Cotisations spéciales (adhésion, événements) +('660e8400-e29b-41d4-a716-446655440009', 'COT-2024-009', '550e8400-e29b-41d4-a716-446655440001', 'Jean-Baptiste Kouassi', 'ADHESION', 50000, 50000, 'PAYEE', '2024-01-15', '2024-01-01', 'Adhésion 2024', 'Frais d''adhésion annuelle', NOW(), NOW()), +('660e8400-e29b-41d4-a716-446655440010', 'COT-2024-010', '550e8400-e29b-41d4-a716-446655440002', 'Aminata Traoré', 'EVENEMENT', 15000, 0, 'EN_ATTENTE', '2024-12-25', '2024-12-01', 'Fête de fin d''année', 'Participation à la fête de fin d''année', NOW(), NOW()), +('660e8400-e29b-41d4-a716-446655440011', 'COT-2024-011', '550e8400-e29b-41d4-a716-446655440003', 'Seydou Bamba', 'SOLIDARITE', 10000, 10000, 'PAYEE', '2024-11-15', '2024-11-01', 'Aide mutuelle', 'Contribution solidarité membre en difficulté', NOW(), NOW()), + +-- Cotisations annuelles +('660e8400-e29b-41d4-a716-446655440012', 'COT-2024-012', '550e8400-e29b-41d4-a716-446655440004', 'Fatoumata Ouattara', 'ANNUELLE', 300000, 150000, 'PARTIELLEMENT_PAYEE', '2024-12-31', '2024-01-01', 'Cotisation annuelle 2024', 'Cotisation annuelle avec paiement échelonné', NOW(), NOW()), +('660e8400-e29b-41d4-a716-446655440013', 'COT-2024-013', '550e8400-e29b-41d4-a716-446655440005', 'Ibrahim Koné', 'ANNUELLE', 300000, 0, 'EN_RETARD', '2024-06-30', '2024-01-01', 'Cotisation annuelle 2024', 'Cotisation annuelle en retard', NOW(), NOW()), + +-- Cotisations diverses montants +('660e8400-e29b-41d4-a716-446655440014', 'COT-2024-014', '550e8400-e29b-41d4-a716-446655440007', 'Moussa Sangaré', 'FORMATION', 75000, 75000, 'PAYEE', '2024-09-30', '2024-09-01', 'Formation professionnelle', 'Participation formation développement personnel', NOW(), NOW()), +('660e8400-e29b-41d4-a716-446655440015', 'COT-2024-015', '550e8400-e29b-41d4-a716-446655440008', 'Awa Coulibaly', 'PROJET', 100000, 25000, 'PARTIELLEMENT_PAYEE', '2024-12-31', '2024-11-01', 'Projet communautaire', 'Financement projet construction école', NOW(), NOW()); diff --git a/unionflow-server-impl-quarkus/src/main/resources/import.sql b/unionflow-server-impl-quarkus/src/main/resources/import.sql new file mode 100644 index 0000000..eb74df2 --- /dev/null +++ b/unionflow-server-impl-quarkus/src/main/resources/import.sql @@ -0,0 +1,44 @@ +-- Script d'insertion de données de test pour UnionFlow +-- Ce fichier sera exécuté automatiquement par Quarkus au démarrage + +-- Insertion de membres de test +INSERT INTO membres (id, numero_membre, nom, prenom, email, telephone, date_naissance, date_adhesion, actif, date_creation) VALUES +(1, 'MBR001', 'Kouassi', 'Jean-Baptiste', 'jb.kouassi@email.ci', '+225071234567', '1985-03-15', '2023-01-15', true, '2024-01-01 10:00:00'), +(2, 'MBR002', 'Traoré', 'Aminata', 'aminata.traore@email.ci', '+225059876543', '1990-07-22', '2023-02-10', true, '2024-01-01 10:00:00'), +(3, 'MBR003', 'Bamba', 'Seydou', 'seydou.bamba@email.ci', '+225012345678', '1988-11-08', '2023-03-05', true, '2024-01-01 10:00:00'), +(4, 'MBR004', 'Ouattara', 'Fatoumata', 'fatoumata.ouattara@email.ci', '+225078765432', '1992-05-18', '2023-04-12', true, '2024-01-01 10:00:00'), +(5, 'MBR005', 'Koné', 'Ibrahim', 'ibrahim.kone@email.ci', '+225051122334', '1987-09-30', '2023-05-20', true, '2024-01-01 10:00:00'), +(6, 'MBR006', 'Diabaté', 'Mariam', 'mariam.diabate@email.ci', '+225015566778', '1991-12-03', '2023-06-08', false, '2024-01-01 10:00:00'), +(7, 'MBR007', 'Sangaré', 'Moussa', 'moussa.sangare@email.ci', '+225079988776', '1989-04-25', '2023-07-15', true, '2024-01-01 10:00:00'), +(8, 'MBR008', 'Coulibaly', 'Awa', 'awa.coulibaly@email.ci', '+225054433221', '1993-08-14', '2023-08-22', true, '2024-01-01 10:00:00'); + +-- Insertion de cotisations de test avec différents statuts +INSERT INTO cotisations (id, numero_reference, membre_id, type_cotisation, montant_du, montant_paye, statut, date_echeance, date_creation, periode, description, annee, mois, code_devise, recurrente, nombre_rappels) VALUES +-- Cotisations payées +(1, 'COT-2024-001', 1, 'MENSUELLE', 25000.00, 25000.00, 'PAYEE', '2024-01-31', '2024-01-01 10:00:00', 'Janvier 2024', 'Cotisation mensuelle janvier', 2024, 1, 'XOF', true, 0), +(2, 'COT-2024-002', 2, 'MENSUELLE', 25000.00, 25000.00, 'PAYEE', '2024-01-31', '2024-01-01 10:00:00', 'Janvier 2024', 'Cotisation mensuelle janvier', 2024, 1, 'XOF', true, 0), +(3, 'COT-2024-003', 3, 'MENSUELLE', 25000.00, 25000.00, 'PAYEE', '2024-02-29', '2024-02-01 10:00:00', 'Février 2024', 'Cotisation mensuelle février', 2024, 2, 'XOF', true, 0), + +-- Cotisations en attente +(4, 'COT-2024-004', 4, 'MENSUELLE', 25000.00, 0.00, 'EN_ATTENTE', '2024-12-31', '2024-12-01 10:00:00', 'Décembre 2024', 'Cotisation mensuelle décembre', 2024, 12, 'XOF', true, 0), +(5, 'COT-2024-005', 5, 'MENSUELLE', 25000.00, 0.00, 'EN_ATTENTE', '2024-12-31', '2024-12-01 10:00:00', 'Décembre 2024', 'Cotisation mensuelle décembre', 2024, 12, 'XOF', true, 0), + +-- Cotisations en retard +(6, 'COT-2024-006', 6, 'MENSUELLE', 25000.00, 0.00, 'EN_RETARD', '2024-11-30', '2024-11-01 10:00:00', 'Novembre 2024', 'Cotisation mensuelle novembre', 2024, 11, 'XOF', true, 2), +(7, 'COT-2024-007', 7, 'MENSUELLE', 25000.00, 0.00, 'EN_RETARD', '2024-10-31', '2024-10-01 10:00:00', 'Octobre 2024', 'Cotisation mensuelle octobre', 2024, 10, 'XOF', true, 3), + +-- Cotisations partiellement payées +(8, 'COT-2024-008', 8, 'MENSUELLE', 25000.00, 15000.00, 'PARTIELLEMENT_PAYEE', '2024-12-31', '2024-12-01 10:00:00', 'Décembre 2024', 'Cotisation mensuelle décembre', 2024, 12, 'XOF', true, 1), + +-- Cotisations spéciales +(9, 'COT-2024-009', 1, 'ADHESION', 50000.00, 50000.00, 'PAYEE', '2024-01-15', '2024-01-01 10:00:00', 'Adhésion 2024', 'Frais d''adhésion annuelle', 2024, null, 'XOF', false, 0), +(10, 'COT-2024-010', 2, 'EVENEMENT', 15000.00, 15000.00, 'PAYEE', '2024-06-15', '2024-06-01 10:00:00', 'Assemblée Générale', 'Participation assemblée générale', 2024, 6, 'XOF', false, 0), +(11, 'COT-2024-011', 3, 'SOLIDARITE', 10000.00, 10000.00, 'PAYEE', '2024-03-31', '2024-03-01 10:00:00', 'Aide Solidarité', 'Contribution solidarité membre', 2024, 3, 'XOF', false, 0), +(12, 'COT-2024-012', 4, 'ANNUELLE', 300000.00, 0.00, 'EN_ATTENTE', '2024-12-31', '2024-01-01 10:00:00', 'Annuelle 2024', 'Cotisation annuelle complète', 2024, null, 'XOF', false, 0), +(13, 'COT-2024-013', 5, 'FORMATION', 75000.00, 75000.00, 'PAYEE', '2024-09-30', '2024-09-01 10:00:00', 'Formation 2024', 'Formation en leadership associatif', 2024, 9, 'XOF', false, 0), +(14, 'COT-2024-014', 6, 'PROJET', 100000.00, 50000.00, 'PARTIELLEMENT_PAYEE', '2024-12-31', '2024-11-01 10:00:00', 'Projet Communauté', 'Contribution projet développement', 2024, 11, 'XOF', false, 1), +(15, 'COT-2024-015', 7, 'MENSUELLE', 25000.00, 0.00, 'EN_RETARD', '2024-09-30', '2024-09-01 10:00:00', 'Septembre 2024', 'Cotisation mensuelle septembre', 2024, 9, 'XOF', true, 4); + +-- Mise à jour des séquences pour éviter les conflits +ALTER SEQUENCE membres_SEQ RESTART WITH 50; +ALTER SEQUENCE cotisations_SEQ RESTART WITH 50; diff --git a/unionflow-server-impl-quarkus/src/test/java/dev/lions/unionflow/server/resource/CotisationResourceTest.java b/unionflow-server-impl-quarkus/src/test/java/dev/lions/unionflow/server/resource/CotisationResourceTest.java new file mode 100644 index 0000000..dbb56ef --- /dev/null +++ b/unionflow-server-impl-quarkus/src/test/java/dev/lions/unionflow/server/resource/CotisationResourceTest.java @@ -0,0 +1,329 @@ +package dev.lions.unionflow.server.resource; + +import dev.lions.unionflow.server.api.dto.finance.CotisationDTO; +import dev.lions.unionflow.server.entity.Cotisation; +import dev.lions.unionflow.server.entity.Membre; +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.http.ContentType; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.junit.jupiter.api.MethodOrderer; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.*; + +/** + * Tests d'intégration pour CotisationResource + * Teste tous les endpoints REST de l'API cotisations + * + * @author UnionFlow Team + * @version 1.0 + * @since 2025-01-15 + */ +@QuarkusTest +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@DisplayName("Tests d'intégration - API Cotisations") +class CotisationResourceTest { + + private static Long membreTestId; + private static Long cotisationTestId; + private static String numeroReferenceTest; + + @BeforeEach + @Transactional + void setUp() { + // Nettoyage et création des données de test + Cotisation.deleteAll(); + Membre.deleteAll(); + + // Création d'un membre de test + Membre membreTest = new Membre(); + membreTest.setNumeroMembre("MBR-TEST-001"); + membreTest.setNom("Dupont"); + membreTest.setPrenom("Jean"); + membreTest.setEmail("jean.dupont@test.com"); + membreTest.setTelephone("+225070123456"); + membreTest.setDateNaissance(LocalDate.of(1985, 5, 15)); + membreTest.setActif(true); + membreTest.persist(); + + membreTestId = membreTest.id; + } + + @Test + @org.junit.jupiter.api.Order(1) + @DisplayName("POST /api/cotisations - Création d'une cotisation") + void testCreateCotisation() { + CotisationDTO nouvelleCotisation = new CotisationDTO(); + nouvelleCotisation.setMembreId(UUID.fromString(membreTestId.toString())); + nouvelleCotisation.setTypeCotisation("MENSUELLE"); + nouvelleCotisation.setMontantDu(new BigDecimal("25000.00")); + nouvelleCotisation.setDateEcheance(LocalDate.now().plusDays(30)); + nouvelleCotisation.setDescription("Cotisation mensuelle janvier 2025"); + nouvelleCotisation.setPeriode("Janvier 2025"); + nouvelleCotisation.setAnnee(2025); + nouvelleCotisation.setMois(1); + + given() + .contentType(ContentType.JSON) + .body(nouvelleCotisation) + .when() + .post("/api/cotisations") + .then() + .statusCode(201) + .body("numeroReference", notNullValue()) + .body("membreId", equalTo(membreTestId.toString())) + .body("typeCotisation", equalTo("MENSUELLE")) + .body("montantDu", equalTo(25000.00f)) + .body("montantPaye", equalTo(0.0f)) + .body("statut", equalTo("EN_ATTENTE")) + .body("codeDevise", equalTo("XOF")) + .body("annee", equalTo(2025)) + .body("mois", equalTo(1)); + } + + @Test + @org.junit.jupiter.api.Order(2) + @DisplayName("GET /api/cotisations - Liste des cotisations") + void testGetAllCotisations() { + given() + .queryParam("page", 0) + .queryParam("size", 10) + .when() + .get("/api/cotisations") + .then() + .statusCode(200) + .body("size()", greaterThanOrEqualTo(0)); + } + + @Test + @org.junit.jupiter.api.Order(3) + @DisplayName("GET /api/cotisations/{id} - Récupération par ID") + void testGetCotisationById() { + // Créer d'abord une cotisation + CotisationDTO cotisation = createTestCotisation(); + cotisationTestId = Long.valueOf(cotisation.getId().toString()); + + given() + .pathParam("id", cotisationTestId) + .when() + .get("/api/cotisations/{id}") + .then() + .statusCode(200) + .body("id", equalTo(cotisationTestId.toString())) + .body("typeCotisation", equalTo("MENSUELLE")); + } + + @Test + @org.junit.jupiter.api.Order(4) + @DisplayName("GET /api/cotisations/reference/{numeroReference} - Récupération par référence") + void testGetCotisationByReference() { + // Utiliser la cotisation créée précédemment + if (numeroReferenceTest == null) { + CotisationDTO cotisation = createTestCotisation(); + numeroReferenceTest = cotisation.getNumeroReference(); + } + + given() + .pathParam("numeroReference", numeroReferenceTest) + .when() + .get("/api/cotisations/reference/{numeroReference}") + .then() + .statusCode(200) + .body("numeroReference", equalTo(numeroReferenceTest)) + .body("typeCotisation", equalTo("MENSUELLE")); + } + + @Test + @org.junit.jupiter.api.Order(5) + @DisplayName("PUT /api/cotisations/{id} - Mise à jour d'une cotisation") + void testUpdateCotisation() { + // Créer une cotisation si nécessaire + if (cotisationTestId == null) { + CotisationDTO cotisation = createTestCotisation(); + cotisationTestId = Long.valueOf(cotisation.getId().toString()); + } + + CotisationDTO cotisationMiseAJour = new CotisationDTO(); + cotisationMiseAJour.setTypeCotisation("TRIMESTRIELLE"); + cotisationMiseAJour.setMontantDu(new BigDecimal("75000.00")); + cotisationMiseAJour.setDescription("Cotisation trimestrielle Q1 2025"); + cotisationMiseAJour.setObservations("Mise à jour du type de cotisation"); + + given() + .contentType(ContentType.JSON) + .pathParam("id", cotisationTestId) + .body(cotisationMiseAJour) + .when() + .put("/api/cotisations/{id}") + .then() + .statusCode(200) + .body("typeCotisation", equalTo("TRIMESTRIELLE")) + .body("montantDu", equalTo(75000.00f)) + .body("observations", equalTo("Mise à jour du type de cotisation")); + } + + @Test + @org.junit.jupiter.api.Order(6) + @DisplayName("GET /api/cotisations/membre/{membreId} - Cotisations d'un membre") + void testGetCotisationsByMembre() { + given() + .pathParam("membreId", membreTestId) + .queryParam("page", 0) + .queryParam("size", 10) + .when() + .get("/api/cotisations/membre/{membreId}") + .then() + .statusCode(200) + .body("size()", greaterThanOrEqualTo(0)); + } + + @Test + @org.junit.jupiter.api.Order(7) + @DisplayName("GET /api/cotisations/statut/{statut} - Cotisations par statut") + void testGetCotisationsByStatut() { + given() + .pathParam("statut", "EN_ATTENTE") + .queryParam("page", 0) + .queryParam("size", 10) + .when() + .get("/api/cotisations/statut/{statut}") + .then() + .statusCode(200) + .body("size()", greaterThanOrEqualTo(0)); + } + + @Test + @org.junit.jupiter.api.Order(8) + @DisplayName("GET /api/cotisations/en-retard - Cotisations en retard") + void testGetCotisationsEnRetard() { + given() + .queryParam("page", 0) + .queryParam("size", 10) + .when() + .get("/api/cotisations/en-retard") + .then() + .statusCode(200) + .body("size()", greaterThanOrEqualTo(0)); + } + + @Test + @org.junit.jupiter.api.Order(9) + @DisplayName("GET /api/cotisations/recherche - Recherche avancée") + void testRechercherCotisations() { + given() + .queryParam("membreId", membreTestId) + .queryParam("statut", "EN_ATTENTE") + .queryParam("annee", 2025) + .queryParam("page", 0) + .queryParam("size", 10) + .when() + .get("/api/cotisations/recherche") + .then() + .statusCode(200) + .body("size()", greaterThanOrEqualTo(0)); + } + + @Test + @org.junit.jupiter.api.Order(10) + @DisplayName("GET /api/cotisations/stats - Statistiques des cotisations") + void testGetStatistiquesCotisations() { + given() + .when() + .get("/api/cotisations/stats") + .then() + .statusCode(200) + .body("totalCotisations", notNullValue()) + .body("cotisationsPayees", notNullValue()) + .body("cotisationsEnRetard", notNullValue()) + .body("tauxPaiement", notNullValue()); + } + + @Test + @org.junit.jupiter.api.Order(11) + @DisplayName("DELETE /api/cotisations/{id} - Suppression d'une cotisation") + void testDeleteCotisation() { + // Créer une cotisation si nécessaire + if (cotisationTestId == null) { + CotisationDTO cotisation = createTestCotisation(); + cotisationTestId = Long.valueOf(cotisation.getId().toString()); + } + + given() + .pathParam("id", cotisationTestId) + .when() + .delete("/api/cotisations/{id}") + .then() + .statusCode(204); + + // Vérifier que la cotisation est marquée comme annulée + given() + .pathParam("id", cotisationTestId) + .when() + .get("/api/cotisations/{id}") + .then() + .statusCode(200) + .body("statut", equalTo("ANNULEE")); + } + + @Test + @DisplayName("GET /api/cotisations/{id} - Cotisation inexistante") + void testGetCotisationByIdNotFound() { + given() + .pathParam("id", 99999L) + .when() + .get("/api/cotisations/{id}") + .then() + .statusCode(404) + .body("error", equalTo("Cotisation non trouvée")); + } + + @Test + @DisplayName("POST /api/cotisations - Données invalides") + void testCreateCotisationInvalidData() { + CotisationDTO cotisationInvalide = new CotisationDTO(); + // Données manquantes ou invalides + cotisationInvalide.setTypeCotisation(""); + cotisationInvalide.setMontantDu(new BigDecimal("-100")); + + given() + .contentType(ContentType.JSON) + .body(cotisationInvalide) + .when() + .post("/api/cotisations") + .then() + .statusCode(400); + } + + /** + * Méthode utilitaire pour créer une cotisation de test + */ + private CotisationDTO createTestCotisation() { + CotisationDTO cotisation = new CotisationDTO(); + cotisation.setMembreId(UUID.fromString(membreTestId.toString())); + cotisation.setTypeCotisation("MENSUELLE"); + cotisation.setMontantDu(new BigDecimal("25000.00")); + cotisation.setDateEcheance(LocalDate.now().plusDays(30)); + cotisation.setDescription("Cotisation de test"); + cotisation.setPeriode("Test 2025"); + cotisation.setAnnee(2025); + cotisation.setMois(1); + + return given() + .contentType(ContentType.JSON) + .body(cotisation) + .when() + .post("/api/cotisations") + .then() + .statusCode(201) + .extract() + .as(CotisationDTO.class); + } +} diff --git a/unionflow-server-impl-quarkus/src/test/java/dev/lions/unionflow/server/resource/MembreResourceTest.java b/unionflow-server-impl-quarkus/src/test/java/dev/lions/unionflow/server/resource/MembreResourceTest.java index c737ef5..bcb99b3 100644 --- a/unionflow-server-impl-quarkus/src/test/java/dev/lions/unionflow/server/resource/MembreResourceTest.java +++ b/unionflow-server-impl-quarkus/src/test/java/dev/lions/unionflow/server/resource/MembreResourceTest.java @@ -1,7 +1,10 @@ package dev.lions.unionflow.server.resource; +import dev.lions.unionflow.server.api.dto.membre.MembreDTO; 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 org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -17,9 +20,7 @@ import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.when; /** @@ -115,14 +116,20 @@ class MembreResourceTest { createTestMembre("Jean", "Dupont"), createTestMembre("Marie", "Martin") ); - when(membreService.listerMembresActifs()).thenReturn(membres); + List membresDTO = Arrays.asList( + createTestMembreDTO("Jean", "Dupont"), + createTestMembreDTO("Marie", "Martin") + ); + + when(membreService.listerMembresActifs(any(Page.class), any(Sort.class))).thenReturn(membres); + when(membreService.convertToDTOList(membres)).thenReturn(membresDTO); // When - Response response = membreResource.listerMembres(); + Response response = membreResource.listerMembres(0, 20, "nom", "asc"); // Then assertThat(response.getStatus()).isEqualTo(200); - assertThat(response.getEntity()).isEqualTo(membres); + assertThat(response.getEntity()).isEqualTo(membresDTO); } @Test @@ -159,17 +166,22 @@ class MembreResourceTest { @DisplayName("Test creerMembre") void testCreerMembre() { // Given + MembreDTO membreDTO = createTestMembreDTO("Jean", "Dupont"); Membre membre = createTestMembre("Jean", "Dupont"); Membre membreCreated = createTestMembre("Jean", "Dupont"); membreCreated.id = 1L; + MembreDTO membreCreatedDTO = createTestMembreDTO("Jean", "Dupont"); + + when(membreService.convertFromDTO(any(MembreDTO.class))).thenReturn(membre); when(membreService.creerMembre(any(Membre.class))).thenReturn(membreCreated); + when(membreService.convertToDTO(any(Membre.class))).thenReturn(membreCreatedDTO); // When - Response response = membreResource.creerMembre(membre); + Response response = membreResource.creerMembre(membreDTO); // Then assertThat(response.getStatus()).isEqualTo(201); - assertThat(response.getEntity()).isEqualTo(membreCreated); + assertThat(response.getEntity()).isEqualTo(membreCreatedDTO); } @Test @@ -177,17 +189,22 @@ class MembreResourceTest { void testMettreAJourMembre() { // Given Long id = 1L; + MembreDTO membreDTO = createTestMembreDTO("Jean", "Dupont"); Membre membre = createTestMembre("Jean", "Dupont"); Membre membreUpdated = createTestMembre("Jean", "Martin"); membreUpdated.id = id; + MembreDTO membreUpdatedDTO = createTestMembreDTO("Jean", "Martin"); + + when(membreService.convertFromDTO(any(MembreDTO.class))).thenReturn(membre); when(membreService.mettreAJourMembre(anyLong(), any(Membre.class))).thenReturn(membreUpdated); + when(membreService.convertToDTO(any(Membre.class))).thenReturn(membreUpdatedDTO); // When - Response response = membreResource.mettreAJourMembre(id, membre); + Response response = membreResource.mettreAJourMembre(id, membreDTO); // Then assertThat(response.getStatus()).isEqualTo(200); - assertThat(response.getEntity()).isEqualTo(membreUpdated); + assertThat(response.getEntity()).isEqualTo(membreUpdatedDTO); } @Test @@ -209,14 +226,16 @@ class MembreResourceTest { // Given String recherche = "Jean"; List membres = Arrays.asList(createTestMembre("Jean", "Dupont")); - when(membreService.rechercherMembres(anyString())).thenReturn(membres); + List membresDTO = Arrays.asList(createTestMembreDTO("Jean", "Dupont")); + when(membreService.rechercherMembres(anyString(), any(Page.class), any(Sort.class))).thenReturn(membres); + when(membreService.convertToDTOList(membres)).thenReturn(membresDTO); // When - Response response = membreResource.rechercherMembres(recherche); + Response response = membreResource.rechercherMembres(recherche, 0, 20, "nom", "asc"); // Then assertThat(response.getStatus()).isEqualTo(200); - assertThat(response.getEntity()).isEqualTo(membres); + assertThat(response.getEntity()).isEqualTo(membresDTO); } @Test @@ -235,14 +254,29 @@ class MembreResourceTest { } private Membre createTestMembre(String prenom, String nom) { - return Membre.builder() - .prenom(prenom) - .nom(nom) - .email(prenom.toLowerCase() + "." + nom.toLowerCase() + "@test.com") - .telephone("221701234567") - .dateNaissance(LocalDate.of(1990, 1, 1)) - .dateAdhesion(LocalDate.now()) - .actif(true) - .build(); + Membre membre = new Membre(); + membre.setPrenom(prenom); + membre.setNom(nom); + membre.setEmail(prenom.toLowerCase() + "." + nom.toLowerCase() + "@test.com"); + membre.setTelephone("221701234567"); + membre.setDateNaissance(LocalDate.of(1990, 1, 1)); + membre.setDateAdhesion(LocalDate.now()); + membre.setActif(true); + membre.setNumeroMembre("UF-2025-TEST01"); + return membre; + } + + private MembreDTO createTestMembreDTO(String prenom, String nom) { + MembreDTO dto = new MembreDTO(); + dto.setPrenom(prenom); + dto.setNom(nom); + dto.setEmail(prenom.toLowerCase() + "." + nom.toLowerCase() + "@test.com"); + dto.setTelephone("221701234567"); + dto.setDateNaissance(LocalDate.of(1990, 1, 1)); + dto.setDateAdhesion(LocalDate.now()); + dto.setStatut("ACTIF"); + dto.setNumeroMembre("UF-2025-TEST01"); + dto.setAssociationId(1L); + return dto; } }