Alignement design systeme OK
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
</trust-anchors>
|
</trust-anchors>
|
||||||
</base-config>
|
</base-config>
|
||||||
<domain-config cleartextTrafficPermitted="true">
|
<domain-config cleartextTrafficPermitted="true">
|
||||||
<domain includeSubdomains="true">192.168.1.145</domain>
|
<domain includeSubdomains="true">192.168.1.11</domain>
|
||||||
<domain includeSubdomains="true">localhost</domain>
|
<domain includeSubdomains="true">localhost</domain>
|
||||||
<domain includeSubdomains="true">10.0.2.2</domain>
|
<domain includeSubdomains="true">10.0.2.2</domain>
|
||||||
<domain includeSubdomains="true">127.0.0.1</domain>
|
<domain includeSubdomains="true">127.0.0.1</domain>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import 'keycloak_webview_auth_service.dart';
|
|||||||
/// Configuration Keycloak pour votre instance
|
/// Configuration Keycloak pour votre instance
|
||||||
class KeycloakConfig {
|
class KeycloakConfig {
|
||||||
/// URL de base de votre Keycloak
|
/// URL de base de votre Keycloak
|
||||||
static const String baseUrl = 'http://192.168.1.145:8180';
|
static const String baseUrl = 'http://192.168.1.11:8180';
|
||||||
|
|
||||||
/// Realm UnionFlow
|
/// Realm UnionFlow
|
||||||
static const String realm = 'unionflow';
|
static const String realm = 'unionflow';
|
||||||
@@ -193,9 +193,9 @@ class KeycloakAuthService {
|
|||||||
lastName: lastName,
|
lastName: lastName,
|
||||||
|
|
||||||
primaryRole: primaryRole,
|
primaryRole: primaryRole,
|
||||||
organizationContexts: [], // À implémenter selon vos besoins
|
organizationContexts: const [], // À implémenter selon vos besoins
|
||||||
additionalPermissions: permissions,
|
additionalPermissions: permissions,
|
||||||
revokedPermissions: [],
|
revokedPermissions: const [],
|
||||||
preferences: const UserPreferences(),
|
preferences: const UserPreferences(),
|
||||||
lastLoginAt: DateTime.now(),
|
lastLoginAt: DateTime.now(),
|
||||||
createdAt: DateTime.now(), // À récupérer depuis Keycloak si disponible
|
createdAt: DateTime.now(), // À récupérer depuis Keycloak si disponible
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import 'keycloak_role_mapper.dart';
|
|||||||
/// Configuration Keycloak pour l'authentification WebView
|
/// Configuration Keycloak pour l'authentification WebView
|
||||||
class KeycloakWebViewConfig {
|
class KeycloakWebViewConfig {
|
||||||
/// URL de base de l'instance Keycloak
|
/// URL de base de l'instance Keycloak
|
||||||
static const String baseUrl = 'http://192.168.1.145:8180';
|
static const String baseUrl = 'http://192.168.1.11:8180';
|
||||||
|
|
||||||
/// Realm UnionFlow
|
/// Realm UnionFlow
|
||||||
static const String realm = 'unionflow';
|
static const String realm = 'unionflow';
|
||||||
@@ -273,7 +273,7 @@ class KeycloakWebViewAuthService {
|
|||||||
},
|
},
|
||||||
body: body,
|
body: body,
|
||||||
)
|
)
|
||||||
.timeout(Duration(seconds: KeycloakWebViewConfig.httpTimeoutSeconds));
|
.timeout(const Duration(seconds: KeycloakWebViewConfig.httpTimeoutSeconds));
|
||||||
|
|
||||||
debugPrint('📡 Réponse token endpoint: ${response.statusCode}');
|
debugPrint('📡 Réponse token endpoint: ${response.statusCode}');
|
||||||
|
|
||||||
@@ -371,7 +371,7 @@ class KeycloakWebViewAuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Vérifier l'issuer
|
// Vérifier l'issuer
|
||||||
final String expectedIssuer = '${KeycloakWebViewConfig.baseUrl}/realms/${KeycloakWebViewConfig.realm}';
|
const String expectedIssuer = '${KeycloakWebViewConfig.baseUrl}/realms/${KeycloakWebViewConfig.realm}';
|
||||||
if (payload['iss'] != expectedIssuer) {
|
if (payload['iss'] != expectedIssuer) {
|
||||||
throw KeycloakWebViewAuthException(
|
throw KeycloakWebViewAuthException(
|
||||||
'Token JWT invalide: issuer incorrect (attendu: $expectedIssuer, reçu: ${payload['iss']})',
|
'Token JWT invalide: issuer incorrect (attendu: $expectedIssuer, reçu: ${payload['iss']})',
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
/// Modèle pour les critères de recherche avancée des membres
|
||||||
|
/// Correspond au DTO Java MembreSearchCriteria
|
||||||
|
class MembreSearchCriteria {
|
||||||
|
/// Terme de recherche général (nom, prénom, email)
|
||||||
|
final String? query;
|
||||||
|
|
||||||
|
/// Recherche par nom exact ou partiel
|
||||||
|
final String? nom;
|
||||||
|
|
||||||
|
/// Recherche par prénom exact ou partiel
|
||||||
|
final String? prenom;
|
||||||
|
|
||||||
|
/// Recherche par email exact ou partiel
|
||||||
|
final String? email;
|
||||||
|
|
||||||
|
/// Filtre par numéro de téléphone
|
||||||
|
final String? telephone;
|
||||||
|
|
||||||
|
/// Liste des IDs d'organisations
|
||||||
|
final List<String>? organisationIds;
|
||||||
|
|
||||||
|
/// Liste des rôles à rechercher
|
||||||
|
final List<String>? roles;
|
||||||
|
|
||||||
|
/// Filtre par statut d'activité
|
||||||
|
final String? statut;
|
||||||
|
|
||||||
|
/// Date d'adhésion minimum (format ISO 8601)
|
||||||
|
final String? dateAdhesionMin;
|
||||||
|
|
||||||
|
/// Date d'adhésion maximum (format ISO 8601)
|
||||||
|
final String? dateAdhesionMax;
|
||||||
|
|
||||||
|
/// Âge minimum
|
||||||
|
final int? ageMin;
|
||||||
|
|
||||||
|
/// Âge maximum
|
||||||
|
final int? ageMax;
|
||||||
|
|
||||||
|
/// Filtre par région
|
||||||
|
final String? region;
|
||||||
|
|
||||||
|
/// Filtre par ville
|
||||||
|
final String? ville;
|
||||||
|
|
||||||
|
/// Filtre par profession
|
||||||
|
final String? profession;
|
||||||
|
|
||||||
|
/// Filtre par nationalité
|
||||||
|
final String? nationalite;
|
||||||
|
|
||||||
|
/// Filtre membres du bureau uniquement
|
||||||
|
final bool? membreBureau;
|
||||||
|
|
||||||
|
/// Filtre responsables uniquement
|
||||||
|
final bool? responsable;
|
||||||
|
|
||||||
|
/// Inclure les membres inactifs dans la recherche
|
||||||
|
final bool includeInactifs;
|
||||||
|
|
||||||
|
const MembreSearchCriteria({
|
||||||
|
this.query,
|
||||||
|
this.nom,
|
||||||
|
this.prenom,
|
||||||
|
this.email,
|
||||||
|
this.telephone,
|
||||||
|
this.organisationIds,
|
||||||
|
this.roles,
|
||||||
|
this.statut,
|
||||||
|
this.dateAdhesionMin,
|
||||||
|
this.dateAdhesionMax,
|
||||||
|
this.ageMin,
|
||||||
|
this.ageMax,
|
||||||
|
this.region,
|
||||||
|
this.ville,
|
||||||
|
this.profession,
|
||||||
|
this.nationalite,
|
||||||
|
this.membreBureau,
|
||||||
|
this.responsable,
|
||||||
|
this.includeInactifs = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Factory constructor pour créer depuis JSON
|
||||||
|
factory MembreSearchCriteria.fromJson(Map<String, dynamic> json) {
|
||||||
|
return MembreSearchCriteria(
|
||||||
|
query: json['query'] as String?,
|
||||||
|
nom: json['nom'] as String?,
|
||||||
|
prenom: json['prenom'] as String?,
|
||||||
|
email: json['email'] as String?,
|
||||||
|
telephone: json['telephone'] as String?,
|
||||||
|
organisationIds: (json['organisationIds'] as List<dynamic>?)?.cast<String>(),
|
||||||
|
roles: (json['roles'] as List<dynamic>?)?.cast<String>(),
|
||||||
|
statut: json['statut'] as String?,
|
||||||
|
dateAdhesionMin: json['dateAdhesionMin'] as String?,
|
||||||
|
dateAdhesionMax: json['dateAdhesionMax'] as String?,
|
||||||
|
ageMin: json['ageMin'] as int?,
|
||||||
|
ageMax: json['ageMax'] as int?,
|
||||||
|
region: json['region'] as String?,
|
||||||
|
ville: json['ville'] as String?,
|
||||||
|
profession: json['profession'] as String?,
|
||||||
|
nationalite: json['nationalite'] as String?,
|
||||||
|
membreBureau: json['membreBureau'] as bool?,
|
||||||
|
responsable: json['responsable'] as bool?,
|
||||||
|
includeInactifs: json['includeInactifs'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convertit vers JSON
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'query': query,
|
||||||
|
'nom': nom,
|
||||||
|
'prenom': prenom,
|
||||||
|
'email': email,
|
||||||
|
'telephone': telephone,
|
||||||
|
'organisationIds': organisationIds,
|
||||||
|
'roles': roles,
|
||||||
|
'statut': statut,
|
||||||
|
'dateAdhesionMin': dateAdhesionMin,
|
||||||
|
'dateAdhesionMax': dateAdhesionMax,
|
||||||
|
'ageMin': ageMin,
|
||||||
|
'ageMax': ageMax,
|
||||||
|
'region': region,
|
||||||
|
'ville': ville,
|
||||||
|
'profession': profession,
|
||||||
|
'nationalite': nationalite,
|
||||||
|
'membreBureau': membreBureau,
|
||||||
|
'responsable': responsable,
|
||||||
|
'includeInactifs': includeInactifs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie si au moins un critère de recherche est défini
|
||||||
|
bool get hasAnyCriteria {
|
||||||
|
return query?.isNotEmpty == true ||
|
||||||
|
nom?.isNotEmpty == true ||
|
||||||
|
prenom?.isNotEmpty == true ||
|
||||||
|
email?.isNotEmpty == true ||
|
||||||
|
telephone?.isNotEmpty == true ||
|
||||||
|
organisationIds?.isNotEmpty == true ||
|
||||||
|
roles?.isNotEmpty == true ||
|
||||||
|
statut?.isNotEmpty == true ||
|
||||||
|
dateAdhesionMin?.isNotEmpty == true ||
|
||||||
|
dateAdhesionMax?.isNotEmpty == true ||
|
||||||
|
ageMin != null ||
|
||||||
|
ageMax != null ||
|
||||||
|
region?.isNotEmpty == true ||
|
||||||
|
ville?.isNotEmpty == true ||
|
||||||
|
profession?.isNotEmpty == true ||
|
||||||
|
nationalite?.isNotEmpty == true ||
|
||||||
|
membreBureau != null ||
|
||||||
|
responsable != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Valide la cohérence des critères de recherche
|
||||||
|
bool get isValid {
|
||||||
|
// Validation des âges
|
||||||
|
if (ageMin != null && ageMax != null) {
|
||||||
|
if (ageMin! > ageMax!) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation des dates (si implémentée)
|
||||||
|
if (dateAdhesionMin != null && dateAdhesionMax != null) {
|
||||||
|
try {
|
||||||
|
final dateMin = DateTime.parse(dateAdhesionMin!);
|
||||||
|
final dateMax = DateTime.parse(dateAdhesionMax!);
|
||||||
|
if (dateMin.isAfter(dateMax)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne une description textuelle des critères actifs
|
||||||
|
String get description {
|
||||||
|
final parts = <String>[];
|
||||||
|
|
||||||
|
if (query?.isNotEmpty == true) parts.add("Recherche: '$query'");
|
||||||
|
if (nom?.isNotEmpty == true) parts.add("Nom: '$nom'");
|
||||||
|
if (prenom?.isNotEmpty == true) parts.add("Prénom: '$prenom'");
|
||||||
|
if (email?.isNotEmpty == true) parts.add("Email: '$email'");
|
||||||
|
if (statut?.isNotEmpty == true) parts.add("Statut: $statut");
|
||||||
|
if (organisationIds?.isNotEmpty == true) {
|
||||||
|
parts.add("Organisations: ${organisationIds!.length}");
|
||||||
|
}
|
||||||
|
if (roles?.isNotEmpty == true) {
|
||||||
|
parts.add("Rôles: ${roles!.join(', ')}");
|
||||||
|
}
|
||||||
|
if (dateAdhesionMin?.isNotEmpty == true) {
|
||||||
|
parts.add("Adhésion >= $dateAdhesionMin");
|
||||||
|
}
|
||||||
|
if (dateAdhesionMax?.isNotEmpty == true) {
|
||||||
|
parts.add("Adhésion <= $dateAdhesionMax");
|
||||||
|
}
|
||||||
|
if (ageMin != null) parts.add("Âge >= $ageMin");
|
||||||
|
if (ageMax != null) parts.add("Âge <= $ageMax");
|
||||||
|
if (region?.isNotEmpty == true) parts.add("Région: '$region'");
|
||||||
|
if (ville?.isNotEmpty == true) parts.add("Ville: '$ville'");
|
||||||
|
if (profession?.isNotEmpty == true) parts.add("Profession: '$profession'");
|
||||||
|
if (nationalite?.isNotEmpty == true) parts.add("Nationalité: '$nationalite'");
|
||||||
|
if (membreBureau == true) parts.add("Membre bureau");
|
||||||
|
if (responsable == true) parts.add("Responsable");
|
||||||
|
|
||||||
|
return parts.join(' • ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crée une copie avec des modifications
|
||||||
|
MembreSearchCriteria copyWith({
|
||||||
|
String? query,
|
||||||
|
String? nom,
|
||||||
|
String? prenom,
|
||||||
|
String? email,
|
||||||
|
String? telephone,
|
||||||
|
List<String>? organisationIds,
|
||||||
|
List<String>? roles,
|
||||||
|
String? statut,
|
||||||
|
String? dateAdhesionMin,
|
||||||
|
String? dateAdhesionMax,
|
||||||
|
int? ageMin,
|
||||||
|
int? ageMax,
|
||||||
|
String? region,
|
||||||
|
String? ville,
|
||||||
|
String? profession,
|
||||||
|
String? nationalite,
|
||||||
|
bool? membreBureau,
|
||||||
|
bool? responsable,
|
||||||
|
bool? includeInactifs,
|
||||||
|
}) {
|
||||||
|
return MembreSearchCriteria(
|
||||||
|
query: query ?? this.query,
|
||||||
|
nom: nom ?? this.nom,
|
||||||
|
prenom: prenom ?? this.prenom,
|
||||||
|
email: email ?? this.email,
|
||||||
|
telephone: telephone ?? this.telephone,
|
||||||
|
organisationIds: organisationIds ?? this.organisationIds,
|
||||||
|
roles: roles ?? this.roles,
|
||||||
|
statut: statut ?? this.statut,
|
||||||
|
dateAdhesionMin: dateAdhesionMin ?? this.dateAdhesionMin,
|
||||||
|
dateAdhesionMax: dateAdhesionMax ?? this.dateAdhesionMax,
|
||||||
|
ageMin: ageMin ?? this.ageMin,
|
||||||
|
ageMax: ageMax ?? this.ageMax,
|
||||||
|
region: region ?? this.region,
|
||||||
|
ville: ville ?? this.ville,
|
||||||
|
profession: profession ?? this.profession,
|
||||||
|
nationalite: nationalite ?? this.nationalite,
|
||||||
|
membreBureau: membreBureau ?? this.membreBureau,
|
||||||
|
responsable: responsable ?? this.responsable,
|
||||||
|
includeInactifs: includeInactifs ?? this.includeInactifs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Critères vides
|
||||||
|
static const empty = MembreSearchCriteria();
|
||||||
|
|
||||||
|
/// Critères pour recherche rapide par nom/prénom
|
||||||
|
static MembreSearchCriteria quickSearch(String query) {
|
||||||
|
return MembreSearchCriteria(query: query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Critères pour membres actifs uniquement
|
||||||
|
static const activeMembers = MembreSearchCriteria(
|
||||||
|
statut: 'ACTIF',
|
||||||
|
includeInactifs: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Critères pour membres du bureau
|
||||||
|
static const bureauMembers = MembreSearchCriteria(
|
||||||
|
membreBureau: true,
|
||||||
|
statut: 'ACTIF',
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is MembreSearchCriteria &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
query == other.query &&
|
||||||
|
nom == other.nom &&
|
||||||
|
prenom == other.prenom &&
|
||||||
|
email == other.email &&
|
||||||
|
telephone == other.telephone &&
|
||||||
|
organisationIds == other.organisationIds &&
|
||||||
|
roles == other.roles &&
|
||||||
|
statut == other.statut &&
|
||||||
|
dateAdhesionMin == other.dateAdhesionMin &&
|
||||||
|
dateAdhesionMax == other.dateAdhesionMax &&
|
||||||
|
ageMin == other.ageMin &&
|
||||||
|
ageMax == other.ageMax &&
|
||||||
|
region == other.region &&
|
||||||
|
ville == other.ville &&
|
||||||
|
profession == other.profession &&
|
||||||
|
nationalite == other.nationalite &&
|
||||||
|
membreBureau == other.membreBureau &&
|
||||||
|
responsable == other.responsable &&
|
||||||
|
includeInactifs == other.includeInactifs;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hashAll([
|
||||||
|
query,
|
||||||
|
nom,
|
||||||
|
prenom,
|
||||||
|
email,
|
||||||
|
telephone,
|
||||||
|
organisationIds,
|
||||||
|
roles,
|
||||||
|
statut,
|
||||||
|
dateAdhesionMin,
|
||||||
|
dateAdhesionMax,
|
||||||
|
ageMin,
|
||||||
|
ageMax,
|
||||||
|
region,
|
||||||
|
ville,
|
||||||
|
profession,
|
||||||
|
nationalite,
|
||||||
|
membreBureau,
|
||||||
|
responsable,
|
||||||
|
includeInactifs,
|
||||||
|
]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'MembreSearchCriteria(${description.isNotEmpty ? description : 'empty'})';
|
||||||
|
}
|
||||||
269
unionflow-mobile-apps/lib/core/models/membre_search_result.dart
Normal file
269
unionflow-mobile-apps/lib/core/models/membre_search_result.dart
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import 'membre_search_criteria.dart';
|
||||||
|
import '../../features/members/data/models/membre_model.dart';
|
||||||
|
|
||||||
|
/// Modèle pour les résultats de recherche avancée des membres
|
||||||
|
/// Correspond au DTO Java MembreSearchResultDTO
|
||||||
|
class MembreSearchResult {
|
||||||
|
/// Liste des membres trouvés
|
||||||
|
final List<MembreModel> membres;
|
||||||
|
|
||||||
|
/// Nombre total de résultats (toutes pages confondues)
|
||||||
|
final int totalElements;
|
||||||
|
|
||||||
|
/// Nombre total de pages
|
||||||
|
final int totalPages;
|
||||||
|
|
||||||
|
/// Numéro de la page actuelle (0-based)
|
||||||
|
final int currentPage;
|
||||||
|
|
||||||
|
/// Taille de la page
|
||||||
|
final int pageSize;
|
||||||
|
|
||||||
|
/// Nombre d'éléments sur la page actuelle
|
||||||
|
final int numberOfElements;
|
||||||
|
|
||||||
|
/// Indique s'il y a une page suivante
|
||||||
|
final bool hasNext;
|
||||||
|
|
||||||
|
/// Indique s'il y a une page précédente
|
||||||
|
final bool hasPrevious;
|
||||||
|
|
||||||
|
/// Indique si c'est la première page
|
||||||
|
final bool isFirst;
|
||||||
|
|
||||||
|
/// Indique si c'est la dernière page
|
||||||
|
final bool isLast;
|
||||||
|
|
||||||
|
/// Critères de recherche utilisés
|
||||||
|
final MembreSearchCriteria criteria;
|
||||||
|
|
||||||
|
/// Temps d'exécution de la recherche en millisecondes
|
||||||
|
final int executionTimeMs;
|
||||||
|
|
||||||
|
/// Statistiques de recherche
|
||||||
|
final SearchStatistics? statistics;
|
||||||
|
|
||||||
|
const MembreSearchResult({
|
||||||
|
required this.membres,
|
||||||
|
required this.totalElements,
|
||||||
|
required this.totalPages,
|
||||||
|
required this.currentPage,
|
||||||
|
required this.pageSize,
|
||||||
|
required this.numberOfElements,
|
||||||
|
required this.hasNext,
|
||||||
|
required this.hasPrevious,
|
||||||
|
required this.isFirst,
|
||||||
|
required this.isLast,
|
||||||
|
required this.criteria,
|
||||||
|
required this.executionTimeMs,
|
||||||
|
this.statistics,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Factory constructor pour créer depuis JSON
|
||||||
|
factory MembreSearchResult.fromJson(Map<String, dynamic> json) {
|
||||||
|
return MembreSearchResult(
|
||||||
|
membres: (json['membres'] as List<dynamic>?)
|
||||||
|
?.map((e) => MembreModel.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList() ?? [],
|
||||||
|
totalElements: json['totalElements'] as int? ?? 0,
|
||||||
|
totalPages: json['totalPages'] as int? ?? 0,
|
||||||
|
currentPage: json['currentPage'] as int? ?? 0,
|
||||||
|
pageSize: json['pageSize'] as int? ?? 20,
|
||||||
|
numberOfElements: json['numberOfElements'] as int? ?? 0,
|
||||||
|
hasNext: json['hasNext'] as bool? ?? false,
|
||||||
|
hasPrevious: json['hasPrevious'] as bool? ?? false,
|
||||||
|
isFirst: json['isFirst'] as bool? ?? true,
|
||||||
|
isLast: json['isLast'] as bool? ?? true,
|
||||||
|
criteria: MembreSearchCriteria.fromJson(json['criteria'] as Map<String, dynamic>? ?? {}),
|
||||||
|
executionTimeMs: json['executionTimeMs'] as int? ?? 0,
|
||||||
|
statistics: json['statistics'] != null
|
||||||
|
? SearchStatistics.fromJson(json['statistics'] as Map<String, dynamic>)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convertit vers JSON
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'membres': membres.map((e) => e.toJson()).toList(),
|
||||||
|
'totalElements': totalElements,
|
||||||
|
'totalPages': totalPages,
|
||||||
|
'currentPage': currentPage,
|
||||||
|
'pageSize': pageSize,
|
||||||
|
'numberOfElements': numberOfElements,
|
||||||
|
'hasNext': hasNext,
|
||||||
|
'hasPrevious': hasPrevious,
|
||||||
|
'isFirst': isFirst,
|
||||||
|
'isLast': isLast,
|
||||||
|
'criteria': criteria.toJson(),
|
||||||
|
'executionTimeMs': executionTimeMs,
|
||||||
|
'statistics': statistics?.toJson(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie si les résultats sont vides
|
||||||
|
bool get isEmpty => membres.isEmpty;
|
||||||
|
|
||||||
|
/// Vérifie si les résultats ne sont pas vides
|
||||||
|
bool get isNotEmpty => membres.isNotEmpty;
|
||||||
|
|
||||||
|
/// Retourne le numéro de la page suivante (1-based pour affichage)
|
||||||
|
int get nextPageNumber => hasNext ? currentPage + 2 : -1;
|
||||||
|
|
||||||
|
/// Retourne le numéro de la page précédente (1-based pour affichage)
|
||||||
|
int get previousPageNumber => hasPrevious ? currentPage : -1;
|
||||||
|
|
||||||
|
/// Retourne une description textuelle des résultats
|
||||||
|
String get resultDescription {
|
||||||
|
if (isEmpty) {
|
||||||
|
return 'Aucun membre trouvé';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalElements == 1) {
|
||||||
|
return '1 membre trouvé';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalPages == 1) {
|
||||||
|
return '$totalElements membres trouvés';
|
||||||
|
}
|
||||||
|
|
||||||
|
final startElement = currentPage * pageSize + 1;
|
||||||
|
final endElement = (startElement + numberOfElements - 1).clamp(1, totalElements);
|
||||||
|
|
||||||
|
return 'Membres $startElement-$endElement sur $totalElements (page ${currentPage + 1}/$totalPages)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Résultat vide
|
||||||
|
static MembreSearchResult empty(MembreSearchCriteria criteria) {
|
||||||
|
return MembreSearchResult(
|
||||||
|
membres: const [],
|
||||||
|
totalElements: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
currentPage: 0,
|
||||||
|
pageSize: 20,
|
||||||
|
numberOfElements: 0,
|
||||||
|
hasNext: false,
|
||||||
|
hasPrevious: false,
|
||||||
|
isFirst: true,
|
||||||
|
isLast: true,
|
||||||
|
criteria: criteria,
|
||||||
|
executionTimeMs: 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'MembreSearchResult($resultDescription, ${executionTimeMs}ms)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Statistiques sur les résultats de recherche
|
||||||
|
class SearchStatistics {
|
||||||
|
/// Nombre de membres actifs dans les résultats
|
||||||
|
final int membresActifs;
|
||||||
|
|
||||||
|
/// Nombre de membres inactifs dans les résultats
|
||||||
|
final int membresInactifs;
|
||||||
|
|
||||||
|
/// Âge moyen des membres trouvés
|
||||||
|
final double ageMoyen;
|
||||||
|
|
||||||
|
/// Âge minimum des membres trouvés
|
||||||
|
final int ageMin;
|
||||||
|
|
||||||
|
/// Âge maximum des membres trouvés
|
||||||
|
final int ageMax;
|
||||||
|
|
||||||
|
/// Nombre d'organisations représentées
|
||||||
|
final int nombreOrganisations;
|
||||||
|
|
||||||
|
/// Nombre de régions représentées
|
||||||
|
final int nombreRegions;
|
||||||
|
|
||||||
|
/// Ancienneté moyenne en années
|
||||||
|
final double ancienneteMoyenne;
|
||||||
|
|
||||||
|
const SearchStatistics({
|
||||||
|
required this.membresActifs,
|
||||||
|
required this.membresInactifs,
|
||||||
|
required this.ageMoyen,
|
||||||
|
required this.ageMin,
|
||||||
|
required this.ageMax,
|
||||||
|
required this.nombreOrganisations,
|
||||||
|
required this.nombreRegions,
|
||||||
|
required this.ancienneteMoyenne,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Factory constructor pour créer depuis JSON
|
||||||
|
factory SearchStatistics.fromJson(Map<String, dynamic> json) {
|
||||||
|
return SearchStatistics(
|
||||||
|
membresActifs: json['membresActifs'] as int? ?? 0,
|
||||||
|
membresInactifs: json['membresInactifs'] as int? ?? 0,
|
||||||
|
ageMoyen: (json['ageMoyen'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
ageMin: json['ageMin'] as int? ?? 0,
|
||||||
|
ageMax: json['ageMax'] as int? ?? 0,
|
||||||
|
nombreOrganisations: json['nombreOrganisations'] as int? ?? 0,
|
||||||
|
nombreRegions: json['nombreRegions'] as int? ?? 0,
|
||||||
|
ancienneteMoyenne: (json['ancienneteMoyenne'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convertit vers JSON
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'membresActifs': membresActifs,
|
||||||
|
'membresInactifs': membresInactifs,
|
||||||
|
'ageMoyen': ageMoyen,
|
||||||
|
'ageMin': ageMin,
|
||||||
|
'ageMax': ageMax,
|
||||||
|
'nombreOrganisations': nombreOrganisations,
|
||||||
|
'nombreRegions': nombreRegions,
|
||||||
|
'ancienneteMoyenne': ancienneteMoyenne,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nombre total de membres
|
||||||
|
int get totalMembres => membresActifs + membresInactifs;
|
||||||
|
|
||||||
|
/// Pourcentage de membres actifs
|
||||||
|
double get pourcentageActifs {
|
||||||
|
if (totalMembres == 0) return 0.0;
|
||||||
|
return (membresActifs / totalMembres) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pourcentage de membres inactifs
|
||||||
|
double get pourcentageInactifs {
|
||||||
|
if (totalMembres == 0) return 0.0;
|
||||||
|
return (membresInactifs / totalMembres) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tranche d'âge
|
||||||
|
String get trancheAge {
|
||||||
|
if (ageMin == ageMax) return '$ageMin ans';
|
||||||
|
return '$ageMin-$ageMax ans';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Description textuelle des statistiques
|
||||||
|
String get description {
|
||||||
|
final parts = <String>[];
|
||||||
|
|
||||||
|
if (totalMembres > 0) {
|
||||||
|
parts.add('$totalMembres membres');
|
||||||
|
if (membresActifs > 0) {
|
||||||
|
parts.add('${pourcentageActifs.toStringAsFixed(1)}% actifs');
|
||||||
|
}
|
||||||
|
if (ageMoyen > 0) {
|
||||||
|
parts.add('âge moyen: ${ageMoyen.toStringAsFixed(1)} ans');
|
||||||
|
}
|
||||||
|
if (nombreOrganisations > 0) {
|
||||||
|
parts.add('$nombreOrganisations organisations');
|
||||||
|
}
|
||||||
|
if (ancienneteMoyenne > 0) {
|
||||||
|
parts.add('ancienneté: ${ancienneteMoyenne.toStringAsFixed(1)} ans');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(' • ');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'SearchStatistics($description)';
|
||||||
|
}
|
||||||
43
unionflow-mobile-apps/lib/core/navigation/app_router.dart
Normal file
43
unionflow-mobile-apps/lib/core/navigation/app_router.dart
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
|
import '../auth/bloc/auth_bloc.dart';
|
||||||
|
import '../../features/auth/presentation/pages/login_page.dart';
|
||||||
|
import 'main_navigation_layout.dart';
|
||||||
|
|
||||||
|
/// Configuration du routeur principal de l'application
|
||||||
|
class AppRouter {
|
||||||
|
static final GoRouter router = GoRouter(
|
||||||
|
initialLocation: '/',
|
||||||
|
redirect: (context, state) {
|
||||||
|
final authState = context.read<AuthBloc>().state;
|
||||||
|
final isAuthenticated = authState is AuthAuthenticated;
|
||||||
|
final isOnLoginPage = state.matchedLocation == '/login';
|
||||||
|
|
||||||
|
// Si pas authentifié et pas sur la page de login, rediriger vers login
|
||||||
|
if (!isAuthenticated && !isOnLoginPage) {
|
||||||
|
return '/login';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si authentifié et sur la page de login, rediriger vers dashboard
|
||||||
|
if (isAuthenticated && isOnLoginPage) {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null; // Pas de redirection
|
||||||
|
},
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/login',
|
||||||
|
name: 'login',
|
||||||
|
builder: (context, state) => const LoginPage(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/',
|
||||||
|
name: 'main',
|
||||||
|
builder: (context, state) => const MainNavigationLayout(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
|
import '../auth/bloc/auth_bloc.dart';
|
||||||
|
import '../auth/models/user_role.dart';
|
||||||
|
|
||||||
|
import '../design_system/tokens/tokens.dart';
|
||||||
|
import '../../features/dashboard/presentation/pages/role_dashboards/role_dashboards.dart';
|
||||||
|
import '../../features/members/presentation/pages/members_page.dart';
|
||||||
|
import '../../features/events/presentation/pages/events_page.dart';
|
||||||
|
|
||||||
|
/// Layout principal avec navigation hybride
|
||||||
|
/// Bottom Navigation pour les sections principales + Drawer pour fonctions avancées
|
||||||
|
class MainNavigationLayout extends StatefulWidget {
|
||||||
|
const MainNavigationLayout({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MainNavigationLayout> createState() => _MainNavigationLayoutState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MainNavigationLayoutState extends State<MainNavigationLayout> {
|
||||||
|
int _selectedIndex = 0;
|
||||||
|
|
||||||
|
/// Obtient le dashboard approprié selon le rôle de l'utilisateur
|
||||||
|
Widget _getDashboardForRole(UserRole role) {
|
||||||
|
switch (role) {
|
||||||
|
case UserRole.superAdmin:
|
||||||
|
return const SuperAdminDashboard();
|
||||||
|
case UserRole.orgAdmin:
|
||||||
|
return const OrgAdminDashboard();
|
||||||
|
case UserRole.moderator:
|
||||||
|
return const ModeratorDashboard();
|
||||||
|
case UserRole.activeMember:
|
||||||
|
return const ActiveMemberDashboard();
|
||||||
|
case UserRole.simpleMember:
|
||||||
|
return const SimpleMemberDashboard();
|
||||||
|
case UserRole.visitor:
|
||||||
|
return const VisitorDashboard();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _getPages(UserRole role) {
|
||||||
|
return [
|
||||||
|
_getDashboardForRole(role),
|
||||||
|
const MembersPage(),
|
||||||
|
const EventsPage(),
|
||||||
|
const MorePage(), // Page "Plus" qui affiche les options avancées
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocBuilder<AuthBloc, AuthState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state is! AuthAuthenticated) {
|
||||||
|
return const Scaffold(
|
||||||
|
body: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: IndexedStack(
|
||||||
|
index: _selectedIndex,
|
||||||
|
children: _getPages(state.effectiveRole),
|
||||||
|
),
|
||||||
|
bottomNavigationBar: BottomNavigationBar(
|
||||||
|
type: BottomNavigationBarType.fixed,
|
||||||
|
currentIndex: _selectedIndex,
|
||||||
|
onTap: (index) {
|
||||||
|
setState(() {
|
||||||
|
_selectedIndex = index;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
selectedItemColor: ColorTokens.primary,
|
||||||
|
unselectedItemColor: Colors.grey,
|
||||||
|
items: const [
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.dashboard),
|
||||||
|
label: 'Dashboard',
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.people),
|
||||||
|
label: 'Membres',
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.event),
|
||||||
|
label: 'Événements',
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.more_horiz),
|
||||||
|
label: 'Plus',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Page "Plus" avec les fonctions avancées selon le rôle
|
||||||
|
class MorePage extends StatelessWidget {
|
||||||
|
const MorePage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocBuilder<AuthBloc, AuthState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state is! AuthAuthenticated) {
|
||||||
|
return Container(
|
||||||
|
color: const Color(0xFFF8F9FA),
|
||||||
|
child: const Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
color: const Color(0xFFF8F9FA),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Titre de la section
|
||||||
|
const Text(
|
||||||
|
'Plus d\'Options',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Color(0xFF6C5CE7),
|
||||||
|
fontSize: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Profil utilisateur
|
||||||
|
_buildUserProfile(state),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Options selon le rôle
|
||||||
|
..._buildRoleBasedOptions(state),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Options communes
|
||||||
|
..._buildCommonOptions(context),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildUserProfile(AuthAuthenticated state) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF6C5CE7),
|
||||||
|
borderRadius: BorderRadius.circular(25),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
state.user.firstName.isNotEmpty ? state.user.firstName[0].toUpperCase() : 'U',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${state.user.firstName} ${state.user.lastName}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF374151),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
state.effectiveRole.displayName,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Color(0xFF6C5CE7),
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
state.user.email,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Color(0xFF6B7280),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildRoleBasedOptions(AuthAuthenticated state) {
|
||||||
|
final options = <Widget>[];
|
||||||
|
|
||||||
|
// Options Super Admin
|
||||||
|
if (state.effectiveRole == UserRole.superAdmin) {
|
||||||
|
options.addAll([
|
||||||
|
_buildSectionTitle('Administration Système'),
|
||||||
|
_buildOptionTile(
|
||||||
|
icon: Icons.settings,
|
||||||
|
title: 'Paramètres Système',
|
||||||
|
subtitle: 'Configuration globale',
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
_buildOptionTile(
|
||||||
|
icon: Icons.backup,
|
||||||
|
title: 'Sauvegarde',
|
||||||
|
subtitle: 'Gestion des sauvegardes',
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
_buildOptionTile(
|
||||||
|
icon: Icons.analytics,
|
||||||
|
title: 'Logs Système',
|
||||||
|
subtitle: 'Surveillance et logs',
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options Admin Organisation
|
||||||
|
if (state.effectiveRole == UserRole.orgAdmin || state.effectiveRole == UserRole.superAdmin) {
|
||||||
|
options.addAll([
|
||||||
|
_buildSectionTitle('Administration'),
|
||||||
|
_buildOptionTile(
|
||||||
|
icon: Icons.business,
|
||||||
|
title: 'Gestion Organisation',
|
||||||
|
subtitle: 'Paramètres organisation',
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
_buildOptionTile(
|
||||||
|
icon: Icons.assessment,
|
||||||
|
title: 'Rapports',
|
||||||
|
subtitle: 'Rapports et statistiques',
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options RH
|
||||||
|
if (state.effectiveRole == UserRole.moderator || state.effectiveRole == UserRole.superAdmin) {
|
||||||
|
options.addAll([
|
||||||
|
_buildSectionTitle('Ressources Humaines'),
|
||||||
|
_buildOptionTile(
|
||||||
|
icon: Icons.people_alt,
|
||||||
|
title: 'Gestion RH',
|
||||||
|
subtitle: 'Outils RH avancés',
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildCommonOptions(BuildContext context) {
|
||||||
|
return [
|
||||||
|
_buildSectionTitle('Général'),
|
||||||
|
_buildOptionTile(
|
||||||
|
icon: Icons.person,
|
||||||
|
title: 'Mon Profil',
|
||||||
|
subtitle: 'Modifier mes informations',
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
_buildOptionTile(
|
||||||
|
icon: Icons.notifications,
|
||||||
|
title: 'Notifications',
|
||||||
|
subtitle: 'Gérer les notifications',
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
_buildOptionTile(
|
||||||
|
icon: Icons.help,
|
||||||
|
title: 'Aide & Support',
|
||||||
|
subtitle: 'Documentation et support',
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
_buildOptionTile(
|
||||||
|
icon: Icons.info,
|
||||||
|
title: 'À propos',
|
||||||
|
subtitle: 'Version et informations',
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildOptionTile(
|
||||||
|
icon: Icons.logout,
|
||||||
|
title: 'Déconnexion',
|
||||||
|
subtitle: 'Se déconnecter de l\'application',
|
||||||
|
color: Colors.red,
|
||||||
|
onTap: () {
|
||||||
|
context.read<AuthBloc>().add(AuthLogoutRequested());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSectionTitle(String title) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
top: 16,
|
||||||
|
bottom: 8,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Color(0xFF6C5CE7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildOptionTile({
|
||||||
|
required IconData icon,
|
||||||
|
required String title,
|
||||||
|
required String subtitle,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
Color? color,
|
||||||
|
}) {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: (color ?? const Color(0xFF6C5CE7)).withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
color: color ?? const Color(0xFF6C5CE7),
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: color ?? const Color(0xFF374151),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Color(0xFF6B7280),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(
|
||||||
|
Icons.chevron_right,
|
||||||
|
color: Color(0xFF6B7280),
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
/// Dashboard Page Stable - Redirecteur vers Dashboard Adaptatif
|
|
||||||
/// Redirige automatiquement vers le nouveau système de dashboard adaptatif
|
|
||||||
library dashboard_page_stable;
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'adaptive_dashboard_page.dart';
|
|
||||||
|
|
||||||
/// Page Dashboard Stable - Maintenant un redirecteur
|
|
||||||
///
|
|
||||||
/// Cette page redirige automatiquement vers le nouveau système
|
|
||||||
/// de dashboard adaptatif basé sur les rôles utilisateurs.
|
|
||||||
class DashboardPageStable extends StatefulWidget {
|
|
||||||
const DashboardPageStable({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<DashboardPageStable> createState() => _DashboardPageStableState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DashboardPageStableState extends State<DashboardPageStable> {
|
|
||||||
final GlobalKey<RefreshIndicatorState> _refreshKey = GlobalKey<RefreshIndicatorState>();
|
|
||||||
bool _isLoading = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: ColorTokens.surface,
|
|
||||||
appBar: AppBar(
|
|
||||||
title: Text(
|
|
||||||
'UnionFlow Dashboard',
|
|
||||||
style: TypographyTokens.headlineSmall.copyWith(
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: ColorTokens.onSurface,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
backgroundColor: ColorTokens.surface,
|
|
||||||
elevation: 0,
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
onPressed: () => _showNotifications(),
|
|
||||||
icon: const Icon(Icons.notifications_outlined),
|
|
||||||
tooltip: 'Notifications',
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
onPressed: () => _showSettings(),
|
|
||||||
icon: const Icon(Icons.settings_outlined),
|
|
||||||
tooltip: 'Paramètres',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
drawer: DashboardDrawer(
|
|
||||||
onNavigate: _onNavigate,
|
|
||||||
onLogout: _onLogout,
|
|
||||||
),
|
|
||||||
body: RefreshIndicator(
|
|
||||||
key: _refreshKey,
|
|
||||||
onRefresh: _refreshData,
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.all(SpacingTokens.md),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// Message de bienvenue
|
|
||||||
DashboardWelcomeSection(
|
|
||||||
title: 'Bienvenue sur UnionFlow',
|
|
||||||
subtitle: 'Votre plateforme de gestion d\'union familiale',
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: SpacingTokens.xl),
|
|
||||||
|
|
||||||
// Statistiques
|
|
||||||
DashboardStatsGrid(
|
|
||||||
onStatTap: _onStatTap,
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: SpacingTokens.xl),
|
|
||||||
|
|
||||||
// Actions rapides
|
|
||||||
DashboardQuickActionsGrid(
|
|
||||||
onActionTap: _onActionTap,
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: SpacingTokens.xl),
|
|
||||||
|
|
||||||
// Activité récente
|
|
||||||
DashboardRecentActivitySection(
|
|
||||||
onActivityTap: _onActivityTap,
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: SpacingTokens.xl),
|
|
||||||
|
|
||||||
// Insights
|
|
||||||
DashboardInsightsSection(
|
|
||||||
onMetricTap: _onMetricTap,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === CALLBACKS POUR LES WIDGETS MODULAIRES ===
|
|
||||||
|
|
||||||
/// Callback pour les actions sur les statistiques
|
|
||||||
void _onStatTap(String statType) {
|
|
||||||
debugPrint('Statistique tapée: $statType');
|
|
||||||
// TODO: Implémenter la navigation vers les détails
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Callback pour les actions rapides
|
|
||||||
void _onActionTap(String actionType) {
|
|
||||||
debugPrint('Action rapide: $actionType');
|
|
||||||
// TODO: Implémenter les actions spécifiques
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Callback pour les activités récentes
|
|
||||||
void _onActivityTap(String activityId) {
|
|
||||||
debugPrint('Activité tapée: $activityId');
|
|
||||||
// TODO: Implémenter la navigation vers les détails
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Callback pour les métriques d'insights
|
|
||||||
void _onMetricTap(String metricType) {
|
|
||||||
debugPrint('Métrique tapée: $metricType');
|
|
||||||
// TODO: Implémenter la navigation vers les rapports
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Callback pour la navigation du drawer
|
|
||||||
void _onNavigate(String route) {
|
|
||||||
Navigator.of(context).pop(); // Fermer le drawer
|
|
||||||
debugPrint('Navigation vers: $route');
|
|
||||||
// TODO: Implémenter la navigation
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Callback pour la déconnexion
|
|
||||||
void _onLogout() {
|
|
||||||
Navigator.of(context).pop(); // Fermer le drawer
|
|
||||||
debugPrint('Déconnexion demandée');
|
|
||||||
// TODO: Implémenter la déconnexion
|
|
||||||
}
|
|
||||||
|
|
||||||
// === MÉTHODES UTILITAIRES ===
|
|
||||||
|
|
||||||
/// Actualise les données du dashboard
|
|
||||||
Future<void> _refreshData() async {
|
|
||||||
setState(() {
|
|
||||||
_isLoading = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Simulation d'un appel API
|
|
||||||
await Future.delayed(const Duration(seconds: 1));
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('Données actualisées'),
|
|
||||||
duration: Duration(seconds: 2),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Affiche les notifications
|
|
||||||
void _showNotifications() {
|
|
||||||
debugPrint('Afficher les notifications');
|
|
||||||
// TODO: Implémenter l'affichage des notifications
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Affiche les paramètres
|
|
||||||
void _showSettings() {
|
|
||||||
debugPrint('Afficher les paramètres');
|
|
||||||
// TODO: Implémenter l'affichage des paramètres
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,322 +1,275 @@
|
|||||||
/// Dashboard Membre Actif - Activity Center Personnalisé
|
|
||||||
/// Interface personnalisée pour participation active
|
|
||||||
library active_member_dashboard;
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../../../core/design_system/tokens/tokens.dart';
|
|
||||||
import '../../widgets/widgets.dart';
|
|
||||||
|
|
||||||
/// Dashboard Activity Center pour Membre Actif
|
/// Dashboard simple pour Membre Actif
|
||||||
class ActiveMemberDashboard extends StatelessWidget {
|
class ActiveMemberDashboard extends StatelessWidget {
|
||||||
const ActiveMemberDashboard({super.key});
|
const ActiveMemberDashboard({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return SingleChildScrollView(
|
||||||
backgroundColor: ColorTokens.surface,
|
padding: const EdgeInsets.all(16),
|
||||||
body: CustomScrollView(
|
child: Column(
|
||||||
slivers: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
// App Bar Membre Actif
|
children: [
|
||||||
SliverAppBar(
|
// En-tête de bienvenue
|
||||||
expandedHeight: 160,
|
Container(
|
||||||
floating: false,
|
width: double.infinity,
|
||||||
pinned: true,
|
padding: const EdgeInsets.all(20),
|
||||||
backgroundColor: const Color(0xFF00B894), // Vert communauté
|
decoration: BoxDecoration(
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
gradient: const LinearGradient(
|
||||||
title: const Text(
|
colors: [Color(0xFF00B894), Color(0xFF00CEC9)],
|
||||||
'Activity Center',
|
begin: Alignment.topLeft,
|
||||||
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
|
end: Alignment.bottomRight,
|
||||||
),
|
|
||||||
background: Container(
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [Color(0xFF00B894), Color(0xFF00A085)],
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: const Center(
|
|
||||||
child: Icon(Icons.groups, color: Colors.white, size: 60),
|
|
||||||
),
|
),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
),
|
child: const Column(
|
||||||
),
|
|
||||||
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(SpacingTokens.md),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Bienvenue personnalisé
|
Text(
|
||||||
_buildPersonalizedWelcome(),
|
'Bonjour !',
|
||||||
const SizedBox(height: SpacingTokens.xl),
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
// Mes statistiques
|
fontSize: 24,
|
||||||
_buildMyStats(),
|
fontWeight: FontWeight.bold,
|
||||||
const SizedBox(height: SpacingTokens.xl),
|
),
|
||||||
|
),
|
||||||
// Actions membres
|
SizedBox(height: 8),
|
||||||
_buildMemberActions(),
|
Text(
|
||||||
const SizedBox(height: SpacingTokens.xl),
|
'Bienvenue sur votre espace membre',
|
||||||
|
style: TextStyle(
|
||||||
// Événements à venir
|
color: Colors.white70,
|
||||||
_buildUpcomingEvents(),
|
fontSize: 16,
|
||||||
const SizedBox(height: SpacingTokens.xl),
|
),
|
||||||
|
),
|
||||||
// Mon activité
|
|
||||||
_buildMyActivity(),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
const SizedBox(height: 24),
|
||||||
),
|
|
||||||
);
|
// Statistiques rapides
|
||||||
}
|
const Text(
|
||||||
|
'Mes Statistiques',
|
||||||
Widget _buildPersonalizedWelcome() {
|
style: TextStyle(
|
||||||
return Container(
|
fontSize: 20,
|
||||||
padding: const EdgeInsets.all(SpacingTokens.lg),
|
fontWeight: FontWeight.bold,
|
||||||
decoration: BoxDecoration(
|
),
|
||||||
gradient: const LinearGradient(
|
),
|
||||||
colors: [Color(0xFF00B894), Color(0xFF00CEC9)],
|
const SizedBox(height: 16),
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
GridView.count(
|
||||||
),
|
shrinkWrap: true,
|
||||||
borderRadius: BorderRadius.circular(RadiusTokens.lg),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
),
|
crossAxisCount: 2,
|
||||||
child: Row(
|
childAspectRatio: 1.2,
|
||||||
children: [
|
crossAxisSpacing: 16,
|
||||||
const CircleAvatar(
|
mainAxisSpacing: 16,
|
||||||
radius: 30,
|
|
||||||
backgroundColor: Colors.white,
|
|
||||||
child: Icon(Icons.person, color: Color(0xFF00B894), size: 30),
|
|
||||||
),
|
|
||||||
const SizedBox(width: SpacingTokens.md),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
Text(
|
_buildStatCard(
|
||||||
'Bonjour, Marie !',
|
icon: Icons.event_available,
|
||||||
style: TypographyTokens.headlineMedium.copyWith(
|
value: '12',
|
||||||
color: Colors.white,
|
title: 'Événements',
|
||||||
fontWeight: FontWeight.bold,
|
color: const Color(0xFF00B894),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Text(
|
_buildStatCard(
|
||||||
'Membre depuis 2 ans • Niveau Actif',
|
icon: Icons.volunteer_activism,
|
||||||
style: TypographyTokens.bodyMedium.copyWith(
|
value: '3',
|
||||||
color: Colors.white.withOpacity(0.9),
|
title: 'Solidarité',
|
||||||
),
|
color: const Color(0xFF00CEC9),
|
||||||
|
),
|
||||||
|
_buildStatCard(
|
||||||
|
icon: Icons.payment,
|
||||||
|
value: 'À jour',
|
||||||
|
title: 'Cotisations',
|
||||||
|
color: const Color(0xFF0984E3),
|
||||||
|
),
|
||||||
|
_buildStatCard(
|
||||||
|
icon: Icons.star,
|
||||||
|
value: '4.8',
|
||||||
|
title: 'Engagement',
|
||||||
|
color: const Color(0xFFE17055),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Actions rapides
|
||||||
|
const Text(
|
||||||
|
'Actions Rapides',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
GridView.count(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
crossAxisCount: 2,
|
||||||
|
childAspectRatio: 1.5,
|
||||||
|
crossAxisSpacing: 16,
|
||||||
|
mainAxisSpacing: 16,
|
||||||
|
children: [
|
||||||
|
_buildActionCard(
|
||||||
|
icon: Icons.event,
|
||||||
|
title: 'Créer Événement',
|
||||||
|
color: const Color(0xFF00B894),
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
_buildActionCard(
|
||||||
|
icon: Icons.volunteer_activism,
|
||||||
|
title: 'Demande Aide',
|
||||||
|
color: const Color(0xFF00CEC9),
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
_buildActionCard(
|
||||||
|
icon: Icons.account_circle,
|
||||||
|
title: 'Mon Profil',
|
||||||
|
color: const Color(0xFF0984E3),
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
_buildActionCard(
|
||||||
|
icon: Icons.message,
|
||||||
|
title: 'Contacter',
|
||||||
|
color: const Color(0xFFE17055),
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Activités récentes
|
||||||
|
const Text(
|
||||||
|
'Activités Récentes',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
Card(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_buildActivityItem(
|
||||||
|
icon: Icons.check_circle,
|
||||||
|
title: 'Participation confirmée',
|
||||||
|
subtitle: 'Assemblée Générale - Il y a 2h',
|
||||||
|
color: const Color(0xFF00B894),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
_buildActivityItem(
|
||||||
|
icon: Icons.payment,
|
||||||
|
title: 'Cotisation payée',
|
||||||
|
subtitle: 'Décembre 2024 - Il y a 1j',
|
||||||
|
color: const Color(0xFF0984E3),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
_buildActivityItem(
|
||||||
|
icon: Icons.event,
|
||||||
|
title: 'Événement créé',
|
||||||
|
subtitle: 'Sortie ski de fond - Il y a 3j',
|
||||||
|
color: const Color(0xFF00CEC9),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatCard({
|
||||||
|
required IconData icon,
|
||||||
|
required String value,
|
||||||
|
required String title,
|
||||||
|
required Color color,
|
||||||
|
}) {
|
||||||
|
return Card(
|
||||||
|
elevation: 2,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: color, size: 32),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMyStats() {
|
Widget _buildActionCard({
|
||||||
return Column(
|
required IconData icon,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
required String title,
|
||||||
children: [
|
required Color color,
|
||||||
Text(
|
required VoidCallback onTap,
|
||||||
'Mes Statistiques',
|
}) {
|
||||||
style: TypographyTokens.headlineMedium.copyWith(fontWeight: FontWeight.bold),
|
return Card(
|
||||||
),
|
elevation: 2,
|
||||||
const SizedBox(height: SpacingTokens.md),
|
child: InkWell(
|
||||||
DashboardStatsGrid(
|
onTap: onTap,
|
||||||
stats: [
|
borderRadius: BorderRadius.circular(8),
|
||||||
DashboardStat(
|
child: Padding(
|
||||||
icon: Icons.event_available,
|
padding: const EdgeInsets.all(16),
|
||||||
value: '12',
|
|
||||||
title: 'Événements',
|
|
||||||
color: const Color(0xFF00B894),
|
|
||||||
onTap: () {},
|
|
||||||
),
|
|
||||||
DashboardStat(
|
|
||||||
icon: Icons.volunteer_activism,
|
|
||||||
value: '3',
|
|
||||||
title: 'Solidarité',
|
|
||||||
color: const Color(0xFF00CEC9),
|
|
||||||
onTap: () {},
|
|
||||||
),
|
|
||||||
DashboardStat(
|
|
||||||
icon: Icons.payment,
|
|
||||||
value: 'À jour',
|
|
||||||
title: 'Cotisations',
|
|
||||||
color: const Color(0xFF0984E3),
|
|
||||||
onTap: () {},
|
|
||||||
),
|
|
||||||
DashboardStat(
|
|
||||||
icon: Icons.star,
|
|
||||||
value: '4.8',
|
|
||||||
title: 'Engagement',
|
|
||||||
color: const Color(0xFFE17055),
|
|
||||||
onTap: () {},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
onStatTap: (type) {},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildMemberActions() {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Actions Rapides',
|
|
||||||
style: TypographyTokens.headlineMedium.copyWith(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
const SizedBox(height: SpacingTokens.md),
|
|
||||||
DashboardQuickActionsGrid(
|
|
||||||
actions: [
|
|
||||||
DashboardQuickAction(
|
|
||||||
icon: Icons.event,
|
|
||||||
title: 'Créer Événement',
|
|
||||||
subtitle: 'Organiser activité',
|
|
||||||
color: const Color(0xFF00B894),
|
|
||||||
onTap: () {},
|
|
||||||
),
|
|
||||||
DashboardQuickAction(
|
|
||||||
icon: Icons.volunteer_activism,
|
|
||||||
title: 'Demande Aide',
|
|
||||||
subtitle: 'Solidarité',
|
|
||||||
color: const Color(0xFF00CEC9),
|
|
||||||
onTap: () {},
|
|
||||||
),
|
|
||||||
DashboardQuickAction(
|
|
||||||
icon: Icons.account_circle,
|
|
||||||
title: 'Mon Profil',
|
|
||||||
subtitle: 'Modifier infos',
|
|
||||||
color: const Color(0xFF0984E3),
|
|
||||||
onTap: () {},
|
|
||||||
),
|
|
||||||
DashboardQuickAction(
|
|
||||||
icon: Icons.message,
|
|
||||||
title: 'Contacter',
|
|
||||||
subtitle: 'Support',
|
|
||||||
color: const Color(0xFFE17055),
|
|
||||||
onTap: () {},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
onActionTap: (type) {},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildUpcomingEvents() {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Événements à Venir',
|
|
||||||
style: TypographyTokens.headlineMedium.copyWith(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {},
|
|
||||||
child: const Text('Voir tout'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: SpacingTokens.md),
|
|
||||||
Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(RadiusTokens.md),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withOpacity(0.05),
|
|
||||||
blurRadius: 10,
|
|
||||||
offset: const Offset(0, 2),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
Icon(icon, color: color, size: 28),
|
||||||
leading: Container(
|
const SizedBox(height: 8),
|
||||||
width: 50,
|
Text(
|
||||||
height: 50,
|
title,
|
||||||
decoration: BoxDecoration(
|
style: const TextStyle(
|
||||||
color: const Color(0xFF00B894).withOpacity(0.1),
|
fontSize: 14,
|
||||||
borderRadius: BorderRadius.circular(25),
|
fontWeight: FontWeight.w500,
|
||||||
),
|
|
||||||
child: const Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Text('15', style: TextStyle(fontWeight: FontWeight.bold)),
|
|
||||||
Text('DÉC', style: TextStyle(fontSize: 10)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
title: const Text('Assemblée Générale'),
|
textAlign: TextAlign.center,
|
||||||
subtitle: const Text('Salle communale • 19h00'),
|
|
||||||
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
|
|
||||||
),
|
|
||||||
const Divider(height: 1),
|
|
||||||
ListTile(
|
|
||||||
leading: Container(
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFF00CEC9).withOpacity(0.1),
|
|
||||||
borderRadius: BorderRadius.circular(25),
|
|
||||||
),
|
|
||||||
child: const Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Text('22', style: TextStyle(fontWeight: FontWeight.bold)),
|
|
||||||
Text('DÉC', style: TextStyle(fontSize: 10)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title: const Text('Soirée de Noël'),
|
|
||||||
subtitle: const Text('Restaurant Le Gourmet • 20h00'),
|
|
||||||
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMyActivity() {
|
Widget _buildActivityItem({
|
||||||
return DashboardRecentActivitySection(
|
required IconData icon,
|
||||||
activities: [
|
required String title,
|
||||||
DashboardActivity(
|
required String subtitle,
|
||||||
title: 'Participation confirmée',
|
required Color color,
|
||||||
subtitle: 'Assemblée Générale',
|
}) {
|
||||||
icon: Icons.check_circle,
|
return ListTile(
|
||||||
color: const Color(0xFF00B894),
|
leading: CircleAvatar(
|
||||||
time: 'Il y a 2h',
|
backgroundColor: color.withOpacity(0.1),
|
||||||
),
|
child: Icon(icon, color: color, size: 20),
|
||||||
DashboardActivity(
|
),
|
||||||
title: 'Cotisation payée',
|
title: Text(
|
||||||
subtitle: 'Décembre 2024',
|
title,
|
||||||
icon: Icons.payment,
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||||
color: const Color(0xFF0984E3),
|
),
|
||||||
time: 'Il y a 1j',
|
subtitle: Text(subtitle),
|
||||||
),
|
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
|
||||||
DashboardActivity(
|
|
||||||
title: 'Événement créé',
|
|
||||||
subtitle: 'Sortie ski de fond',
|
|
||||||
icon: Icons.event,
|
|
||||||
color: const Color(0xFF00CEC9),
|
|
||||||
time: 'Il y a 3j',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
onActivityTap: (id) {},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,725 @@
|
|||||||
|
/// Dashboard Consultant - Interface Limitée
|
||||||
|
/// Interface spécialisée pour consultants externes
|
||||||
|
library consultant_dashboard;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Dashboard pour Consultant Externe
|
||||||
|
class ConsultantDashboard extends StatefulWidget {
|
||||||
|
const ConsultantDashboard({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ConsultantDashboard> createState() => _ConsultantDashboardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ConsultantDashboardState extends State<ConsultantDashboard> {
|
||||||
|
int _selectedIndex = 0;
|
||||||
|
|
||||||
|
final List<String> _consultantSections = [
|
||||||
|
'Mes Projets',
|
||||||
|
'Contacts',
|
||||||
|
'Profil',
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFFF8F9FA),
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(
|
||||||
|
'Consultant - ${_consultantSections[_selectedIndex]}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF6C5CE7),
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
elevation: 2,
|
||||||
|
centerTitle: false,
|
||||||
|
actions: [
|
||||||
|
// Notifications consultant
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.notifications_outlined, color: Color(0xFF6C5CE7)),
|
||||||
|
onPressed: () => _showConsultantNotifications(),
|
||||||
|
tooltip: 'Mes notifications',
|
||||||
|
),
|
||||||
|
// Menu consultant
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
icon: const Icon(Icons.more_vert, color: Color(0xFF6C5CE7)),
|
||||||
|
onSelected: (value) {
|
||||||
|
switch (value) {
|
||||||
|
case 'profile':
|
||||||
|
_editProfile();
|
||||||
|
break;
|
||||||
|
case 'contact':
|
||||||
|
_contactSupport();
|
||||||
|
break;
|
||||||
|
case 'help':
|
||||||
|
_showHelp();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'profile',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.person, size: 20, color: Color(0xFF6C5CE7)),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
Text('Mon Profil'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'contact',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.support_agent, size: 20, color: Color(0xFF6C5CE7)),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
Text('Support'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'help',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.help, size: 20, color: Color(0xFF6C5CE7)),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
Text('Aide'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
drawer: _buildConsultantDrawer(),
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
_buildSelectedContent(),
|
||||||
|
// Navigation rapide consultant
|
||||||
|
Positioned(
|
||||||
|
bottom: 20,
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
child: _buildConsultantQuickNavigation(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drawer de navigation consultant
|
||||||
|
Widget _buildConsultantDrawer() {
|
||||||
|
return Drawer(
|
||||||
|
child: Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
Color(0xFF6C5CE7),
|
||||||
|
Color(0xFF5A4FCF),
|
||||||
|
Color(0xFF4834D4),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Header consultant
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 60, 20, 20),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(30),
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.white.withOpacity(0.3),
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.business_center,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 30,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
const Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Sophie Martin',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Consultant IT',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Menu de navigation
|
||||||
|
Expanded(
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
itemCount: _consultantSections.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final isSelected = _selectedIndex == index;
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected
|
||||||
|
? Colors.white.withOpacity(0.2)
|
||||||
|
: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
_getConsultantSectionIcon(index),
|
||||||
|
color: Colors.white,
|
||||||
|
size: 22,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
_consultantSections[index],
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
setState(() => _selectedIndex = index);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Footer avec déconnexion
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
// TODO: Implémenter la déconnexion
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.logout, size: 16),
|
||||||
|
label: const Text('Déconnexion'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.white.withOpacity(0.2),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
minimumSize: const Size(double.infinity, 40),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Icône pour chaque section consultant
|
||||||
|
IconData _getConsultantSectionIcon(int index) {
|
||||||
|
switch (index) {
|
||||||
|
case 0: return Icons.work;
|
||||||
|
case 1: return Icons.contacts;
|
||||||
|
case 2: return Icons.person;
|
||||||
|
default: return Icons.work;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Contenu de la section sélectionnée
|
||||||
|
Widget _buildSelectedContent() {
|
||||||
|
switch (_selectedIndex) {
|
||||||
|
case 0:
|
||||||
|
return _buildProjectsContent();
|
||||||
|
case 1:
|
||||||
|
return _buildContactsContent();
|
||||||
|
case 2:
|
||||||
|
return _buildProfileContent();
|
||||||
|
default:
|
||||||
|
return _buildProjectsContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mes Projets - Vue des projets assignés
|
||||||
|
Widget _buildProjectsContent() {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 80),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header projets
|
||||||
|
_buildProjectsHeader(),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Projets actifs
|
||||||
|
_buildActiveProjects(),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Tâches en cours
|
||||||
|
_buildCurrentTasks(),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Statistiques consultant
|
||||||
|
_buildConsultantStats(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Placeholder pour les autres sections
|
||||||
|
Widget _buildContactsContent() {
|
||||||
|
return const Center(
|
||||||
|
child: Text(
|
||||||
|
'Contacts\n(En développement)',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 18),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildProfileContent() {
|
||||||
|
return const Center(
|
||||||
|
child: Text(
|
||||||
|
'Mon Profil\n(En développement)',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 18),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Header projets
|
||||||
|
Widget _buildProjectsHeader() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.work, color: Color(0xFF6C5CE7), size: 24),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Mes Projets Assignés',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'3 projets actifs',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Projets actifs
|
||||||
|
Widget _buildActiveProjects() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Projets Actifs',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildProjectCard(
|
||||||
|
'Refonte Site Web',
|
||||||
|
'Développement frontend',
|
||||||
|
'75%',
|
||||||
|
const Color(0xFF00B894),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildProjectCard(
|
||||||
|
'App Mobile',
|
||||||
|
'Interface utilisateur',
|
||||||
|
'45%',
|
||||||
|
const Color(0xFF0984E3),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildProjectCard(
|
||||||
|
'API Backend',
|
||||||
|
'Architecture serveur',
|
||||||
|
'90%',
|
||||||
|
const Color(0xFFE17055),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget pour une carte de projet
|
||||||
|
Widget _buildProjectCard(String title, String description, String progress, Color color) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(Icons.folder, color: color, size: 20),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
description,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey[600],
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
progress,
|
||||||
|
style: TextStyle(
|
||||||
|
color: color,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: double.parse(progress.replaceAll('%', '')) / 100,
|
||||||
|
backgroundColor: color.withOpacity(0.2),
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(color),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tâches en cours
|
||||||
|
Widget _buildCurrentTasks() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Tâches du Jour',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_buildTaskItem('Révision code frontend', true),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildTaskItem('Réunion client 15h', false),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildTaskItem('Tests unitaires', false),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget pour un élément de tâche
|
||||||
|
Widget _buildTaskItem(String task, bool completed) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: completed ? const Color(0xFF6C5CE7) : Colors.transparent,
|
||||||
|
border: Border.all(color: const Color(0xFF6C5CE7), width: 2),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: completed
|
||||||
|
? const Icon(Icons.check, color: Colors.white, size: 14)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
task,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
decoration: completed ? TextDecoration.lineThrough : null,
|
||||||
|
color: completed ? Colors.grey[600] : Colors.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Statistiques consultant
|
||||||
|
Widget _buildConsultantStats() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Mes Statistiques',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildStatCard('Projets', '3', Icons.work, const Color(0xFF6C5CE7)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _buildStatCard('Tâches', '12', Icons.task, const Color(0xFF00B894)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildStatCard('Heures', '156h', Icons.schedule, const Color(0xFF0984E3)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _buildStatCard('Éval.', '4.8/5', Icons.star, const Color(0xFFFDAB00)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget pour une carte de statistique
|
||||||
|
Widget _buildStatCard(String title, String value, IconData icon, Color color) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: color, size: 24),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey[600],
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigation rapide consultant
|
||||||
|
Widget _buildConsultantQuickNavigation() {
|
||||||
|
return Container(
|
||||||
|
height: 60,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(30),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.1),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
_buildNavItem(Icons.work, 'Projets', 0),
|
||||||
|
_buildNavItem(Icons.contacts, 'Contacts', 1),
|
||||||
|
_buildNavItem(Icons.person, 'Profil', 2),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget pour un élément de navigation
|
||||||
|
Widget _buildNavItem(IconData icon, String label, int index) {
|
||||||
|
final isSelected = _selectedIndex == index;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => setState(() => _selectedIndex = index),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected
|
||||||
|
? const Color(0xFF6C5CE7).withOpacity(0.1)
|
||||||
|
: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
size: 18,
|
||||||
|
color: isSelected
|
||||||
|
? const Color(0xFF6C5CE7)
|
||||||
|
: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
color: isSelected
|
||||||
|
? const Color(0xFF6C5CE7)
|
||||||
|
: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Méthodes d'action
|
||||||
|
void _showConsultantNotifications() {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Notifications consultant - Fonctionnalité à implémenter'),
|
||||||
|
backgroundColor: Color(0xFF6C5CE7),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _editProfile() {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Éditer profil - Fonctionnalité à implémenter'),
|
||||||
|
backgroundColor: Color(0xFF6C5CE7),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _contactSupport() {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Contacter support - Fonctionnalité à implémenter'),
|
||||||
|
backgroundColor: Color(0xFF6C5CE7),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showHelp() {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Aide - Fonctionnalité à implémenter'),
|
||||||
|
backgroundColor: Color(0xFF6C5CE7),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,914 @@
|
|||||||
|
/// Dashboard Gestionnaire RH - Interface Ressources Humaines
|
||||||
|
/// Outils spécialisés pour la gestion des employés et RH
|
||||||
|
library hr_manager_dashboard;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Dashboard spécialisé pour Gestionnaire RH
|
||||||
|
///
|
||||||
|
/// Fonctionnalités RH :
|
||||||
|
/// - Gestion des employés
|
||||||
|
/// - Recrutement et onboarding
|
||||||
|
/// - Évaluations de performance
|
||||||
|
/// - Congés et absences
|
||||||
|
/// - Reporting RH
|
||||||
|
/// - Formation et développement
|
||||||
|
class HRManagerDashboard extends StatefulWidget {
|
||||||
|
const HRManagerDashboard({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<HRManagerDashboard> createState() => _HRManagerDashboardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HRManagerDashboardState extends State<HRManagerDashboard>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
|
late TabController _tabController;
|
||||||
|
int _selectedIndex = 0;
|
||||||
|
|
||||||
|
final List<String> _hrSections = [
|
||||||
|
'Vue d\'ensemble',
|
||||||
|
'Employés',
|
||||||
|
'Recrutement',
|
||||||
|
'Évaluations',
|
||||||
|
'Congés',
|
||||||
|
'Formation',
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tabController = TabController(length: _hrSections.length, vsync: this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tabController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFFF8F9FA),
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(
|
||||||
|
'RH Manager - ${_hrSections[_selectedIndex]}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF00B894),
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
elevation: 2,
|
||||||
|
centerTitle: false,
|
||||||
|
actions: [
|
||||||
|
// Recherche employés
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.search, color: Color(0xFF00B894)),
|
||||||
|
onPressed: () {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Recherche avancée - Fonctionnalité à implémenter'),
|
||||||
|
backgroundColor: Color(0xFF00B894),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
tooltip: 'Rechercher employés',
|
||||||
|
),
|
||||||
|
// Notifications RH
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.notifications_outlined, color: Color(0xFF00B894)),
|
||||||
|
onPressed: () => _showHRNotifications(),
|
||||||
|
tooltip: 'Notifications RH',
|
||||||
|
),
|
||||||
|
// Menu RH
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
icon: const Icon(Icons.more_vert, color: Color(0xFF00B894)),
|
||||||
|
onSelected: (value) {
|
||||||
|
switch (value) {
|
||||||
|
case 'reports':
|
||||||
|
_generateHRReports();
|
||||||
|
break;
|
||||||
|
case 'settings':
|
||||||
|
_openHRSettings();
|
||||||
|
break;
|
||||||
|
case 'export':
|
||||||
|
_exportHRData();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'reports',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.assessment, size: 20, color: Color(0xFF00B894)),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
Text('Rapports RH'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'settings',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.settings, size: 20, color: Color(0xFF00B894)),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
Text('Paramètres RH'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'export',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.download, size: 20, color: Color(0xFF00B894)),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
Text('Exporter données'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
drawer: _buildHRDrawer(),
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
_buildSelectedContent(),
|
||||||
|
// Navigation rapide RH
|
||||||
|
Positioned(
|
||||||
|
bottom: 20,
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
child: _buildHRQuickNavigation(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drawer de navigation RH
|
||||||
|
Widget _buildHRDrawer() {
|
||||||
|
return Drawer(
|
||||||
|
child: Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
Color(0xFF00B894),
|
||||||
|
Color(0xFF00A085),
|
||||||
|
Color(0xFF008B75),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Header RH
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 60, 20, 20),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(30),
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.white.withOpacity(0.3),
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.people,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 30,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
const Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Gestionnaire RH',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Ressources Humaines',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Menu de navigation
|
||||||
|
Expanded(
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
itemCount: _hrSections.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final isSelected = _selectedIndex == index;
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected
|
||||||
|
? Colors.white.withOpacity(0.2)
|
||||||
|
: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
_getHRSectionIcon(index),
|
||||||
|
color: Colors.white,
|
||||||
|
size: 22,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
_hrSections[index],
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
setState(() => _selectedIndex = index);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Footer avec déconnexion
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
// TODO: Implémenter la déconnexion
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.logout, size: 16),
|
||||||
|
label: const Text('Déconnexion'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.white.withOpacity(0.2),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
minimumSize: const Size(double.infinity, 40),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Icône pour chaque section RH
|
||||||
|
IconData _getHRSectionIcon(int index) {
|
||||||
|
switch (index) {
|
||||||
|
case 0: return Icons.dashboard;
|
||||||
|
case 1: return Icons.people;
|
||||||
|
case 2: return Icons.person_add;
|
||||||
|
case 3: return Icons.star_rate;
|
||||||
|
case 4: return Icons.event_busy;
|
||||||
|
case 5: return Icons.school;
|
||||||
|
default: return Icons.dashboard;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Contenu de la section sélectionnée
|
||||||
|
Widget _buildSelectedContent() {
|
||||||
|
switch (_selectedIndex) {
|
||||||
|
case 0:
|
||||||
|
return _buildOverviewContent();
|
||||||
|
case 1:
|
||||||
|
return _buildEmployeesContent();
|
||||||
|
case 2:
|
||||||
|
return _buildRecruitmentContent();
|
||||||
|
case 3:
|
||||||
|
return _buildEvaluationsContent();
|
||||||
|
case 4:
|
||||||
|
return _buildLeavesContent();
|
||||||
|
case 5:
|
||||||
|
return _buildTrainingContent();
|
||||||
|
default:
|
||||||
|
return _buildOverviewContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vue d'ensemble RH
|
||||||
|
Widget _buildOverviewContent() {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 80),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header avec statut RH
|
||||||
|
_buildHRStatusHeader(),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// KPIs RH
|
||||||
|
_buildHRKPIsSection(),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Actions rapides RH
|
||||||
|
_buildHRQuickActions(),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Alertes RH importantes
|
||||||
|
_buildHRAlerts(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Placeholder pour les autres sections
|
||||||
|
Widget _buildEmployeesContent() {
|
||||||
|
return const Center(
|
||||||
|
child: Text(
|
||||||
|
'Gestion des Employés\n(En développement)',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 18),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRecruitmentContent() {
|
||||||
|
return const Center(
|
||||||
|
child: Text(
|
||||||
|
'Recrutement\n(En développement)',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 18),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEvaluationsContent() {
|
||||||
|
return const Center(
|
||||||
|
child: Text(
|
||||||
|
'Évaluations\n(En développement)',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 18),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLeavesContent() {
|
||||||
|
return const Center(
|
||||||
|
child: Text(
|
||||||
|
'Congés et Absences\n(En développement)',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 18),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTrainingContent() {
|
||||||
|
return const Center(
|
||||||
|
child: Text(
|
||||||
|
'Formation\n(En développement)',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 18),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Header avec statut RH
|
||||||
|
Widget _buildHRStatusHeader() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
colors: [Color(0xFF00B894), Color(0xFF00A085)],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF00B894).withOpacity(0.3),
|
||||||
|
blurRadius: 15,
|
||||||
|
offset: const Offset(0, 5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Département RH Actif',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Dernière sync: ${DateTime.now().hour.toString().padLeft(2, '0')}:${DateTime.now().minute.toString().padLeft(2, '0')}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white.withOpacity(0.8),
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.people,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Section KPIs RH
|
||||||
|
Widget _buildHRKPIsSection() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Indicateurs RH',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildHRKPICard(
|
||||||
|
'Employés Actifs',
|
||||||
|
'247',
|
||||||
|
'+12 ce mois',
|
||||||
|
Icons.people,
|
||||||
|
const Color(0xFF00B894),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _buildHRKPICard(
|
||||||
|
'Candidatures',
|
||||||
|
'34',
|
||||||
|
'+8 cette semaine',
|
||||||
|
Icons.person_add,
|
||||||
|
const Color(0xFF0984E3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildHRKPICard(
|
||||||
|
'En Congé',
|
||||||
|
'18',
|
||||||
|
'7.3% de l\'effectif',
|
||||||
|
Icons.event_busy,
|
||||||
|
const Color(0xFFFDAB00),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _buildHRKPICard(
|
||||||
|
'Évaluations',
|
||||||
|
'156',
|
||||||
|
'89% complétées',
|
||||||
|
Icons.star_rate,
|
||||||
|
const Color(0xFFE17055),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget pour une carte KPI RH
|
||||||
|
Widget _buildHRKPICard(
|
||||||
|
String title,
|
||||||
|
String value,
|
||||||
|
String subtitle,
|
||||||
|
IconData icon,
|
||||||
|
Color color,
|
||||||
|
) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
color: color,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Actions rapides RH
|
||||||
|
Widget _buildHRQuickActions() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Actions Rapides',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
GridView.count(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
crossAxisCount: 2,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
childAspectRatio: 1.5,
|
||||||
|
children: [
|
||||||
|
_buildHRActionCard(
|
||||||
|
'Nouveau Employé',
|
||||||
|
Icons.person_add,
|
||||||
|
const Color(0xFF00B894),
|
||||||
|
() => _addNewEmployee(),
|
||||||
|
),
|
||||||
|
_buildHRActionCard(
|
||||||
|
'Demandes Congés',
|
||||||
|
Icons.event_busy,
|
||||||
|
const Color(0xFFFDAB00),
|
||||||
|
() => _viewLeaveRequests(),
|
||||||
|
),
|
||||||
|
_buildHRActionCard(
|
||||||
|
'Évaluations',
|
||||||
|
Icons.star_rate,
|
||||||
|
const Color(0xFFE17055),
|
||||||
|
() => _viewEvaluations(),
|
||||||
|
),
|
||||||
|
_buildHRActionCard(
|
||||||
|
'Recrutement',
|
||||||
|
Icons.work,
|
||||||
|
const Color(0xFF0984E3),
|
||||||
|
() => _viewRecruitment(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget pour une action RH
|
||||||
|
Widget _buildHRActionCard(
|
||||||
|
String title,
|
||||||
|
IconData icon,
|
||||||
|
Color color,
|
||||||
|
VoidCallback onTap,
|
||||||
|
) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: color.withOpacity(0.2)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
color: color,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alertes RH importantes
|
||||||
|
Widget _buildHRAlerts() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Alertes Importantes',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildHRAlertItem(
|
||||||
|
'Évaluations en retard',
|
||||||
|
'12 évaluations annuelles à finaliser',
|
||||||
|
Icons.warning,
|
||||||
|
const Color(0xFFE17055),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildHRAlertItem(
|
||||||
|
'Congés à approuver',
|
||||||
|
'5 demandes de congé en attente',
|
||||||
|
Icons.pending_actions,
|
||||||
|
const Color(0xFFFDAB00),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildHRAlertItem(
|
||||||
|
'Nouveaux candidats',
|
||||||
|
'8 candidatures reçues cette semaine',
|
||||||
|
Icons.person_add,
|
||||||
|
const Color(0xFF0984E3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget pour un élément d'alerte RH
|
||||||
|
Widget _buildHRAlertItem(
|
||||||
|
String title,
|
||||||
|
String message,
|
||||||
|
IconData icon,
|
||||||
|
Color color,
|
||||||
|
) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: color.withOpacity(0.3)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: color, size: 20),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 12,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: Colors.grey[700],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigation rapide RH
|
||||||
|
Widget _buildHRQuickNavigation() {
|
||||||
|
return Container(
|
||||||
|
height: 60,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(30),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.1),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
_buildHRNavItem(Icons.dashboard, 'Vue', 0),
|
||||||
|
_buildHRNavItem(Icons.people, 'Employés', 1),
|
||||||
|
_buildHRNavItem(Icons.person_add, 'Recrutement', 2),
|
||||||
|
_buildHRNavItem(Icons.star_rate, 'Évaluations', 3),
|
||||||
|
_buildHRNavItem(Icons.event_busy, 'Congés', 4),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget pour un élément de navigation RH
|
||||||
|
Widget _buildHRNavItem(IconData icon, String label, int index) {
|
||||||
|
final isSelected = _selectedIndex == index;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => setState(() => _selectedIndex = index),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected
|
||||||
|
? const Color(0xFF00B894).withOpacity(0.1)
|
||||||
|
: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
size: 18,
|
||||||
|
color: isSelected
|
||||||
|
? const Color(0xFF00B894)
|
||||||
|
: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
color: isSelected
|
||||||
|
? const Color(0xFF00B894)
|
||||||
|
: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Méthodes d'action
|
||||||
|
void _showHRNotifications() {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Notifications RH - Fonctionnalité à implémenter'),
|
||||||
|
backgroundColor: Color(0xFF00B894),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _generateHRReports() {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Rapports RH - Fonctionnalité à implémenter'),
|
||||||
|
backgroundColor: Color(0xFF00B894),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openHRSettings() {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Paramètres RH - Fonctionnalité à implémenter'),
|
||||||
|
backgroundColor: Color(0xFF00B894),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _exportHRData() {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Export données RH - Fonctionnalité à implémenter'),
|
||||||
|
backgroundColor: Color(0xFF00B894),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addNewEmployee() {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Ajouter employé - Fonctionnalité à implémenter'),
|
||||||
|
backgroundColor: Color(0xFF00B894),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _viewLeaveRequests() {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Demandes de congé - Fonctionnalité à implémenter'),
|
||||||
|
backgroundColor: Color(0xFFFDAB00),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _viewEvaluations() {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Évaluations - Fonctionnalité à implémenter'),
|
||||||
|
backgroundColor: Color(0xFFE17055),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _viewRecruitment() {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Recrutement - Fonctionnalité à implémenter'),
|
||||||
|
backgroundColor: Color(0xFF0984E3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
|
|||||||
import '../../../../../core/design_system/tokens/tokens.dart';
|
import '../../../../../core/design_system/tokens/tokens.dart';
|
||||||
import '../../widgets/widgets.dart';
|
import '../../widgets/widgets.dart';
|
||||||
|
|
||||||
|
|
||||||
/// Dashboard Control Panel pour Administrateur d'Organisation
|
/// Dashboard Control Panel pour Administrateur d'Organisation
|
||||||
///
|
///
|
||||||
/// Fonctionnalités exclusives :
|
/// Fonctionnalités exclusives :
|
||||||
@@ -34,6 +35,89 @@ class _OrgAdminDashboardState extends State<OrgAdminDashboard> {
|
|||||||
floating: false,
|
floating: false,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
backgroundColor: const Color(0xFF0984E3), // Bleu corporate
|
backgroundColor: const Color(0xFF0984E3), // Bleu corporate
|
||||||
|
actions: [
|
||||||
|
// Recherche des membres
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.search, color: Colors.white),
|
||||||
|
onPressed: () {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Recherche avancée - Fonctionnalité à implémenter'),
|
||||||
|
backgroundColor: Color(0xFF0984E3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
tooltip: 'Rechercher des membres',
|
||||||
|
),
|
||||||
|
// Notifications organisation
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.notifications_outlined, color: Colors.white),
|
||||||
|
onPressed: () => _showOrgNotifications(),
|
||||||
|
tooltip: 'Notifications organisation',
|
||||||
|
),
|
||||||
|
// Menu d'options
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
icon: const Icon(Icons.more_vert, color: Colors.white),
|
||||||
|
onSelected: (value) {
|
||||||
|
switch (value) {
|
||||||
|
case 'settings':
|
||||||
|
_openOrgSettings();
|
||||||
|
break;
|
||||||
|
case 'reports':
|
||||||
|
_generateReports();
|
||||||
|
break;
|
||||||
|
case 'export':
|
||||||
|
_exportOrgData();
|
||||||
|
break;
|
||||||
|
case 'backup':
|
||||||
|
_backupOrgData();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'settings',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.settings, size: 20, color: Color(0xFF0984E3)),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
Text('Paramètres Org'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'reports',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.assessment, size: 20, color: Color(0xFF0984E3)),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
Text('Rapports'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'export',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.download, size: 20, color: Color(0xFF0984E3)),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
Text('Exporter données'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'backup',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.backup, size: 20, color: Color(0xFF0984E3)),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
Text('Sauvegarde'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
title: const Text(
|
title: const Text(
|
||||||
'Control Panel',
|
'Control Panel',
|
||||||
@@ -419,7 +503,7 @@ class _OrgAdminDashboardState extends State<OrgAdminDashboard> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Icon(
|
const Icon(
|
||||||
Icons.arrow_forward_ios,
|
Icons.arrow_forward_ios,
|
||||||
size: 12,
|
size: 12,
|
||||||
color: ColorTokens.textSecondary,
|
color: ColorTokens.textSecondary,
|
||||||
@@ -443,25 +527,25 @@ class _OrgAdminDashboardState extends State<OrgAdminDashboard> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: SpacingTokens.md),
|
const SizedBox(height: SpacingTokens.md),
|
||||||
|
|
||||||
DashboardInsightsSection(
|
const DashboardInsightsSection(
|
||||||
metrics: [
|
metrics: [
|
||||||
DashboardMetric(
|
DashboardMetric(
|
||||||
label: 'Cotisations collectées',
|
label: 'Cotisations collectées',
|
||||||
value: '89%',
|
value: '89%',
|
||||||
progress: 0.89,
|
progress: 0.89,
|
||||||
color: const Color(0xFF00B894),
|
color: Color(0xFF00B894),
|
||||||
),
|
),
|
||||||
DashboardMetric(
|
DashboardMetric(
|
||||||
label: 'Budget utilisé',
|
label: 'Budget utilisé',
|
||||||
value: '67%',
|
value: '67%',
|
||||||
progress: 0.67,
|
progress: 0.67,
|
||||||
color: const Color(0xFF0984E3),
|
color: Color(0xFF0984E3),
|
||||||
),
|
),
|
||||||
DashboardMetric(
|
DashboardMetric(
|
||||||
label: 'Objectif annuel',
|
label: 'Objectif annuel',
|
||||||
value: '78%',
|
value: '78%',
|
||||||
progress: 0.78,
|
progress: 0.78,
|
||||||
color: const Color(0xFFE17055),
|
color: Color(0xFFE17055),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -483,26 +567,26 @@ class _OrgAdminDashboardState extends State<OrgAdminDashboard> {
|
|||||||
const SizedBox(height: SpacingTokens.md),
|
const SizedBox(height: SpacingTokens.md),
|
||||||
|
|
||||||
DashboardRecentActivitySection(
|
DashboardRecentActivitySection(
|
||||||
activities: [
|
activities: const [
|
||||||
DashboardActivity(
|
DashboardActivity(
|
||||||
title: 'Nouveau membre approuvé',
|
title: 'Nouveau membre approuvé',
|
||||||
subtitle: 'Sophie Laurent rejoint l\'organisation',
|
subtitle: 'Sophie Laurent rejoint l\'organisation',
|
||||||
icon: Icons.person_add,
|
icon: Icons.person_add,
|
||||||
color: const Color(0xFF00B894),
|
color: Color(0xFF00B894),
|
||||||
time: 'Il y a 2h',
|
time: 'Il y a 2h',
|
||||||
),
|
),
|
||||||
DashboardActivity(
|
DashboardActivity(
|
||||||
title: 'Budget mis à jour',
|
title: 'Budget mis à jour',
|
||||||
subtitle: 'Allocation événements modifiée',
|
subtitle: 'Allocation événements modifiée',
|
||||||
icon: Icons.account_balance_wallet,
|
icon: Icons.account_balance_wallet,
|
||||||
color: const Color(0xFF0984E3),
|
color: Color(0xFF0984E3),
|
||||||
time: 'Il y a 4h',
|
time: 'Il y a 4h',
|
||||||
),
|
),
|
||||||
DashboardActivity(
|
DashboardActivity(
|
||||||
title: 'Rapport généré',
|
title: 'Rapport généré',
|
||||||
subtitle: 'Rapport mensuel d\'activité',
|
subtitle: 'Rapport mensuel d\'activité',
|
||||||
icon: Icons.assessment,
|
icon: Icons.assessment,
|
||||||
color: const Color(0xFF6C5CE7),
|
color: Color(0xFF6C5CE7),
|
||||||
time: 'Il y a 1j',
|
time: 'Il y a 1j',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -533,6 +617,319 @@ class _OrgAdminDashboardState extends State<OrgAdminDashboard> {
|
|||||||
void _onActivityTap(String activityId) {
|
void _onActivityTap(String activityId) {
|
||||||
// Navigation vers les détails de l'activité
|
// Navigation vers les détails de l'activité
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Afficher les notifications de l'organisation
|
||||||
|
void _showOrgNotifications() {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (context) => Container(
|
||||||
|
height: MediaQuery.of(context).size.height * 0.7,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0xFF0984E3),
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.business, color: Colors.white),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Text(
|
||||||
|
'Notifications Organisation',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
icon: const Icon(Icons.close, color: Colors.white),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
_buildOrgNotificationItem(
|
||||||
|
'Nouveau membre',
|
||||||
|
'Marie Dubois a rejoint le département Marketing',
|
||||||
|
Icons.person_add,
|
||||||
|
const Color(0xFF00B894),
|
||||||
|
'10 min',
|
||||||
|
),
|
||||||
|
_buildOrgNotificationItem(
|
||||||
|
'Budget dépassé',
|
||||||
|
'Le département IT a dépassé son budget mensuel',
|
||||||
|
Icons.warning,
|
||||||
|
const Color(0xFFE17055),
|
||||||
|
'1h',
|
||||||
|
),
|
||||||
|
_buildOrgNotificationItem(
|
||||||
|
'Rapport mensuel',
|
||||||
|
'Le rapport d\'activité de mars est disponible',
|
||||||
|
Icons.assessment,
|
||||||
|
const Color(0xFF0984E3),
|
||||||
|
'2h',
|
||||||
|
),
|
||||||
|
_buildOrgNotificationItem(
|
||||||
|
'Demande de congé',
|
||||||
|
'3 nouvelles demandes de congé en attente',
|
||||||
|
Icons.event_busy,
|
||||||
|
const Color(0xFFFDAB00),
|
||||||
|
'3h',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget pour un élément de notification organisation
|
||||||
|
Widget _buildOrgNotificationItem(
|
||||||
|
String title,
|
||||||
|
String message,
|
||||||
|
IconData icon,
|
||||||
|
Color color,
|
||||||
|
String time,
|
||||||
|
) {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[50],
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: Colors.grey[200]!),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: color, size: 20),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey[600],
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
time,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey[500],
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ouvrir les paramètres de l'organisation
|
||||||
|
void _openOrgSettings() {
|
||||||
|
// TODO: Naviguer vers la page des paramètres organisation
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Paramètres Organisation - Fonctionnalité à implémenter'),
|
||||||
|
backgroundColor: Color(0xFF0984E3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Générer des rapports
|
||||||
|
void _generateReports() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Générer un rapport'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text('Sélectionnez le type de rapport :'),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.people, color: Color(0xFF0984E3)),
|
||||||
|
title: const Text('Rapport Membres'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_generateMemberReport();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.attach_money, color: Color(0xFF00B894)),
|
||||||
|
title: const Text('Rapport Financier'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_generateFinancialReport();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.analytics, color: Color(0xFFE17055)),
|
||||||
|
title: const Text('Rapport d\'Activité'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_generateActivityReport();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Générer rapport des membres
|
||||||
|
void _generateMemberReport() {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Génération du rapport membres en cours...'),
|
||||||
|
backgroundColor: Color(0xFF0984E3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Générer rapport financier
|
||||||
|
void _generateFinancialReport() {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Génération du rapport financier en cours...'),
|
||||||
|
backgroundColor: Color(0xFF00B894),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Générer rapport d'activité
|
||||||
|
void _generateActivityReport() {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Génération du rapport d\'activité en cours...'),
|
||||||
|
backgroundColor: Color(0xFFE17055),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exporter les données de l'organisation
|
||||||
|
void _exportOrgData() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Exporter les données'),
|
||||||
|
content: const Text(
|
||||||
|
'Sélectionnez le format d\'export souhaité :',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Export CSV en cours...'),
|
||||||
|
backgroundColor: Color(0xFF00B894),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF0984E3),
|
||||||
|
),
|
||||||
|
child: const Text('CSV', style: TextStyle(color: Colors.white)),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Export Excel en cours...'),
|
||||||
|
backgroundColor: Color(0xFF00B894),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF0984E3),
|
||||||
|
),
|
||||||
|
child: const Text('Excel', style: TextStyle(color: Colors.white)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sauvegarder les données de l'organisation
|
||||||
|
void _backupOrgData() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Sauvegarde Organisation'),
|
||||||
|
content: const Text(
|
||||||
|
'Voulez-vous créer une sauvegarde complète des données de l\'organisation ?',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Sauvegarde en cours...'),
|
||||||
|
backgroundColor: Color(0xFF0984E3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF0984E3),
|
||||||
|
),
|
||||||
|
child: const Text('Confirmer', style: TextStyle(color: Colors.white)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Painter pour le motif corporate de l'en-tête
|
/// Painter pour le motif corporate de l'en-tête
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,9 +3,7 @@
|
|||||||
library dashboard_quick_action_button;
|
library dashboard_quick_action_button;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../../core/design_system/tokens/color_tokens.dart';
|
|
||||||
import '../../../../core/design_system/tokens/spacing_tokens.dart';
|
import '../../../../core/design_system/tokens/spacing_tokens.dart';
|
||||||
import '../../../../core/design_system/tokens/typography_tokens.dart';
|
|
||||||
|
|
||||||
/// Modèle de données pour une action rapide
|
/// Modèle de données pour une action rapide
|
||||||
class DashboardQuickAction {
|
class DashboardQuickAction {
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../../core/design_system/tokens/tokens.dart';
|
||||||
|
|
||||||
|
/// Widget pour afficher une grille d'actions rapides
|
||||||
|
class DashboardQuickActionsGrid extends StatelessWidget {
|
||||||
|
final List<Widget> children;
|
||||||
|
final int crossAxisCount;
|
||||||
|
|
||||||
|
const DashboardQuickActionsGrid({
|
||||||
|
super.key,
|
||||||
|
required this.children,
|
||||||
|
this.crossAxisCount = 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GridView.count(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
crossAxisCount: crossAxisCount,
|
||||||
|
childAspectRatio: 1.2,
|
||||||
|
crossAxisSpacing: SpacingTokens.md,
|
||||||
|
mainAxisSpacing: SpacingTokens.md,
|
||||||
|
children: children,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget pour une action rapide
|
||||||
|
class DashboardQuickAction extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final IconData icon;
|
||||||
|
final Color? color;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
const DashboardQuickAction({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.icon,
|
||||||
|
this.color,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
elevation: 2,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(RadiusTokens.md),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(SpacingTokens.lg),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
size: 32,
|
||||||
|
color: color ?? ColorTokens.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: SpacingTokens.sm),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TypographyTokens.bodyMedium,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget pour afficher une section d'activité récente
|
||||||
|
class DashboardRecentActivitySection extends StatelessWidget {
|
||||||
|
final List<Widget> children;
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
const DashboardRecentActivitySection({
|
||||||
|
super.key,
|
||||||
|
required this.children,
|
||||||
|
this.title = 'Activité Récente',
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TypographyTokens.headlineSmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: SpacingTokens.md),
|
||||||
|
...children,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget pour une activité
|
||||||
|
class DashboardActivity extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final IconData icon;
|
||||||
|
final Color? color;
|
||||||
|
|
||||||
|
const DashboardActivity({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.icon,
|
||||||
|
this.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: SpacingTokens.sm),
|
||||||
|
child: ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: color ?? ColorTokens.primary,
|
||||||
|
child: Icon(icon, color: Colors.white),
|
||||||
|
),
|
||||||
|
title: Text(title),
|
||||||
|
subtitle: Text(subtitle),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget pour une section d'insights
|
||||||
|
class DashboardInsightsSection extends StatelessWidget {
|
||||||
|
final List<Widget> children;
|
||||||
|
|
||||||
|
const DashboardInsightsSection({
|
||||||
|
super.key,
|
||||||
|
required this.children,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Insights',
|
||||||
|
style: TypographyTokens.headlineSmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: SpacingTokens.md),
|
||||||
|
...children,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget pour une statistique
|
||||||
|
class DashboardStat extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String value;
|
||||||
|
final IconData icon;
|
||||||
|
final Color? color;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
const DashboardStat({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.value,
|
||||||
|
required this.icon,
|
||||||
|
this.color,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
elevation: 2,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(RadiusTokens.md),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(SpacingTokens.lg),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
size: 32,
|
||||||
|
color: color ?? ColorTokens.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: SpacingTokens.sm),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: TypographyTokens.headlineSmall.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: color ?? ColorTokens.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: SpacingTokens.xs),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TypographyTokens.bodySmall,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget pour la grille de statistiques
|
||||||
|
class DashboardStatsGrid extends StatelessWidget {
|
||||||
|
final List<Widget> children;
|
||||||
|
final int crossAxisCount;
|
||||||
|
|
||||||
|
const DashboardStatsGrid({
|
||||||
|
super.key,
|
||||||
|
required this.children,
|
||||||
|
this.crossAxisCount = 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GridView.count(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
crossAxisCount: crossAxisCount,
|
||||||
|
childAspectRatio: 1.2,
|
||||||
|
crossAxisSpacing: SpacingTokens.md,
|
||||||
|
mainAxisSpacing: SpacingTokens.md,
|
||||||
|
children: children,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget pour le drawer du dashboard
|
||||||
|
class DashboardDrawer extends StatelessWidget {
|
||||||
|
const DashboardDrawer({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Drawer(
|
||||||
|
child: ListView(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
children: [
|
||||||
|
const DrawerHeader(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: ColorTokens.primary,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'UnionFlow',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.dashboard),
|
||||||
|
title: const Text('Dashboard'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.people),
|
||||||
|
title: const Text('Membres'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.event),
|
||||||
|
title: const Text('Événements'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.settings),
|
||||||
|
title: const Text('Paramètres'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
|||||||
|
/// Modèle de données pour un membre
|
||||||
|
class MembreModel {
|
||||||
|
final String id;
|
||||||
|
final String nom;
|
||||||
|
final String prenom;
|
||||||
|
final String email;
|
||||||
|
final String? telephone;
|
||||||
|
final String? statut;
|
||||||
|
final String? role;
|
||||||
|
final OrganisationModel? organisation;
|
||||||
|
|
||||||
|
const MembreModel({
|
||||||
|
required this.id,
|
||||||
|
required this.nom,
|
||||||
|
required this.prenom,
|
||||||
|
required this.email,
|
||||||
|
this.telephone,
|
||||||
|
this.statut,
|
||||||
|
this.role,
|
||||||
|
this.organisation,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory MembreModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return MembreModel(
|
||||||
|
id: json['id'] as String,
|
||||||
|
nom: json['nom'] as String,
|
||||||
|
prenom: json['prenom'] as String,
|
||||||
|
email: json['email'] as String,
|
||||||
|
telephone: json['telephone'] as String?,
|
||||||
|
statut: json['statut'] as String?,
|
||||||
|
role: json['role'] as String?,
|
||||||
|
organisation: json['organisation'] != null
|
||||||
|
? OrganisationModel.fromJson(json['organisation'] as Map<String, dynamic>)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'nom': nom,
|
||||||
|
'prenom': prenom,
|
||||||
|
'email': email,
|
||||||
|
'telephone': telephone,
|
||||||
|
'statut': statut,
|
||||||
|
'role': role,
|
||||||
|
'organisation': organisation?.toJson(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Modèle pour une organisation
|
||||||
|
class OrganisationModel {
|
||||||
|
final String? nom;
|
||||||
|
|
||||||
|
const OrganisationModel({this.nom});
|
||||||
|
|
||||||
|
factory OrganisationModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return OrganisationModel(
|
||||||
|
nom: json['nom'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'nom': nom,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../../../core/models/membre_search_criteria.dart';
|
||||||
|
import '../../../../core/models/membre_search_result.dart';
|
||||||
|
|
||||||
|
/// Service pour la recherche avancée de membres
|
||||||
|
/// Gère les appels API vers l'endpoint de recherche sophistiquée
|
||||||
|
class MembreSearchService {
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
MembreSearchService(this._dio);
|
||||||
|
|
||||||
|
/// Effectue une recherche avancée de membres
|
||||||
|
///
|
||||||
|
/// [criteria] Critères de recherche
|
||||||
|
/// [page] Numéro de page (0-based)
|
||||||
|
/// [size] Taille de la page
|
||||||
|
/// [sortField] Champ de tri
|
||||||
|
/// [sortDirection] Direction du tri (asc/desc)
|
||||||
|
///
|
||||||
|
/// Returns [MembreSearchResult] avec les résultats paginés
|
||||||
|
Future<MembreSearchResult> searchMembresAdvanced({
|
||||||
|
required MembreSearchCriteria criteria,
|
||||||
|
int page = 0,
|
||||||
|
int size = 20,
|
||||||
|
String sortField = 'nom',
|
||||||
|
String sortDirection = 'asc',
|
||||||
|
}) async {
|
||||||
|
print('Recherche avancée de membres: ${criteria.description}');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validation des critères
|
||||||
|
if (!criteria.hasAnyCriteria) {
|
||||||
|
throw Exception('Au moins un critère de recherche doit être spécifié');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!criteria.isValid) {
|
||||||
|
throw Exception('Critères de recherche invalides');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Préparation des paramètres de requête
|
||||||
|
final queryParams = {
|
||||||
|
'page': page.toString(),
|
||||||
|
'size': size.toString(),
|
||||||
|
'sort': sortField,
|
||||||
|
'direction': sortDirection,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Appel API
|
||||||
|
final response = await _dio.post(
|
||||||
|
'/api/membres/search/advanced',
|
||||||
|
data: criteria.toJson(),
|
||||||
|
queryParameters: queryParams,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parsing de la réponse
|
||||||
|
final result = MembreSearchResult.fromJson(response.data);
|
||||||
|
|
||||||
|
print('Recherche terminée: ${result.totalElements} résultats en ${result.executionTimeMs}ms');
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} on DioException catch (e) {
|
||||||
|
print('Erreur lors de la recherche avancée: $e');
|
||||||
|
rethrow;
|
||||||
|
} catch (e) {
|
||||||
|
print('Erreur inattendue lors de la recherche: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recherche rapide par terme général
|
||||||
|
///
|
||||||
|
/// [query] Terme de recherche
|
||||||
|
/// [page] Numéro de page
|
||||||
|
/// [size] Taille de la page
|
||||||
|
///
|
||||||
|
/// Returns [MembreSearchResult] avec les résultats
|
||||||
|
Future<MembreSearchResult> quickSearch({
|
||||||
|
required String query,
|
||||||
|
int page = 0,
|
||||||
|
int size = 20,
|
||||||
|
}) async {
|
||||||
|
final criteria = MembreSearchCriteria.quickSearch(query);
|
||||||
|
return searchMembresAdvanced(
|
||||||
|
criteria: criteria,
|
||||||
|
page: page,
|
||||||
|
size: size,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recherche des membres actifs uniquement
|
||||||
|
///
|
||||||
|
/// [page] Numéro de page
|
||||||
|
/// [size] Taille de la page
|
||||||
|
///
|
||||||
|
/// Returns [MembreSearchResult] avec les membres actifs
|
||||||
|
Future<MembreSearchResult> searchActiveMembers({
|
||||||
|
int page = 0,
|
||||||
|
int size = 20,
|
||||||
|
}) async {
|
||||||
|
return searchMembresAdvanced(
|
||||||
|
criteria: MembreSearchCriteria.activeMembers,
|
||||||
|
page: page,
|
||||||
|
size: size,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recherche des membres du bureau
|
||||||
|
///
|
||||||
|
/// [page] Numéro de page
|
||||||
|
/// [size] Taille de la page
|
||||||
|
///
|
||||||
|
/// Returns [MembreSearchResult] avec les membres du bureau
|
||||||
|
Future<MembreSearchResult> searchBureauMembers({
|
||||||
|
int page = 0,
|
||||||
|
int size = 20,
|
||||||
|
}) async {
|
||||||
|
return searchMembresAdvanced(
|
||||||
|
criteria: MembreSearchCriteria.bureauMembers,
|
||||||
|
page: page,
|
||||||
|
size: size,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recherche par organisation
|
||||||
|
///
|
||||||
|
/// [organisationIds] Liste des IDs d'organisations
|
||||||
|
/// [page] Numéro de page
|
||||||
|
/// [size] Taille de la page
|
||||||
|
///
|
||||||
|
/// Returns [MembreSearchResult] avec les membres des organisations
|
||||||
|
Future<MembreSearchResult> searchByOrganisations({
|
||||||
|
required List<String> organisationIds,
|
||||||
|
int page = 0,
|
||||||
|
int size = 20,
|
||||||
|
}) async {
|
||||||
|
final criteria = MembreSearchCriteria(
|
||||||
|
organisationIds: organisationIds,
|
||||||
|
statut: 'ACTIF',
|
||||||
|
);
|
||||||
|
return searchMembresAdvanced(
|
||||||
|
criteria: criteria,
|
||||||
|
page: page,
|
||||||
|
size: size,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recherche par tranche d'âge
|
||||||
|
///
|
||||||
|
/// [ageMin] Âge minimum
|
||||||
|
/// [ageMax] Âge maximum
|
||||||
|
/// [page] Numéro de page
|
||||||
|
/// [size] Taille de la page
|
||||||
|
///
|
||||||
|
/// Returns [MembreSearchResult] avec les membres dans la tranche d'âge
|
||||||
|
Future<MembreSearchResult> searchByAgeRange({
|
||||||
|
int? ageMin,
|
||||||
|
int? ageMax,
|
||||||
|
int page = 0,
|
||||||
|
int size = 20,
|
||||||
|
}) async {
|
||||||
|
final criteria = MembreSearchCriteria(
|
||||||
|
ageMin: ageMin,
|
||||||
|
ageMax: ageMax,
|
||||||
|
statut: 'ACTIF',
|
||||||
|
);
|
||||||
|
return searchMembresAdvanced(
|
||||||
|
criteria: criteria,
|
||||||
|
page: page,
|
||||||
|
size: size,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recherche par région
|
||||||
|
///
|
||||||
|
/// [region] Nom de la région
|
||||||
|
/// [page] Numéro de page
|
||||||
|
/// [size] Taille de la page
|
||||||
|
///
|
||||||
|
/// Returns [MembreSearchResult] avec les membres de la région
|
||||||
|
Future<MembreSearchResult> searchByRegion({
|
||||||
|
required String region,
|
||||||
|
int page = 0,
|
||||||
|
int size = 20,
|
||||||
|
}) async {
|
||||||
|
final criteria = MembreSearchCriteria(
|
||||||
|
region: region,
|
||||||
|
statut: 'ACTIF',
|
||||||
|
);
|
||||||
|
return searchMembresAdvanced(
|
||||||
|
criteria: criteria,
|
||||||
|
page: page,
|
||||||
|
size: size,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recherche par rôles
|
||||||
|
///
|
||||||
|
/// [roles] Liste des rôles
|
||||||
|
/// [page] Numéro de page
|
||||||
|
/// [size] Taille de la page
|
||||||
|
///
|
||||||
|
/// Returns [MembreSearchResult] avec les membres ayant ces rôles
|
||||||
|
Future<MembreSearchResult> searchByRoles({
|
||||||
|
required List<String> roles,
|
||||||
|
int page = 0,
|
||||||
|
int size = 20,
|
||||||
|
}) async {
|
||||||
|
final criteria = MembreSearchCriteria(
|
||||||
|
roles: roles,
|
||||||
|
statut: 'ACTIF',
|
||||||
|
);
|
||||||
|
return searchMembresAdvanced(
|
||||||
|
criteria: criteria,
|
||||||
|
page: page,
|
||||||
|
size: size,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recherche par période d'adhésion
|
||||||
|
///
|
||||||
|
/// [dateMin] Date minimum (ISO 8601)
|
||||||
|
/// [dateMax] Date maximum (ISO 8601)
|
||||||
|
/// [page] Numéro de page
|
||||||
|
/// [size] Taille de la page
|
||||||
|
///
|
||||||
|
/// Returns [MembreSearchResult] avec les membres adhérés dans la période
|
||||||
|
Future<MembreSearchResult> searchByAdhesionPeriod({
|
||||||
|
String? dateMin,
|
||||||
|
String? dateMax,
|
||||||
|
int page = 0,
|
||||||
|
int size = 20,
|
||||||
|
}) async {
|
||||||
|
final criteria = MembreSearchCriteria(
|
||||||
|
dateAdhesionMin: dateMin,
|
||||||
|
dateAdhesionMax: dateMax,
|
||||||
|
statut: 'ACTIF',
|
||||||
|
);
|
||||||
|
return searchMembresAdvanced(
|
||||||
|
criteria: criteria,
|
||||||
|
page: page,
|
||||||
|
size: size,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Valide les critères de recherche avant envoi
|
||||||
|
bool validateCriteria(MembreSearchCriteria criteria) {
|
||||||
|
if (!criteria.hasAnyCriteria) {
|
||||||
|
print('Aucun critère de recherche spécifié');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!criteria.isValid) {
|
||||||
|
print('Critères de recherche invalides: ${criteria.description}');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Estime le temps de recherche basé sur les critères
|
||||||
|
Duration estimateSearchTime(MembreSearchCriteria criteria) {
|
||||||
|
// Estimation basique - peut être améliorée avec des métriques réelles
|
||||||
|
int complexityScore = 0;
|
||||||
|
|
||||||
|
if (criteria.query?.isNotEmpty == true) complexityScore += 2;
|
||||||
|
if (criteria.organisationIds?.isNotEmpty == true) complexityScore += 1;
|
||||||
|
if (criteria.roles?.isNotEmpty == true) complexityScore += 1;
|
||||||
|
if (criteria.ageMin != null || criteria.ageMax != null) complexityScore += 1;
|
||||||
|
if (criteria.dateAdhesionMin != null || criteria.dateAdhesionMax != null) complexityScore += 1;
|
||||||
|
|
||||||
|
// Temps de base + complexité
|
||||||
|
final baseTime = 100; // 100ms de base
|
||||||
|
final additionalTime = complexityScore * 50; // 50ms par critère
|
||||||
|
|
||||||
|
return Duration(milliseconds: baseTime + additionalTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,579 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
|
import '../../../../core/models/membre_search_criteria.dart';
|
||||||
|
import '../../../../core/models/membre_search_result.dart';
|
||||||
|
import '../../../dashboard/presentation/widgets/dashboard_activity_tile.dart';
|
||||||
|
import '../widgets/membre_search_form.dart';
|
||||||
|
import '../widgets/membre_search_results.dart';
|
||||||
|
import '../widgets/search_statistics_card.dart';
|
||||||
|
|
||||||
|
/// Page de recherche avancée des membres
|
||||||
|
/// Interface complète pour la recherche sophistiquée avec filtres multiples
|
||||||
|
class AdvancedSearchPage extends StatefulWidget {
|
||||||
|
const AdvancedSearchPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AdvancedSearchPage> createState() => _AdvancedSearchPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdvancedSearchPageState extends State<AdvancedSearchPage>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
|
late TabController _tabController;
|
||||||
|
MembreSearchCriteria _currentCriteria = MembreSearchCriteria.empty;
|
||||||
|
MembreSearchResult? _currentResult;
|
||||||
|
bool _isSearching = false;
|
||||||
|
String? _errorMessage;
|
||||||
|
|
||||||
|
// Contrôleurs pour les champs de recherche
|
||||||
|
final _queryController = TextEditingController();
|
||||||
|
final _nomController = TextEditingController();
|
||||||
|
final _prenomController = TextEditingController();
|
||||||
|
final _emailController = TextEditingController();
|
||||||
|
final _telephoneController = TextEditingController();
|
||||||
|
final _regionController = TextEditingController();
|
||||||
|
final _villeController = TextEditingController();
|
||||||
|
final _professionController = TextEditingController();
|
||||||
|
|
||||||
|
// Valeurs pour les filtres
|
||||||
|
String? _selectedStatut;
|
||||||
|
List<String> _selectedRoles = [];
|
||||||
|
List<String> _selectedOrganisations = [];
|
||||||
|
RangeValues _ageRange = const RangeValues(18, 65);
|
||||||
|
DateTimeRange? _adhesionDateRange;
|
||||||
|
bool _includeInactifs = false;
|
||||||
|
bool _membreBureau = false;
|
||||||
|
bool _responsable = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tabController = TabController(length: 3, vsync: this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tabController.dispose();
|
||||||
|
_queryController.dispose();
|
||||||
|
_nomController.dispose();
|
||||||
|
_prenomController.dispose();
|
||||||
|
_emailController.dispose();
|
||||||
|
_telephoneController.dispose();
|
||||||
|
_regionController.dispose();
|
||||||
|
_villeController.dispose();
|
||||||
|
_professionController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Recherche Avancée'),
|
||||||
|
backgroundColor: Theme.of(context).primaryColor,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
elevation: 0,
|
||||||
|
bottom: TabBar(
|
||||||
|
controller: _tabController,
|
||||||
|
indicatorColor: Colors.white,
|
||||||
|
labelColor: Colors.white,
|
||||||
|
unselectedLabelColor: Colors.white70,
|
||||||
|
tabs: const [
|
||||||
|
Tab(icon: Icon(Icons.search), text: 'Critères'),
|
||||||
|
Tab(icon: Icon(Icons.list), text: 'Résultats'),
|
||||||
|
Tab(icon: Icon(Icons.analytics), text: 'Statistiques'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: TabBarView(
|
||||||
|
controller: _tabController,
|
||||||
|
children: [
|
||||||
|
_buildSearchCriteriaTab(),
|
||||||
|
_buildSearchResultsTab(),
|
||||||
|
_buildStatisticsTab(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
floatingActionButton: _buildSearchFab(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Onglet des critères de recherche
|
||||||
|
Widget _buildSearchCriteriaTab() {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Recherche rapide
|
||||||
|
_buildQuickSearchSection(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Critères détaillés
|
||||||
|
_buildDetailedCriteriaSection(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Filtres avancés
|
||||||
|
_buildAdvancedFiltersSection(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Boutons d'action
|
||||||
|
_buildActionButtons(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Section de recherche rapide
|
||||||
|
Widget _buildQuickSearchSection() {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.flash_on, color: Theme.of(context).primaryColor),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Recherche Rapide',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _queryController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Rechercher un membre',
|
||||||
|
hintText: 'Nom, prénom ou email...',
|
||||||
|
prefixIcon: Icon(Icons.search),
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => _performQuickSearch(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
_buildQuickFilterChip('Membres actifs', () {
|
||||||
|
_selectedStatut = 'ACTIF';
|
||||||
|
_includeInactifs = false;
|
||||||
|
}),
|
||||||
|
_buildQuickFilterChip('Membres bureau', () {
|
||||||
|
_membreBureau = true;
|
||||||
|
_selectedStatut = 'ACTIF';
|
||||||
|
}),
|
||||||
|
_buildQuickFilterChip('Responsables', () {
|
||||||
|
_responsable = true;
|
||||||
|
_selectedStatut = 'ACTIF';
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Section des critères détaillés
|
||||||
|
Widget _buildDetailedCriteriaSection() {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.tune, color: Theme.of(context).primaryColor),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Critères Détaillés',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _nomController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Nom',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _prenomController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Prénom',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _emailController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Email',
|
||||||
|
hintText: 'exemple@unionflow.com',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _telephoneController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Téléphone',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: DropdownButtonFormField<String>(
|
||||||
|
value: _selectedStatut,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Statut',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(value: null, child: Text('Tous')),
|
||||||
|
DropdownMenuItem(value: 'ACTIF', child: Text('Actif')),
|
||||||
|
DropdownMenuItem(value: 'INACTIF', child: Text('Inactif')),
|
||||||
|
DropdownMenuItem(value: 'SUSPENDU', child: Text('Suspendu')),
|
||||||
|
DropdownMenuItem(value: 'RADIE', child: Text('Radié')),
|
||||||
|
],
|
||||||
|
onChanged: (value) => setState(() => _selectedStatut = value),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Section des filtres avancés
|
||||||
|
Widget _buildAdvancedFiltersSection() {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.filter_alt, color: Theme.of(context).primaryColor),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Filtres Avancés',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Tranche d'âge
|
||||||
|
Text('Tranche d\'âge: ${_ageRange.start.round()}-${_ageRange.end.round()} ans'),
|
||||||
|
RangeSlider(
|
||||||
|
values: _ageRange,
|
||||||
|
min: 18,
|
||||||
|
max: 80,
|
||||||
|
divisions: 62,
|
||||||
|
labels: RangeLabels(
|
||||||
|
'${_ageRange.start.round()}',
|
||||||
|
'${_ageRange.end.round()}',
|
||||||
|
),
|
||||||
|
onChanged: (values) => setState(() => _ageRange = values),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Options booléennes
|
||||||
|
CheckboxListTile(
|
||||||
|
title: const Text('Inclure les membres inactifs'),
|
||||||
|
value: _includeInactifs,
|
||||||
|
onChanged: (value) => setState(() => _includeInactifs = value ?? false),
|
||||||
|
),
|
||||||
|
CheckboxListTile(
|
||||||
|
title: const Text('Membres du bureau uniquement'),
|
||||||
|
value: _membreBureau,
|
||||||
|
onChanged: (value) => setState(() => _membreBureau = value ?? false),
|
||||||
|
),
|
||||||
|
CheckboxListTile(
|
||||||
|
title: const Text('Responsables uniquement'),
|
||||||
|
value: _responsable,
|
||||||
|
onChanged: (value) => setState(() => _responsable = value ?? false),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Boutons d'action
|
||||||
|
Widget _buildActionButtons() {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _clearCriteria,
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
label: const Text('Effacer'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: _isSearching ? null : _performAdvancedSearch,
|
||||||
|
icon: _isSearching
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.search),
|
||||||
|
label: Text(_isSearching ? 'Recherche...' : 'Rechercher'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Onglet des résultats
|
||||||
|
Widget _buildSearchResultsTab() {
|
||||||
|
if (_currentResult == null) {
|
||||||
|
return const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.search, size: 64, color: Colors.grey),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Aucune recherche effectuée',
|
||||||
|
style: TextStyle(fontSize: 18, color: Colors.grey),
|
||||||
|
),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Utilisez l\'onglet Critères pour lancer une recherche',
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_errorMessage != null) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error, size: 64, color: Colors.red),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Erreur de recherche',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_errorMessage!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => setState(() => _errorMessage = null),
|
||||||
|
child: const Text('Réessayer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return MembreSearchResults(result: _currentResult!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Onglet des statistiques
|
||||||
|
Widget _buildStatisticsTab() {
|
||||||
|
if (_currentResult?.statistics == null) {
|
||||||
|
return const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.analytics, size: 64, color: Colors.grey),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Aucune statistique disponible',
|
||||||
|
style: TextStyle(fontSize: 18, color: Colors.grey),
|
||||||
|
),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Effectuez une recherche pour voir les statistiques',
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SearchStatisticsCard(statistics: _currentResult!.statistics!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FAB de recherche
|
||||||
|
Widget _buildSearchFab() {
|
||||||
|
return FloatingActionButton.extended(
|
||||||
|
onPressed: _isSearching ? null : _performAdvancedSearch,
|
||||||
|
icon: _isSearching
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.search),
|
||||||
|
label: Text(_isSearching ? 'Recherche...' : 'Rechercher'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chip de filtre rapide
|
||||||
|
Widget _buildQuickFilterChip(String label, VoidCallback onTap) {
|
||||||
|
return ActionChip(
|
||||||
|
label: Text(label),
|
||||||
|
onPressed: onTap,
|
||||||
|
backgroundColor: Theme.of(context).primaryColor.withOpacity(0.1),
|
||||||
|
labelStyle: TextStyle(color: Theme.of(context).primaryColor),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Effectue une recherche rapide
|
||||||
|
void _performQuickSearch() {
|
||||||
|
if (_queryController.text.trim().isEmpty) return;
|
||||||
|
|
||||||
|
final criteria = MembreSearchCriteria.quickSearch(_queryController.text.trim());
|
||||||
|
_performSearch(criteria);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Effectue une recherche avancée
|
||||||
|
void _performAdvancedSearch() {
|
||||||
|
final criteria = _buildSearchCriteria();
|
||||||
|
_performSearch(criteria);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construit les critères de recherche à partir des champs
|
||||||
|
MembreSearchCriteria _buildSearchCriteria() {
|
||||||
|
return MembreSearchCriteria(
|
||||||
|
query: _queryController.text.trim().isEmpty ? null : _queryController.text.trim(),
|
||||||
|
nom: _nomController.text.trim().isEmpty ? null : _nomController.text.trim(),
|
||||||
|
prenom: _prenomController.text.trim().isEmpty ? null : _prenomController.text.trim(),
|
||||||
|
email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(),
|
||||||
|
telephone: _telephoneController.text.trim().isEmpty ? null : _telephoneController.text.trim(),
|
||||||
|
statut: _selectedStatut,
|
||||||
|
ageMin: _ageRange.start.round(),
|
||||||
|
ageMax: _ageRange.end.round(),
|
||||||
|
region: _regionController.text.trim().isEmpty ? null : _regionController.text.trim(),
|
||||||
|
ville: _villeController.text.trim().isEmpty ? null : _villeController.text.trim(),
|
||||||
|
profession: _professionController.text.trim().isEmpty ? null : _professionController.text.trim(),
|
||||||
|
organisationIds: _selectedOrganisations.isEmpty ? null : _selectedOrganisations,
|
||||||
|
roles: _selectedRoles.isEmpty ? null : _selectedRoles,
|
||||||
|
membreBureau: _membreBureau ? true : null,
|
||||||
|
responsable: _responsable ? true : null,
|
||||||
|
includeInactifs: _includeInactifs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Effectue la recherche
|
||||||
|
void _performSearch(MembreSearchCriteria criteria) async {
|
||||||
|
if (!criteria.hasAnyCriteria) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Veuillez spécifier au moins un critère de recherche'),
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSearching = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
_currentCriteria = criteria;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// TODO: Appeler le service de recherche
|
||||||
|
// final result = await _searchService.searchMembresAdvanced(criteria: criteria);
|
||||||
|
|
||||||
|
// Simulation pour l'instant
|
||||||
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
|
final result = MembreSearchResult.empty(criteria);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_currentResult = result;
|
||||||
|
_isSearching = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Basculer vers l'onglet des résultats
|
||||||
|
_tabController.animateTo(1);
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(result.resultDescription),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = e.toString();
|
||||||
|
_isSearching = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Erreur de recherche: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Efface tous les critères
|
||||||
|
void _clearCriteria() {
|
||||||
|
setState(() {
|
||||||
|
_queryController.clear();
|
||||||
|
_nomController.clear();
|
||||||
|
_prenomController.clear();
|
||||||
|
_emailController.clear();
|
||||||
|
_telephoneController.clear();
|
||||||
|
_regionController.clear();
|
||||||
|
_villeController.clear();
|
||||||
|
_professionController.clear();
|
||||||
|
_selectedStatut = null;
|
||||||
|
_selectedRoles.clear();
|
||||||
|
_selectedOrganisations.clear();
|
||||||
|
_ageRange = const RangeValues(18, 65);
|
||||||
|
_adhesionDateRange = null;
|
||||||
|
_includeInactifs = false;
|
||||||
|
_membreBureau = false;
|
||||||
|
_responsable = false;
|
||||||
|
_currentResult = null;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,433 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../../core/models/membre_search_criteria.dart';
|
||||||
|
|
||||||
|
/// Formulaire de recherche de membres
|
||||||
|
/// Widget réutilisable pour la saisie des critères de recherche
|
||||||
|
class MembreSearchForm extends StatefulWidget {
|
||||||
|
final MembreSearchCriteria initialCriteria;
|
||||||
|
final Function(MembreSearchCriteria) onCriteriaChanged;
|
||||||
|
final VoidCallback? onSearch;
|
||||||
|
final VoidCallback? onClear;
|
||||||
|
final bool isCompact;
|
||||||
|
|
||||||
|
const MembreSearchForm({
|
||||||
|
super.key,
|
||||||
|
this.initialCriteria = MembreSearchCriteria.empty,
|
||||||
|
required this.onCriteriaChanged,
|
||||||
|
this.onSearch,
|
||||||
|
this.onClear,
|
||||||
|
this.isCompact = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MembreSearchForm> createState() => _MembreSearchFormState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MembreSearchFormState extends State<MembreSearchForm> {
|
||||||
|
late TextEditingController _queryController;
|
||||||
|
late TextEditingController _nomController;
|
||||||
|
late TextEditingController _prenomController;
|
||||||
|
late TextEditingController _emailController;
|
||||||
|
late TextEditingController _telephoneController;
|
||||||
|
|
||||||
|
String? _selectedStatut;
|
||||||
|
List<String> _selectedRoles = [];
|
||||||
|
RangeValues _ageRange = const RangeValues(18, 65);
|
||||||
|
bool _includeInactifs = false;
|
||||||
|
bool _membreBureau = false;
|
||||||
|
bool _responsable = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initializeControllers();
|
||||||
|
_loadInitialCriteria();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_queryController.dispose();
|
||||||
|
_nomController.dispose();
|
||||||
|
_prenomController.dispose();
|
||||||
|
_emailController.dispose();
|
||||||
|
_telephoneController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initializeControllers() {
|
||||||
|
_queryController = TextEditingController();
|
||||||
|
_nomController = TextEditingController();
|
||||||
|
_prenomController = TextEditingController();
|
||||||
|
_emailController = TextEditingController();
|
||||||
|
_telephoneController = TextEditingController();
|
||||||
|
|
||||||
|
// Écouter les changements pour mettre à jour les critères
|
||||||
|
_queryController.addListener(_updateCriteria);
|
||||||
|
_nomController.addListener(_updateCriteria);
|
||||||
|
_prenomController.addListener(_updateCriteria);
|
||||||
|
_emailController.addListener(_updateCriteria);
|
||||||
|
_telephoneController.addListener(_updateCriteria);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _loadInitialCriteria() {
|
||||||
|
final criteria = widget.initialCriteria;
|
||||||
|
_queryController.text = criteria.query ?? '';
|
||||||
|
_nomController.text = criteria.nom ?? '';
|
||||||
|
_prenomController.text = criteria.prenom ?? '';
|
||||||
|
_emailController.text = criteria.email ?? '';
|
||||||
|
_telephoneController.text = criteria.telephone ?? '';
|
||||||
|
_selectedStatut = criteria.statut;
|
||||||
|
_selectedRoles = criteria.roles ?? [];
|
||||||
|
_ageRange = RangeValues(
|
||||||
|
criteria.ageMin?.toDouble() ?? 18,
|
||||||
|
criteria.ageMax?.toDouble() ?? 65,
|
||||||
|
);
|
||||||
|
_includeInactifs = criteria.includeInactifs;
|
||||||
|
_membreBureau = criteria.membreBureau ?? false;
|
||||||
|
_responsable = criteria.responsable ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (widget.isCompact) {
|
||||||
|
return _buildCompactForm();
|
||||||
|
}
|
||||||
|
return _buildFullForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formulaire compact pour recherche rapide
|
||||||
|
Widget _buildCompactForm() {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _queryController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Rechercher un membre',
|
||||||
|
hintText: 'Nom, prénom ou email...',
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
suffixIcon: _queryController.text.isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: () {
|
||||||
|
_queryController.clear();
|
||||||
|
_updateCriteria();
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => widget.onSearch?.call(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: DropdownButtonFormField<String>(
|
||||||
|
value: _selectedStatut,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Statut',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(value: null, child: Text('Tous')),
|
||||||
|
DropdownMenuItem(value: 'ACTIF', child: Text('Actif')),
|
||||||
|
DropdownMenuItem(value: 'INACTIF', child: Text('Inactif')),
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => _selectedStatut = value);
|
||||||
|
_updateCriteria();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
if (widget.onSearch != null)
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: widget.onSearch,
|
||||||
|
icon: const Icon(Icons.search),
|
||||||
|
label: const Text('Rechercher'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formulaire complet avec tous les critères
|
||||||
|
Widget _buildFullForm() {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
// Recherche générale
|
||||||
|
_buildGeneralSearchSection(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Critères détaillés
|
||||||
|
_buildDetailedCriteriaSection(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Filtres avancés
|
||||||
|
_buildAdvancedFiltersSection(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Boutons d'action
|
||||||
|
_buildActionButtons(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Section de recherche générale
|
||||||
|
Widget _buildGeneralSearchSection() {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Recherche Générale',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _queryController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Terme de recherche',
|
||||||
|
hintText: 'Nom, prénom, email...',
|
||||||
|
prefixIcon: Icon(Icons.search),
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Section des critères détaillés
|
||||||
|
Widget _buildDetailedCriteriaSection() {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Critères Détaillés',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _nomController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Nom',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _prenomController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Prénom',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _emailController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Email',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _telephoneController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Téléphone',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: DropdownButtonFormField<String>(
|
||||||
|
value: _selectedStatut,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Statut',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(value: null, child: Text('Tous')),
|
||||||
|
DropdownMenuItem(value: 'ACTIF', child: Text('Actif')),
|
||||||
|
DropdownMenuItem(value: 'INACTIF', child: Text('Inactif')),
|
||||||
|
DropdownMenuItem(value: 'SUSPENDU', child: Text('Suspendu')),
|
||||||
|
DropdownMenuItem(value: 'RADIE', child: Text('Radié')),
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => _selectedStatut = value);
|
||||||
|
_updateCriteria();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Section des filtres avancés
|
||||||
|
Widget _buildAdvancedFiltersSection() {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Filtres Avancés',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Tranche d'âge
|
||||||
|
Text('Tranche d\'âge: ${_ageRange.start.round()}-${_ageRange.end.round()} ans'),
|
||||||
|
RangeSlider(
|
||||||
|
values: _ageRange,
|
||||||
|
min: 18,
|
||||||
|
max: 80,
|
||||||
|
divisions: 62,
|
||||||
|
labels: RangeLabels(
|
||||||
|
'${_ageRange.start.round()}',
|
||||||
|
'${_ageRange.end.round()}',
|
||||||
|
),
|
||||||
|
onChanged: (values) {
|
||||||
|
setState(() => _ageRange = values);
|
||||||
|
_updateCriteria();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Options booléennes
|
||||||
|
CheckboxListTile(
|
||||||
|
title: const Text('Inclure les membres inactifs'),
|
||||||
|
value: _includeInactifs,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => _includeInactifs = value ?? false);
|
||||||
|
_updateCriteria();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
CheckboxListTile(
|
||||||
|
title: const Text('Membres du bureau uniquement'),
|
||||||
|
value: _membreBureau,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => _membreBureau = value ?? false);
|
||||||
|
_updateCriteria();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
CheckboxListTile(
|
||||||
|
title: const Text('Responsables uniquement'),
|
||||||
|
value: _responsable,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => _responsable = value ?? false);
|
||||||
|
_updateCriteria();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Boutons d'action
|
||||||
|
Widget _buildActionButtons() {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
if (widget.onClear != null)
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
_clearForm();
|
||||||
|
widget.onClear?.call();
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
label: const Text('Effacer'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.onClear != null && widget.onSearch != null)
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
if (widget.onSearch != null)
|
||||||
|
Expanded(
|
||||||
|
flex: widget.onClear != null ? 2 : 1,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: widget.onSearch,
|
||||||
|
icon: const Icon(Icons.search),
|
||||||
|
label: const Text('Rechercher'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Met à jour les critères de recherche
|
||||||
|
void _updateCriteria() {
|
||||||
|
final criteria = MembreSearchCriteria(
|
||||||
|
query: _queryController.text.trim().isEmpty ? null : _queryController.text.trim(),
|
||||||
|
nom: _nomController.text.trim().isEmpty ? null : _nomController.text.trim(),
|
||||||
|
prenom: _prenomController.text.trim().isEmpty ? null : _prenomController.text.trim(),
|
||||||
|
email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(),
|
||||||
|
telephone: _telephoneController.text.trim().isEmpty ? null : _telephoneController.text.trim(),
|
||||||
|
statut: _selectedStatut,
|
||||||
|
roles: _selectedRoles.isEmpty ? null : _selectedRoles,
|
||||||
|
ageMin: _ageRange.start.round(),
|
||||||
|
ageMax: _ageRange.end.round(),
|
||||||
|
membreBureau: _membreBureau ? true : null,
|
||||||
|
responsable: _responsable ? true : null,
|
||||||
|
includeInactifs: _includeInactifs,
|
||||||
|
);
|
||||||
|
|
||||||
|
widget.onCriteriaChanged(criteria);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Efface le formulaire
|
||||||
|
void _clearForm() {
|
||||||
|
setState(() {
|
||||||
|
_queryController.clear();
|
||||||
|
_nomController.clear();
|
||||||
|
_prenomController.clear();
|
||||||
|
_emailController.clear();
|
||||||
|
_telephoneController.clear();
|
||||||
|
_selectedStatut = null;
|
||||||
|
_selectedRoles.clear();
|
||||||
|
_ageRange = const RangeValues(18, 65);
|
||||||
|
_includeInactifs = false;
|
||||||
|
_membreBureau = false;
|
||||||
|
_responsable = false;
|
||||||
|
});
|
||||||
|
_updateCriteria();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../../core/models/membre_search_result.dart' as search_model;
|
||||||
|
import '../../data/models/membre_model.dart' as member_model;
|
||||||
|
|
||||||
|
/// Widget d'affichage des résultats de recherche de membres
|
||||||
|
/// Gère la pagination, le tri et l'affichage des membres trouvés
|
||||||
|
class MembreSearchResults extends StatefulWidget {
|
||||||
|
final search_model.MembreSearchResult result;
|
||||||
|
final Function(member_model.MembreModel)? onMembreSelected;
|
||||||
|
final bool showPagination;
|
||||||
|
|
||||||
|
const MembreSearchResults({
|
||||||
|
super.key,
|
||||||
|
required this.result,
|
||||||
|
this.onMembreSelected,
|
||||||
|
this.showPagination = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MembreSearchResults> createState() => _MembreSearchResultsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MembreSearchResultsState extends State<MembreSearchResults> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (widget.result.isEmpty) {
|
||||||
|
return _buildEmptyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
// En-tête avec informations sur les résultats
|
||||||
|
_buildResultsHeader(),
|
||||||
|
|
||||||
|
// Liste des membres
|
||||||
|
Expanded(
|
||||||
|
child: _buildMembersList(),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Pagination si activée
|
||||||
|
if (widget.showPagination && widget.result.totalPages > 1)
|
||||||
|
_buildPagination(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// État vide quand aucun résultat
|
||||||
|
Widget _buildEmptyState() {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.search_off,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.grey[400],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Aucun membre trouvé',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Essayez de modifier vos critères de recherche',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Colors.grey[500],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
icon: const Icon(Icons.tune),
|
||||||
|
label: const Text('Modifier les critères'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// En-tête avec informations sur les résultats
|
||||||
|
Widget _buildResultsHeader() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).primaryColor.withOpacity(0.1),
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: Theme.of(context).dividerColor,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.search,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
widget.result.resultDescription,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Chip(
|
||||||
|
label: Text('${widget.result.executionTimeMs}ms'),
|
||||||
|
backgroundColor: Colors.green.withOpacity(0.1),
|
||||||
|
labelStyle: const TextStyle(
|
||||||
|
color: Colors.green,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (widget.result.criteria.description.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Critères: ${widget.result.criteria.description}',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liste des membres trouvés
|
||||||
|
Widget _buildMembersList() {
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: widget.result.membres.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final membre = widget.result.membres[index];
|
||||||
|
return _buildMembreCard(membre, index);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Carte d'affichage d'un membre
|
||||||
|
Widget _buildMembreCard(member_model.MembreModel membre, int index) {
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
child: ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: _getStatusColor(membre.statut ?? 'ACTIF'),
|
||||||
|
child: Text(
|
||||||
|
_getInitials(membre.nom, membre.prenom),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
'${membre.prenom} ${membre.nom}',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (membre.email.isNotEmpty)
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.email, size: 14, color: Colors.grey),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
membre.email,
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (membre.telephone?.isNotEmpty == true)
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.phone, size: 14, color: Colors.grey),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
membre.telephone!,
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (membre.organisation?.nom?.isNotEmpty == true)
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.business, size: 14, color: Colors.grey),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
membre.organisation!.nom!,
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trailing: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
_buildStatusChip(membre.statut ?? 'ACTIF'),
|
||||||
|
if (membre.role?.isNotEmpty == true) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
_formatRoles(membre.role!),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: widget.onMembreSelected != null
|
||||||
|
? () => widget.onMembreSelected!(membre)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pagination
|
||||||
|
Widget _buildPagination() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).cardColor,
|
||||||
|
border: Border(
|
||||||
|
top: BorderSide(
|
||||||
|
color: Theme.of(context).dividerColor,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
// Bouton page précédente
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: widget.result.hasPrevious ? _goToPreviousPage : null,
|
||||||
|
icon: const Icon(Icons.chevron_left),
|
||||||
|
label: const Text('Précédent'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.grey[100],
|
||||||
|
foregroundColor: Colors.grey[700],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Indicateur de page
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).primaryColor.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Page ${widget.result.currentPage + 1} / ${widget.result.totalPages}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Bouton page suivante
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: widget.result.hasNext ? _goToNextPage : null,
|
||||||
|
icon: const Icon(Icons.chevron_right),
|
||||||
|
label: const Text('Suivant'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(context).primaryColor,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chip de statut
|
||||||
|
Widget _buildStatusChip(String statut) {
|
||||||
|
final color = _getStatusColor(statut);
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: color, width: 1),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_getStatusLabel(statut),
|
||||||
|
style: TextStyle(
|
||||||
|
color: color,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obtient la couleur du statut
|
||||||
|
Color _getStatusColor(String statut) {
|
||||||
|
switch (statut.toUpperCase()) {
|
||||||
|
case 'ACTIF':
|
||||||
|
return Colors.green;
|
||||||
|
case 'INACTIF':
|
||||||
|
return Colors.orange;
|
||||||
|
case 'SUSPENDU':
|
||||||
|
return Colors.red;
|
||||||
|
case 'RADIE':
|
||||||
|
return Colors.grey;
|
||||||
|
default:
|
||||||
|
return Colors.grey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obtient le libellé du statut
|
||||||
|
String _getStatusLabel(String statut) {
|
||||||
|
switch (statut.toUpperCase()) {
|
||||||
|
case 'ACTIF':
|
||||||
|
return 'Actif';
|
||||||
|
case 'INACTIF':
|
||||||
|
return 'Inactif';
|
||||||
|
case 'SUSPENDU':
|
||||||
|
return 'Suspendu';
|
||||||
|
case 'RADIE':
|
||||||
|
return 'Radié';
|
||||||
|
default:
|
||||||
|
return statut;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obtient les initiales d'un membre
|
||||||
|
String _getInitials(String nom, String prenom) {
|
||||||
|
final nomInitial = nom.isNotEmpty ? nom[0].toUpperCase() : '';
|
||||||
|
final prenomInitial = prenom.isNotEmpty ? prenom[0].toUpperCase() : '';
|
||||||
|
return '$prenomInitial$nomInitial';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formate les rôles pour l'affichage
|
||||||
|
String _formatRoles(String roles) {
|
||||||
|
final rolesList = roles.split(',').map((r) => r.trim()).toList();
|
||||||
|
if (rolesList.length <= 2) {
|
||||||
|
return rolesList.join(', ');
|
||||||
|
}
|
||||||
|
return '${rolesList.take(2).join(', ')}...';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigation vers la page précédente
|
||||||
|
void _goToPreviousPage() {
|
||||||
|
// TODO: Implémenter la navigation vers la page précédente
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Navigation vers la page précédente'),
|
||||||
|
duration: Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigation vers la page suivante
|
||||||
|
void _goToNextPage() {
|
||||||
|
// TODO: Implémenter la navigation vers la page suivante
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Navigation vers la page suivante'),
|
||||||
|
duration: Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
|
|
||||||
|
import '../../../../core/models/membre_search_result.dart';
|
||||||
|
|
||||||
|
/// Widget d'affichage des statistiques de recherche
|
||||||
|
/// Présente les métriques et graphiques des résultats de recherche
|
||||||
|
class SearchStatisticsCard extends StatelessWidget {
|
||||||
|
final SearchStatistics statistics;
|
||||||
|
|
||||||
|
const SearchStatisticsCard({
|
||||||
|
super.key,
|
||||||
|
required this.statistics,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Titre
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.analytics,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Statistiques de Recherche',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Métriques principales
|
||||||
|
_buildMainMetrics(context),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Graphique de répartition actifs/inactifs
|
||||||
|
_buildStatusChart(context),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Métriques détaillées
|
||||||
|
_buildDetailedMetrics(context),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Informations complémentaires
|
||||||
|
_buildAdditionalInfo(context),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Métriques principales
|
||||||
|
Widget _buildMainMetrics(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Vue d\'ensemble',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildMetricCard(
|
||||||
|
context,
|
||||||
|
'Total Membres',
|
||||||
|
statistics.totalMembres.toString(),
|
||||||
|
Icons.people,
|
||||||
|
Colors.blue,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _buildMetricCard(
|
||||||
|
context,
|
||||||
|
'Membres Actifs',
|
||||||
|
statistics.membresActifs.toString(),
|
||||||
|
Icons.person,
|
||||||
|
Colors.green,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildMetricCard(
|
||||||
|
context,
|
||||||
|
'Âge Moyen',
|
||||||
|
'${statistics.ageMoyen.toStringAsFixed(1)} ans',
|
||||||
|
Icons.cake,
|
||||||
|
Colors.orange,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _buildMetricCard(
|
||||||
|
context,
|
||||||
|
'Ancienneté',
|
||||||
|
'${statistics.ancienneteMoyenne.toStringAsFixed(1)} ans',
|
||||||
|
Icons.schedule,
|
||||||
|
Colors.purple,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Carte de métrique individuelle
|
||||||
|
Widget _buildMetricCard(
|
||||||
|
BuildContext context,
|
||||||
|
String title,
|
||||||
|
String value,
|
||||||
|
IconData icon,
|
||||||
|
Color color,
|
||||||
|
) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12.0),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: color.withOpacity(0.3)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: color, size: 24),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Graphique de répartition des statuts
|
||||||
|
Widget _buildStatusChart(BuildContext context) {
|
||||||
|
if (statistics.totalMembres == 0) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Répartition par Statut',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
height: 200,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Graphique en secteurs
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: PieChart(
|
||||||
|
PieChartData(
|
||||||
|
sections: [
|
||||||
|
PieChartSectionData(
|
||||||
|
value: statistics.membresActifs.toDouble(),
|
||||||
|
title: '${statistics.pourcentageActifs.toStringAsFixed(1)}%',
|
||||||
|
color: Colors.green,
|
||||||
|
radius: 60,
|
||||||
|
titleStyle: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (statistics.membresInactifs > 0)
|
||||||
|
PieChartSectionData(
|
||||||
|
value: statistics.membresInactifs.toDouble(),
|
||||||
|
title: '${statistics.pourcentageInactifs.toStringAsFixed(1)}%',
|
||||||
|
color: Colors.orange,
|
||||||
|
radius: 60,
|
||||||
|
titleStyle: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
sectionsSpace: 2,
|
||||||
|
centerSpaceRadius: 40,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Légende
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildLegendItem(
|
||||||
|
'Actifs',
|
||||||
|
statistics.membresActifs,
|
||||||
|
Colors.green,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (statistics.membresInactifs > 0)
|
||||||
|
_buildLegendItem(
|
||||||
|
'Inactifs',
|
||||||
|
statistics.membresInactifs,
|
||||||
|
Colors.orange,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Item de légende
|
||||||
|
Widget _buildLegendItem(String label, int count, Color color) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'$label ($count)',
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Métriques détaillées
|
||||||
|
Widget _buildDetailedMetrics(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Détails Démographiques',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildDetailRow(
|
||||||
|
context,
|
||||||
|
'Tranche d\'âge',
|
||||||
|
statistics.trancheAge,
|
||||||
|
Icons.calendar_today,
|
||||||
|
),
|
||||||
|
_buildDetailRow(
|
||||||
|
context,
|
||||||
|
'Organisations',
|
||||||
|
'${statistics.nombreOrganisations} représentées',
|
||||||
|
Icons.business,
|
||||||
|
),
|
||||||
|
_buildDetailRow(
|
||||||
|
context,
|
||||||
|
'Régions',
|
||||||
|
'${statistics.nombreRegions} représentées',
|
||||||
|
Icons.location_on,
|
||||||
|
),
|
||||||
|
_buildDetailRow(
|
||||||
|
context,
|
||||||
|
'Taux d\'activité',
|
||||||
|
'${statistics.pourcentageActifs.toStringAsFixed(1)}%',
|
||||||
|
Icons.trending_up,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ligne de détail
|
||||||
|
Widget _buildDetailRow(
|
||||||
|
BuildContext context,
|
||||||
|
String label,
|
||||||
|
String value,
|
||||||
|
IconData icon,
|
||||||
|
) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
size: 20,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Informations complémentaires
|
||||||
|
Widget _buildAdditionalInfo(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.info_outline,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Informations Complémentaires',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
statistics.description,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Colors.grey[700],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12.0),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blue.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.blue.withOpacity(0.3)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.lightbulb, color: Colors.blue),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Ces statistiques sont calculées en temps réel sur les résultats de votre recherche.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Colors.blue[700],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Page de recherche avancée des membres
|
||||||
|
class AdvancedSearchPage extends StatefulWidget {
|
||||||
|
const AdvancedSearchPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AdvancedSearchPage> createState() => _AdvancedSearchPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdvancedSearchPageState extends State<AdvancedSearchPage> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _queryController = TextEditingController();
|
||||||
|
final _nomController = TextEditingController();
|
||||||
|
final _prenomController = TextEditingController();
|
||||||
|
final _emailController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_queryController.dispose();
|
||||||
|
_nomController.dispose();
|
||||||
|
_prenomController.dispose();
|
||||||
|
_emailController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Recherche Avancée'),
|
||||||
|
backgroundColor: Theme.of(context).primaryColor,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
body: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
TextFormField(
|
||||||
|
controller: _queryController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Recherche générale',
|
||||||
|
hintText: 'Nom, prénom, email...',
|
||||||
|
prefixIcon: Icon(Icons.search),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _nomController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Nom',
|
||||||
|
prefixIcon: Icon(Icons.person),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _prenomController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Prénom',
|
||||||
|
prefixIcon: Icon(Icons.person_outline),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _emailController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Email',
|
||||||
|
prefixIcon: Icon(Icons.email),
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _performSearch,
|
||||||
|
child: const Text('Rechercher'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: _clearForm,
|
||||||
|
child: const Text('Effacer'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _performSearch() {
|
||||||
|
if (_formKey.currentState!.validate()) {
|
||||||
|
// TODO: Implémenter la recherche
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Recherche en cours...'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearForm() {
|
||||||
|
_queryController.clear();
|
||||||
|
_nomController.clear();
|
||||||
|
_prenomController.clear();
|
||||||
|
_emailController.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import 'core/design_system/theme/app_theme_sophisticated.dart';
|
|||||||
import 'core/auth/bloc/auth_bloc.dart';
|
import 'core/auth/bloc/auth_bloc.dart';
|
||||||
import 'core/cache/dashboard_cache_manager.dart';
|
import 'core/cache/dashboard_cache_manager.dart';
|
||||||
import 'features/auth/presentation/pages/login_page.dart';
|
import 'features/auth/presentation/pages/login_page.dart';
|
||||||
import 'features/dashboard/presentation/pages/adaptive_dashboard_page.dart';
|
import 'core/navigation/main_navigation_layout.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@@ -85,13 +85,13 @@ class UnionFlowApp extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else if (state is AuthAuthenticated) {
|
} else if (state is AuthAuthenticated) {
|
||||||
return const AdaptiveDashboardPage();
|
return const MainNavigationLayout();
|
||||||
} else {
|
} else {
|
||||||
return const LoginPage();
|
return const LoginPage();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
'/dashboard': (context) => const AdaptiveDashboardPage(),
|
'/dashboard': (context) => const MainNavigationLayout(),
|
||||||
'/login': (context) => const LoginPage(),
|
'/login': (context) => const LoginPage(),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
24
unionflow-mobile-apps/run_app.bat
Normal file
24
unionflow-mobile-apps/run_app.bat
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
@echo off
|
||||||
|
echo Lancement de l'application UnionFlow Mobile...
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo Verification des devices connectes...
|
||||||
|
flutter devices
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo Nettoyage du projet...
|
||||||
|
flutter clean
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo Installation des dependances...
|
||||||
|
flutter pub get
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo Analyse du code...
|
||||||
|
flutter analyze --no-fatal-infos
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo Lancement de l'application sur le device R58R34HT85V...
|
||||||
|
flutter run -d R58R34HT85V --verbose
|
||||||
|
|
||||||
|
pause
|
||||||
51
unionflow-mobile-apps/test_app.dart
Normal file
51
unionflow-mobile-apps/test_app.dart
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
runApp(const TestApp());
|
||||||
|
}
|
||||||
|
|
||||||
|
class TestApp extends StatelessWidget {
|
||||||
|
const TestApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MaterialApp(
|
||||||
|
title: 'Test UnionFlow',
|
||||||
|
home: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Test UnionFlow'),
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
body: const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
size: 100,
|
||||||
|
color: Colors.green,
|
||||||
|
),
|
||||||
|
SizedBox(height: 20),
|
||||||
|
Text(
|
||||||
|
'UnionFlow Mobile App',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
'Application lancée avec succès !',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
package dev.lions.unionflow.server.api.dto.membre;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.eclipse.microprofile.openapi.annotations.media.Schema;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO pour les critères de recherche avancée des membres
|
||||||
|
* Permet de filtrer les membres selon de multiples critères
|
||||||
|
*
|
||||||
|
* @author UnionFlow Team
|
||||||
|
* @version 1.0
|
||||||
|
* @since 2025-01-19
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
@Schema(description = "Critères de recherche avancée pour les membres")
|
||||||
|
public class MembreSearchCriteria {
|
||||||
|
|
||||||
|
/** Terme de recherche général (nom, prénom, email) */
|
||||||
|
@Schema(description = "Terme de recherche général dans nom, prénom ou email", example = "marie")
|
||||||
|
@Size(max = 100, message = "Le terme de recherche ne peut pas dépasser 100 caractères")
|
||||||
|
private String query;
|
||||||
|
|
||||||
|
/** Recherche par nom exact ou partiel */
|
||||||
|
@Schema(description = "Filtre par nom (recherche partielle)", example = "Dupont")
|
||||||
|
@Size(max = 50, message = "Le nom ne peut pas dépasser 50 caractères")
|
||||||
|
private String nom;
|
||||||
|
|
||||||
|
/** Recherche par prénom exact ou partiel */
|
||||||
|
@Schema(description = "Filtre par prénom (recherche partielle)", example = "Marie")
|
||||||
|
@Size(max = 50, message = "Le prénom ne peut pas dépasser 50 caractères")
|
||||||
|
private String prenom;
|
||||||
|
|
||||||
|
/** Recherche par email exact ou partiel */
|
||||||
|
@Schema(description = "Filtre par email (recherche partielle)", example = "@unionflow.com")
|
||||||
|
@Size(max = 100, message = "L'email ne peut pas dépasser 100 caractères")
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
/** Filtre par numéro de téléphone */
|
||||||
|
@Schema(description = "Filtre par numéro de téléphone", example = "+221")
|
||||||
|
@Size(max = 20, message = "Le téléphone ne peut pas dépasser 20 caractères")
|
||||||
|
private String telephone;
|
||||||
|
|
||||||
|
/** Liste des IDs d'organisations */
|
||||||
|
@Schema(description = "Liste des IDs d'organisations à inclure")
|
||||||
|
private List<UUID> organisationIds;
|
||||||
|
|
||||||
|
/** Liste des rôles à rechercher */
|
||||||
|
@Schema(description = "Liste des rôles à rechercher", example = "[\"PRESIDENT\", \"SECRETAIRE\"]")
|
||||||
|
private List<String> roles;
|
||||||
|
|
||||||
|
/** Filtre par statut d'activité */
|
||||||
|
@Schema(description = "Filtre par statut d'activité", example = "ACTIF")
|
||||||
|
@Pattern(regexp = "^(ACTIF|INACTIF|SUSPENDU|RADIE)$", message = "Statut invalide")
|
||||||
|
private String statut;
|
||||||
|
|
||||||
|
/** Date d'adhésion minimum */
|
||||||
|
@Schema(description = "Date d'adhésion minimum", example = "2020-01-01")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd")
|
||||||
|
private LocalDate dateAdhesionMin;
|
||||||
|
|
||||||
|
/** Date d'adhésion maximum */
|
||||||
|
@Schema(description = "Date d'adhésion maximum", example = "2025-12-31")
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd")
|
||||||
|
private LocalDate dateAdhesionMax;
|
||||||
|
|
||||||
|
/** Âge minimum */
|
||||||
|
@Schema(description = "Âge minimum", example = "18")
|
||||||
|
@Min(value = 0, message = "L'âge minimum doit être positif")
|
||||||
|
@Max(value = 120, message = "L'âge minimum ne peut pas dépasser 120 ans")
|
||||||
|
private Integer ageMin;
|
||||||
|
|
||||||
|
/** Âge maximum */
|
||||||
|
@Schema(description = "Âge maximum", example = "65")
|
||||||
|
@Min(value = 0, message = "L'âge maximum doit être positif")
|
||||||
|
@Max(value = 120, message = "L'âge maximum ne peut pas dépasser 120 ans")
|
||||||
|
private Integer ageMax;
|
||||||
|
|
||||||
|
/** Filtre par région */
|
||||||
|
@Schema(description = "Filtre par région", example = "Dakar")
|
||||||
|
@Size(max = 50, message = "La région ne peut pas dépasser 50 caractères")
|
||||||
|
private String region;
|
||||||
|
|
||||||
|
/** Filtre par ville */
|
||||||
|
@Schema(description = "Filtre par ville", example = "Dakar")
|
||||||
|
@Size(max = 50, message = "La ville ne peut pas dépasser 50 caractères")
|
||||||
|
private String ville;
|
||||||
|
|
||||||
|
/** Filtre par profession */
|
||||||
|
@Schema(description = "Filtre par profession", example = "Ingénieur")
|
||||||
|
@Size(max = 100, message = "La profession ne peut pas dépasser 100 caractères")
|
||||||
|
private String profession;
|
||||||
|
|
||||||
|
/** Filtre par nationalité */
|
||||||
|
@Schema(description = "Filtre par nationalité", example = "Sénégalaise")
|
||||||
|
@Size(max = 50, message = "La nationalité ne peut pas dépasser 50 caractères")
|
||||||
|
private String nationalite;
|
||||||
|
|
||||||
|
/** Filtre membres du bureau uniquement */
|
||||||
|
@Schema(description = "Filtre pour les membres du bureau uniquement")
|
||||||
|
private Boolean membreBureau;
|
||||||
|
|
||||||
|
/** Filtre responsables uniquement */
|
||||||
|
@Schema(description = "Filtre pour les responsables uniquement")
|
||||||
|
private Boolean responsable;
|
||||||
|
|
||||||
|
/** Inclure les membres inactifs dans la recherche */
|
||||||
|
@Schema(description = "Inclure les membres inactifs", defaultValue = "false")
|
||||||
|
@Builder.Default
|
||||||
|
private Boolean includeInactifs = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si au moins un critère de recherche est défini
|
||||||
|
*
|
||||||
|
* @return true si au moins un critère est défini
|
||||||
|
*/
|
||||||
|
public boolean hasAnyCriteria() {
|
||||||
|
return query != null && !query.trim().isEmpty() ||
|
||||||
|
nom != null && !nom.trim().isEmpty() ||
|
||||||
|
prenom != null && !prenom.trim().isEmpty() ||
|
||||||
|
email != null && !email.trim().isEmpty() ||
|
||||||
|
telephone != null && !telephone.trim().isEmpty() ||
|
||||||
|
organisationIds != null && !organisationIds.isEmpty() ||
|
||||||
|
roles != null && !roles.isEmpty() ||
|
||||||
|
statut != null && !statut.trim().isEmpty() ||
|
||||||
|
dateAdhesionMin != null ||
|
||||||
|
dateAdhesionMax != null ||
|
||||||
|
ageMin != null ||
|
||||||
|
ageMax != null ||
|
||||||
|
region != null && !region.trim().isEmpty() ||
|
||||||
|
ville != null && !ville.trim().isEmpty() ||
|
||||||
|
profession != null && !profession.trim().isEmpty() ||
|
||||||
|
nationalite != null && !nationalite.trim().isEmpty() ||
|
||||||
|
membreBureau != null ||
|
||||||
|
responsable != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valide la cohérence des critères de recherche
|
||||||
|
*
|
||||||
|
* @return true si les critères sont cohérents
|
||||||
|
*/
|
||||||
|
public boolean isValid() {
|
||||||
|
// Validation des dates
|
||||||
|
if (dateAdhesionMin != null && dateAdhesionMax != null) {
|
||||||
|
if (dateAdhesionMin.isAfter(dateAdhesionMax)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation des âges
|
||||||
|
if (ageMin != null && ageMax != null) {
|
||||||
|
if (ageMin > ageMax) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nettoie les chaînes de caractères (trim et null si vide)
|
||||||
|
*/
|
||||||
|
public void sanitize() {
|
||||||
|
query = sanitizeString(query);
|
||||||
|
nom = sanitizeString(nom);
|
||||||
|
prenom = sanitizeString(prenom);
|
||||||
|
email = sanitizeString(email);
|
||||||
|
telephone = sanitizeString(telephone);
|
||||||
|
statut = sanitizeString(statut);
|
||||||
|
region = sanitizeString(region);
|
||||||
|
ville = sanitizeString(ville);
|
||||||
|
profession = sanitizeString(profession);
|
||||||
|
nationalite = sanitizeString(nationalite);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String sanitizeString(String str) {
|
||||||
|
if (str == null) return null;
|
||||||
|
str = str.trim();
|
||||||
|
return str.isEmpty() ? null : str;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne une description textuelle des critères actifs
|
||||||
|
*
|
||||||
|
* @return Description des critères
|
||||||
|
*/
|
||||||
|
public String getDescription() {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
|
||||||
|
if (query != null) sb.append("Recherche: '").append(query).append("' ");
|
||||||
|
if (nom != null) sb.append("Nom: '").append(nom).append("' ");
|
||||||
|
if (prenom != null) sb.append("Prénom: '").append(prenom).append("' ");
|
||||||
|
if (email != null) sb.append("Email: '").append(email).append("' ");
|
||||||
|
if (statut != null) sb.append("Statut: ").append(statut).append(" ");
|
||||||
|
if (organisationIds != null && !organisationIds.isEmpty()) {
|
||||||
|
sb.append("Organisations: ").append(organisationIds.size()).append(" ");
|
||||||
|
}
|
||||||
|
if (roles != null && !roles.isEmpty()) {
|
||||||
|
sb.append("Rôles: ").append(String.join(", ", roles)).append(" ");
|
||||||
|
}
|
||||||
|
if (dateAdhesionMin != null) sb.append("Adhésion >= ").append(dateAdhesionMin).append(" ");
|
||||||
|
if (dateAdhesionMax != null) sb.append("Adhésion <= ").append(dateAdhesionMax).append(" ");
|
||||||
|
if (ageMin != null) sb.append("Âge >= ").append(ageMin).append(" ");
|
||||||
|
if (ageMax != null) sb.append("Âge <= ").append(ageMax).append(" ");
|
||||||
|
if (region != null) sb.append("Région: '").append(region).append("' ");
|
||||||
|
if (ville != null) sb.append("Ville: '").append(ville).append("' ");
|
||||||
|
if (profession != null) sb.append("Profession: '").append(profession).append("' ");
|
||||||
|
if (nationalite != null) sb.append("Nationalité: '").append(nationalite).append("' ");
|
||||||
|
if (Boolean.TRUE.equals(membreBureau)) sb.append("Membre bureau ");
|
||||||
|
if (Boolean.TRUE.equals(responsable)) sb.append("Responsable ");
|
||||||
|
|
||||||
|
return sb.toString().trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package dev.lions.unionflow.server.api.dto.membre;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.eclipse.microprofile.openapi.annotations.media.Schema;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO pour les résultats de recherche avancée des membres
|
||||||
|
* Contient les résultats paginés et les métadonnées de recherche
|
||||||
|
*
|
||||||
|
* @author UnionFlow Team
|
||||||
|
* @version 1.0
|
||||||
|
* @since 2025-01-19
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
@Schema(description = "Résultats de recherche avancée des membres avec pagination")
|
||||||
|
public class MembreSearchResultDTO {
|
||||||
|
|
||||||
|
/** Liste des membres trouvés */
|
||||||
|
@Schema(description = "Liste des membres correspondant aux critères")
|
||||||
|
private List<MembreDTO> membres;
|
||||||
|
|
||||||
|
/** Nombre total de résultats (toutes pages confondues) */
|
||||||
|
@Schema(description = "Nombre total de résultats trouvés", example = "247")
|
||||||
|
private long totalElements;
|
||||||
|
|
||||||
|
/** Nombre total de pages */
|
||||||
|
@Schema(description = "Nombre total de pages", example = "13")
|
||||||
|
private int totalPages;
|
||||||
|
|
||||||
|
/** Numéro de la page actuelle (0-based) */
|
||||||
|
@Schema(description = "Numéro de la page actuelle", example = "0")
|
||||||
|
private int currentPage;
|
||||||
|
|
||||||
|
/** Taille de la page */
|
||||||
|
@Schema(description = "Nombre d'éléments par page", example = "20")
|
||||||
|
private int pageSize;
|
||||||
|
|
||||||
|
/** Nombre d'éléments sur la page actuelle */
|
||||||
|
@Schema(description = "Nombre d'éléments sur cette page", example = "20")
|
||||||
|
private int numberOfElements;
|
||||||
|
|
||||||
|
/** Indique s'il y a une page suivante */
|
||||||
|
@Schema(description = "Indique s'il y a une page suivante")
|
||||||
|
private boolean hasNext;
|
||||||
|
|
||||||
|
/** Indique s'il y a une page précédente */
|
||||||
|
@Schema(description = "Indique s'il y a une page précédente")
|
||||||
|
private boolean hasPrevious;
|
||||||
|
|
||||||
|
/** Indique si c'est la première page */
|
||||||
|
@Schema(description = "Indique si c'est la première page")
|
||||||
|
private boolean isFirst;
|
||||||
|
|
||||||
|
/** Indique si c'est la dernière page */
|
||||||
|
@Schema(description = "Indique si c'est la dernière page")
|
||||||
|
private boolean isLast;
|
||||||
|
|
||||||
|
/** Critères de recherche utilisés */
|
||||||
|
@Schema(description = "Critères de recherche qui ont été appliqués")
|
||||||
|
private MembreSearchCriteria criteria;
|
||||||
|
|
||||||
|
/** Temps d'exécution de la recherche en millisecondes */
|
||||||
|
@Schema(description = "Temps d'exécution de la recherche en ms", example = "45")
|
||||||
|
private long executionTimeMs;
|
||||||
|
|
||||||
|
/** Statistiques de recherche */
|
||||||
|
@Schema(description = "Statistiques sur les résultats de recherche")
|
||||||
|
private SearchStatistics statistics;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statistiques sur les résultats de recherche
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
@Schema(description = "Statistiques sur les résultats de recherche")
|
||||||
|
public static class SearchStatistics {
|
||||||
|
|
||||||
|
/** Répartition par statut */
|
||||||
|
@Schema(description = "Nombre de membres actifs dans les résultats")
|
||||||
|
private long membresActifs;
|
||||||
|
|
||||||
|
@Schema(description = "Nombre de membres inactifs dans les résultats")
|
||||||
|
private long membresInactifs;
|
||||||
|
|
||||||
|
/** Répartition par âge */
|
||||||
|
@Schema(description = "Âge moyen des membres trouvés")
|
||||||
|
private double ageMoyen;
|
||||||
|
|
||||||
|
@Schema(description = "Âge minimum des membres trouvés")
|
||||||
|
private int ageMin;
|
||||||
|
|
||||||
|
@Schema(description = "Âge maximum des membres trouvés")
|
||||||
|
private int ageMax;
|
||||||
|
|
||||||
|
/** Répartition par organisation */
|
||||||
|
@Schema(description = "Nombre d'organisations représentées")
|
||||||
|
private long nombreOrganisations;
|
||||||
|
|
||||||
|
/** Répartition par région */
|
||||||
|
@Schema(description = "Nombre de régions représentées")
|
||||||
|
private long nombreRegions;
|
||||||
|
|
||||||
|
/** Ancienneté moyenne */
|
||||||
|
@Schema(description = "Ancienneté moyenne en années")
|
||||||
|
private double ancienneteMoyenne;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule et met à jour les indicateurs de pagination
|
||||||
|
*/
|
||||||
|
public void calculatePaginationFlags() {
|
||||||
|
this.isFirst = currentPage == 0;
|
||||||
|
this.isLast = currentPage >= totalPages - 1;
|
||||||
|
this.hasPrevious = currentPage > 0;
|
||||||
|
this.hasNext = currentPage < totalPages - 1;
|
||||||
|
this.numberOfElements = membres != null ? membres.size() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si les résultats sont vides
|
||||||
|
*
|
||||||
|
* @return true si aucun résultat
|
||||||
|
*/
|
||||||
|
public boolean isEmpty() {
|
||||||
|
return membres == null || membres.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne le numéro de la page suivante (1-based pour affichage)
|
||||||
|
*
|
||||||
|
* @return Numéro de page suivante ou -1 si pas de page suivante
|
||||||
|
*/
|
||||||
|
public int getNextPageNumber() {
|
||||||
|
return hasNext ? currentPage + 2 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne le numéro de la page précédente (1-based pour affichage)
|
||||||
|
*
|
||||||
|
* @return Numéro de page précédente ou -1 si pas de page précédente
|
||||||
|
*/
|
||||||
|
public int getPreviousPageNumber() {
|
||||||
|
return hasPrevious ? currentPage : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne une description textuelle des résultats
|
||||||
|
*
|
||||||
|
* @return Description des résultats
|
||||||
|
*/
|
||||||
|
public String getResultDescription() {
|
||||||
|
if (isEmpty()) {
|
||||||
|
return "Aucun membre trouvé";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalElements == 1) {
|
||||||
|
return "1 membre trouvé";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalPages == 1) {
|
||||||
|
return String.format("%d membres trouvés", totalElements);
|
||||||
|
}
|
||||||
|
|
||||||
|
int startElement = currentPage * pageSize + 1;
|
||||||
|
int endElement = Math.min(startElement + numberOfElements - 1, (int) totalElements);
|
||||||
|
|
||||||
|
return String.format("Membres %d-%d sur %d (page %d/%d)",
|
||||||
|
startElement, endElement, totalElements,
|
||||||
|
currentPage + 1, totalPages);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory method pour créer un résultat vide
|
||||||
|
*
|
||||||
|
* @param criteria Critères de recherche
|
||||||
|
* @return Résultat vide
|
||||||
|
*/
|
||||||
|
public static MembreSearchResultDTO empty(MembreSearchCriteria criteria) {
|
||||||
|
return MembreSearchResultDTO.builder()
|
||||||
|
.membres(List.of())
|
||||||
|
.totalElements(0)
|
||||||
|
.totalPages(0)
|
||||||
|
.currentPage(0)
|
||||||
|
.pageSize(20)
|
||||||
|
.numberOfElements(0)
|
||||||
|
.hasNext(false)
|
||||||
|
.hasPrevious(false)
|
||||||
|
.isFirst(true)
|
||||||
|
.isLast(true)
|
||||||
|
.criteria(criteria)
|
||||||
|
.executionTimeMs(0)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
package dev.lions.unionflow.server.resource;
|
package dev.lions.unionflow.server.resource;
|
||||||
|
|
||||||
import dev.lions.unionflow.server.api.dto.membre.MembreDTO;
|
import dev.lions.unionflow.server.api.dto.membre.MembreDTO;
|
||||||
|
import dev.lions.unionflow.server.api.dto.membre.MembreSearchCriteria;
|
||||||
|
import dev.lions.unionflow.server.api.dto.membre.MembreSearchResultDTO;
|
||||||
import dev.lions.unionflow.server.entity.Membre;
|
import dev.lions.unionflow.server.entity.Membre;
|
||||||
import dev.lions.unionflow.server.service.MembreService;
|
import dev.lions.unionflow.server.service.MembreService;
|
||||||
import io.quarkus.panache.common.Page;
|
import io.quarkus.panache.common.Page;
|
||||||
import io.quarkus.panache.common.Sort;
|
import io.quarkus.panache.common.Sort;
|
||||||
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
@@ -12,8 +15,15 @@ import jakarta.ws.rs.*;
|
|||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import jakarta.ws.rs.core.Response;
|
import jakarta.ws.rs.core.Response;
|
||||||
import org.eclipse.microprofile.openapi.annotations.Operation;
|
import org.eclipse.microprofile.openapi.annotations.Operation;
|
||||||
|
import org.eclipse.microprofile.openapi.annotations.enums.SchemaType;
|
||||||
|
import org.eclipse.microprofile.openapi.annotations.media.Content;
|
||||||
|
import org.eclipse.microprofile.openapi.annotations.media.ExampleObject;
|
||||||
|
import org.eclipse.microprofile.openapi.annotations.media.Schema;
|
||||||
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
|
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
|
||||||
|
import org.eclipse.microprofile.openapi.annotations.parameters.RequestBody;
|
||||||
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
|
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
|
||||||
|
import org.eclipse.microprofile.openapi.annotations.responses.APIResponses;
|
||||||
|
import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement;
|
||||||
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
|
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
|
||||||
import org.jboss.logging.Logger;
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
@@ -186,8 +196,9 @@ public class MembreResource {
|
|||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Path("/recherche-avancee")
|
@Path("/recherche-avancee")
|
||||||
@Operation(summary = "Recherche avancée de membres avec filtres multiples")
|
@Operation(summary = "Recherche avancée de membres avec filtres multiples (DEPRECATED)")
|
||||||
@APIResponse(responseCode = "200", description = "Résultats de la recherche avancée")
|
@APIResponse(responseCode = "200", description = "Résultats de la recherche avancée")
|
||||||
|
@Deprecated
|
||||||
public Response rechercheAvancee(
|
public Response rechercheAvancee(
|
||||||
@Parameter(description = "Terme de recherche") @QueryParam("q") String recherche,
|
@Parameter(description = "Terme de recherche") @QueryParam("q") String recherche,
|
||||||
@Parameter(description = "Statut actif (true/false)") @QueryParam("actif") Boolean actif,
|
@Parameter(description = "Statut actif (true/false)") @QueryParam("actif") Boolean actif,
|
||||||
@@ -198,7 +209,7 @@ public class MembreResource {
|
|||||||
@Parameter(description = "Champ de tri") @QueryParam("sort") @DefaultValue("nom") String sortField,
|
@Parameter(description = "Champ de tri") @QueryParam("sort") @DefaultValue("nom") String sortField,
|
||||||
@Parameter(description = "Direction du tri (asc/desc)") @QueryParam("direction") @DefaultValue("asc") String sortDirection) {
|
@Parameter(description = "Direction du tri (asc/desc)") @QueryParam("direction") @DefaultValue("asc") String sortDirection) {
|
||||||
|
|
||||||
LOG.infof("Recherche avancée de membres - recherche: %s, actif: %s", recherche, actif);
|
LOG.infof("Recherche avancée de membres (DEPRECATED) - recherche: %s, actif: %s", recherche, actif);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Sort sort = "desc".equalsIgnoreCase(sortDirection) ?
|
Sort sort = "desc".equalsIgnoreCase(sortDirection) ?
|
||||||
@@ -222,4 +233,178 @@ public class MembreResource {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nouvelle recherche avancée avec critères complets et résultats enrichis
|
||||||
|
* Réservée aux super administrateurs pour des recherches sophistiquées
|
||||||
|
*/
|
||||||
|
@POST
|
||||||
|
@Path("/search/advanced")
|
||||||
|
@RolesAllowed({"SUPER_ADMIN", "ADMIN"})
|
||||||
|
@Operation(
|
||||||
|
summary = "Recherche avancée de membres avec critères multiples",
|
||||||
|
description = """
|
||||||
|
Recherche sophistiquée de membres avec de nombreux critères de filtrage :
|
||||||
|
- Recherche textuelle dans nom, prénom, email
|
||||||
|
- Filtres par organisation, rôles, statut
|
||||||
|
- Filtres par âge, région, profession
|
||||||
|
- Filtres par dates d'adhésion
|
||||||
|
- Résultats paginés avec statistiques
|
||||||
|
|
||||||
|
Réservée aux super administrateurs et administrateurs.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
@APIResponses({
|
||||||
|
@APIResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "Recherche effectuée avec succès",
|
||||||
|
content = @Content(
|
||||||
|
mediaType = MediaType.APPLICATION_JSON,
|
||||||
|
schema = @Schema(implementation = MembreSearchResultDTO.class),
|
||||||
|
examples = @ExampleObject(
|
||||||
|
name = "Exemple de résultats",
|
||||||
|
value = """
|
||||||
|
{
|
||||||
|
"membres": [...],
|
||||||
|
"totalElements": 247,
|
||||||
|
"totalPages": 13,
|
||||||
|
"currentPage": 0,
|
||||||
|
"pageSize": 20,
|
||||||
|
"hasNext": true,
|
||||||
|
"hasPrevious": false,
|
||||||
|
"executionTimeMs": 45,
|
||||||
|
"statistics": {
|
||||||
|
"membresActifs": 230,
|
||||||
|
"membresInactifs": 17,
|
||||||
|
"ageMoyen": 34.5,
|
||||||
|
"nombreOrganisations": 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
@APIResponse(
|
||||||
|
responseCode = "400",
|
||||||
|
description = "Critères de recherche invalides",
|
||||||
|
content = @Content(
|
||||||
|
mediaType = MediaType.APPLICATION_JSON,
|
||||||
|
examples = @ExampleObject(
|
||||||
|
value = """
|
||||||
|
{
|
||||||
|
"message": "Critères de recherche invalides",
|
||||||
|
"details": "La date minimum ne peut pas être postérieure à la date maximum"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
@APIResponse(
|
||||||
|
responseCode = "403",
|
||||||
|
description = "Accès non autorisé - Rôle SUPER_ADMIN ou ADMIN requis"
|
||||||
|
),
|
||||||
|
@APIResponse(
|
||||||
|
responseCode = "500",
|
||||||
|
description = "Erreur interne du serveur"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
@SecurityRequirement(name = "keycloak")
|
||||||
|
public Response searchMembresAdvanced(
|
||||||
|
@RequestBody(
|
||||||
|
description = "Critères de recherche avancée",
|
||||||
|
required = true,
|
||||||
|
content = @Content(
|
||||||
|
mediaType = MediaType.APPLICATION_JSON,
|
||||||
|
schema = @Schema(implementation = MembreSearchCriteria.class),
|
||||||
|
examples = @ExampleObject(
|
||||||
|
name = "Exemple de critères",
|
||||||
|
value = """
|
||||||
|
{
|
||||||
|
"query": "marie",
|
||||||
|
"statut": "ACTIF",
|
||||||
|
"ageMin": 25,
|
||||||
|
"ageMax": 45,
|
||||||
|
"region": "Dakar",
|
||||||
|
"roles": ["PRESIDENT", "SECRETAIRE"],
|
||||||
|
"dateAdhesionMin": "2020-01-01",
|
||||||
|
"includeInactifs": false
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
@Valid MembreSearchCriteria criteria,
|
||||||
|
|
||||||
|
@Parameter(description = "Numéro de page (0-based)", example = "0")
|
||||||
|
@QueryParam("page") @DefaultValue("0") int page,
|
||||||
|
|
||||||
|
@Parameter(description = "Taille de la page", example = "20")
|
||||||
|
@QueryParam("size") @DefaultValue("20") int size,
|
||||||
|
|
||||||
|
@Parameter(description = "Champ de tri", example = "nom")
|
||||||
|
@QueryParam("sort") @DefaultValue("nom") String sortField,
|
||||||
|
|
||||||
|
@Parameter(description = "Direction du tri (asc/desc)", example = "asc")
|
||||||
|
@QueryParam("direction") @DefaultValue("asc") String sortDirection) {
|
||||||
|
|
||||||
|
long startTime = System.currentTimeMillis();
|
||||||
|
|
||||||
|
LOG.infof("Recherche avancée de membres - critères: %s, page: %d, size: %d",
|
||||||
|
criteria.getDescription(), page, size);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validation des critères
|
||||||
|
if (criteria == null) {
|
||||||
|
return Response.status(Response.Status.BAD_REQUEST)
|
||||||
|
.entity(Map.of("message", "Les critères de recherche sont requis"))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nettoyage et validation des critères
|
||||||
|
criteria.sanitize();
|
||||||
|
|
||||||
|
if (!criteria.hasAnyCriteria()) {
|
||||||
|
return Response.status(Response.Status.BAD_REQUEST)
|
||||||
|
.entity(Map.of("message", "Au moins un critère de recherche doit être spécifié"))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!criteria.isValid()) {
|
||||||
|
return Response.status(Response.Status.BAD_REQUEST)
|
||||||
|
.entity(Map.of(
|
||||||
|
"message", "Critères de recherche invalides",
|
||||||
|
"details", "Vérifiez la cohérence des dates et des âges"
|
||||||
|
))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construction du tri
|
||||||
|
Sort sort = "desc".equalsIgnoreCase(sortDirection) ?
|
||||||
|
Sort.by(sortField).descending() : Sort.by(sortField).ascending();
|
||||||
|
|
||||||
|
// Exécution de la recherche
|
||||||
|
MembreSearchResultDTO result = membreService.searchMembresAdvanced(
|
||||||
|
criteria, Page.of(page, size), sort);
|
||||||
|
|
||||||
|
// Calcul du temps d'exécution
|
||||||
|
long executionTime = System.currentTimeMillis() - startTime;
|
||||||
|
result.setExecutionTimeMs(executionTime);
|
||||||
|
|
||||||
|
LOG.infof("Recherche avancée terminée - %d résultats trouvés en %d ms",
|
||||||
|
result.getTotalElements(), executionTime);
|
||||||
|
|
||||||
|
return Response.ok(result).build();
|
||||||
|
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
LOG.warnf("Erreur de validation dans la recherche avancée: %s", e.getMessage());
|
||||||
|
return Response.status(Response.Status.BAD_REQUEST)
|
||||||
|
.entity(Map.of("message", "Paramètres de recherche invalides", "details", e.getMessage()))
|
||||||
|
.build();
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.errorf(e, "Erreur lors de la recherche avancée de membres");
|
||||||
|
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||||
|
.entity(Map.of("message", "Erreur interne lors de la recherche", "error", e.getMessage()))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package dev.lions.unionflow.server.service;
|
package dev.lions.unionflow.server.service;
|
||||||
|
|
||||||
import dev.lions.unionflow.server.api.dto.membre.MembreDTO;
|
import dev.lions.unionflow.server.api.dto.membre.MembreDTO;
|
||||||
|
import dev.lions.unionflow.server.api.dto.membre.MembreSearchCriteria;
|
||||||
|
import dev.lions.unionflow.server.api.dto.membre.MembreSearchResultDTO;
|
||||||
import dev.lions.unionflow.server.entity.Membre;
|
import dev.lions.unionflow.server.entity.Membre;
|
||||||
import dev.lions.unionflow.server.repository.MembreRepository;
|
import dev.lions.unionflow.server.repository.MembreRepository;
|
||||||
import io.quarkus.panache.common.Page;
|
import io.quarkus.panache.common.Page;
|
||||||
@@ -12,6 +14,9 @@ import org.jboss.logging.Logger;
|
|||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.Period;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -285,14 +290,240 @@ public class MembreService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recherche avancée de membres avec filtres multiples
|
* Recherche avancée de membres avec filtres multiples (DEPRECATED)
|
||||||
*/
|
*/
|
||||||
public List<Membre> rechercheAvancee(String recherche, Boolean actif,
|
public List<Membre> rechercheAvancee(String recherche, Boolean actif,
|
||||||
LocalDate dateAdhesionMin, LocalDate dateAdhesionMax,
|
LocalDate dateAdhesionMin, LocalDate dateAdhesionMax,
|
||||||
Page page, Sort sort) {
|
Page page, Sort sort) {
|
||||||
LOG.infof("Recherche avancée - recherche: %s, actif: %s, dateMin: %s, dateMax: %s",
|
LOG.infof("Recherche avancée (DEPRECATED) - recherche: %s, actif: %s, dateMin: %s, dateMax: %s",
|
||||||
recherche, actif, dateAdhesionMin, dateAdhesionMax);
|
recherche, actif, dateAdhesionMin, dateAdhesionMax);
|
||||||
|
|
||||||
return membreRepository.rechercheAvancee(recherche, actif, dateAdhesionMin, dateAdhesionMax, page, sort);
|
return membreRepository.rechercheAvancee(recherche, actif, dateAdhesionMin, dateAdhesionMax, page, sort);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nouvelle recherche avancée de membres avec critères complets
|
||||||
|
* Retourne des résultats paginés avec statistiques
|
||||||
|
*
|
||||||
|
* @param criteria Critères de recherche
|
||||||
|
* @param page Pagination
|
||||||
|
* @param sort Tri
|
||||||
|
* @return Résultats de recherche avec métadonnées
|
||||||
|
*/
|
||||||
|
public MembreSearchResultDTO searchMembresAdvanced(MembreSearchCriteria criteria, Page page, Sort sort) {
|
||||||
|
LOG.infof("Recherche avancée de membres - critères: %s", criteria.getDescription());
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Construction de la requête dynamique
|
||||||
|
StringBuilder queryBuilder = new StringBuilder("SELECT m FROM Membre m WHERE 1=1");
|
||||||
|
Map<String, Object> parameters = new HashMap<>();
|
||||||
|
|
||||||
|
// Ajout des critères de recherche
|
||||||
|
addSearchCriteria(queryBuilder, parameters, criteria);
|
||||||
|
|
||||||
|
// Requête pour compter le total
|
||||||
|
String countQuery = queryBuilder.toString().replace("SELECT m FROM Membre m", "SELECT COUNT(m) FROM Membre m");
|
||||||
|
|
||||||
|
// Exécution de la requête de comptage
|
||||||
|
long totalElements = Membre.find(countQuery, parameters).count();
|
||||||
|
|
||||||
|
if (totalElements == 0) {
|
||||||
|
return MembreSearchResultDTO.empty(criteria);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajout du tri et pagination
|
||||||
|
String finalQuery = queryBuilder.toString();
|
||||||
|
if (sort != null) {
|
||||||
|
finalQuery += " ORDER BY " + buildOrderByClause(sort);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exécution de la requête principale
|
||||||
|
List<Membre> membres = Membre.find(finalQuery, parameters)
|
||||||
|
.page(page)
|
||||||
|
.list();
|
||||||
|
|
||||||
|
// Conversion en DTOs
|
||||||
|
List<MembreDTO> membresDTO = convertToDTOList(membres);
|
||||||
|
|
||||||
|
// Calcul des statistiques
|
||||||
|
MembreSearchResultDTO.SearchStatistics statistics = calculateSearchStatistics(membres);
|
||||||
|
|
||||||
|
// Construction du résultat
|
||||||
|
MembreSearchResultDTO result = MembreSearchResultDTO.builder()
|
||||||
|
.membres(membresDTO)
|
||||||
|
.totalElements(totalElements)
|
||||||
|
.totalPages((int) Math.ceil((double) totalElements / page.size))
|
||||||
|
.currentPage(page.index)
|
||||||
|
.pageSize(page.size)
|
||||||
|
.criteria(criteria)
|
||||||
|
.statistics(statistics)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Calcul des indicateurs de pagination
|
||||||
|
result.calculatePaginationFlags();
|
||||||
|
|
||||||
|
return result;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.errorf(e, "Erreur lors de la recherche avancée de membres");
|
||||||
|
throw new RuntimeException("Erreur lors de la recherche avancée", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajoute les critères de recherche à la requête
|
||||||
|
*/
|
||||||
|
private void addSearchCriteria(StringBuilder queryBuilder, Map<String, Object> parameters, MembreSearchCriteria criteria) {
|
||||||
|
|
||||||
|
// Recherche générale dans nom, prénom, email
|
||||||
|
if (criteria.getQuery() != null) {
|
||||||
|
queryBuilder.append(" AND (LOWER(m.nom) LIKE LOWER(:query) OR LOWER(m.prenom) LIKE LOWER(:query) OR LOWER(m.email) LIKE LOWER(:query))");
|
||||||
|
parameters.put("query", "%" + criteria.getQuery() + "%");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recherche par nom
|
||||||
|
if (criteria.getNom() != null) {
|
||||||
|
queryBuilder.append(" AND LOWER(m.nom) LIKE LOWER(:nom)");
|
||||||
|
parameters.put("nom", "%" + criteria.getNom() + "%");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recherche par prénom
|
||||||
|
if (criteria.getPrenom() != null) {
|
||||||
|
queryBuilder.append(" AND LOWER(m.prenom) LIKE LOWER(:prenom)");
|
||||||
|
parameters.put("prenom", "%" + criteria.getPrenom() + "%");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recherche par email
|
||||||
|
if (criteria.getEmail() != null) {
|
||||||
|
queryBuilder.append(" AND LOWER(m.email) LIKE LOWER(:email)");
|
||||||
|
parameters.put("email", "%" + criteria.getEmail() + "%");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recherche par téléphone
|
||||||
|
if (criteria.getTelephone() != null) {
|
||||||
|
queryBuilder.append(" AND m.telephone LIKE :telephone");
|
||||||
|
parameters.put("telephone", "%" + criteria.getTelephone() + "%");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtre par statut
|
||||||
|
if (criteria.getStatut() != null) {
|
||||||
|
boolean isActif = "ACTIF".equals(criteria.getStatut());
|
||||||
|
queryBuilder.append(" AND m.actif = :actif");
|
||||||
|
parameters.put("actif", isActif);
|
||||||
|
} else if (!Boolean.TRUE.equals(criteria.getIncludeInactifs())) {
|
||||||
|
// Par défaut, exclure les inactifs
|
||||||
|
queryBuilder.append(" AND m.actif = true");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtre par dates d'adhésion
|
||||||
|
if (criteria.getDateAdhesionMin() != null) {
|
||||||
|
queryBuilder.append(" AND m.dateAdhesion >= :dateAdhesionMin");
|
||||||
|
parameters.put("dateAdhesionMin", criteria.getDateAdhesionMin());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (criteria.getDateAdhesionMax() != null) {
|
||||||
|
queryBuilder.append(" AND m.dateAdhesion <= :dateAdhesionMax");
|
||||||
|
parameters.put("dateAdhesionMax", criteria.getDateAdhesionMax());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtre par âge (calculé à partir de la date de naissance)
|
||||||
|
if (criteria.getAgeMin() != null) {
|
||||||
|
LocalDate maxBirthDate = LocalDate.now().minusYears(criteria.getAgeMin());
|
||||||
|
queryBuilder.append(" AND m.dateNaissance <= :maxBirthDateForMinAge");
|
||||||
|
parameters.put("maxBirthDateForMinAge", maxBirthDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (criteria.getAgeMax() != null) {
|
||||||
|
LocalDate minBirthDate = LocalDate.now().minusYears(criteria.getAgeMax() + 1).plusDays(1);
|
||||||
|
queryBuilder.append(" AND m.dateNaissance >= :minBirthDateForMaxAge");
|
||||||
|
parameters.put("minBirthDateForMaxAge", minBirthDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtre par organisations (si implémenté dans l'entité)
|
||||||
|
if (criteria.getOrganisationIds() != null && !criteria.getOrganisationIds().isEmpty()) {
|
||||||
|
queryBuilder.append(" AND m.organisation.id IN :organisationIds");
|
||||||
|
parameters.put("organisationIds", criteria.getOrganisationIds());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtre par rôles (recherche dans le champ roles)
|
||||||
|
if (criteria.getRoles() != null && !criteria.getRoles().isEmpty()) {
|
||||||
|
StringBuilder roleCondition = new StringBuilder(" AND (");
|
||||||
|
for (int i = 0; i < criteria.getRoles().size(); i++) {
|
||||||
|
if (i > 0) roleCondition.append(" OR ");
|
||||||
|
roleCondition.append("m.roles LIKE :role").append(i);
|
||||||
|
parameters.put("role" + i, "%" + criteria.getRoles().get(i) + "%");
|
||||||
|
}
|
||||||
|
roleCondition.append(")");
|
||||||
|
queryBuilder.append(roleCondition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit la clause ORDER BY à partir du Sort
|
||||||
|
*/
|
||||||
|
private String buildOrderByClause(Sort sort) {
|
||||||
|
if (sort == null || sort.getColumns().isEmpty()) {
|
||||||
|
return "m.nom ASC";
|
||||||
|
}
|
||||||
|
|
||||||
|
return sort.getColumns().stream()
|
||||||
|
.map(column -> "m." + column.getName() + " " + column.getDirection().name())
|
||||||
|
.collect(Collectors.joining(", "));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule les statistiques sur les résultats de recherche
|
||||||
|
*/
|
||||||
|
private MembreSearchResultDTO.SearchStatistics calculateSearchStatistics(List<Membre> membres) {
|
||||||
|
if (membres.isEmpty()) {
|
||||||
|
return MembreSearchResultDTO.SearchStatistics.builder()
|
||||||
|
.membresActifs(0)
|
||||||
|
.membresInactifs(0)
|
||||||
|
.ageMoyen(0.0)
|
||||||
|
.ageMin(0)
|
||||||
|
.ageMax(0)
|
||||||
|
.nombreOrganisations(0)
|
||||||
|
.nombreRegions(0)
|
||||||
|
.ancienneteMoyenne(0.0)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
long membresActifs = membres.stream().mapToLong(m -> Boolean.TRUE.equals(m.getActif()) ? 1 : 0).sum();
|
||||||
|
long membresInactifs = membres.size() - membresActifs;
|
||||||
|
|
||||||
|
// Calcul des âges
|
||||||
|
List<Integer> ages = membres.stream()
|
||||||
|
.filter(m -> m.getDateNaissance() != null)
|
||||||
|
.map(m -> Period.between(m.getDateNaissance(), LocalDate.now()).getYears())
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
double ageMoyen = ages.stream().mapToInt(Integer::intValue).average().orElse(0.0);
|
||||||
|
int ageMin = ages.stream().mapToInt(Integer::intValue).min().orElse(0);
|
||||||
|
int ageMax = ages.stream().mapToInt(Integer::intValue).max().orElse(0);
|
||||||
|
|
||||||
|
// Calcul de l'ancienneté moyenne
|
||||||
|
double ancienneteMoyenne = membres.stream()
|
||||||
|
.filter(m -> m.getDateAdhesion() != null)
|
||||||
|
.mapToDouble(m -> Period.between(m.getDateAdhesion(), LocalDate.now()).getYears())
|
||||||
|
.average()
|
||||||
|
.orElse(0.0);
|
||||||
|
|
||||||
|
// Nombre d'organisations (si relation disponible)
|
||||||
|
long nombreOrganisations = membres.stream()
|
||||||
|
.filter(m -> m.getOrganisation() != null)
|
||||||
|
.map(m -> m.getOrganisation().id)
|
||||||
|
.distinct()
|
||||||
|
.count();
|
||||||
|
|
||||||
|
return MembreSearchResultDTO.SearchStatistics.builder()
|
||||||
|
.membresActifs(membresActifs)
|
||||||
|
.membresInactifs(membresInactifs)
|
||||||
|
.ageMoyen(ageMoyen)
|
||||||
|
.ageMin(ageMin)
|
||||||
|
.ageMax(ageMax)
|
||||||
|
.nombreOrganisations(nombreOrganisations)
|
||||||
|
.nombreRegions(0) // À implémenter si champ région disponible
|
||||||
|
.ancienneteMoyenne(ancienneteMoyenne)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ quarkus.flyway.baseline-on-migrate=true
|
|||||||
quarkus.flyway.baseline-version=1.0.0
|
quarkus.flyway.baseline-version=1.0.0
|
||||||
|
|
||||||
# Configuration Keycloak OIDC
|
# Configuration Keycloak OIDC
|
||||||
quarkus.oidc.auth-server-url=http://192.168.1.145:8180/realms/unionflow
|
quarkus.oidc.auth-server-url=http://192.168.1.11:8180/realms/unionflow
|
||||||
quarkus.oidc.client-id=unionflow-server
|
quarkus.oidc.client-id=unionflow-server
|
||||||
quarkus.oidc.credentials.secret=unionflow-secret-2025
|
quarkus.oidc.credentials.secret=unionflow-secret-2025
|
||||||
quarkus.oidc.tls.verification=none
|
quarkus.oidc.tls.verification=none
|
||||||
@@ -85,7 +85,7 @@ quarkus.log.category."io.quarkus".level=INFO
|
|||||||
|
|
||||||
# Configuration Keycloak pour développement (temporairement désactivé)
|
# Configuration Keycloak pour développement (temporairement désactivé)
|
||||||
%dev.quarkus.oidc.tenant-enabled=false
|
%dev.quarkus.oidc.tenant-enabled=false
|
||||||
%dev.quarkus.oidc.auth-server-url=http://192.168.1.145:8180/realms/unionflow
|
%dev.quarkus.oidc.auth-server-url=http://192.168.1.11:8180/realms/unionflow
|
||||||
%dev.quarkus.oidc.client-id=unionflow-server
|
%dev.quarkus.oidc.client-id=unionflow-server
|
||||||
%dev.quarkus.oidc.credentials.secret=unionflow-secret-2025
|
%dev.quarkus.oidc.credentials.secret=unionflow-secret-2025
|
||||||
%dev.quarkus.oidc.tls.verification=none
|
%dev.quarkus.oidc.tls.verification=none
|
||||||
@@ -114,7 +114,7 @@ quarkus.log.category."io.quarkus".level=INFO
|
|||||||
%prod.quarkus.log.category.root.level=WARN
|
%prod.quarkus.log.category.root.level=WARN
|
||||||
|
|
||||||
# Configuration Keycloak pour production
|
# Configuration Keycloak pour production
|
||||||
%prod.quarkus.oidc.auth-server-url=${KEYCLOAK_SERVER_URL:http://192.168.1.145:8180/realms/unionflow}
|
%prod.quarkus.oidc.auth-server-url=${KEYCLOAK_SERVER_URL:http://192.168.1.11:8180/realms/unionflow}
|
||||||
%prod.quarkus.oidc.client-id=${KEYCLOAK_CLIENT_ID:unionflow-server}
|
%prod.quarkus.oidc.client-id=${KEYCLOAK_CLIENT_ID:unionflow-server}
|
||||||
%prod.quarkus.oidc.credentials.secret=${KEYCLOAK_CLIENT_SECRET}
|
%prod.quarkus.oidc.credentials.secret=${KEYCLOAK_CLIENT_SECRET}
|
||||||
%prod.quarkus.oidc.tls.verification=required
|
%prod.quarkus.oidc.tls.verification=required
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
package dev.lions.unionflow.server.resource;
|
||||||
|
|
||||||
|
import dev.lions.unionflow.server.api.dto.membre.MembreSearchCriteria;
|
||||||
|
import io.quarkus.test.junit.QuarkusTest;
|
||||||
|
import io.quarkus.test.security.TestSecurity;
|
||||||
|
import io.restassured.http.ContentType;
|
||||||
|
import org.junit.jupiter.api.*;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static io.restassured.RestAssured.given;
|
||||||
|
import static org.hamcrest.Matchers.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests d'intégration pour l'endpoint de recherche avancée des membres
|
||||||
|
*
|
||||||
|
* @author UnionFlow Team
|
||||||
|
* @version 1.0
|
||||||
|
* @since 2025-01-19
|
||||||
|
*/
|
||||||
|
@QuarkusTest
|
||||||
|
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
||||||
|
class MembreResourceAdvancedSearchTest {
|
||||||
|
|
||||||
|
private static final String ADVANCED_SEARCH_ENDPOINT = "/api/membres/search/advanced";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(1)
|
||||||
|
@TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"})
|
||||||
|
@DisplayName("POST /api/membres/search/advanced doit fonctionner avec critères valides")
|
||||||
|
void testAdvancedSearchWithValidCriteria() {
|
||||||
|
MembreSearchCriteria criteria = MembreSearchCriteria.builder()
|
||||||
|
.query("marie")
|
||||||
|
.statut("ACTIF")
|
||||||
|
.ageMin(20)
|
||||||
|
.ageMax(50)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
given()
|
||||||
|
.contentType(ContentType.JSON)
|
||||||
|
.body(criteria)
|
||||||
|
.queryParam("page", 0)
|
||||||
|
.queryParam("size", 20)
|
||||||
|
.queryParam("sort", "nom")
|
||||||
|
.queryParam("direction", "asc")
|
||||||
|
.when()
|
||||||
|
.post(ADVANCED_SEARCH_ENDPOINT)
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.contentType(ContentType.JSON)
|
||||||
|
.body("membres", notNullValue())
|
||||||
|
.body("totalElements", greaterThanOrEqualTo(0))
|
||||||
|
.body("totalPages", greaterThanOrEqualTo(0))
|
||||||
|
.body("currentPage", equalTo(0))
|
||||||
|
.body("pageSize", equalTo(20))
|
||||||
|
.body("hasNext", notNullValue())
|
||||||
|
.body("hasPrevious", equalTo(false))
|
||||||
|
.body("isFirst", equalTo(true))
|
||||||
|
.body("executionTimeMs", greaterThan(0))
|
||||||
|
.body("statistics", notNullValue())
|
||||||
|
.body("statistics.membresActifs", greaterThanOrEqualTo(0))
|
||||||
|
.body("statistics.membresInactifs", greaterThanOrEqualTo(0))
|
||||||
|
.body("criteria.query", equalTo("marie"))
|
||||||
|
.body("criteria.statut", equalTo("ACTIF"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(2)
|
||||||
|
@TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"})
|
||||||
|
@DisplayName("POST /api/membres/search/advanced doit fonctionner avec critères multiples")
|
||||||
|
void testAdvancedSearchWithMultipleCriteria() {
|
||||||
|
MembreSearchCriteria criteria = MembreSearchCriteria.builder()
|
||||||
|
.email("@unionflow.com")
|
||||||
|
.dateAdhesionMin(LocalDate.of(2020, 1, 1))
|
||||||
|
.dateAdhesionMax(LocalDate.of(2025, 12, 31))
|
||||||
|
.roles(List.of("ADMIN", "SUPER_ADMIN"))
|
||||||
|
.includeInactifs(false)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
given()
|
||||||
|
.contentType(ContentType.JSON)
|
||||||
|
.body(criteria)
|
||||||
|
.queryParam("page", 0)
|
||||||
|
.queryParam("size", 10)
|
||||||
|
.when()
|
||||||
|
.post(ADVANCED_SEARCH_ENDPOINT)
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.contentType(ContentType.JSON)
|
||||||
|
.body("membres", notNullValue())
|
||||||
|
.body("totalElements", greaterThanOrEqualTo(0))
|
||||||
|
.body("criteria.email", equalTo("@unionflow.com"))
|
||||||
|
.body("criteria.roles", hasItems("ADMIN", "SUPER_ADMIN"))
|
||||||
|
.body("criteria.includeInactifs", equalTo(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(3)
|
||||||
|
@TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"})
|
||||||
|
@DisplayName("POST /api/membres/search/advanced doit gérer la pagination")
|
||||||
|
void testAdvancedSearchPagination() {
|
||||||
|
MembreSearchCriteria criteria = MembreSearchCriteria.builder()
|
||||||
|
.includeInactifs(true) // Inclure tous les membres
|
||||||
|
.build();
|
||||||
|
|
||||||
|
given()
|
||||||
|
.contentType(ContentType.JSON)
|
||||||
|
.body(criteria)
|
||||||
|
.queryParam("page", 0)
|
||||||
|
.queryParam("size", 2) // Petite taille pour tester la pagination
|
||||||
|
.when()
|
||||||
|
.post(ADVANCED_SEARCH_ENDPOINT)
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.contentType(ContentType.JSON)
|
||||||
|
.body("currentPage", equalTo(0))
|
||||||
|
.body("pageSize", equalTo(2))
|
||||||
|
.body("isFirst", equalTo(true))
|
||||||
|
.body("hasPrevious", equalTo(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(4)
|
||||||
|
@TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"})
|
||||||
|
@DisplayName("POST /api/membres/search/advanced doit gérer le tri")
|
||||||
|
void testAdvancedSearchSorting() {
|
||||||
|
MembreSearchCriteria criteria = MembreSearchCriteria.builder()
|
||||||
|
.statut("ACTIF")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
given()
|
||||||
|
.contentType(ContentType.JSON)
|
||||||
|
.body(criteria)
|
||||||
|
.queryParam("sort", "nom")
|
||||||
|
.queryParam("direction", "desc")
|
||||||
|
.when()
|
||||||
|
.post(ADVANCED_SEARCH_ENDPOINT)
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.contentType(ContentType.JSON)
|
||||||
|
.body("membres", notNullValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(5)
|
||||||
|
@TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"})
|
||||||
|
@DisplayName("POST /api/membres/search/advanced doit retourner 400 pour critères vides")
|
||||||
|
void testAdvancedSearchWithEmptyCriteria() {
|
||||||
|
MembreSearchCriteria emptyCriteria = MembreSearchCriteria.builder().build();
|
||||||
|
|
||||||
|
given()
|
||||||
|
.contentType(ContentType.JSON)
|
||||||
|
.body(emptyCriteria)
|
||||||
|
.when()
|
||||||
|
.post(ADVANCED_SEARCH_ENDPOINT)
|
||||||
|
.then()
|
||||||
|
.statusCode(400)
|
||||||
|
.contentType(ContentType.JSON)
|
||||||
|
.body("message", containsString("Au moins un critère de recherche doit être spécifié"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(6)
|
||||||
|
@TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"})
|
||||||
|
@DisplayName("POST /api/membres/search/advanced doit retourner 400 pour critères invalides")
|
||||||
|
void testAdvancedSearchWithInvalidCriteria() {
|
||||||
|
MembreSearchCriteria invalidCriteria = MembreSearchCriteria.builder()
|
||||||
|
.ageMin(50)
|
||||||
|
.ageMax(30) // Âge max < âge min
|
||||||
|
.build();
|
||||||
|
|
||||||
|
given()
|
||||||
|
.contentType(ContentType.JSON)
|
||||||
|
.body(invalidCriteria)
|
||||||
|
.when()
|
||||||
|
.post(ADVANCED_SEARCH_ENDPOINT)
|
||||||
|
.then()
|
||||||
|
.statusCode(400)
|
||||||
|
.contentType(ContentType.JSON)
|
||||||
|
.body("message", containsString("Critères de recherche invalides"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(7)
|
||||||
|
@TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"})
|
||||||
|
@DisplayName("POST /api/membres/search/advanced doit retourner 400 pour body null")
|
||||||
|
void testAdvancedSearchWithNullBody() {
|
||||||
|
given()
|
||||||
|
.contentType(ContentType.JSON)
|
||||||
|
.when()
|
||||||
|
.post(ADVANCED_SEARCH_ENDPOINT)
|
||||||
|
.then()
|
||||||
|
.statusCode(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(8)
|
||||||
|
@TestSecurity(user = "marie.active@unionflow.com", roles = {"MEMBRE_ACTIF"})
|
||||||
|
@DisplayName("POST /api/membres/search/advanced doit retourner 403 pour utilisateur non autorisé")
|
||||||
|
void testAdvancedSearchUnauthorized() {
|
||||||
|
MembreSearchCriteria criteria = MembreSearchCriteria.builder()
|
||||||
|
.query("test")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
given()
|
||||||
|
.contentType(ContentType.JSON)
|
||||||
|
.body(criteria)
|
||||||
|
.when()
|
||||||
|
.post(ADVANCED_SEARCH_ENDPOINT)
|
||||||
|
.then()
|
||||||
|
.statusCode(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(9)
|
||||||
|
@DisplayName("POST /api/membres/search/advanced doit retourner 401 pour utilisateur non authentifié")
|
||||||
|
void testAdvancedSearchUnauthenticated() {
|
||||||
|
MembreSearchCriteria criteria = MembreSearchCriteria.builder()
|
||||||
|
.query("test")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
given()
|
||||||
|
.contentType(ContentType.JSON)
|
||||||
|
.body(criteria)
|
||||||
|
.when()
|
||||||
|
.post(ADVANCED_SEARCH_ENDPOINT)
|
||||||
|
.then()
|
||||||
|
.statusCode(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(10)
|
||||||
|
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"})
|
||||||
|
@DisplayName("POST /api/membres/search/advanced doit fonctionner pour ADMIN")
|
||||||
|
void testAdvancedSearchForAdmin() {
|
||||||
|
MembreSearchCriteria criteria = MembreSearchCriteria.builder()
|
||||||
|
.statut("ACTIF")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
given()
|
||||||
|
.contentType(ContentType.JSON)
|
||||||
|
.body(criteria)
|
||||||
|
.when()
|
||||||
|
.post(ADVANCED_SEARCH_ENDPOINT)
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.contentType(ContentType.JSON)
|
||||||
|
.body("membres", notNullValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(11)
|
||||||
|
@TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"})
|
||||||
|
@DisplayName("POST /api/membres/search/advanced doit inclure le temps d'exécution")
|
||||||
|
void testAdvancedSearchExecutionTime() {
|
||||||
|
MembreSearchCriteria criteria = MembreSearchCriteria.builder()
|
||||||
|
.query("test")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
given()
|
||||||
|
.contentType(ContentType.JSON)
|
||||||
|
.body(criteria)
|
||||||
|
.when()
|
||||||
|
.post(ADVANCED_SEARCH_ENDPOINT)
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.contentType(ContentType.JSON)
|
||||||
|
.body("executionTimeMs", greaterThan(0))
|
||||||
|
.body("executionTimeMs", lessThan(5000)); // Moins de 5 secondes
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(12)
|
||||||
|
@TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"})
|
||||||
|
@DisplayName("POST /api/membres/search/advanced doit retourner des statistiques complètes")
|
||||||
|
void testAdvancedSearchStatistics() {
|
||||||
|
MembreSearchCriteria criteria = MembreSearchCriteria.builder()
|
||||||
|
.includeInactifs(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
given()
|
||||||
|
.contentType(ContentType.JSON)
|
||||||
|
.body(criteria)
|
||||||
|
.when()
|
||||||
|
.post(ADVANCED_SEARCH_ENDPOINT)
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.contentType(ContentType.JSON)
|
||||||
|
.body("statistics", notNullValue())
|
||||||
|
.body("statistics.membresActifs", greaterThanOrEqualTo(0))
|
||||||
|
.body("statistics.membresInactifs", greaterThanOrEqualTo(0))
|
||||||
|
.body("statistics.ageMoyen", greaterThanOrEqualTo(0.0))
|
||||||
|
.body("statistics.ageMin", greaterThanOrEqualTo(0))
|
||||||
|
.body("statistics.ageMax", greaterThanOrEqualTo(0))
|
||||||
|
.body("statistics.nombreOrganisations", greaterThanOrEqualTo(0))
|
||||||
|
.body("statistics.ancienneteMoyenne", greaterThanOrEqualTo(0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(13)
|
||||||
|
@TestSecurity(user = "superadmin@unionflow.com", roles = {"SUPER_ADMIN"})
|
||||||
|
@DisplayName("POST /api/membres/search/advanced doit gérer les caractères spéciaux")
|
||||||
|
void testAdvancedSearchWithSpecialCharacters() {
|
||||||
|
MembreSearchCriteria criteria = MembreSearchCriteria.builder()
|
||||||
|
.query("marie-josé")
|
||||||
|
.nom("o'connor")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
given()
|
||||||
|
.contentType(ContentType.JSON)
|
||||||
|
.body(criteria)
|
||||||
|
.when()
|
||||||
|
.post(ADVANCED_SEARCH_ENDPOINT)
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.contentType(ContentType.JSON)
|
||||||
|
.body("membres", notNullValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
package dev.lions.unionflow.server.service;
|
||||||
|
|
||||||
|
import dev.lions.unionflow.server.api.dto.membre.MembreSearchCriteria;
|
||||||
|
import dev.lions.unionflow.server.api.dto.membre.MembreSearchResultDTO;
|
||||||
|
import dev.lions.unionflow.server.entity.Membre;
|
||||||
|
import dev.lions.unionflow.server.entity.Organisation;
|
||||||
|
import io.quarkus.panache.common.Page;
|
||||||
|
import io.quarkus.panache.common.Sort;
|
||||||
|
import io.quarkus.test.junit.QuarkusTest;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
import org.junit.jupiter.api.*;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests pour la recherche avancée de membres
|
||||||
|
*
|
||||||
|
* @author UnionFlow Team
|
||||||
|
* @version 1.0
|
||||||
|
* @since 2025-01-19
|
||||||
|
*/
|
||||||
|
@QuarkusTest
|
||||||
|
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
||||||
|
class MembreServiceAdvancedSearchTest {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
MembreService membreService;
|
||||||
|
|
||||||
|
private static Organisation testOrganisation;
|
||||||
|
private static List<Membre> testMembres;
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
@Transactional
|
||||||
|
static void setupTestData() {
|
||||||
|
// Créer une organisation de test
|
||||||
|
testOrganisation = Organisation.builder()
|
||||||
|
.nom("Organisation Test")
|
||||||
|
.typeOrganisation("ASSOCIATION")
|
||||||
|
.statut("ACTIF")
|
||||||
|
.actif(true)
|
||||||
|
.dateCreation(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
testOrganisation.persist();
|
||||||
|
|
||||||
|
// Créer des membres de test avec différents profils
|
||||||
|
testMembres = List.of(
|
||||||
|
// Membre actif jeune
|
||||||
|
Membre.builder()
|
||||||
|
.numeroMembre("UF-2025-TEST001")
|
||||||
|
.nom("Dupont")
|
||||||
|
.prenom("Marie")
|
||||||
|
.email("marie.dupont@test.com")
|
||||||
|
.telephone("+221701234567")
|
||||||
|
.dateNaissance(LocalDate.of(1995, 5, 15))
|
||||||
|
.dateAdhesion(LocalDate.of(2023, 1, 15))
|
||||||
|
.roles("MEMBRE,SECRETAIRE")
|
||||||
|
.actif(true)
|
||||||
|
.organisation(testOrganisation)
|
||||||
|
.dateCreation(LocalDateTime.now())
|
||||||
|
.build(),
|
||||||
|
|
||||||
|
// Membre actif âgé
|
||||||
|
Membre.builder()
|
||||||
|
.numeroMembre("UF-2025-TEST002")
|
||||||
|
.nom("Martin")
|
||||||
|
.prenom("Jean")
|
||||||
|
.email("jean.martin@test.com")
|
||||||
|
.telephone("+221701234568")
|
||||||
|
.dateNaissance(LocalDate.of(1970, 8, 20))
|
||||||
|
.dateAdhesion(LocalDate.of(2020, 3, 10))
|
||||||
|
.roles("MEMBRE,PRESIDENT")
|
||||||
|
.actif(true)
|
||||||
|
.organisation(testOrganisation)
|
||||||
|
.dateCreation(LocalDateTime.now())
|
||||||
|
.build(),
|
||||||
|
|
||||||
|
// Membre inactif
|
||||||
|
Membre.builder()
|
||||||
|
.numeroMembre("UF-2025-TEST003")
|
||||||
|
.nom("Diallo")
|
||||||
|
.prenom("Fatou")
|
||||||
|
.email("fatou.diallo@test.com")
|
||||||
|
.telephone("+221701234569")
|
||||||
|
.dateNaissance(LocalDate.of(1985, 12, 3))
|
||||||
|
.dateAdhesion(LocalDate.of(2021, 6, 5))
|
||||||
|
.roles("MEMBRE")
|
||||||
|
.actif(false)
|
||||||
|
.organisation(testOrganisation)
|
||||||
|
.dateCreation(LocalDateTime.now())
|
||||||
|
.build(),
|
||||||
|
|
||||||
|
// Membre avec email spécifique
|
||||||
|
Membre.builder()
|
||||||
|
.numeroMembre("UF-2025-TEST004")
|
||||||
|
.nom("Sow")
|
||||||
|
.prenom("Amadou")
|
||||||
|
.email("amadou.sow@unionflow.com")
|
||||||
|
.telephone("+221701234570")
|
||||||
|
.dateNaissance(LocalDate.of(1988, 3, 12))
|
||||||
|
.dateAdhesion(LocalDate.of(2022, 9, 20))
|
||||||
|
.roles("MEMBRE,TRESORIER")
|
||||||
|
.actif(true)
|
||||||
|
.organisation(testOrganisation)
|
||||||
|
.dateCreation(LocalDateTime.now())
|
||||||
|
.build()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Persister tous les membres
|
||||||
|
testMembres.forEach(membre -> membre.persist());
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterAll
|
||||||
|
@Transactional
|
||||||
|
static void cleanupTestData() {
|
||||||
|
// Nettoyer les données de test
|
||||||
|
if (testMembres != null) {
|
||||||
|
testMembres.forEach(membre -> {
|
||||||
|
if (membre.isPersistent()) {
|
||||||
|
membre.delete();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (testOrganisation != null && testOrganisation.isPersistent()) {
|
||||||
|
testOrganisation.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(1)
|
||||||
|
@DisplayName("Doit effectuer une recherche par terme général")
|
||||||
|
void testSearchByGeneralQuery() {
|
||||||
|
// Given
|
||||||
|
MembreSearchCriteria criteria = MembreSearchCriteria.builder()
|
||||||
|
.query("marie")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// When
|
||||||
|
MembreSearchResultDTO result = membreService.searchMembresAdvanced(
|
||||||
|
criteria, Page.of(0, 10), Sort.by("nom"));
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isNotNull();
|
||||||
|
assertThat(result.getTotalElements()).isEqualTo(1);
|
||||||
|
assertThat(result.getMembres()).hasSize(1);
|
||||||
|
assertThat(result.getMembres().get(0).getPrenom()).isEqualToIgnoringCase("Marie");
|
||||||
|
assertThat(result.isFirst()).isTrue();
|
||||||
|
assertThat(result.isLast()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(2)
|
||||||
|
@DisplayName("Doit filtrer par statut actif")
|
||||||
|
void testSearchByActiveStatus() {
|
||||||
|
// Given
|
||||||
|
MembreSearchCriteria criteria = MembreSearchCriteria.builder()
|
||||||
|
.statut("ACTIF")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// When
|
||||||
|
MembreSearchResultDTO result = membreService.searchMembresAdvanced(
|
||||||
|
criteria, Page.of(0, 10), Sort.by("nom"));
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isNotNull();
|
||||||
|
assertThat(result.getTotalElements()).isEqualTo(3); // 3 membres actifs
|
||||||
|
assertThat(result.getMembres()).hasSize(3);
|
||||||
|
assertThat(result.getMembres()).allMatch(membre -> "ACTIF".equals(membre.getStatut()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(3)
|
||||||
|
@DisplayName("Doit filtrer par tranche d'âge")
|
||||||
|
void testSearchByAgeRange() {
|
||||||
|
// Given
|
||||||
|
MembreSearchCriteria criteria = MembreSearchCriteria.builder()
|
||||||
|
.ageMin(25)
|
||||||
|
.ageMax(35)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// When
|
||||||
|
MembreSearchResultDTO result = membreService.searchMembresAdvanced(
|
||||||
|
criteria, Page.of(0, 10), Sort.by("nom"));
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isNotNull();
|
||||||
|
assertThat(result.getTotalElements()).isGreaterThan(0);
|
||||||
|
|
||||||
|
// Vérifier que tous les membres sont dans la tranche d'âge
|
||||||
|
result.getMembres().forEach(membre -> {
|
||||||
|
if (membre.getDateNaissance() != null) {
|
||||||
|
int age = LocalDate.now().getYear() - membre.getDateNaissance().getYear();
|
||||||
|
assertThat(age).isBetween(25, 35);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(4)
|
||||||
|
@DisplayName("Doit filtrer par période d'adhésion")
|
||||||
|
void testSearchByAdhesionPeriod() {
|
||||||
|
// Given
|
||||||
|
MembreSearchCriteria criteria = MembreSearchCriteria.builder()
|
||||||
|
.dateAdhesionMin(LocalDate.of(2022, 1, 1))
|
||||||
|
.dateAdhesionMax(LocalDate.of(2023, 12, 31))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// When
|
||||||
|
MembreSearchResultDTO result = membreService.searchMembresAdvanced(
|
||||||
|
criteria, Page.of(0, 10), Sort.by("dateAdhesion"));
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isNotNull();
|
||||||
|
assertThat(result.getTotalElements()).isGreaterThan(0);
|
||||||
|
|
||||||
|
// Vérifier que toutes les dates d'adhésion sont dans la période
|
||||||
|
result.getMembres().forEach(membre -> {
|
||||||
|
if (membre.getDateAdhesion() != null) {
|
||||||
|
assertThat(membre.getDateAdhesion())
|
||||||
|
.isAfterOrEqualTo(LocalDate.of(2022, 1, 1))
|
||||||
|
.isBeforeOrEqualTo(LocalDate.of(2023, 12, 31));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(5)
|
||||||
|
@DisplayName("Doit rechercher par email avec domaine spécifique")
|
||||||
|
void testSearchByEmailDomain() {
|
||||||
|
// Given
|
||||||
|
MembreSearchCriteria criteria = MembreSearchCriteria.builder()
|
||||||
|
.email("@unionflow.com")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// When
|
||||||
|
MembreSearchResultDTO result = membreService.searchMembresAdvanced(
|
||||||
|
criteria, Page.of(0, 10), Sort.by("nom"));
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isNotNull();
|
||||||
|
assertThat(result.getTotalElements()).isEqualTo(1);
|
||||||
|
assertThat(result.getMembres()).hasSize(1);
|
||||||
|
assertThat(result.getMembres().get(0).getEmail()).contains("@unionflow.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(6)
|
||||||
|
@DisplayName("Doit filtrer par rôles")
|
||||||
|
void testSearchByRoles() {
|
||||||
|
// Given
|
||||||
|
MembreSearchCriteria criteria = MembreSearchCriteria.builder()
|
||||||
|
.roles(List.of("PRESIDENT", "SECRETAIRE"))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// When
|
||||||
|
MembreSearchResultDTO result = membreService.searchMembresAdvanced(
|
||||||
|
criteria, Page.of(0, 10), Sort.by("nom"));
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isNotNull();
|
||||||
|
assertThat(result.getTotalElements()).isGreaterThan(0);
|
||||||
|
|
||||||
|
// Vérifier que tous les membres ont au moins un des rôles recherchés
|
||||||
|
result.getMembres().forEach(membre -> {
|
||||||
|
assertThat(membre.getRole()).satisfiesAnyOf(
|
||||||
|
role -> assertThat(role).contains("PRESIDENT"),
|
||||||
|
role -> assertThat(role).contains("SECRETAIRE")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(7)
|
||||||
|
@DisplayName("Doit gérer la pagination correctement")
|
||||||
|
void testPagination() {
|
||||||
|
// Given
|
||||||
|
MembreSearchCriteria criteria = MembreSearchCriteria.builder()
|
||||||
|
.includeInactifs(true) // Inclure tous les membres
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// When - Première page
|
||||||
|
MembreSearchResultDTO firstPage = membreService.searchMembresAdvanced(
|
||||||
|
criteria, Page.of(0, 2), Sort.by("nom"));
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(firstPage).isNotNull();
|
||||||
|
assertThat(firstPage.getCurrentPage()).isEqualTo(0);
|
||||||
|
assertThat(firstPage.getPageSize()).isEqualTo(2);
|
||||||
|
assertThat(firstPage.getMembres()).hasSizeLessThanOrEqualTo(2);
|
||||||
|
assertThat(firstPage.isFirst()).isTrue();
|
||||||
|
|
||||||
|
if (firstPage.getTotalElements() > 2) {
|
||||||
|
assertThat(firstPage.isLast()).isFalse();
|
||||||
|
assertThat(firstPage.isHasNext()).isTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(8)
|
||||||
|
@DisplayName("Doit calculer les statistiques correctement")
|
||||||
|
void testStatisticsCalculation() {
|
||||||
|
// Given
|
||||||
|
MembreSearchCriteria criteria = MembreSearchCriteria.builder()
|
||||||
|
.includeInactifs(true) // Inclure tous les membres
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// When
|
||||||
|
MembreSearchResultDTO result = membreService.searchMembresAdvanced(
|
||||||
|
criteria, Page.of(0, 10), Sort.by("nom"));
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isNotNull();
|
||||||
|
assertThat(result.getStatistics()).isNotNull();
|
||||||
|
|
||||||
|
MembreSearchResultDTO.SearchStatistics stats = result.getStatistics();
|
||||||
|
assertThat(stats.getMembresActifs()).isEqualTo(3);
|
||||||
|
assertThat(stats.getMembresInactifs()).isEqualTo(1);
|
||||||
|
assertThat(stats.getAgeMoyen()).isGreaterThan(0);
|
||||||
|
assertThat(stats.getAgeMin()).isGreaterThan(0);
|
||||||
|
assertThat(stats.getAgeMax()).isGreaterThan(stats.getAgeMin());
|
||||||
|
assertThat(stats.getAncienneteMoyenne()).isGreaterThanOrEqualTo(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(9)
|
||||||
|
@DisplayName("Doit retourner un résultat vide pour critères impossibles")
|
||||||
|
void testEmptyResultForImpossibleCriteria() {
|
||||||
|
// Given
|
||||||
|
MembreSearchCriteria criteria = MembreSearchCriteria.builder()
|
||||||
|
.query("membre_inexistant_xyz")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// When
|
||||||
|
MembreSearchResultDTO result = membreService.searchMembresAdvanced(
|
||||||
|
criteria, Page.of(0, 10), Sort.by("nom"));
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isNotNull();
|
||||||
|
assertThat(result.getTotalElements()).isEqualTo(0);
|
||||||
|
assertThat(result.getMembres()).isEmpty();
|
||||||
|
assertThat(result.isEmpty()).isTrue();
|
||||||
|
assertThat(result.getTotalPages()).isEqualTo(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(10)
|
||||||
|
@DisplayName("Doit valider la cohérence des critères")
|
||||||
|
void testCriteriaValidation() {
|
||||||
|
// Given - Critères incohérents
|
||||||
|
MembreSearchCriteria invalidCriteria = MembreSearchCriteria.builder()
|
||||||
|
.ageMin(50)
|
||||||
|
.ageMax(30) // Âge max < âge min
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// When & Then
|
||||||
|
assertThat(invalidCriteria.isValid()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(11)
|
||||||
|
@DisplayName("Doit avoir des performances acceptables (< 500ms)")
|
||||||
|
void testSearchPerformance() {
|
||||||
|
// Given
|
||||||
|
MembreSearchCriteria criteria = MembreSearchCriteria.builder()
|
||||||
|
.includeInactifs(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// When & Then - Mesurer le temps d'exécution
|
||||||
|
long startTime = System.currentTimeMillis();
|
||||||
|
|
||||||
|
MembreSearchResultDTO result = membreService.searchMembresAdvanced(
|
||||||
|
criteria, Page.of(0, 20), Sort.by("nom"));
|
||||||
|
|
||||||
|
long executionTime = System.currentTimeMillis() - startTime;
|
||||||
|
|
||||||
|
// Vérifications
|
||||||
|
assertThat(result).isNotNull();
|
||||||
|
assertThat(executionTime).isLessThan(500L); // Moins de 500ms
|
||||||
|
|
||||||
|
// Log pour monitoring
|
||||||
|
System.out.printf("Recherche avancée exécutée en %d ms pour %d résultats%n",
|
||||||
|
executionTime, result.getTotalElements());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(12)
|
||||||
|
@DisplayName("Doit gérer les critères avec caractères spéciaux")
|
||||||
|
void testSearchWithSpecialCharacters() {
|
||||||
|
// Given
|
||||||
|
MembreSearchCriteria criteria = MembreSearchCriteria.builder()
|
||||||
|
.query("marie-josé")
|
||||||
|
.nom("o'connor")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// When
|
||||||
|
MembreSearchResultDTO result = membreService.searchMembresAdvanced(
|
||||||
|
criteria, Page.of(0, 10), Sort.by("nom"));
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isNotNull();
|
||||||
|
// La recherche ne doit pas échouer même avec des caractères spéciaux
|
||||||
|
assertThat(result.getTotalElements()).isGreaterThanOrEqualTo(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user