- Mise à jour VERSION vers 3.3.4 - Optimisations et révisions architecture API (deploy-api.sh, scripts de migration) - Ajout documentation Stripe Tap to Pay complète - Migration vers polices Inter Variable pour Flutter - Optimisations build Android et nettoyage fichiers temporaires - Amélioration système de déploiement avec gestion backups - Ajout scripts CRON et migrations base de données 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1737 lines
65 KiB
Dart
1737 lines
65 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/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<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> {
|
|
// 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<SectorModel> _sectors = [];
|
|
List<MembreModel> _membres = [];
|
|
List<UserModel> _users = []; // Liste des users pour le filtre
|
|
|
|
// Passages originaux pour l'édition
|
|
List<PassageModel> _originalPassages = [];
|
|
List<PassageModel> _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<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 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<String>(
|
|
initialValue: _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>(
|
|
initialValue: _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?>(
|
|
initialValue: _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: ValueListenableBuilder<Box<UserModel>>(
|
|
valueListenable: Hive.box<UserModel>(AppKeys.userBoxName).listenable(),
|
|
builder: (context, usersBox, child) {
|
|
final users = usersBox.values.where((user) => user.role == 1).toList();
|
|
|
|
return DropdownButtonFormField<int?>(
|
|
initialValue: _selectedUserId,
|
|
decoration: const InputDecoration(
|
|
border: OutlineInputBorder(),
|
|
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
isDense: true,
|
|
),
|
|
items: [
|
|
const DropdownMenuItem<int?>(
|
|
value: null,
|
|
child: Text('Membres'),
|
|
),
|
|
...users.map((UserModel user) {
|
|
return DropdownMenuItem<int?>(
|
|
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 = <int>{};
|
|
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<UserModel> _convertMembresToUsers() {
|
|
final users = <UserModel>[];
|
|
|
|
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<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 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<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 == 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<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,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} |