Files
geo/app/lib/presentation/widgets/members_board_passages.dart
pierre 2f5946a184 feat: Version 3.5.2 - Configuration Stripe et gestion des immeubles
- Configuration complète Stripe pour les 3 environnements (DEV/REC/PROD)
  * DEV: Clés TEST Pierre (mode test)
  * REC: Clés TEST Client (mode test)
  * PROD: Clés LIVE Client (mode live)
- Ajout de la gestion des bases de données immeubles/bâtiments
  * Configuration buildings_database pour DEV/REC/PROD
  * Service BuildingService pour enrichissement des adresses
- Optimisations pages et améliorations ergonomie
- Mises à jour des dépendances Composer
- Nettoyage des fichiers obsolètes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 18:26:27 +01:00

1323 lines
48 KiB
Dart

import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:geosector_app/core/data/models/membre_model.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/data/models/sector_model.dart';
import 'package:geosector_app/core/repositories/operation_repository.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:geosector_app/app.dart';
/// Widget affichant un tableau détaillé des membres avec leurs statistiques de passages
/// Uniquement visible sur plateforme Web
class MembersBoardPassages extends StatefulWidget {
final String title;
final double? height;
const MembersBoardPassages({
super.key,
this.title = 'Détails par membre',
this.height,
});
@override
State<MembersBoardPassages> createState() => _MembersBoardPassagesState();
}
class _MembersBoardPassagesState extends State<MembersBoardPassages> {
// Repository pour récupérer l'opération courante uniquement
final OperationRepository _operationRepository = operationRepository;
// Variables pour le tri (valeurs par défaut, seront restaurées depuis settings)
int? _sortColumnIndex;
bool _sortAscending = true;
@override
void initState() {
super.initState();
_loadSortSettings();
}
// Charger les paramètres de tri depuis la box settings
void _loadSortSettings() {
try {
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
final settingsBox = Hive.box(AppKeys.settingsBoxName);
_sortColumnIndex = settingsBox.get('membersBoardSortColumn', defaultValue: 2); // 2 = Effectués par défaut
_sortAscending = settingsBox.get('membersBoardSortAscending', defaultValue: false); // Descendant par défaut
}
} catch (e) {
debugPrint('Erreur lors du chargement des paramètres de tri: $e');
// Valeurs par défaut en cas d'erreur
_sortColumnIndex = 2;
_sortAscending = false;
}
}
// Sauvegarder les paramètres de tri dans la box settings
void _saveSortSettings() {
try {
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
final settingsBox = Hive.box(AppKeys.settingsBoxName);
settingsBox.put('membersBoardSortColumn', _sortColumnIndex);
settingsBox.put('membersBoardSortAscending', _sortAscending);
}
} catch (e) {
debugPrint('Erreur lors de la sauvegarde des paramètres de tri: $e');
}
}
// Vérifier si le type Lot doit être affiché
bool _shouldShowLotType() {
final currentUser = CurrentUserService.instance.currentUser;
if (currentUser != null && currentUser.fkEntite != null) {
final userAmicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!);
if (userAmicale != null) {
return userAmicale.chkLotActif;
}
}
return true; // Par défaut, on affiche
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
boxShadow: AppTheme.cardShadow,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// En-tête de la card
Container(
padding: const EdgeInsets.all(AppTheme.spacingM),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withOpacity(0.05),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(AppTheme.borderRadiusMedium),
topRight: Radius.circular(AppTheme.borderRadiusMedium),
),
),
child: Row(
children: [
Icon(
Icons.people_outline,
color: theme.colorScheme.primary,
size: 24,
),
const SizedBox(width: AppTheme.spacingS),
Text(
widget.title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
],
),
),
// Corps avec le tableau
ValueListenableBuilder<Box<MembreModel>>(
valueListenable: Hive.box<MembreModel>(AppKeys.membresBoxName).listenable(),
builder: (context, membresBox, child) {
final membres = membresBox.values.toList();
// Récupérer l'opération courante
final currentOperation = _operationRepository.getCurrentOperation();
if (currentOperation == null) {
return const Center(
child: Padding(
padding: EdgeInsets.all(AppTheme.spacingL),
child: Text('Aucune opération en cours'),
),
);
}
// Trier les membres selon la colonne sélectionnée
_sortMembers(membres, currentOperation.id);
// Construire les lignes : TOTAL en première position + détails membres
final allRows = [
_buildTotalRow(membres, currentOperation.id, theme),
..._buildRows(membres, currentOperation.id, theme),
];
// Afficher le tableau complet sans scroll interne
return SizedBox(
width: double.infinity, // Prendre toute la largeur disponible
child: Theme(
data: Theme.of(context).copyWith(
dataTableTheme: DataTableThemeData(
headingRowColor: WidgetStateProperty.resolveWith<Color?>(
(Set<WidgetState> states) {
return theme.colorScheme.primary.withOpacity(0.08);
},
),
dataRowColor: WidgetStateProperty.resolveWith<Color?>(
(Set<WidgetState> states) {
if (states.contains(WidgetState.selected)) {
return theme.colorScheme.primary.withOpacity(0.08);
}
return null;
},
),
),
),
child: DataTable(
columnSpacing: 4, // Espacement minimal entre colonnes
horizontalMargin: 4, // Marges horizontales minimales
headingRowHeight: 42, // Hauteur de l'en-tête optimisée
dataRowMinHeight: 42,
dataRowMaxHeight: 42,
// Utiliser les flèches natives de DataTable
sortColumnIndex: _sortColumnIndex,
sortAscending: _sortAscending,
columns: _buildColumns(theme),
rows: allRows,
),
),
);
},
),
],
),
);
}
/// Trie les membres selon la colonne sélectionnée
void _sortMembers(List<MembreModel> membres, int operationId) {
if (_sortColumnIndex == null) {
// Tri par défaut : par nom
membres.sort((a, b) {
final nameA = '${a.firstName ?? ''} ${a.name ?? ''}'.trim();
final nameB = '${b.firstName ?? ''} ${b.name ?? ''}'.trim();
return nameA.compareTo(nameB);
});
return;
}
final passageBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
final allPassages = passageBox.values.where((p) => p.fkOperation == operationId).toList();
final showLotType = _shouldShowLotType();
// Fonction helper pour obtenir les stats d'un membre
Map<String, dynamic> getMemberStats(MembreModel membre) {
final memberPassages = allPassages.where((p) => p.fkUser == membre.opeUserId).toList();
int totalCount = memberPassages.length;
int effectueCount = 0;
double effectueMontant = 0.0;
int aFinaliserCount = 0;
int refuseCount = 0;
int donCount = 0;
int lotsCount = 0;
int videCount = 0;
for (final passage in memberPassages) {
switch (passage.fkType) {
case 1:
effectueCount++;
if (passage.montant.isNotEmpty) {
effectueMontant += double.tryParse(passage.montant) ?? 0.0;
}
break;
case 2:
aFinaliserCount++;
break;
case 3:
refuseCount++;
break;
case 4:
donCount++;
break;
case 5:
if (showLotType) lotsCount++;
break;
case 6:
videCount++;
break;
}
}
double montantMoyen = effectueCount > 0 ? effectueMontant / effectueCount : 0.0;
final memberSectorIds = memberPassages.where((p) => p.fkSector != null).map((p) => p.fkSector!).toSet();
final sectorCount = memberSectorIds.length;
final passagesNonAFinaliser = totalCount - aFinaliserCount;
double tauxAvancement = totalCount > 0 ? passagesNonAFinaliser / totalCount : 0.0;
return {
'total': totalCount,
'effectue': effectueCount,
'effectueMontant': effectueMontant,
'montantMoyen': montantMoyen,
'aFinaliser': aFinaliserCount,
'refuse': refuseCount,
'don': donCount,
'lots': lotsCount,
'vide': videCount,
'tauxAvancement': tauxAvancement,
'secteurs': sectorCount,
};
}
membres.sort((a, b) {
final statsA = getMemberStats(a);
final statsB = getMemberStats(b);
int result = 0;
switch (_sortColumnIndex) {
case 0: // Nom
final nameA = '${a.firstName ?? ''} ${a.name ?? ''}'.trim();
final nameB = '${b.firstName ?? ''} ${b.name ?? ''}'.trim();
result = nameA.compareTo(nameB);
break;
case 1: // Total
result = statsA['total'].compareTo(statsB['total']);
break;
case 2: // Effectués
result = statsA['effectue'].compareTo(statsB['effectue']);
break;
case 3: // Montant moyen
result = statsA['montantMoyen'].compareTo(statsB['montantMoyen']);
break;
case 4: // À finaliser
result = statsA['aFinaliser'].compareTo(statsB['aFinaliser']);
break;
case 5: // Refusés
result = statsA['refuse'].compareTo(statsB['refuse']);
break;
case 6: // Dons
result = statsA['don'].compareTo(statsB['don']);
break;
case 7: // Lots (si affiché)
if (showLotType) {
result = statsA['lots'].compareTo(statsB['lots']);
} else {
result = statsA['vide'].compareTo(statsB['vide']);
}
break;
case 8: // Vides ou Taux d'avancement (dépend si Lots affiché)
if (showLotType) {
result = statsA['vide'].compareTo(statsB['vide']);
} else {
result = statsA['tauxAvancement'].compareTo(statsB['tauxAvancement']);
}
break;
case 9: // Taux d'avancement ou Secteurs (dépend si Lots affiché)
if (showLotType) {
result = statsA['tauxAvancement'].compareTo(statsB['tauxAvancement']);
} else {
result = statsA['secteurs'].compareTo(statsB['secteurs']);
}
break;
case 10: // Secteurs (si Lots affiché)
result = statsA['secteurs'].compareTo(statsB['secteurs']);
break;
}
return _sortAscending ? result : -result;
});
}
/// Helper pour construire le texte du header
Widget _buildHeaderText(String text, int columnIndex, TextStyle? style) {
return Text(
text,
style: style,
);
}
/// Helper pour gérer le tri de colonne
void _handleSort(int columnIndex) {
setState(() {
if (_sortColumnIndex == columnIndex) {
// Même colonne : basculer l'ordre
_sortAscending = !_sortAscending;
} else {
// Nouvelle colonne : tri ascendant par défaut
_sortColumnIndex = columnIndex;
_sortAscending = true;
}
// Sauvegarder les nouveaux paramètres de tri
_saveSortSettings();
});
}
/// Construit les colonnes du tableau
List<DataColumn> _buildColumns(ThemeData theme) {
// Utilise le thème pour une meilleure lisibilité
final headerStyle = theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
) ?? const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
);
final showLotType = _shouldShowLotType();
final columns = [
// Nom
DataColumn(
label: Expanded(
child: _buildHeaderText('Nom', 0, headerStyle),
),
onSort: (columnIndex, _) => _handleSort(columnIndex),
),
// Total
DataColumn(
label: Expanded(
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.route,
size: 16,
color: theme.colorScheme.primary,
),
const SizedBox(width: 4),
_buildHeaderText('Total', 1, headerStyle),
],
),
),
),
numeric: true,
onSort: (columnIndex, _) => _handleSort(columnIndex),
),
// Effectués
DataColumn(
label: Expanded(
child: Container(
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(vertical: 8),
color: Colors.green.withOpacity(0.2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.task_alt,
size: 16,
color: Colors.green,
),
const SizedBox(width: 4),
_buildHeaderText('Effectués', 2, headerStyle),
],
),
),
),
numeric: true,
onSort: (columnIndex, _) => _handleSort(columnIndex),
),
// Montant moyen
DataColumn(
label: Expanded(
child: Center(
child: _buildHeaderText('Moy./passage', 3, headerStyle),
),
),
numeric: true,
onSort: (columnIndex, _) => _handleSort(columnIndex),
),
// À finaliser
DataColumn(
label: Expanded(
child: Container(
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(vertical: 8),
color: Colors.orange.withOpacity(0.2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.refresh,
size: 16,
color: Colors.orange,
),
const SizedBox(width: 4),
_buildHeaderText('À finaliser', 4, headerStyle),
],
),
),
),
numeric: true,
onSort: (columnIndex, _) => _handleSort(columnIndex),
),
// Refusés
DataColumn(
label: Expanded(
child: Container(
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(vertical: 8),
color: Colors.red.withOpacity(0.2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.block,
size: 16,
color: Colors.red,
),
const SizedBox(width: 4),
_buildHeaderText('Refusés', 5, headerStyle),
],
),
),
),
numeric: true,
onSort: (columnIndex, _) => _handleSort(columnIndex),
),
// Dons
DataColumn(
label: Expanded(
child: Container(
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(vertical: 8),
color: Colors.lightBlue.withOpacity(0.2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.volunteer_activism,
size: 16,
color: Colors.lightBlue,
),
const SizedBox(width: 4),
_buildHeaderText('Dons', 6, headerStyle),
],
),
),
),
numeric: true,
onSort: (columnIndex, _) => _handleSort(columnIndex),
),
// Lots - affiché seulement si chkLotActif = true
if (showLotType)
DataColumn(
label: Expanded(
child: Container(
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(vertical: 8),
color: Colors.blue.withOpacity(0.2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.layers,
size: 16,
color: Colors.blue,
),
const SizedBox(width: 4),
_buildHeaderText('Lots', 7, headerStyle),
],
),
),
),
numeric: true,
onSort: (columnIndex, _) => _handleSort(columnIndex),
),
// Vides
DataColumn(
label: Expanded(
child: Container(
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(vertical: 8),
color: Colors.grey.withOpacity(0.2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.home_outlined,
size: 16,
color: Colors.grey,
),
const SizedBox(width: 4),
_buildHeaderText('Vides', showLotType ? 8 : 7, headerStyle),
],
),
),
),
numeric: true,
onSort: (columnIndex, _) => _handleSort(columnIndex),
),
// Taux d'avancement
DataColumn(
label: Expanded(
child: Center(
child: _buildHeaderText('Avancement', showLotType ? 9 : 8, headerStyle),
),
),
onSort: (columnIndex, _) => _handleSort(columnIndex),
),
// Secteurs
DataColumn(
label: Expanded(
child: Center(
child: _buildHeaderText('Secteurs', showLotType ? 10 : 9, headerStyle),
),
),
numeric: true,
onSort: (columnIndex, _) => _handleSort(columnIndex),
),
];
return columns;
}
/// Navigation vers l'historique avec un type de passage
void _navigateToHistoryWithType(int typeId, {int? memberId}) {
final settingsBox = Hive.box(AppKeys.settingsBoxName);
// Réinitialiser TOUS les filtres
settingsBox.delete('history_selectedSectorId');
settingsBox.delete('history_selectedSectorName');
settingsBox.delete('history_selectedPaymentTypeId');
settingsBox.delete('history_startDate');
settingsBox.delete('history_endDate');
// Sélectionner le type de passage
settingsBox.put('history_selectedTypeId', typeId);
// Sélectionner le membre si spécifié, sinon réinitialiser (Tous les membres)
if (memberId != null) {
settingsBox.put('history_selectedMemberId', memberId);
} else {
settingsBox.delete('history_selectedMemberId');
}
// Naviguer vers la page historique
debugPrint('MembersBoardPassages: Navigation vers /admin/history avec typeId=$typeId${memberId != null ? ', memberId=$memberId' : ' (tous les membres)'}');
if (mounted) {
context.go('/admin/history');
}
}
/// Construit la ligne de totaux
DataRow _buildTotalRow(List<MembreModel> membres, int operationId, ThemeData theme) {
final showLotType = _shouldShowLotType();
// Récupérer directement depuis les boxes Hive (déjà ouvertes)
final passageBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
final allPassages = passageBox.values.where((p) => p.fkOperation == operationId).toList();
// Calculer les totaux globaux
int totalCount = allPassages.length;
int effectueCount = 0;
double effectueMontant = 0.0;
int aFinaliserCount = 0;
int refuseCount = 0;
int donCount = 0;
int lotsCount = 0;
double lotsMontant = 0.0;
int videCount = 0;
for (final passage in allPassages) {
switch (passage.fkType) {
case 1: // Effectué
effectueCount++;
if (passage.montant.isNotEmpty) {
effectueMontant += double.tryParse(passage.montant) ?? 0.0;
}
break;
case 2: // À finaliser
aFinaliserCount++;
break;
case 3: // Refusé
refuseCount++;
break;
case 4: // Don
donCount++;
break;
case 5: // Lots
if (showLotType) {
lotsCount++;
if (passage.montant.isNotEmpty) {
lotsMontant += double.tryParse(passage.montant) ?? 0.0;
}
}
break;
case 6: // Vide
videCount++;
break;
}
}
// Calculer le montant moyen global
double montantMoyen = effectueCount > 0 ? effectueMontant / effectueCount : 0.0;
// Compter les secteurs uniques
final Set<int> uniqueSectorIds = {};
for (final passage in allPassages) {
if (passage.fkSector != null) {
uniqueSectorIds.add(passage.fkSector!);
}
}
final sectorCount = uniqueSectorIds.length;
// Calculer le taux d'avancement global (passages != 2 / total passages)
double tauxAvancement = 0.0;
if (totalCount > 0) {
final passagesNonAFinaliser = totalCount - aFinaliserCount;
tauxAvancement = passagesNonAFinaliser / totalCount;
}
return DataRow(
color: WidgetStateProperty.all(theme.colorScheme.primary.withOpacity(0.15)),
cells: [
// Nom
DataCell(
Container(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'TOTAL',
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
fontSize: 16,
) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
),
),
// Total
DataCell(
Container(
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
totalCount.toString(),
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
),
),
// Effectués - Cliquable pour naviguer vers l'historique
DataCell(
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _navigateToHistoryWithType(1), // Type 1 = Effectués, tous les membres
child: Container(
width: double.infinity,
height: double.infinity,
color: Colors.green.withOpacity(0.2),
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
effectueCount.toString(),
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
Text(
'(${effectueMontant.toStringAsFixed(2)}€)',
style: theme.textTheme.bodySmall ?? const TextStyle(fontSize: 12),
),
],
),
),
),
),
),
// Montant moyen
DataCell(
Center(
child: Text(
montantMoyen > 0 ? '${montantMoyen.toStringAsFixed(2)}' : '-',
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
),
),
// À finaliser - Cliquable pour naviguer vers l'historique
DataCell(
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _navigateToHistoryWithType(2), // Type 2 = À finaliser, tous les membres
child: Container(
width: double.infinity,
height: double.infinity,
color: Colors.orange.withOpacity(0.2),
alignment: Alignment.center,
child: Text(
aFinaliserCount.toString(),
style: (theme.textTheme.bodyMedium ?? const TextStyle(fontSize: 14))
.copyWith(fontWeight: FontWeight.bold, fontStyle: FontStyle.italic),
),
),
),
),
),
// Refusés - Cliquable pour naviguer vers l'historique
DataCell(
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _navigateToHistoryWithType(3), // Type 3 = Refusés, tous les membres
child: Container(
width: double.infinity,
height: double.infinity,
color: Colors.red.withOpacity(0.2),
alignment: Alignment.center,
child: Text(
refuseCount.toString(),
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
),
),
),
),
// Dons - Cliquable pour naviguer vers l'historique
DataCell(
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _navigateToHistoryWithType(4), // Type 4 = Dons, tous les membres
child: Container(
width: double.infinity,
height: double.infinity,
color: Colors.lightBlue.withOpacity(0.2),
alignment: Alignment.center,
child: Text(
donCount.toString(),
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
),
),
),
),
// Lots - affiché seulement si chkLotActif = true, cliquable pour naviguer vers l'historique
if (showLotType)
DataCell(
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _navigateToHistoryWithType(5), // Type 5 = Lots, tous les membres
child: Container(
width: double.infinity,
height: double.infinity,
color: Colors.blue.withOpacity(0.2),
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
lotsCount.toString(),
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
Text(
'(${lotsMontant.toStringAsFixed(2)}€)',
style: theme.textTheme.bodySmall ?? const TextStyle(fontSize: 12),
),
],
),
),
),
),
),
// Vides - Cliquable pour naviguer vers l'historique
DataCell(
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _navigateToHistoryWithType(6), // Type 6 = Vides, tous les membres
child: Container(
width: double.infinity,
height: double.infinity,
color: Colors.grey.withOpacity(0.2),
alignment: Alignment.center,
child: Text(
videCount.toString(),
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
),
),
),
),
// Taux d'avancement
DataCell(
SizedBox(
width: 100,
child: Row(
children: [
Expanded(
child: LinearProgressIndicator(
value: tauxAvancement,
backgroundColor: Colors.grey.shade300,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.blue.shade600,
),
),
),
const SizedBox(width: 8),
Text(
'${(tauxAvancement * 100).toStringAsFixed(1)}%',
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
],
),
),
),
// Secteurs
DataCell(
Center(
child: Text(
sectorCount.toString(),
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
),
),
],
);
}
/// Construit les lignes du tableau
List<DataRow> _buildRows(List<MembreModel> membres, int operationId, ThemeData theme) {
final List<DataRow> rows = [];
final showLotType = _shouldShowLotType();
// Récupérer directement depuis les boxes Hive (déjà ouvertes)
final passageBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
final allPassages = passageBox.values.where((p) => p.fkOperation == operationId).toList();
// Récupérer tous les secteurs directement depuis la box
final sectorBox = Hive.box<SectorModel>(AppKeys.sectorsBoxName);
final allSectors = sectorBox.values.toList();
for (int index = 0; index < membres.length; index++) {
final membre = membres[index];
final isEvenRow = index % 2 == 0;
// Récupérer les passages du membre (via ope_user_id)
final memberPassages = allPassages.where((p) => p.fkUser == membre.opeUserId).toList();
// Calculer les statistiques par type
int totalCount = memberPassages.length;
int effectueCount = 0;
double effectueMontant = 0.0;
int aFinaliserCount = 0;
int refuseCount = 0;
int donCount = 0;
int lotsCount = 0;
double lotsMontant = 0.0;
int videCount = 0;
for (final passage in memberPassages) {
switch (passage.fkType) {
case 1: // Effectué
effectueCount++;
if (passage.montant.isNotEmpty) {
effectueMontant += double.tryParse(passage.montant) ?? 0.0;
}
break;
case 2: // À finaliser
aFinaliserCount++;
break;
case 3: // Refusé
refuseCount++;
break;
case 4: // Don
donCount++;
break;
case 5: // Lots
if (showLotType) { // Compter seulement si Lots est activé
lotsCount++;
if (passage.montant.isNotEmpty) {
lotsMontant += double.tryParse(passage.montant) ?? 0.0;
}
}
break;
case 6: // Vide
videCount++;
break;
}
}
// Calculer le montant moyen
double montantMoyen = effectueCount > 0 ? effectueMontant / effectueCount : 0.0;
// Récupérer les secteurs uniques du membre via ses passages
final Set<int> memberSectorIds = {};
for (final passage in memberPassages) {
if (passage.fkSector != null) {
memberSectorIds.add(passage.fkSector!);
}
}
final sectorCount = memberSectorIds.length;
final memberSectors = allSectors.where((s) => memberSectorIds.contains(s.id)).toList();
// Calculer le taux d'avancement (passages != 2 / total passages)
double tauxAvancement = 0.0;
if (totalCount > 0) {
// Compter les passages dont le type != 2 (tous sauf "À finaliser")
final passagesNonAFinaliser = totalCount - aFinaliserCount;
tauxAvancement = passagesNonAFinaliser / totalCount;
}
rows.add(
DataRow(
color: WidgetStateProperty.all(
isEvenRow ? Colors.white : Colors.grey.shade50,
),
cells: [
// Nom - Cliquable pour naviguer vers l'historique avec le membre sélectionné
DataCell(
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
debugPrint('MembersBoardPassages: Clic sur membre ${membre.id} (opeUserId: ${membre.opeUserId})');
// Réinitialiser TOUS les filtres sauf le membre
final settingsBox = Hive.box(AppKeys.settingsBoxName);
settingsBox.delete('history_selectedSectorId');
settingsBox.delete('history_selectedSectorName');
settingsBox.delete('history_selectedTypeId');
settingsBox.delete('history_selectedPaymentTypeId');
settingsBox.delete('history_startDate');
settingsBox.delete('history_endDate');
// Sélectionner uniquement le membre (via opeUserId)
settingsBox.put('history_selectedMemberId', membre.opeUserId);
// Naviguer vers la page historique
debugPrint('MembersBoardPassages: Navigation vers /admin/history avec opeUserId ${membre.opeUserId}');
if (mounted) {
context.go('/admin/history');
}
},
child: Container(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
_buildMemberDisplayName(membre),
style: theme.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600) ??
const TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
),
),
),
),
),
// Total - Cliquable pour naviguer vers l'historique avec le membre sélectionné
DataCell(
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
debugPrint('MembersBoardPassages: Clic sur Total pour membre ${membre.id} (opeUserId: ${membre.opeUserId})');
// Réinitialiser TOUS les filtres sauf le membre
final settingsBox = Hive.box(AppKeys.settingsBoxName);
settingsBox.delete('history_selectedSectorId');
settingsBox.delete('history_selectedSectorName');
settingsBox.delete('history_selectedTypeId');
settingsBox.delete('history_selectedPaymentTypeId');
settingsBox.delete('history_startDate');
settingsBox.delete('history_endDate');
// Sélectionner uniquement le membre (via opeUserId)
settingsBox.put('history_selectedMemberId', membre.opeUserId);
// Naviguer vers la page historique
debugPrint('MembersBoardPassages: Navigation vers /admin/history avec opeUserId ${membre.opeUserId}');
if (mounted) {
context.go('/admin/history');
}
},
child: Container(
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
totalCount.toString(),
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
),
),
),
),
// Effectués - Cliquable pour naviguer vers l'historique avec ce membre
DataCell(
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _navigateToHistoryWithType(1, memberId: membre.opeUserId), // Type 1 = Effectués
child: Container(
width: double.infinity,
height: double.infinity,
color: Colors.green.withOpacity(0.1),
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
effectueCount.toString(),
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
Text(
'(${effectueMontant.toStringAsFixed(2)}€)',
style: theme.textTheme.bodySmall ?? const TextStyle(fontSize: 12),
),
],
),
),
),
),
),
// Montant moyen
DataCell(Center(child: Text(
montantMoyen > 0 ? '${montantMoyen.toStringAsFixed(2)}' : '-',
style: theme.textTheme.bodyMedium ?? const TextStyle(fontSize: 14),
))),
// À finaliser - Cliquable pour naviguer vers l'historique avec ce membre
DataCell(
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _navigateToHistoryWithType(2, memberId: membre.opeUserId), // Type 2 = À finaliser
child: Container(
width: double.infinity,
height: double.infinity,
color: Colors.orange.withOpacity(0.1),
alignment: Alignment.center,
child: Text(
aFinaliserCount.toString(),
style: (theme.textTheme.bodyMedium ?? const TextStyle(fontSize: 14))
.copyWith(fontStyle: FontStyle.italic),
),
),
),
),
),
// Refusés - Cliquable pour naviguer vers l'historique avec ce membre
DataCell(
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _navigateToHistoryWithType(3, memberId: membre.opeUserId), // Type 3 = Refusés
child: Container(
width: double.infinity,
height: double.infinity,
color: Colors.red.withOpacity(0.1),
alignment: Alignment.center,
child: Text(
refuseCount.toString(),
style: theme.textTheme.bodyMedium ?? const TextStyle(fontSize: 14),
),
),
),
),
),
// Dons - Cliquable pour naviguer vers l'historique avec ce membre
DataCell(
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _navigateToHistoryWithType(4, memberId: membre.opeUserId), // Type 4 = Dons
child: Container(
width: double.infinity,
height: double.infinity,
color: Colors.lightBlue.withOpacity(0.1),
alignment: Alignment.center,
child: Text(
donCount.toString(),
style: theme.textTheme.bodyMedium ?? const TextStyle(fontSize: 14),
),
),
),
),
),
// Lots - affiché seulement si chkLotActif = true, cliquable pour naviguer vers l'historique avec ce membre
if (showLotType)
DataCell(
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _navigateToHistoryWithType(5, memberId: membre.opeUserId), // Type 5 = Lots
child: Container(
width: double.infinity,
height: double.infinity,
color: Colors.blue.withOpacity(0.1),
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
lotsCount.toString(),
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
Text(
'(${lotsMontant.toStringAsFixed(2)}€)',
style: theme.textTheme.bodySmall ?? const TextStyle(fontSize: 12),
),
],
),
),
),
),
),
// Vides - Cliquable pour naviguer vers l'historique avec ce membre
DataCell(
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _navigateToHistoryWithType(6, memberId: membre.opeUserId), // Type 6 = Vides
child: Container(
width: double.infinity,
height: double.infinity,
color: Colors.grey.withOpacity(0.1),
alignment: Alignment.center,
child: Text(
videCount.toString(),
style: theme.textTheme.bodyMedium ?? const TextStyle(fontSize: 14),
),
),
),
),
),
// Taux d'avancement
DataCell(
SizedBox(
width: 100,
child: Row(
children: [
Expanded(
child: LinearProgressIndicator(
value: tauxAvancement,
backgroundColor: Colors.grey.shade300,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.blue.shade600,
),
),
),
const SizedBox(width: 8),
Text(
'${(tauxAvancement * 100).toStringAsFixed(1)}%',
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
],
),
),
),
// Secteurs
DataCell(
Row(
children: [
if (sectorCount == 0)
Icon(
Icons.warning,
color: Colors.red.shade400,
size: 16,
),
const SizedBox(width: 4),
Text(
sectorCount.toString(),
style: TextStyle(
fontSize: theme.textTheme.bodyMedium?.fontSize ?? 14,
fontWeight: sectorCount > 0 ? FontWeight.bold : FontWeight.normal,
color: sectorCount > 0 ? Colors.green.shade700 : Colors.red.shade700,
),
),
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.map_outlined, size: 16),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () {
_showMemberSectorsDialog(context, membre, memberSectors.toList());
},
),
],
),
),
],
),
);
}
return rows;
}
/// Construit le nom d'affichage d'un membre avec son sectName si disponible
String _buildMemberDisplayName(MembreModel membre) {
String displayName = '${membre.firstName ?? ''} ${membre.name ?? ''}'.trim();
// Ajouter le sectName entre parenthèses s'il existe
if (membre.sectName != null && membre.sectName!.isNotEmpty) {
displayName += ' (${membre.sectName})';
}
return displayName;
}
/// Affiche un dialogue avec les secteurs du membre
void _showMemberSectorsDialog(BuildContext context, MembreModel membre, List<SectorModel> memberSectors) {
final theme = Theme.of(context);
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text('Secteurs de ${membre.firstName} ${membre.name}'),
content: SizedBox(
width: 400,
child: memberSectors.isEmpty
? const Text('Aucun secteur attribué')
: ListView.builder(
shrinkWrap: true,
itemCount: memberSectors.length,
itemBuilder: (context, index) {
final sector = memberSectors[index];
return ListTile(
leading: Icon(
Icons.map,
color: theme.colorScheme.primary,
),
title: Text(sector.libelle),
subtitle: Text('Secteur #${sector.id}'),
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
],
);
},
);
}
}