import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales import 'package:flutter/material.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:geosector_app/core/constants/app_keys.dart'; import 'package:geosector_app/core/services/current_user_service.dart'; import 'package:geosector_app/core/theme/app_theme.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/data/models/membre_model.dart'; import 'package:geosector_app/core/data/models/user_model.dart'; import 'package:geosector_app/presentation/widgets/passages/passages_list_widget.dart'; import 'package:geosector_app/presentation/widgets/passage_form_dialog.dart'; import 'package:geosector_app/presentation/widgets/app_scaffold.dart'; import 'package:geosector_app/presentation/widgets/charts/charts.dart'; import 'package:intl/intl.dart'; /// Page d'historique unifiée utilisant AppScaffold class HistoryPage extends StatelessWidget { /// ID du membre à filtrer (optionnel, pour les admins) final int? memberId; const HistoryPage({ super.key, this.memberId, }); @override Widget build(BuildContext context) { return AppScaffold( selectedIndex: 1, // Index de la page History dans la navigation pageTitle: 'Historique', body: HistoryContent(memberId: memberId), ); } } /// Contenu de la page historique unifié pour admin et user class HistoryContent extends StatefulWidget { /// ID du membre à filtrer (optionnel, pour les admins) final int? memberId; const HistoryContent({ super.key, this.memberId, }); @override State createState() => _HistoryContentState(); } // Enum pour gérer les types de tri enum PassageSortType { dateDesc, // Plus récent en premier (défaut) dateAsc, // Plus ancien en premier addressAsc, // Adresse A-Z addressDesc, // Adresse Z-A } class _HistoryContentState extends State { // Détection du rôle et permissions late final bool isAdmin; late final int currentUserId; late final bool canDeletePassages; // Permission de suppression pour les users // Filtres principaux (nouveaux) String _selectedTypeFilter = 'Tous les types'; String _selectedPaymentFilter = 'Tous les règlements'; String _searchQuery = ''; int? _selectedSectorId; int? _selectedUserId; // Pour admin seulement int? _selectedPaymentTypeId; // ID du type de règlement sélectionné // Contrôleurs final TextEditingController _startDateController = TextEditingController(); final TextEditingController _endDateController = TextEditingController(); final TextEditingController _searchController = TextEditingController(); // Anciens filtres (à supprimer progressivement) int? selectedSectorId; String selectedSector = 'Tous'; String selectedType = 'Tous'; int? selectedMemberId; int? selectedTypeId; int? selectedPaymentTypeId; DateTime? startDate; DateTime? endDate; String selectedPeriod = 'Toutes'; DateTimeRange? selectedDateRange; // Listes pour les filtres List _sectors = []; List _membres = []; List _users = []; // Liste des users pour le filtre // Passages originaux pour l'édition List _originalPassages = []; List _filteredPassages = []; // État de chargement bool _isLoading = true; String _errorMessage = ''; // Statistiques pour l'affichage int _totalSectors = 0; int _sharedMembersCount = 0; // État de la section graphiques bool _isGraphicsExpanded = true; // Listener pour les changements de secteur depuis map_page late final Box _settingsBox; @override void initState() { super.initState(); // Initialiser la box settings et écouter les changements de secteur _initSettingsListener(); // Déterminer le rôle et les permissions de l'utilisateur (prend en compte le mode d'affichage) final currentUser = userRepository.getCurrentUser(); isAdmin = CurrentUserService.instance.shouldShowAdminUI; currentUserId = currentUser?.id ?? 0; // Vérifier la permission de suppression pour les users bool userCanDelete = false; if (!isAdmin && currentUser != null && currentUser.fkEntite != null) { final userAmicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!); if (userAmicale != null) { userCanDelete = userAmicale.chkUserDeletePass; } } canDeletePassages = isAdmin || userCanDelete; // Si un memberId est passé en paramètre et que c'est un admin, l'utiliser if (widget.memberId != null && isAdmin) { selectedMemberId = widget.memberId; debugPrint('HistoryPage: Filtre membre activé pour ID ${widget.memberId}'); // Sauvegarder aussi dans Hive pour la persistance _saveMemberFilter(widget.memberId!); } else if (!isAdmin) { // Pour un user standard, toujours filtrer sur son propre ID selectedMemberId = currentUserId; } else { // Admin sans memberId spécifique, charger les filtres depuis Hive _loadPreselectedFilters(); } _initializeNewFilters(); _initializeFilters(); _updateDateControllers(); _loadGraphicsExpandedState(); } @override void dispose() { _startDateController.dispose(); _endDateController.dispose(); _searchController.dispose(); // Pas besoin de fermer _settingsBox car c'est une box partagée super.dispose(); } // Initialiser le listener pour les changements de secteur Future _initSettingsListener() async { try { if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) { _settingsBox = await Hive.openBox(AppKeys.settingsBoxName); } else { _settingsBox = Hive.box(AppKeys.settingsBoxName); } // Charger le secteur depuis Hive au démarrage final savedSectorId = _settingsBox.get('selectedSectorId'); if (savedSectorId != null && savedSectorId is int) { if (mounted) { setState(() { _selectedSectorId = savedSectorId; selectedSectorId = savedSectorId; // Sync avec l'ancien système }); } debugPrint('HistoryPage: Secteur chargé depuis Hive: $savedSectorId'); } // Écouter les changements futurs _settingsBox.listenable(keys: ['selectedSectorId']).addListener(_onSectorChanged); } catch (e) { debugPrint('HistoryPage: Erreur initialisation settings listener: $e'); } } // Callback quand le secteur change depuis map_page void _onSectorChanged() { final newSectorId = _settingsBox.get('selectedSectorId'); if (newSectorId != _selectedSectorId) { if (mounted) { setState(() { _selectedSectorId = newSectorId; selectedSectorId = newSectorId; // Sync avec l'ancien système }); debugPrint('HistoryPage: Secteur mis à jour depuis map_page: $newSectorId'); _notifyFiltersChanged(); } } } // Initialiser les nouveaux filtres void _initializeNewFilters() { // Initialiser le contrôleur de recherche _searchController.text = _searchQuery; // Initialiser les filtres selon le rôle if (isAdmin) { // Admin peut sélectionner un membre ou "Tous les membres" _selectedUserId = selectedMemberId; // Utiliser l'ancien système pour la transition } else { // User : toujours filtré sur son ID _selectedUserId = currentUserId; } // Initialiser le secteur _selectedSectorId = selectedSectorId; // Utiliser l'ancien système pour la transition debugPrint('HistoryPage: Nouveaux filtres initialisés'); debugPrint(' _selectedTypeFilter: $_selectedTypeFilter'); debugPrint(' _selectedPaymentFilter: $_selectedPaymentFilter'); debugPrint(' _selectedSectorId: $_selectedSectorId'); debugPrint(' _selectedUserId: $_selectedUserId'); // Appliquer les filtres initiaux (seulement si les passages sont déjà chargés) if (_originalPassages.isNotEmpty) { _notifyFiltersChanged(); } } // Vérifier si le type Lot doit être affiché (extrait de PassagesListWidget) bool _shouldShowLotType() { final currentUser = userRepository.getCurrentUser(); 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 } // Obtenir la liste filtrée des types de passages (extrait de PassagesListWidget) List _getFilteredPassageTypes() { final showLotType = _shouldShowLotType(); final types = []; AppKeys.typesPassages.forEach((typeId, typeInfo) { // Exclure le type Lot (5) si chkLotActif = false if (typeId == 5 && !showLotType) { return; // Skip ce type } types.add(typeInfo['titre'] as String); }); return types; } /// Appliquer les filtres aux données et synchroniser GraphicsSection + liste void _notifyFiltersChanged() { debugPrint('HistoryPage: Application des filtres'); debugPrint(' Type: $_selectedTypeFilter'); debugPrint(' Paiement: $_selectedPaymentFilter'); debugPrint(' Recherche: $_searchQuery'); debugPrint(' Secteur: $_selectedSectorId'); debugPrint(' Utilisateur: $_selectedUserId'); debugPrint(' Dates: $startDate à $endDate'); // Appliquer les filtres aux passages originaux List filteredPassages = _originalPassages.where((passage) { // Filtre par type de passage if (_selectedTypeFilter != 'Tous les types') { final typeInfo = AppKeys.typesPassages.entries.firstWhere( (entry) => entry.value['titre'] == _selectedTypeFilter, orElse: () => const MapEntry(-1, {'titre': '', 'couleur': ''}), ); if (typeInfo.key == -1 || passage.fkType != typeInfo.key) { return false; } } // Filtre par type de règlement if (_selectedPaymentTypeId != null && passage.fkTypeReglement != _selectedPaymentTypeId) { return false; } // Filtre par secteur if (_selectedSectorId != null && passage.fkSector != _selectedSectorId) { return false; } // Filtre par utilisateur (admin seulement) if (isAdmin && _selectedUserId != null && passage.fkUser != _selectedUserId) { return false; } // Filtre par dates // Si une date de début ou de fin est définie et que le passage est de type 2 (À finaliser) // sans date, on l'exclut if ((startDate != null || endDate != null) && passage.fkType == 2 && passage.passedAt == null) { return false; // Exclure les passages "À finaliser" sans date quand une date est définie } // Filtre par date de début - ne filtrer que si le passage a une date if (startDate != null) { // Si le passage a une date, vérifier qu'elle est après la date de début if (passage.passedAt != null) { final passageDate = passage.passedAt!; if (passageDate.isBefore(startDate!)) { return false; } } // Si le passage n'a pas de date ET n'est pas de type 2, on le garde // (les passages type 2 sans date ont déjà été exclus au-dessus) } // Filtre par date de fin - ne filtrer que si le passage a une date if (endDate != null) { // Si le passage a une date, vérifier qu'elle est avant la date de fin if (passage.passedAt != null) { final passageDate = passage.passedAt!; if (passageDate.isAfter(endDate!.add(const Duration(days: 1)))) { return false; } } // Si le passage n'a pas de date ET n'est pas de type 2, on le garde // (les passages type 2 sans date ont déjà été exclus au-dessus) } // Filtre par recherche textuelle if (_searchQuery.isNotEmpty) { final query = _searchQuery.toLowerCase(); // Construire l'adresse complète pour la recherche final fullAddress = '${passage.numero} ${passage.rueBis} ${passage.rue} ${passage.residence} ${passage.ville}' .toLowerCase() .trim(); // Ajouter le nom et l'email final name = passage.name.toLowerCase(); final email = passage.email.toLowerCase(); // Vérifier si la recherche correspond à l'adresse, au nom ou à l'email if (!fullAddress.contains(query) && !name.contains(query) && !email.contains(query)) { return false; } } return true; }).toList(); // Mettre à jour les données filtrées setState(() { _filteredPassages = filteredPassages; }); debugPrint('HistoryPage: ${filteredPassages.length} passages filtrés sur ${_originalPassages.length}'); } /// Construire la card de filtres intégrée Widget _buildFiltersCard() { final screenWidth = MediaQuery.of(context).size.width; final isDesktop = screenWidth > 800; return Card( elevation: 2, color: Colors.transparent, child: Padding( padding: const EdgeInsets.all(12.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Première ligne : Type de passage et Mode de paiement Row( children: [ // Filtre Type de passage Expanded( child: DropdownButtonFormField( initialValue: _selectedTypeFilter, decoration: const InputDecoration( border: OutlineInputBorder(), contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), isDense: true, ), items: [ const DropdownMenuItem( value: 'Tous les types', child: Text('Passage'), ), ..._getFilteredPassageTypes().map((String type) { return DropdownMenuItem( value: type, child: Text(type), ); }), ], onChanged: (String? newValue) { if (newValue != null) { setState(() { _selectedTypeFilter = newValue; }); _notifyFiltersChanged(); } }, ), ), const SizedBox(width: 12), // Filtre Mode de règlement Expanded( child: DropdownButtonFormField( initialValue: _selectedPaymentFilter, decoration: const InputDecoration( border: OutlineInputBorder(), contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), isDense: true, ), items: [ const DropdownMenuItem( value: 'Tous les règlements', child: Text('Règlements'), ), ...AppKeys.typesReglements.entries.map((entry) { final typeInfo = entry.value; final titre = typeInfo['titre'] as String; return DropdownMenuItem( value: titre, child: Text(titre), ); }), ], onChanged: (String? newValue) { if (newValue != null) { setState(() { _selectedPaymentFilter = newValue; // Trouver l'ID correspondant au type de règlement sélectionné if (newValue == 'Tous les règlements') { _selectedPaymentTypeId = null; } else { final entry = AppKeys.typesReglements.entries.firstWhere( (e) => e.value['titre'] == newValue, orElse: () => const MapEntry(-1, {}), ); _selectedPaymentTypeId = entry.key != -1 ? entry.key : null; } }); _notifyFiltersChanged(); } }, ), ), ], ), const SizedBox(height: 12), // Deuxième ligne : Secteur et Membre (admin seulement) Row( children: [ // Filtre Secteur Expanded( child: ValueListenableBuilder>( valueListenable: Hive.box(AppKeys.sectorsBoxName).listenable(), builder: (context, sectorsBox, child) { final sectors = sectorsBox.values.toList(); return DropdownButtonFormField( initialValue: _selectedSectorId, decoration: const InputDecoration( border: OutlineInputBorder(), contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), isDense: true, ), items: [ const DropdownMenuItem( value: null, child: Text('Secteurs'), ), ...sectors.map((SectorModel sector) { return DropdownMenuItem( value: sector.id, child: Text(sector.libelle), ); }), ], onChanged: (int? newValue) async { setState(() { _selectedSectorId = newValue; }); // Sauvegarder dans Hive pour synchronisation avec map_page try { if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) { await Hive.openBox(AppKeys.settingsBoxName); } final settingsBox = Hive.box(AppKeys.settingsBoxName); if (newValue != null) { await settingsBox.put('selectedSectorId', newValue); } else { await settingsBox.delete('selectedSectorId'); } } catch (e) { debugPrint('Erreur sauvegarde secteur: $e'); } _notifyFiltersChanged(); }, ); }, ), ), // Espace ou filtre Membre (admin seulement) const SizedBox(width: 12), if (isAdmin) Expanded( child: ValueListenableBuilder>( valueListenable: Hive.box(AppKeys.userBoxName).listenable(), builder: (context, usersBox, child) { final users = usersBox.values.where((user) => user.role == 1).toList(); return DropdownButtonFormField( initialValue: _selectedUserId, decoration: const InputDecoration( border: OutlineInputBorder(), contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), isDense: true, ), items: [ const DropdownMenuItem( value: null, child: Text('Membres'), ), ...users.map((UserModel user) { return DropdownMenuItem( value: user.id, child: Text('${user.firstName ?? ''} ${user.name ?? ''}'), ); }), ], onChanged: (int? newValue) { setState(() { _selectedUserId = newValue; }); _notifyFiltersChanged(); }, ); }, ), ) else const Expanded(child: SizedBox()), ], ), const SizedBox(height: 12), // Troisième ligne : Dates Row( children: [ // Date de début Expanded( child: TextFormField( controller: _startDateController, decoration: InputDecoration( border: const OutlineInputBorder(), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), hintText: 'Début', isDense: true, suffixIcon: Row( mainAxisSize: MainAxisSize.min, children: [ if (_startDateController.text.isNotEmpty) IconButton( icon: const Icon(Icons.clear, size: 20), padding: EdgeInsets.zero, constraints: const BoxConstraints(), onPressed: () { setState(() { startDate = null; _startDateController.clear(); }); _notifyFiltersChanged(); }, ), IconButton( icon: const Icon(Icons.calendar_today, size: 20), padding: EdgeInsets.zero, constraints: const BoxConstraints(), onPressed: () async { final DateTime? picked = await showDatePicker( context: context, initialDate: startDate ?? DateTime.now().subtract(const Duration(days: 30)), firstDate: DateTime(2020), lastDate: DateTime.now(), locale: const Locale('fr', 'FR'), ); if (picked != null) { setState(() { startDate = picked; }); _updateDateControllers(); _notifyFiltersChanged(); } }, ), const SizedBox(width: 8), ], ), ), readOnly: false, onChanged: (value) { // Valider et parser la date au format JJ/MM/AAAA if (value.length == 10) { final parts = value.split('/'); if (parts.length == 3) { try { final day = int.parse(parts[0]); final month = int.parse(parts[1]); final year = int.parse(parts[2]); final date = DateTime(year, month, day); // Vérifier que la date est valide if (date.year >= 2020 && date.isBefore(DateTime.now().add(const Duration(days: 1)))) { setState(() { startDate = date; }); _notifyFiltersChanged(); } } catch (e) { // Date invalide, ignorer } } } else if (value.isEmpty) { setState(() { startDate = null; }); _notifyFiltersChanged(); } }, ), ), const SizedBox(width: 12), // Date de fin Expanded( child: TextFormField( controller: _endDateController, decoration: InputDecoration( border: const OutlineInputBorder(), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), hintText: 'Fin', isDense: true, suffixIcon: Row( mainAxisSize: MainAxisSize.min, children: [ if (_endDateController.text.isNotEmpty) IconButton( icon: const Icon(Icons.clear, size: 20), padding: EdgeInsets.zero, constraints: const BoxConstraints(), onPressed: () { setState(() { endDate = null; _endDateController.clear(); }); _notifyFiltersChanged(); }, ), IconButton( icon: const Icon(Icons.calendar_today, size: 20), padding: EdgeInsets.zero, constraints: const BoxConstraints(), onPressed: () async { final DateTime? picked = await showDatePicker( context: context, initialDate: endDate ?? DateTime.now(), firstDate: startDate ?? DateTime(2020), lastDate: DateTime.now(), locale: const Locale('fr', 'FR'), ); if (picked != null) { setState(() { endDate = picked; }); _updateDateControllers(); _notifyFiltersChanged(); } }, ), const SizedBox(width: 8), ], ), ), readOnly: false, onChanged: (value) { // Valider et parser la date au format JJ/MM/AAAA if (value.length == 10) { final parts = value.split('/'); if (parts.length == 3) { try { final day = int.parse(parts[0]); final month = int.parse(parts[1]); final year = int.parse(parts[2]); final date = DateTime(year, month, day); // Vérifier que la date est valide et après la date de début si définie if (date.year >= 2020 && date.isBefore(DateTime.now().add(const Duration(days: 1))) && (startDate == null || date.isAfter(startDate!) || date.isAtSameMomentAs(startDate!))) { setState(() { endDate = date; }); _notifyFiltersChanged(); } } catch (e) { // Date invalide, ignorer } } } else if (value.isEmpty) { setState(() { endDate = null; }); _notifyFiltersChanged(); } }, ), ), ], ), const SizedBox(height: 12), // Quatrième ligne : Recherche et actions Row( children: [ // Barre de recherche Expanded( flex: 3, child: TextFormField( controller: _searchController, decoration: const InputDecoration( border: OutlineInputBorder(), contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), hintText: '...', prefixIcon: Icon(Icons.search, size: 20), isDense: true, ), onChanged: (String value) { setState(() { _searchQuery = value; }); _notifyFiltersChanged(); }, ), ), const SizedBox(width: 12), // Bouton Réinitialiser (adaptatif) isDesktop ? OutlinedButton.icon( onPressed: () { setState(() { _selectedTypeFilter = 'Tous les types'; _selectedPaymentFilter = 'Tous les règlements'; _selectedPaymentTypeId = null; _selectedSectorId = null; _selectedUserId = null; _searchQuery = ''; startDate = null; endDate = null; _searchController.clear(); _startDateController.clear(); _endDateController.clear(); }); _notifyFiltersChanged(); }, icon: const Icon(Icons.filter_alt_off, size: 18), label: const Text('Réinitialiser'), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), ), ) : IconButton( onPressed: () { setState(() { _selectedTypeFilter = 'Tous les types'; _selectedPaymentFilter = 'Tous les règlements'; _selectedPaymentTypeId = null; _selectedSectorId = null; _selectedUserId = null; _searchQuery = ''; startDate = null; endDate = null; _searchController.clear(); _startDateController.clear(); _endDateController.clear(); }); _notifyFiltersChanged(); }, icon: const Icon(Icons.filter_alt_off, size: 20), tooltip: 'Réinitialiser les filtres', style: IconButton.styleFrom( side: BorderSide(color: Theme.of(context).colorScheme.outline), ), ), ], ), ], ), ), ); } // Mettre à jour les contrôleurs de date void _updateDateControllers() { if (startDate != null) { _startDateController.text = '${startDate!.day.toString().padLeft(2, '0')}/${startDate!.month.toString().padLeft(2, '0')}/${startDate!.year}'; } if (endDate != null) { _endDateController.text = '${endDate!.day.toString().padLeft(2, '0')}/${endDate!.month.toString().padLeft(2, '0')}/${endDate!.year}'; } } // Sauvegarder le filtre membre dans Hive (pour les admins) void _saveMemberFilter(int memberId) async { if (!isAdmin) return; // Seulement pour les admins try { if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) { await Hive.openBox(AppKeys.settingsBoxName); } final settingsBox = Hive.box(AppKeys.settingsBoxName); await settingsBox.put('selectedMemberId', memberId); await settingsBox.put('history_selectedMemberId', memberId); debugPrint('HistoryPage: MemberId $memberId sauvegardé dans Hive'); } catch (e) { debugPrint('Erreur lors de la sauvegarde du filtre membre: $e'); } } // Charger l'état de la section graphiques depuis Hive void _loadGraphicsExpandedState() async { try { // Définir l'état par défaut selon la taille d'écran (mobile = rétracté) final screenWidth = MediaQuery.of(context).size.width; final isMobile = screenWidth < 800; _isGraphicsExpanded = !isMobile; // true sur desktop, false sur mobile if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) { await Hive.openBox(AppKeys.settingsBoxName); } final settingsBox = Hive.box(AppKeys.settingsBoxName); final saved = settingsBox.get('history_graphics_expanded'); if (saved != null && saved is bool) { setState(() { _isGraphicsExpanded = saved; }); debugPrint('HistoryPage: État graphics chargé depuis Hive: $_isGraphicsExpanded'); } } catch (e) { debugPrint('Erreur lors du chargement de l\'état graphics: $e'); } } // Sauvegarder l'état de la section graphiques dans Hive void _saveGraphicsExpandedState() async { try { if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) { await Hive.openBox(AppKeys.settingsBoxName); } final settingsBox = Hive.box(AppKeys.settingsBoxName); await settingsBox.put('history_graphics_expanded', _isGraphicsExpanded); debugPrint('HistoryPage: État graphics sauvegardé: $_isGraphicsExpanded'); } catch (e) { debugPrint('Erreur lors de la sauvegarde de l\'état graphics: $e'); } } // Charger les filtres présélectionnés depuis Hive void _loadPreselectedFilters() async { try { if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) { await Hive.openBox(AppKeys.settingsBoxName); } final settingsBox = Hive.box(AppKeys.settingsBoxName); // Charger le membre sélectionné (admins seulement) if (isAdmin) { final memberId = settingsBox.get('history_selectedMemberId') ?? settingsBox.get('selectedMemberId'); if (memberId != null && memberId is int) { setState(() { selectedMemberId = memberId; }); debugPrint('HistoryPage: Membre présélectionné chargé: $memberId'); } } // Charger le secteur sélectionné final sectorId = settingsBox.get('history_selectedSectorId'); if (sectorId != null && sectorId is int) { setState(() { selectedSectorId = sectorId; }); debugPrint('HistoryPage: Secteur présélectionné chargé: $sectorId'); } // Charger le type de passage sélectionné final typeId = settingsBox.get('history_selectedTypeId'); if (typeId != null && typeId is int) { setState(() { selectedTypeId = typeId; final typeInfo = AppKeys.typesPassages[typeId]; selectedType = typeInfo != null ? typeInfo['titre'] as String : 'Inconnu'; }); debugPrint('HistoryPage: Type de passage présélectionné: $typeId'); } // Charger le type de règlement sélectionné final paymentTypeId = settingsBox.get('history_selectedPaymentTypeId'); if (paymentTypeId != null && paymentTypeId is int) { setState(() { selectedPaymentTypeId = paymentTypeId; }); debugPrint('HistoryPage: Type de règlement présélectionné: $paymentTypeId'); } // Charger les dates de période si disponibles final startDateMs = settingsBox.get('history_startDate'); final endDateMs = settingsBox.get('history_endDate'); if (startDateMs != null && startDateMs is int) { setState(() { startDate = DateTime.fromMillisecondsSinceEpoch(startDateMs); }); debugPrint('HistoryPage: Date de début chargée: $startDate'); } if (endDateMs != null && endDateMs is int) { setState(() { endDate = DateTime.fromMillisecondsSinceEpoch(endDateMs); }); debugPrint('HistoryPage: Date de fin chargée: $endDate'); } } catch (e) { debugPrint('Erreur lors du chargement des filtres: $e'); } } // Initialiser les listes de filtres void _initializeFilters() { try { setState(() { _isLoading = true; _errorMessage = ''; }); // Charger les secteurs if (isAdmin) { // Admin : tous les secteurs _sectors = sectorRepository.getAllSectors(); } else { // User : seulement ses secteurs assignés final userSectors = userRepository.getUserSectors(); final userSectorIds = userSectors.map((us) => us.id).toSet(); _sectors = sectorRepository.getAllSectors() .where((s) => userSectorIds.contains(s.id)) .toList(); // Calculer les statistiques pour l'utilisateur _totalSectors = _sectors.length; // Compter les membres partageant les mêmes secteurs final allUserSectors = userRepository.getUserSectors(); final sharedMembers = {}; for (final userSector in allUserSectors) { if (userSectorIds.contains(userSector.id) && userSector.id != currentUserId) { sharedMembers.add(userSector.id); } } _sharedMembersCount = sharedMembers.length; } debugPrint('Nombre de secteurs récupérés: ${_sectors.length}'); // Charger les membres (admin seulement) if (isAdmin) { _membres = membreRepository.getAllMembres(); debugPrint('Nombre de membres récupérés: ${_membres.length}'); // Convertir les membres en users pour le filtre _users = _convertMembresToUsers(); debugPrint('Nombre d\'utilisateurs pour le filtre: ${_users.length}'); } // Charger les passages final currentOperation = userRepository.getCurrentOperation(); if (currentOperation != null) { if (isAdmin) { // Admin : tous les passages de l'opération _originalPassages = passageRepository.getPassagesByOperation(currentOperation.id); } else { // User : logique spéciale selon le type de passage final allPassages = passageRepository.getPassagesByOperation(currentOperation.id); _originalPassages = allPassages.where((p) { // Type 2 (À finaliser) : afficher TOUS les passages if (p.fkType == 2) { return true; } // Autres types : seulement les passages de l'utilisateur return p.fkUser == currentUserId; }).toList(); } debugPrint('Nombre de passages récupérés: ${_originalPassages.length}'); // Initialiser les passages filtrés avec tous les passages _filteredPassages = List.from(_originalPassages); // Appliquer les filtres initiaux après le chargement _notifyFiltersChanged(); } setState(() { _isLoading = false; }); } catch (e) { setState(() { _isLoading = false; _errorMessage = 'Erreur lors du chargement des données: $e'; }); debugPrint('Erreur lors de l\'initialisation des filtres: $e'); } } // Convertir les MembreModel en UserModel pour le filtre (admin seulement) List _convertMembresToUsers() { final users = []; for (final membre in _membres) { // Utiliser l'ID du membre pour récupérer l'utilisateur associé final user = userRepository.getUserById(membre.id); if (user != null) { // Si l'utilisateur existe, copier avec le sectName du membre users.add(user.copyWith( sectName: membre.sectName ?? user.sectName, )); } else { // Créer un UserModel temporaire si l'utilisateur n'existe pas users.add(UserModel( id: membre.id, username: membre.username ?? 'membre_${membre.id}', name: membre.name, firstName: membre.firstName, email: membre.email, role: membre.role, isActive: membre.isActive, createdAt: membre.createdAt, lastSyncedAt: DateTime.now(), sectName: membre.sectName, )); } } // Trier par nom complet users.sort((a, b) { final nameA = '${a.firstName ?? ''} ${a.name ?? ''}'.trim().toLowerCase(); final nameB = '${b.firstName ?? ''} ${b.name ?? ''}'.trim().toLowerCase(); return nameA.compareTo(nameB); }); return users; } @override Widget build(BuildContext context) { // Le contenu sans scaffold (AppScaffold est déjà dans HistoryPage) if (_isLoading) { return const Center(child: CircularProgressIndicator()); } if (_errorMessage.isNotEmpty) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.error_outline, size: 64, color: Colors.red), const SizedBox(height: 16), Text(_errorMessage, style: const TextStyle(fontSize: 16)), const SizedBox(height: 16), ElevatedButton( onPressed: _initializeFilters, child: const Text('Réessayer'), ), ], ), ); } return _buildContent(); } Widget _buildContent() { // Titre unique pour tous const pageTitle = 'Historique des passages'; // Statistiques pour les users final statsText = !isAdmin ? '$_totalSectors secteur${_totalSectors > 1 ? 's' : ''} | $_sharedMembersCount membre${_sharedMembersCount > 1 ? 's' : ''} en partage' : null; final screenWidth = MediaQuery.of(context).size.width; final isDesktop = screenWidth > 800; return SingleChildScrollView( child: Padding( padding: EdgeInsets.symmetric( horizontal: isDesktop ? AppTheme.spacingL : AppTheme.spacingS, vertical: AppTheme.spacingL, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // En-tête avec titre Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Titre Text( pageTitle, style: Theme.of(context).textTheme.headlineMedium?.copyWith( fontWeight: FontWeight.bold, ), ), if (statsText != null) ...[ const SizedBox(height: 8), Text( statsText, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Colors.grey[600], ), ), ], ], ), const SizedBox(height: 16), // 1. Card de filtres intégrée _buildFiltersCard(), const SizedBox(height: 16), // 2. Section graphiques (rétractable) _buildGraphicsSection(), const SizedBox(height: 16), // 3. Liste des passages avec hauteur maximale Card( elevation: 2, child: Container( constraints: const BoxConstraints( maxHeight: 700, ), child: PassagesListWidget( passages: _convertPassagesToMaps(), showActions: true, // Actions disponibles pour tous (avec vérification des droits) showAddButton: true, // Bouton + pour tous onPassageEdit: _handlePassageEditMap, // Tous peuvent éditer (avec vérification) onPassageDelete: canDeletePassages ? _handlePassageDeleteMap : null, // Selon permissions onAddPassage: () async { await _showPassageFormDialog(context); }, ), ), ), ], ), ), ); } // Construction de la section graphiques rétractable (pour intégration dans PassagesListWidget) Widget _buildGraphicsSection() { // final screenWidth = MediaQuery.of(context).size.width; // Non utilisé actuellement return Card( elevation: 0, color: Colors.transparent, child: Theme( data: Theme.of(context).copyWith( dividerColor: Colors.transparent, colorScheme: Theme.of(context).colorScheme.copyWith( primary: AppTheme.primaryColor, ), ), child: ExpansionTile( title: Row( children: [ Icon(Icons.analytics_outlined, color: AppTheme.primaryColor, size: 20), const SizedBox(width: 8), Text( 'Statistiques graphiques', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), ), ], ), subtitle: !_isGraphicsExpanded ? Text( isAdmin ? "Tous les passages de l'opération" : "Mes passages de l'opération", style: TextStyle(fontSize: 12, color: Colors.grey[600]), ) : null, initiallyExpanded: _isGraphicsExpanded, onExpansionChanged: (expanded) { setState(() { _isGraphicsExpanded = expanded; }); _saveGraphicsExpandedState(); }, tilePadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), childrenPadding: const EdgeInsets.only(top: 0, bottom: 16.0), expandedCrossAxisAlignment: CrossAxisAlignment.start, children: [ _buildGraphicsContent(), ], ), ), ); } // Contenu des statistiques adaptatif selon la taille d'écran Widget _buildGraphicsContent() { final screenWidth = MediaQuery.of(context).size.width; final isDesktop = screenWidth > 800; return Column( children: [ // Graphiques en camembert (côte à côte sur desktop) isDesktop ? Row( children: [ Expanded(child: _buildPassageSummaryCard()), const SizedBox(width: AppTheme.spacingM), Expanded(child: _buildPaymentSummaryCard()), ], ) : Column( children: [ _buildPassageSummaryCard(), const SizedBox(height: AppTheme.spacingM), _buildPaymentSummaryCard(), ], ), const SizedBox(height: AppTheme.spacingL), // Graphique d'activité _buildActivityChart(), ], ); } // Graphique camembert des types de passage Widget _buildPassageSummaryCard() { // Calculer les données filtrées pour le graphique final passagesByType = _calculatePassagesByType(); return PassageSummaryCard( title: isAdmin ? 'Types de passage' : 'Mes types de passage', titleColor: AppTheme.primaryColor, titleIcon: Icons.route, height: 300, useValueListenable: false, // Utiliser les données filtrées directement passagesByType: passagesByType, // Passer les données calculées showAllPassages: isAdmin, userId: isAdmin ? _selectedUserId : currentUserId, excludePassageTypes: const [], // Ne pas exclure "À finaliser" ici, c'est déjà filtré isDesktop: MediaQuery.of(context).size.width > 800, backgroundIcon: Icons.route, backgroundIconColor: AppTheme.primaryColor, backgroundIconOpacity: 0.07, backgroundIconSize: 120, ); } // Calculer la répartition des passages par type depuis les données filtrées Map _calculatePassagesByType() { final counts = {}; for (final passage in _filteredPassages) { final typeId = passage.fkType; counts[typeId] = (counts[typeId] ?? 0) + 1; } return counts; } // Graphique camembert des modes de paiement Widget _buildPaymentSummaryCard() { // Calculer les données filtrées pour le graphique final paymentsByType = _calculatePaymentsByType(); return PaymentSummaryCard( title: isAdmin ? 'Modes de règlement' : 'Mes règlements', titleColor: AppTheme.accentColor, titleIcon: Icons.euro_symbol, height: 300, useValueListenable: false, // Utiliser les données filtrées directement paymentsByType: paymentsByType, // Passer les données calculées showAllPayments: isAdmin, userId: isAdmin ? _selectedUserId : currentUserId, isDesktop: MediaQuery.of(context).size.width > 800, backgroundIcon: Icons.euro_symbol, backgroundIconColor: AppTheme.accentColor, backgroundIconOpacity: 0.07, backgroundIconSize: 120, ); } // Calculer la répartition des montants par type de règlement depuis les données filtrées Map _calculatePaymentsByType() { final amounts = {}; for (final passage in _filteredPassages) { // Récupérer le montant du passage final montantStr = _safeString(passage.montant); final montantDouble = double.tryParse(montantStr) ?? 0.0; // Ne prendre en compte que les passages payés (montant > 0) if (montantDouble > 0.0) { // Utiliser le type de règlement pour catégoriser final typeReglement = passage.fkTypeReglement; amounts[typeReglement] = (amounts[typeReglement] ?? 0.0) + montantDouble; } // Les passages non payés sont ignorés (ne comptent ni dans le total ni dans le graphique) } return amounts; } // Graphique d'évolution temporelle des passages Widget _buildActivityChart() { // Calculer les données d'activité depuis les passages filtrés final activityData = _calculateActivityData(); // Calculer le titre dynamique basé sur la plage de dates final dateRange = _getDateRangeForActivityChart(); String title; if (dateRange != null) { final dateFormat = DateFormat('dd/MM/yyyy'); title = isAdmin ? 'Évolution des passages (${dateFormat.format(dateRange.start)} - ${dateFormat.format(dateRange.end)})' : 'Évolution de mes passages (${dateFormat.format(dateRange.start)} - ${dateFormat.format(dateRange.end)})'; } else { title = isAdmin ? 'Évolution des passages' : 'Évolution de mes passages'; } return Card( elevation: 2, child: ActivityChart( height: 350, useValueListenable: false, // Utiliser les données filtrées directement passageData: activityData, // Passer les données calculées showAllPassages: isAdmin, title: title, daysToShow: 0, // 0 = utiliser toutes les données fournies userId: isAdmin ? _selectedUserId : currentUserId, excludePassageTypes: const [], // Ne pas exclure ici, c'est déjà filtré ), ); } // Obtenir la plage de dates pour le graphique d'activité DateTimeRange? _getDateRangeForActivityChart() { // Si des dates sont explicitement définies par l'utilisateur, les utiliser if (startDate != null || endDate != null) { final start = startDate ?? DateTime.now().subtract(const Duration(days: 90)); final end = endDate ?? DateTime.now(); return DateTimeRange(start: start, end: end); } // Par défaut : utiliser les 90 derniers jours final now = DateTime.now(); return DateTimeRange( start: now.subtract(const Duration(days: 90)), end: now, ); } // Calculer les données d'activité pour le graphique temporel List> _calculateActivityData() { final data = >[]; // Obtenir la plage de dates à afficher (ne peut plus être null avec la nouvelle logique) final dateRange = _getDateRangeForActivityChart(); if (dateRange == null) { // Fallback : retourner des données vides pour les 90 derniers jours final now = DateTime.now(); final dateFormat = DateFormat('yyyy-MM-dd'); for (int i = 89; i >= 0; i--) { final date = now.subtract(Duration(days: i)); data.add({ 'date': dateFormat.format(date), 'type_passage': 0, 'nb': 0, }); } return data; } final dateFormat = DateFormat('yyyy-MM-dd'); final dataByDate = >{}; // Parcourir les passages filtrés pour compter par date et type // Exclure les passages de type 2 (À finaliser) du graphique for (final passage in _filteredPassages) { // Exclure les passages de type 2 if (passage.fkType == 2) { continue; } if (passage.passedAt != null) { // Ne compter que les passages dans la plage de dates if (passage.passedAt!.isBefore(dateRange.start) || passage.passedAt!.isAfter(dateRange.end.add(const Duration(days: 1)))) { continue; } final dateKey = dateFormat.format(passage.passedAt!); if (!dataByDate.containsKey(dateKey)) { dataByDate[dateKey] = {}; } final typeId = passage.fkType; dataByDate[dateKey]![typeId] = (dataByDate[dateKey]![typeId] ?? 0) + 1; } } // Calculer le nombre de jours dans la plage final daysDiff = dateRange.end.difference(dateRange.start).inDays + 1; // Limiter l'affichage à 90 jours maximum pour la lisibilité final daysToShow = daysDiff > 90 ? 90 : daysDiff; final startDate = daysDiff > 90 ? dateRange.end.subtract(Duration(days: 89)) : dateRange.start; // Créer une entrée pour chaque jour de la plage for (int i = 0; i < daysToShow; i++) { final date = startDate.add(Duration(days: i)); final dateKey = dateFormat.format(date); final passagesByType = dataByDate[dateKey] ?? {}; // Ajouter les données pour chaque type de passage présent ce jour (sauf type 2) bool hasDataForDay = false; if (passagesByType.isNotEmpty) { for (final entry in passagesByType.entries) { if (entry.key != 2) { // Double vérification pour exclure type 2 data.add({ 'date': dateKey, 'type_passage': entry.key, 'nb': entry.value, }); hasDataForDay = true; } } } // Si aucune donnée pour ce jour, ajouter une entrée vide pour maintenir la continuité if (!hasDataForDay) { data.add({ 'date': dateKey, 'type_passage': 0, 'nb': 0, }); } } return data; } // Afficher le formulaire de création de passage (users seulement) Future _showPassageFormDialog(BuildContext context) async { await showDialog( context: context, builder: (context) => PassageFormDialog( title: 'Nouveau passage', readOnly: false, passageRepository: passageRepository, userRepository: userRepository, operationRepository: operationRepository, amicaleRepository: amicaleRepository, onSuccess: () { _initializeFilters(); // Recharger les données }, ), ); } // Méthode helper pour convertir une valeur potentiellement null en String String _safeString(dynamic value) { if (value == null) return ''; return value.toString(); } // Convertir les passages en Map pour PassagesListWidget List> _convertPassagesToMaps() { try { // Utiliser les passages filtrés (filtrage déjà appliqué par _notifyFiltersChanged) // Ne PAS filtrer par passedAt ici car les passages "À finaliser" n'ont pas de date var passages = _filteredPassages.toList(); // Convertir chaque passage en Map final maps = >[]; for (final passage in passages) { try { // Construire l'adresse complète final fullAddress = '${_safeString(passage.numero)} ${_safeString(passage.rue)} ${_safeString(passage.ville)}'.trim(); // Convertir le montant en double (normalement un String dans PassageModel) double? montantDouble = 0.0; final montantStr = _safeString(passage.montant); if (montantStr.isNotEmpty) { montantDouble = double.tryParse(montantStr) ?? 0.0; } // Gestion de la date selon le type de passage // Pour les passages "À finaliser" (type 2), la date peut être null DateTime? passageDate = passage.passedAt; final map = { 'id': passage.id, 'fk_operation': passage.fkOperation, 'fk_sector': passage.fkSector, 'fk_user': passage.fkUser, 'fkUser': passage.fkUser, 'type': passage.fkType, 'fk_type': passage.fkType, 'payment': passage.fkTypeReglement, 'fk_type_reglement': passage.fkTypeReglement, 'fk_adresse': _safeString(passage.fkAdresse), 'address': fullAddress.isNotEmpty ? fullAddress : 'Adresse non renseignée', 'date': passageDate, 'datePassage': passageDate, // Ajouter aussi datePassage pour compatibilité 'passed_at': passage.passedAt?.toIso8601String(), 'passedAt': passageDate, // Ajouter aussi passedAt comme DateTime 'numero': _safeString(passage.numero), 'rue': _safeString(passage.rue), 'rue_bis': _safeString(passage.rueBis), 'ville': _safeString(passage.ville), 'residence': _safeString(passage.residence), 'fk_habitat': passage.fkHabitat, 'appt': _safeString(passage.appt), 'niveau': _safeString(passage.niveau), 'gps_lat': _safeString(passage.gpsLat), 'gps_lng': _safeString(passage.gpsLng), 'nom_recu': _safeString(passage.nomRecu), 'remarque': _safeString(passage.remarque), 'notes': _safeString(passage.remarque), 'montant': _safeString(passage.montant), 'amount': montantDouble, 'email_erreur': _safeString(passage.emailErreur), 'nb_passages': passage.nbPassages, 'name': _safeString(passage.name), 'email': _safeString(passage.email), 'phone': _safeString(passage.phone), 'fullAddress': fullAddress, }; maps.add(map); } catch (e) { debugPrint('Erreur lors de la conversion du passage ${passage.id}: $e'); } } debugPrint('${maps.length} passages convertis avec succès'); return maps; } catch (e) { debugPrint('Erreur lors de la conversion des passages en Maps: $e'); return []; } } // Gérer l'édition d'un passage depuis le Map void _handlePassageEditMap(Map passageMap) { final passageId = passageMap['id'] as int; final passage = _originalPassages.firstWhere( (p) => p.id == passageId, orElse: () => PassageModel.fromJson(passageMap), ); // Vérifier les permissions : admin peut tout éditer, user seulement ses propres passages if (isAdmin || passage.fkUser == currentUserId) { _handlePassageEdit(passage); } else { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Vous ne pouvez éditer que vos propres passages'), backgroundColor: Colors.orange, ), ); } } // Gérer la suppression d'un passage depuis le Map void _handlePassageDeleteMap(Map passageMap) { final passageId = passageMap['id'] as int; final passage = _originalPassages.firstWhere( (p) => p.id == passageId, orElse: () => PassageModel.fromJson(passageMap), ); // Vérifier les permissions : admin peut tout supprimer, user seulement ses propres passages si autorisé if (isAdmin || (canDeletePassages && passage.fkUser == currentUserId)) { _handlePassageDelete(passage); } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( !canDeletePassages ? 'Vous n\'êtes pas autorisé à supprimer des passages' : 'Vous ne pouvez supprimer que vos propres passages' ), backgroundColor: Colors.orange, ), ); } } // Sauvegarder les filtres dans Hive // NOTE: Méthode non utilisée pour le moment - conservée pour référence future // void _saveFiltersToHive() async { // try { // if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) { // await Hive.openBox(AppKeys.settingsBoxName); // } // final settingsBox = Hive.box(AppKeys.settingsBoxName); // // // Sauvegarder tous les filtres // await settingsBox.put('history_selectedSectorId', selectedSectorId); // if (isAdmin) { // await settingsBox.put('history_selectedMemberId', selectedMemberId); // } // await settingsBox.put('history_selectedTypeId', selectedTypeId); // await settingsBox.put('history_selectedPaymentTypeId', selectedPaymentTypeId); // // // Sauvegarder les dates // if (startDate != null) { // await settingsBox.put('history_startDate', startDate!.millisecondsSinceEpoch); // } else { // await settingsBox.delete('history_startDate'); // } // if (endDate != null) { // await settingsBox.put('history_endDate', endDate!.millisecondsSinceEpoch); // } else { // await settingsBox.delete('history_endDate'); // } // // debugPrint('Filtres sauvegardés dans Hive'); // } catch (e) { // debugPrint('Erreur lors de la sauvegarde des filtres: $e'); // } // } // Gérer l'édition d'un passage void _handlePassageEdit(PassageModel passage) async { await showDialog( context: context, builder: (context) => PassageFormDialog( passage: passage, title: 'Modifier le passage', readOnly: false, passageRepository: passageRepository, userRepository: userRepository, operationRepository: operationRepository, amicaleRepository: amicaleRepository, onSuccess: () { _initializeFilters(); // Recharger les données }, ), ); } // Gérer la suppression d'un passage void _handlePassageDelete(PassageModel passage) async { final confirm = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Confirmer la suppression'), content: Text( 'Êtes-vous sûr de vouloir supprimer le passage chez ${passage.name} ?', ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('Annuler'), ), TextButton( onPressed: () => Navigator.of(context).pop(true), style: TextButton.styleFrom(foregroundColor: Colors.red), child: const Text('Supprimer'), ), ], ), ); if (confirm == true) { try { await passageRepository.deletePassage(passage.id); _initializeFilters(); // Recharger les données if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Passage supprimé avec succès'), backgroundColor: Colors.green, ), ); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Erreur lors de la suppression: $e'), backgroundColor: Colors.red, ), ); } } } } }