- 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>
1787 lines
67 KiB
Dart
1787 lines
67 KiB
Dart
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/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:geosector_app/presentation/widgets/btn_passages.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<HistoryContent> 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<HistoryContent> with SingleTickerProviderStateMixin {
|
|
// Détection du rôle et permissions
|
|
late final bool isAdmin;
|
|
late final int currentUserId; // users.id (table centrale)
|
|
late final int? currentOpeUserId; // ope_users.id (pour comparaisons avec passages)
|
|
late final bool canDeletePassages; // Permission de suppression pour les users
|
|
|
|
// TabController pour les onglets Filtres / Statistiques
|
|
late TabController _tabController;
|
|
|
|
// 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<SectorModel> _sectors = [];
|
|
List<MembreModel> _membres = []; // Liste des membres de l'opération
|
|
|
|
// Passages originaux pour l'édition
|
|
List<PassageModel> _originalPassages = [];
|
|
List<PassageModel> _filteredPassages = [];
|
|
|
|
// État de chargement
|
|
bool _isLoading = true;
|
|
String _errorMessage = '';
|
|
|
|
// État de la section graphiques
|
|
bool _isGraphicsExpanded = true;
|
|
|
|
// Hauteur dynamique du TabBarView selon l'onglet actif
|
|
double _tabBarViewHeight = 280.0; // Hauteur par défaut (Filtres)
|
|
|
|
// Onglet précédemment sélectionné (pour détecter les clics sur le même onglet)
|
|
int _previousTabIndex = 0;
|
|
|
|
// Listener pour les changements de secteur depuis map_page
|
|
late final Box _settingsBox;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
// Initialiser le TabController (2 onglets)
|
|
_tabController = TabController(length: 2, vsync: this);
|
|
|
|
// 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;
|
|
currentOpeUserId = currentUser?.opeUserId; // ID dans ope_users pour comparaisons avec passages
|
|
|
|
// 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 {
|
|
// Pour tous les autres cas (admin et user), charger les filtres depuis Hive
|
|
_loadPreselectedFilters();
|
|
|
|
// Pour un user standard, toujours filtrer sur son propre ID
|
|
if (!isAdmin) {
|
|
selectedMemberId = currentUserId;
|
|
}
|
|
}
|
|
|
|
_initializeNewFilters();
|
|
_initializeFilters();
|
|
_updateDateControllers();
|
|
_loadGraphicsExpandedState();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_tabController.dispose();
|
|
_startDateController.dispose();
|
|
_endDateController.dispose();
|
|
_searchController.dispose();
|
|
// Pas besoin de fermer _settingsBox car c'est une box partagée
|
|
super.dispose();
|
|
}
|
|
|
|
// Callback pour gérer les clics sur les onglets
|
|
void _onTabTapped(int index) {
|
|
setState(() {
|
|
// Si on clique sur le même onglet alors que l'ExpansionTile est ouvert → le fermer
|
|
if (index == _previousTabIndex && _isGraphicsExpanded) {
|
|
_isGraphicsExpanded = false;
|
|
_saveGraphicsExpandedState();
|
|
}
|
|
// Sinon, ouvrir l'ExpansionTile et ajuster la hauteur
|
|
else {
|
|
if (!_isGraphicsExpanded) {
|
|
_isGraphicsExpanded = true;
|
|
_saveGraphicsExpandedState();
|
|
}
|
|
// Onglet 0 = Filtres (hauteur plus petite)
|
|
// Onglet 1 = Statistiques (hauteur plus grande)
|
|
_tabBarViewHeight = index == 0 ? 280.0 : 800.0;
|
|
}
|
|
|
|
_previousTabIndex = index;
|
|
});
|
|
}
|
|
|
|
// Callback pour gérer les clics sur les boutons de type de passage
|
|
void _handleTypeSelected(int? typeId) {
|
|
setState(() {
|
|
// Réinitialiser tous les filtres
|
|
_selectedPaymentFilter = 'Tous les règlements';
|
|
_selectedPaymentTypeId = null;
|
|
selectedPaymentTypeId = null;
|
|
startDate = null;
|
|
endDate = null;
|
|
_startDateController.clear();
|
|
_endDateController.clear();
|
|
_searchQuery = '';
|
|
_searchController.clear();
|
|
_selectedSectorId = null;
|
|
selectedSectorId = null;
|
|
if (isAdmin) {
|
|
_selectedUserId = null;
|
|
selectedMemberId = null;
|
|
}
|
|
|
|
// Appliquer le filtre de type
|
|
if (typeId == null) {
|
|
// Tous les passages
|
|
_selectedTypeFilter = 'Tous les types';
|
|
selectedTypeId = null;
|
|
} else {
|
|
// Type spécifique
|
|
selectedTypeId = typeId;
|
|
final typeInfo = AppKeys.typesPassages[typeId];
|
|
if (typeInfo != null) {
|
|
_selectedTypeFilter = typeInfo['titre'] as String;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Appliquer les filtres
|
|
_notifyFiltersChanged();
|
|
}
|
|
|
|
// Initialiser le listener pour les changements de secteur
|
|
Future<void> _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<String> _getFilteredPassageTypes() {
|
|
final showLotType = _shouldShowLotType();
|
|
final types = <String>[];
|
|
|
|
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<PassageModel> 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 section TabBar + ExpansionTile
|
|
Widget _buildTabBarSection() {
|
|
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(
|
|
key: ValueKey('expansion_tile_$_isGraphicsExpanded'),
|
|
initiallyExpanded: _isGraphicsExpanded,
|
|
trailing: const SizedBox.shrink(), // Masquer la flèche d'expansion
|
|
onExpansionChanged: (expanded) {
|
|
setState(() {
|
|
_isGraphicsExpanded = expanded;
|
|
// Réinitialiser _previousTabIndex quand on ferme manuellement
|
|
// pour permettre de rouvrir en cliquant sur l'onglet actif
|
|
if (!expanded) {
|
|
_previousTabIndex = -1;
|
|
}
|
|
});
|
|
_saveGraphicsExpandedState();
|
|
},
|
|
tilePadding: EdgeInsets.zero,
|
|
childrenPadding: EdgeInsets.zero,
|
|
title: Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
|
boxShadow: AppTheme.cardShadow,
|
|
),
|
|
child: TabBar(
|
|
controller: _tabController,
|
|
labelColor: AppTheme.primaryColor,
|
|
unselectedLabelColor: Colors.grey[600],
|
|
indicatorColor: AppTheme.primaryColor,
|
|
indicatorWeight: 3,
|
|
onTap: _onTabTapped,
|
|
tabs: const [
|
|
Tab(
|
|
icon: Icon(Icons.filter_list, size: 20),
|
|
text: 'Filtres',
|
|
),
|
|
Tab(
|
|
icon: Icon(Icons.analytics_outlined, size: 20),
|
|
text: 'Statistiques',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
children: [
|
|
AnimatedContainer(
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeInOut,
|
|
height: _tabBarViewHeight,
|
|
child: TabBarView(
|
|
controller: _tabController,
|
|
children: [
|
|
// Onglet 1 : Filtres
|
|
_buildFiltersContent(),
|
|
// Onglet 2 : Statistiques
|
|
_buildGraphicsContent(),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Construire le contenu des filtres (ancien _buildFiltersCard sans la Card)
|
|
Widget _buildFiltersContent() {
|
|
final screenWidth = MediaQuery.of(context).size.width;
|
|
final isDesktop = screenWidth > 800;
|
|
|
|
return SingleChildScrollView(
|
|
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<String>(
|
|
value: _selectedTypeFilter,
|
|
decoration: const InputDecoration(
|
|
border: OutlineInputBorder(),
|
|
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
isDense: true,
|
|
),
|
|
items: [
|
|
const DropdownMenuItem<String>(
|
|
value: 'Tous les types',
|
|
child: Text('Passage'),
|
|
),
|
|
..._getFilteredPassageTypes().map((String type) {
|
|
return DropdownMenuItem<String>(
|
|
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<String>(
|
|
value: _selectedPaymentFilter,
|
|
decoration: const InputDecoration(
|
|
border: OutlineInputBorder(),
|
|
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
isDense: true,
|
|
),
|
|
items: [
|
|
const DropdownMenuItem<String>(
|
|
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<String>(
|
|
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<Box<SectorModel>>(
|
|
valueListenable: Hive.box<SectorModel>(AppKeys.sectorsBoxName).listenable(),
|
|
builder: (context, sectorsBox, child) {
|
|
final sectors = sectorsBox.values.toList();
|
|
|
|
return DropdownButtonFormField<int?>(
|
|
value: _selectedSectorId,
|
|
decoration: const InputDecoration(
|
|
border: OutlineInputBorder(),
|
|
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
isDense: true,
|
|
),
|
|
items: [
|
|
const DropdownMenuItem<int?>(
|
|
value: null,
|
|
child: Text('Secteurs'),
|
|
),
|
|
...sectors.map((SectorModel sector) {
|
|
return DropdownMenuItem<int?>(
|
|
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: DropdownButtonFormField<int?>(
|
|
value: _selectedUserId,
|
|
decoration: const InputDecoration(
|
|
border: OutlineInputBorder(),
|
|
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
isDense: true,
|
|
),
|
|
items: [
|
|
const DropdownMenuItem<int?>(
|
|
value: null,
|
|
child: Text('Membres'),
|
|
),
|
|
..._membres.map((MembreModel membre) {
|
|
return DropdownMenuItem<int?>(
|
|
value: membre.opeUserId,
|
|
child: Text('${membre.firstName ?? ''} ${membre.name ?? ''}${membre.sectName != null && membre.sectName!.isNotEmpty ? ' (${membre.sectName})' : ''}'),
|
|
);
|
|
}),
|
|
],
|
|
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;
|
|
_selectedUserId = memberId; // Synchroniser avec le nouveau filtre
|
|
});
|
|
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;
|
|
_selectedSectorId = sectorId; // Synchroniser avec le nouveau filtre
|
|
});
|
|
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) {
|
|
// Réinitialiser TOUS les filtres avant d'appliquer le type
|
|
setState(() {
|
|
// Réinitialiser les filtres de type et paiement
|
|
_selectedPaymentFilter = 'Tous les règlements';
|
|
_selectedPaymentTypeId = null;
|
|
selectedPaymentTypeId = null;
|
|
|
|
// Réinitialiser les dates
|
|
startDate = null;
|
|
endDate = null;
|
|
_startDateController.clear();
|
|
_endDateController.clear();
|
|
|
|
// Réinitialiser la recherche
|
|
_searchQuery = '';
|
|
_searchController.clear();
|
|
|
|
// Réinitialiser le secteur
|
|
_selectedSectorId = null;
|
|
selectedSectorId = null;
|
|
|
|
// Réinitialiser le membre (admin seulement)
|
|
if (isAdmin) {
|
|
_selectedUserId = null;
|
|
selectedMemberId = null;
|
|
}
|
|
|
|
// Appliquer le type de passage sélectionné
|
|
selectedTypeId = typeId;
|
|
final typeInfo = AppKeys.typesPassages[typeId];
|
|
selectedType = typeInfo != null ? typeInfo['titre'] as String : 'Inconnu';
|
|
// Synchroniser avec le nouveau filtre
|
|
if (typeInfo != null) {
|
|
_selectedTypeFilter = typeInfo['titre'] as String;
|
|
}
|
|
});
|
|
|
|
// Supprimer le typeId de Hive après l'avoir utilisé
|
|
settingsBox.delete('history_selectedTypeId');
|
|
|
|
debugPrint('HistoryPage: Type de passage présélectionné: $typeId (tous les autres filtres réinitialisés)');
|
|
}
|
|
|
|
// Charger le type de règlement sélectionné
|
|
final paymentTypeId = settingsBox.get('history_selectedPaymentTypeId');
|
|
if (paymentTypeId != null && paymentTypeId is int) {
|
|
setState(() {
|
|
selectedPaymentTypeId = paymentTypeId;
|
|
_selectedPaymentTypeId = paymentTypeId; // Synchroniser avec le nouveau filtre
|
|
// Mettre à jour aussi le label du filtre
|
|
final paymentInfo = AppKeys.typesReglements[paymentTypeId];
|
|
if (paymentInfo != null) {
|
|
_selectedPaymentFilter = paymentInfo['titre'] as String;
|
|
}
|
|
});
|
|
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();
|
|
}
|
|
debugPrint('Nombre de secteurs récupérés: ${_sectors.length}');
|
|
|
|
// Charger les membres de l'opération (admin seulement)
|
|
if (isAdmin) {
|
|
// Charger directement depuis MembreModel (déjà unique, pas de déduplication nécessaire)
|
|
final membreBox = Hive.box<MembreModel>(AppKeys.membresBoxName);
|
|
_membres = membreBox.values.whereType<MembreModel>().toList();
|
|
debugPrint('Nombre de membres de l\'opération récupérés: ${_membres.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 == currentOpeUserId;
|
|
}).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');
|
|
}
|
|
}
|
|
|
|
@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() {
|
|
final screenWidth = MediaQuery.of(context).size.width;
|
|
final isDesktop = screenWidth > 800;
|
|
|
|
return Padding(
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal: isDesktop ? AppTheme.spacingL : AppTheme.spacingS,
|
|
vertical: AppTheme.spacingL,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// 0. BtnPassages
|
|
BtnPassages(
|
|
onTypeSelected: _handleTypeSelected,
|
|
selectedTypeId: selectedTypeId,
|
|
),
|
|
const SizedBox(height: AppTheme.spacingL),
|
|
|
|
// 1. TabBar + ExpansionTile (Filtres / Statistiques) - FIXE EN HAUT
|
|
_buildTabBarSection(),
|
|
|
|
SizedBox(height: _isGraphicsExpanded ? 8 : 16),
|
|
|
|
// 2. Liste des passages - EXPANDED pour prendre tout l'espace restant
|
|
Expanded(
|
|
child: Card(
|
|
elevation: 2,
|
|
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);
|
|
},
|
|
filteredPassageType: _selectedTypeFilter != 'Tous les types' ? _selectedTypeFilter : null,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// Contenu des statistiques adaptatif selon la taille d'écran
|
|
Widget _buildGraphicsContent() {
|
|
final screenWidth = MediaQuery.of(context).size.width;
|
|
final isDesktop = screenWidth > 800;
|
|
|
|
return SingleChildScrollView(
|
|
child: 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<int, int> _calculatePassagesByType() {
|
|
final counts = <int, int>{};
|
|
|
|
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<int, double> _calculatePaymentsByType() {
|
|
final amounts = <int, double>{};
|
|
|
|
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<Map<String, dynamic>> _calculateActivityData() {
|
|
final data = <Map<String, dynamic>>[];
|
|
|
|
// 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 = <String, Map<int, int>>{};
|
|
|
|
// 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<void> _showPassageFormDialog(BuildContext context) async {
|
|
await showDialog<PassageModel>(
|
|
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<Map<String, dynamic>> _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 = <Map<String, dynamic>>[];
|
|
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 = <String, dynamic>{
|
|
'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<String, dynamic> 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 peut éditer ses propres passages
|
|
// - Type 2 (À finaliser) : éditable par tous les utilisateurs
|
|
if (isAdmin || passage.fkUser == currentOpeUserId || passage.fkType == 2) {
|
|
_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<String, dynamic> 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 == currentOpeUserId)) {
|
|
_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<PassageModel>(
|
|
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<bool>(
|
|
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,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} |