Livraison d ela gestion des opérations v0.4.0

This commit is contained in:
d6soft
2025-06-24 13:01:43 +02:00
parent b9672a6228
commit 7763d02fae
819 changed files with 306790 additions and 145462 deletions

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/core/services/api_service.dart';
import 'dart:math' as math;
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
import 'package:geosector_app/core/data/models/membre_model.dart';
@@ -16,29 +15,6 @@ import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/repositories/passage_repository.dart';
import 'package:geosector_app/core/repositories/operation_repository.dart';
/// Class pour dessiner les petits points blancs sur le fond
class DotsPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.white.withOpacity(0.5)
..style = PaintingStyle.fill;
final random = math.Random(42); // Seed fixe pour consistance
final numberOfDots = (size.width * size.height) ~/ 1500;
for (int i = 0; i < numberOfDots; i++) {
final x = random.nextDouble() * size.width;
final y = random.nextDouble() * size.height;
final radius = 1.0 + random.nextDouble() * 2.0;
canvas.drawCircle(Offset(x, y), radius, paint);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
/// Page d'administration de l'amicale et des membres
/// Cette page est intégrée dans le tableau de bord administrateur
class AdminAmicalePage extends StatefulWidget {
@@ -571,128 +547,165 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Stack(
children: [
// Fond dégradé avec petits points blancs
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.white, Colors.red.shade300],
return SafeArea(
child:
// Contenu principal
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre de la page
Text(
'Mon amicale et ses membres',
style: theme.textTheme.headlineMedium?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
child: CustomPaint(
painter: DotsPainter(),
child: const SizedBox(width: double.infinity, height: double.infinity),
),
),
// Contenu principal
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre de la page
Text(
'Mon amicale et ses membres',
style: theme.textTheme.headlineMedium?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
const SizedBox(height: 24),
// Message d'erreur si présent
if (_errorMessage != null)
Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.withOpacity(0.3)),
),
child: Row(
children: [
const Icon(Icons.error_outline, color: Colors.red),
const SizedBox(width: 12),
Expanded(
child: Text(
_errorMessage!,
style: const TextStyle(color: Colors.red),
),
),
],
),
),
const SizedBox(height: 24),
// Message d'erreur si présent
if (_errorMessage != null)
Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.withOpacity(0.3)),
),
child: Row(
children: [
const Icon(Icons.error_outline, color: Colors.red),
const SizedBox(width: 12),
Expanded(
child: Text(
_errorMessage!,
style: const TextStyle(color: Colors.red),
// Contenu principal avec ValueListenableBuilder
if (_currentUser != null && _currentUser!.fkEntite != null)
Expanded(
child: ValueListenableBuilder<Box<AmicaleModel>>(
valueListenable: widget.amicaleRepository.getAmicalesBox().listenable(),
builder: (context, amicalesBox, child) {
debugPrint('🔍 AmicalesBox - Nombre d\'amicales: ${amicalesBox.length}');
debugPrint('🔍 AmicalesBox - Clés disponibles: ${amicalesBox.keys.toList()}');
debugPrint('🔍 Recherche amicale avec fkEntite: ${_currentUser!.fkEntite}');
final amicale = amicalesBox.get(_currentUser!.fkEntite!);
debugPrint('🔍 Amicale récupérée: ${amicale?.name ?? 'AUCUNE'}');
if (amicale == null) {
// Ajouter plus d'informations de debug
debugPrint('❌ PROBLÈME: Amicale non trouvée');
debugPrint('❌ fkEntite recherché: ${_currentUser!.fkEntite}');
debugPrint('❌ Contenu de la box: ${amicalesBox.values.map((a) => '${a.id}: ${a.name}').join(', ')}');
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.business_outlined,
size: 64,
color: theme.colorScheme.primary.withOpacity(0.7),
),
const SizedBox(height: 16),
Text(
'Amicale non trouvée',
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'L\'amicale associée à votre compte n\'existe plus.\nfkEntite: ${_currentUser!.fkEntite}',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
],
),
),
],
),
),
);
}
// Contenu principal avec ValueListenableBuilder
if (_currentUser != null && _currentUser!.fkEntite != null)
Expanded(
child: ValueListenableBuilder<Box<AmicaleModel>>(
valueListenable: widget.amicaleRepository.getAmicalesBox().listenable(),
builder: (context, amicalesBox, child) {
debugPrint('🔍 AmicalesBox - Nombre d\'amicales: ${amicalesBox.length}');
debugPrint('🔍 AmicalesBox - Clés disponibles: ${amicalesBox.keys.toList()}');
debugPrint('🔍 Recherche amicale avec fkEntite: ${_currentUser!.fkEntite}');
return ValueListenableBuilder<Box<MembreModel>>(
valueListenable: widget.membreRepository.getMembresBox().listenable(),
builder: (context, membresBox, child) {
// Filtrer les membres par amicale
// Note: Il faudra ajouter le champ fkEntite au modèle MembreModel
final membres = membresBox.values.where((membre) => membre.fkEntite == _currentUser!.fkEntite).toList();
final amicale = amicalesBox.get(_currentUser!.fkEntite!);
debugPrint('🔍 Amicale récupérée: ${amicale?.name ?? 'AUCUNE'}');
if (amicale == null) {
// Ajouter plus d'informations de debug
debugPrint('❌ PROBLÈME: Amicale non trouvée');
debugPrint('❌ fkEntite recherché: ${_currentUser!.fkEntite}');
debugPrint('❌ Contenu de la box: ${amicalesBox.values.map((a) => '${a.id}: ${a.name}').join(', ')}');
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.business_outlined,
size: 64,
color: theme.colorScheme.primary.withOpacity(0.7),
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section Amicale
Text(
'Informations de l\'amicale',
style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
const SizedBox(height: 16),
Text(
'Amicale non trouvée',
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'L\'amicale associée à votre compte n\'existe plus.\nfkEntite: ${_currentUser!.fkEntite}',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
],
),
);
}
),
const SizedBox(height: 16),
return ValueListenableBuilder<Box<MembreModel>>(
valueListenable: widget.membreRepository.getMembresBox().listenable(),
builder: (context, membresBox, child) {
// Filtrer les membres par amicale
// Note: Il faudra ajouter le champ fkEntite au modèle MembreModel
final membres = membresBox.values.where((membre) => membre.fkEntite == _currentUser!.fkEntite).toList();
// Tableau Amicale
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: AmicaleTableWidget(
amicales: [amicale],
onEdit: null,
onDelete: null,
amicaleRepository: widget.amicaleRepository,
userRepository: widget.userRepository,
apiService: ApiService.instance,
showActionsColumn: false,
),
),
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section Amicale
Text(
'Informations de l\'amicale',
style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
const SizedBox(height: 32),
// Section Membres
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Membres de l\'amicale (${membres.length})',
style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _handleAddMembre,
icon: const Icon(Icons.add),
label: const Text('Ajouter un membre'),
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: Colors.white,
),
),
],
),
const SizedBox(height: 16),
// Tableau Amicale
Container(
// Tableau Membres
Expanded(
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
@@ -704,84 +717,32 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
),
],
),
child: AmicaleTableWidget(
amicales: [amicale],
onEdit: null,
onDelete: null,
amicaleRepository: widget.amicaleRepository,
userRepository: widget.userRepository,
apiService: ApiService.instance,
showActionsColumn: false,
child: MembreTableWidget(
membres: membres,
onEdit: _handleEditMembre,
onDelete: _handleDeleteMembre,
membreRepository: widget.membreRepository,
),
),
const SizedBox(height: 32),
// Section Membres
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Membres de l\'amicale (${membres.length})',
style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
ElevatedButton.icon(
onPressed: _handleAddMembre,
icon: const Icon(Icons.add),
label: const Text('Ajouter un membre'),
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: Colors.white,
),
),
],
),
const SizedBox(height: 16),
// Tableau Membres
Expanded(
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: MembreTableWidget(
membres: membres,
onEdit: _handleEditMembre,
onDelete: _handleDeleteMembre,
membreRepository: widget.membreRepository,
),
),
),
],
);
},
);
},
),
),
],
);
},
);
},
),
),
// Message si pas d'utilisateur connecté
if (_currentUser == null)
const Expanded(
child: Center(
child: CircularProgressIndicator(),
),
// Message si pas d'utilisateur connecté
if (_currentUser == null)
const Expanded(
child: Center(
child: CircularProgressIndicator(),
),
],
),
),
],
),
],
),
);
}
}

View File

@@ -2,12 +2,8 @@ import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'dart:math' as math;
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/presentation/widgets/sector_distribution_card.dart';
import 'package:geosector_app/presentation/widgets/charts/charts.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/data/models/operation_model.dart';
import 'package:geosector_app/core/data/models/sector_model.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
@@ -35,7 +31,7 @@ class DotsPainter extends CustomPainter {
}
class AdminDashboardHomePage extends StatefulWidget {
const AdminDashboardHomePage({Key? key}) : super(key: key);
const AdminDashboardHomePage({super.key});
@override
State<AdminDashboardHomePage> createState() => _AdminDashboardHomePageState();
@@ -54,127 +50,10 @@ class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
List<PaymentData> paymentData = [];
Map<int, int> passagesByType = {};
// Future pour initialiser les boîtes Hive
late Future<void> _initFuture;
@override
void initState() {
super.initState();
// Initialiser les boîtes Hive avant de charger les données
_initFuture = _initHiveBoxes().then((_) {
// Charger les données une fois les boîtes initialisées
_loadDashboardData();
// Après l'affichage des logs "VERIFICATION FINALE DES DONNEES",
// attendre un court délai puis rafraîchir automatiquement les données
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) {
setState(() {
isLoading = true; // Afficher le spinner pendant le rafraîchissement
});
_loadDashboardData(); // Rafraîchir les données
}
});
});
}
// Méthode pour initialiser les boîtes Hive nécessaires
Future<void> _initHiveBoxes() async {
try {
debugPrint('AdminDashboardHomePage: Initialisation des boîtes Hive...');
// Liste des boîtes à ouvrir
final boxesToOpen = [
{
'name': AppKeys.operationsBoxName,
'type': 'OperationModel',
'opened': false
},
{
'name': AppKeys.passagesBoxName,
'type': 'PassageModel',
'opened': false
},
{
'name': AppKeys.sectorsBoxName,
'type': 'SectorModel',
'opened': false
},
];
// Ouvrir chaque boîte
for (final boxInfo in boxesToOpen) {
final boxName = boxInfo['name'] as String;
if (!Hive.isBoxOpen(boxName)) {
debugPrint(
'AdminDashboardHomePage: Ouverture de la boîte $boxName...');
try {
switch (boxInfo['type']) {
case 'OperationModel':
await Hive.openBox<OperationModel>(boxName);
break;
case 'PassageModel':
await Hive.openBox<PassageModel>(boxName);
break;
case 'SectorModel':
await Hive.openBox<SectorModel>(boxName);
break;
}
boxInfo['opened'] = true;
debugPrint(
'AdminDashboardHomePage: Boîte $boxName ouverte avec succès');
} catch (boxError) {
debugPrint(
'AdminDashboardHomePage: Erreur lors de l\'ouverture de la boîte $boxName: $boxError');
// Continuer malgré l'erreur
}
} else {
boxInfo['opened'] = true;
debugPrint('AdminDashboardHomePage: Boîte $boxName déjà ouverte');
}
}
// Vérifier si toutes les boîtes ont été ouvertes
final allBoxesOpened = boxesToOpen.every((box) => box['opened'] == true);
if (allBoxesOpened) {
debugPrint(
'AdminDashboardHomePage: Toutes les boîtes Hive ont été ouvertes avec succès');
} else {
// Identifier les boîtes qui n'ont pas pu être ouvertes
final failedBoxes = boxesToOpen
.where((box) => box['opened'] == false)
.map((box) => box['name'])
.join(', ');
debugPrint(
'AdminDashboardHomePage: Certaines boîtes n\'ont pas pu être ouvertes: $failedBoxes');
}
// Afficher le nombre d'éléments dans chaque boîte
debugPrint('VERIFICATION FINALE DES DONNEES');
if (Hive.isBoxOpen(AppKeys.operationsBoxName)) {
final operationsBox =
Hive.box<OperationModel>(AppKeys.operationsBoxName);
debugPrint('Nombre d\'opérations: ${operationsBox.length}');
}
if (Hive.isBoxOpen(AppKeys.passagesBoxName)) {
final passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
debugPrint('Nombre de passages: ${passagesBox.length}');
}
if (Hive.isBoxOpen(AppKeys.sectorsBoxName)) {
final sectorsBox = Hive.box<SectorModel>(AppKeys.sectorsBoxName);
debugPrint('Nombre de secteurs: ${sectorsBox.length}');
}
debugPrint(
'AdminDashboardHomePage: Initialisation des boîtes Hive terminée');
} catch (e) {
debugPrint(
'AdminDashboardHomePage: Erreur lors de l\'initialisation des boîtes Hive: $e');
// Ne pas propager l'erreur, mais retourner normalement
// pour éviter que le FutureBuilder ne reste bloqué en état d'erreur
}
_loadDashboardData();
}
/// Prépare les données pour le graphique de paiement
@@ -192,9 +71,7 @@ class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
// Calculer les montants par type de règlement
for (final passage in passages) {
if (passage.fkTypeReglement != null &&
passage.montant != null &&
passage.montant.isNotEmpty) {
if (passage.fkTypeReglement != null && passage.montant != null && passage.montant.isNotEmpty) {
final typeId = passage.fkTypeReglement;
final amount = double.tryParse(passage.montant) ?? 0.0;
paymentAmounts[typeId] = (paymentAmounts[typeId] ?? 0.0) + amount;
@@ -224,61 +101,25 @@ class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
}
try {
debugPrint(
'AdminDashboardHomePage: Chargement des données du tableau de bord...');
debugPrint('AdminDashboardHomePage: Chargement des données du tableau de bord...');
// Utiliser les instances globales définies dans app.dart
// Pas besoin de Provider.of car les instances sont déjà disponibles
// S'assurer que la boîte des opérations est ouverte avant d'y accéder
OperationModel? currentOperation;
try {
// Vérifier si la boîte Hive est ouverte
if (!Hive.isBoxOpen(AppKeys.operationsBoxName)) {
debugPrint(
'AdminDashboardHomePage: Ouverture de la boîte operations dans _loadDashboardData...');
try {
await Hive.openBox<OperationModel>(AppKeys.operationsBoxName);
debugPrint(
'AdminDashboardHomePage: Boîte operations ouverte avec succès dans _loadDashboardData');
} catch (boxError) {
debugPrint(
'AdminDashboardHomePage: Erreur lors de l\'ouverture de la boîte operations dans _loadDashboardData: $boxError');
// Continuer malgré l'erreur
}
}
// Récupérer l'opération en cours
debugPrint(
'AdminDashboardHomePage: Récupération de l\'opération en cours...');
currentOperation = userRepository.getCurrentOperation();
debugPrint(
'AdminDashboardHomePage: Opération récupérée: ${currentOperation?.id ?? "null"}');
} catch (boxError) {
debugPrint(
'AdminDashboardHomePage: Erreur lors de la récupération de l\'opération: $boxError');
// Afficher un message d'erreur ou gérer l'erreur de manière appropriée
}
// Récupérer l'opération en cours (les boxes sont déjà ouvertes par SplashPage)
final currentOperation = userRepository.getCurrentOperation();
debugPrint('AdminDashboardHomePage: Opération récupérée: ${currentOperation?.id ?? "null"}');
if (currentOperation != null) {
// Charger les passages pour l'opération en cours
debugPrint(
'AdminDashboardHomePage: Chargement des passages pour l\'opération ${currentOperation.id}...');
final passages =
passageRepository.getPassagesByOperation(currentOperation.id);
debugPrint(
'AdminDashboardHomePage: ${passages.length} passages récupérés');
debugPrint('AdminDashboardHomePage: Chargement des passages pour l\'opération ${currentOperation.id}...');
final passages = passageRepository.getPassagesByOperation(currentOperation.id);
debugPrint('AdminDashboardHomePage: ${passages.length} passages récupérés');
// Calculer le nombre total de passages
totalPassages = passages.length;
// Calculer le montant total collecté
totalAmounts = passages.fold(
0.0,
(sum, passage) =>
sum +
(passage.montant.isNotEmpty
? double.tryParse(passage.montant) ?? 0.0
: 0.0));
totalAmounts = passages.fold(0.0, (sum, passage) => sum + (passage.montant.isNotEmpty ? double.tryParse(passage.montant) ?? 0.0 : 0.0));
// Préparer les données pour le graphique de paiement
_preparePaymentData(passages);
@@ -295,8 +136,7 @@ class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
passagesByType.forEach((typeId, count) {
final typeInfo = AppKeys.typesPassages[typeId];
final typeName = typeInfo != null ? typeInfo['titre'] : 'Inconnu';
debugPrint(
'AdminDashboardHomePage: Type $typeId ($typeName): $count passages');
debugPrint('AdminDashboardHomePage: Type $typeId ($typeName): $count passages');
});
// Charger les statistiques par membre
@@ -305,8 +145,7 @@ class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
// Compter les passages par membre
for (final passage in passages) {
memberCounts[passage.fkUser] =
(memberCounts[passage.fkUser] ?? 0) + 1;
memberCounts[passage.fkUser] = (memberCounts[passage.fkUser] ?? 0) + 1;
}
// Récupérer les informations des membres
@@ -321,11 +160,9 @@ class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
}
// Trier les membres par nombre de passages (décroissant)
memberStats
.sort((a, b) => (b['count'] as int).compareTo(a['count'] as int));
memberStats.sort((a, b) => (b['count'] as int).compareTo(a['count'] as int));
} else {
debugPrint(
'AdminDashboardHomePage: Aucune opération en cours, impossible de charger les passages');
debugPrint('AdminDashboardHomePage: Aucune opération en cours, impossible de charger les passages');
}
if (mounted) {
@@ -340,8 +177,7 @@ class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
debugPrint(
'AdminDashboardHomePage: Données chargées: isDataLoaded=$isDataLoaded, totalPassages=$totalPassages, passagesByType=${passagesByType.length} types');
} catch (e) {
debugPrint(
'AdminDashboardHomePage: Erreur lors du chargement des données: $e');
debugPrint('AdminDashboardHomePage: Erreur lors du chargement des données: $e');
if (mounted) {
setState(() {
isLoading = false;
@@ -353,289 +189,230 @@ class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
@override
Widget build(BuildContext context) {
debugPrint('Building AdminDashboardHomePage');
return FutureBuilder<void>(
future: _initFuture,
builder: (context, snapshot) {
// Afficher un indicateur de chargement pendant l'initialisation des boîtes Hive
if (snapshot.connectionState == ConnectionState.waiting) {
debugPrint('FutureBuilder: ConnectionState.waiting');
return const Center(
child: CircularProgressIndicator(),
);
}
// Même si nous avons une erreur, nous continuons à afficher le contenu
// car nous avons modifié _initHiveBoxes pour ne pas propager les erreurs
if (snapshot.hasError) {
debugPrint('FutureBuilder: hasError - ${snapshot.error}');
// Nous affichons un message d'erreur mais continuons à afficher le contenu
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text('Erreur lors de l\'initialisation: ${snapshot.error}'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 5),
action: SnackBarAction(
label: 'Réessayer',
onPressed: () {
setState(() {
_initFuture = _initHiveBoxes().then((_) {
_loadDashboardData();
});
});
},
),
),
);
} else {
debugPrint('FutureBuilder: Initialisation réussie');
}
final screenWidth = MediaQuery.of(context).size.width;
final isDesktop = screenWidth > 800;
// L'initialisation a réussi, afficher le contenu
final screenWidth = MediaQuery.of(context).size.width;
final isDesktop = screenWidth > 800;
// Utiliser l'instance globale définie dans app.dart
// Récupérer l'opération en cours (les boîtes sont déjà ouvertes par SplashPage)
final currentOperation = userRepository.getCurrentOperation();
// Récupérer l'opération en cours (les boîtes sont déjà ouvertes)
final currentOperation = userRepository.getCurrentOperation();
// Titre dynamique avec l'ID et le nom de l'opération
final String title = currentOperation != null ? 'Synthèse de l\'opération #${currentOperation.id} ${currentOperation.name}' : 'Synthèse de l\'opération';
// Titre dynamique avec l'ID et le nom de l'opération
final String title = currentOperation != null
? 'Synthèse de l\'opération #${currentOperation.id} ${currentOperation.name}'
: 'Synthèse de l\'opération';
return Stack(children: [
// Fond dégradé avec petits points blancs
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.white, Colors.red.shade300],
),
),
child: CustomPaint(
painter: DotsPainter(),
child: Container(width: double.infinity, height: double.infinity),
),
return Stack(children: [
// Fond dégradé avec petits points blancs
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.white, Colors.red.shade300],
),
// Contenu de la page
SingleChildScrollView(
padding: const EdgeInsets.all(AppTheme.spacingL),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
),
child: CustomPaint(
painter: DotsPainter(),
child: const SizedBox(width: double.infinity, height: double.infinity),
),
),
// Contenu de la page
SingleChildScrollView(
padding: const EdgeInsets.all(AppTheme.spacingL),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre avec bouton de rafraîchissement sur la même ligne
Row(
children: [
// Titre avec bouton de rafraîchissement sur la même ligne
Row(
children: [
Expanded(
child: Text(
title,
style:
Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
// Bouton de rafraîchissement
if (!isLoading)
IconButton(
icon: const Icon(Icons.refresh),
tooltip: 'Rafraîchir les données',
onPressed: _loadDashboardData,
)
else
const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
Expanded(
child: Text(
title,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: AppTheme.spacingM),
// Afficher un indicateur de chargement si les données ne sont pas encore chargées
if (isLoading && !isDataLoaded)
const Center(
child: Padding(
padding: EdgeInsets.all(32.0),
child: CircularProgressIndicator(),
),
// Bouton de rafraîchissement
if (!isLoading)
IconButton(
icon: const Icon(Icons.refresh),
tooltip: 'Rafraîchir les données',
onPressed: _loadDashboardData,
)
else
const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
),
const SizedBox(height: AppTheme.spacingM),
// Afficher un indicateur de chargement si les données ne sont pas encore chargées
if (isLoading && !isDataLoaded)
const Center(
child: Padding(
padding: EdgeInsets.all(32.0),
child: CircularProgressIndicator(),
),
),
// Afficher le contenu seulement si les données sont chargées ou en cours de mise à jour
if (isDataLoaded || isLoading) ...[
// Cartes de synthèse
isDesktop
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 2,
child: _buildSummaryCard(
context,
'Passages totaux',
totalPassages.toString(),
Icons.map_outlined,
AppTheme.primaryColor,
),
),
const SizedBox(width: AppTheme.spacingM),
Expanded(
flex: 2,
child: _buildSummaryCard(
context,
'Montant collecté',
'${totalAmounts.toStringAsFixed(2)}',
Icons.euro_outlined,
AppTheme.buttonSuccessColor,
),
),
const SizedBox(width: AppTheme.spacingM),
Expanded(
flex: 3,
child: SectorDistributionCard(
key: ValueKey(
'sector_distribution_${isFirstLoad ? 'initial' : 'refreshed'}_$isLoading'),
height: 200,
),
),
],
)
: Column(
children: [
_buildSummaryCard(
context,
'Passages totaux',
totalPassages.toString(),
Icons.map_outlined,
AppTheme.primaryColor,
),
const SizedBox(height: AppTheme.spacingM),
_buildSummaryCard(
context,
'Montant collecté',
'${totalAmounts.toStringAsFixed(2)}',
Icons.euro_outlined,
AppTheme.buttonSuccessColor,
),
const SizedBox(height: AppTheme.spacingM),
SectorDistributionCard(
key: ValueKey(
'sector_distribution_${isFirstLoad ? 'initial' : 'refreshed'}_$isLoading'),
height: 200,
),
],
),
const SizedBox(height: AppTheme.spacingL),
// Graphique d'activité
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius:
BorderRadius.circular(AppTheme.borderRadiusMedium),
boxShadow: AppTheme.cardShadow,
),
child: ActivityChart(
key: ValueKey(
'activity_chart_${isFirstLoad ? 'initial' : 'refreshed'}_$isLoading'),
height: 350,
showAllPassages:
true, // Tous les passages, pas seulement ceux de l'utilisateur courant
title: 'Passages réalisés par jour (15 derniers jours)',
daysToShow: 15,
),
// Si vous avez besoin de passer l'ID de l'opération en cours, décommentez les lignes suivantes
// child: ActivityChart(
// height: 350,
// loadFromHive: true,
// showAllPassages: true,
// title: 'Passages réalisés par jour (15 derniers jours)',
// daysToShow: 15,
// operationId: userRepository.getCurrentOperation()?.id,
// ),
),
const SizedBox(height: AppTheme.spacingL),
// Graphiques de répartition
isDesktop
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _buildPassageTypeCard(context),
),
const SizedBox(width: AppTheme.spacingM),
Expanded(
child: _buildPaymentTypeCard(context),
),
],
)
: Column(
children: [
_buildPassageTypeCard(context),
const SizedBox(height: AppTheme.spacingM),
_buildPaymentTypeCard(context),
],
),
const SizedBox(height: AppTheme.spacingL),
// Actions rapides - uniquement visible sur le web
if (kIsWeb) ...[
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius:
BorderRadius.circular(AppTheme.borderRadiusMedium),
boxShadow: AppTheme.cardShadow,
),
padding: const EdgeInsets.all(AppTheme.spacingM),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Actions sur cette opération',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: AppTheme.primaryColor,
),
// Afficher le contenu seulement si les données sont chargées ou en cours de mise à jour
if (isDataLoaded || isLoading) ...[
// Cartes de synthèse
isDesktop
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 2,
child: _buildSummaryCard(
context,
'Passages totaux',
totalPassages.toString(),
Icons.map_outlined,
AppTheme.primaryColor,
),
const SizedBox(height: AppTheme.spacingM),
Wrap(
spacing: AppTheme.spacingM,
runSpacing: AppTheme.spacingM,
children: [
_buildActionButton(
context,
'Exporter les données',
Icons.file_download_outlined,
AppTheme.primaryColor,
() {},
),
_buildActionButton(
context,
'Gérer les secteurs',
Icons.map_outlined,
AppTheme.accentColor,
() {},
),
],
),
const SizedBox(width: AppTheme.spacingM),
Expanded(
flex: 2,
child: _buildSummaryCard(
context,
'Montant collecté',
'${totalAmounts.toStringAsFixed(2)}',
Icons.euro_outlined,
AppTheme.buttonSuccessColor,
),
),
const SizedBox(width: AppTheme.spacingM),
Expanded(
flex: 3,
child: SectorDistributionCard(
key: ValueKey('sector_distribution_${isFirstLoad ? 'initial' : 'refreshed'}_$isLoading'),
height: 200,
),
),
],
)
: Column(
children: [
_buildSummaryCard(
context,
'Passages totaux',
totalPassages.toString(),
Icons.map_outlined,
AppTheme.primaryColor,
),
const SizedBox(height: AppTheme.spacingM),
_buildSummaryCard(
context,
'Montant collecté',
'${totalAmounts.toStringAsFixed(2)}',
Icons.euro_outlined,
AppTheme.buttonSuccessColor,
),
const SizedBox(height: AppTheme.spacingM),
SectorDistributionCard(
key: ValueKey('sector_distribution_${isFirstLoad ? 'initial' : 'refreshed'}_$isLoading'),
height: 200,
),
],
),
const SizedBox(height: AppTheme.spacingL),
// Graphique d'activité
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
boxShadow: AppTheme.cardShadow,
),
child: ActivityChart(
key: ValueKey('activity_chart_${isFirstLoad ? 'initial' : 'refreshed'}_$isLoading'),
height: 350,
showAllPassages: true, // Tous les passages, pas seulement ceux de l'utilisateur courant
title: 'Passages réalisés par jour (15 derniers jours)',
daysToShow: 15,
),
),
const SizedBox(height: AppTheme.spacingL),
// Graphiques de répartition
isDesktop
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _buildPassageTypeCard(context),
),
const SizedBox(width: AppTheme.spacingM),
Expanded(
child: _buildPaymentTypeCard(context),
),
],
)
: Column(
children: [
_buildPassageTypeCard(context),
const SizedBox(height: AppTheme.spacingM),
_buildPaymentTypeCard(context),
],
),
const SizedBox(height: AppTheme.spacingL),
// Actions rapides - uniquement visible sur le web
if (kIsWeb) ...[
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
boxShadow: AppTheme.cardShadow,
),
padding: const EdgeInsets.all(AppTheme.spacingM),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Actions sur cette opération',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: AppTheme.primaryColor,
),
),
const SizedBox(height: AppTheme.spacingM),
Wrap(
spacing: AppTheme.spacingM,
runSpacing: AppTheme.spacingM,
children: [
_buildActionButton(
context,
'Exporter les données',
Icons.file_download_outlined,
AppTheme.primaryColor,
() {},
),
_buildActionButton(
context,
'Gérer les secteurs',
Icons.map_outlined,
AppTheme.accentColor,
() {},
),
],
),
),
],
],
],
),
),
],
),
)
]);
},
);
],
],
),
),
]);
}
Widget _buildSummaryCard(
@@ -661,8 +438,7 @@ class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius:
BorderRadius.circular(AppTheme.borderRadiusSmall),
borderRadius: BorderRadius.circular(AppTheme.borderRadiusSmall),
),
child: Icon(
icon,

View File

@@ -12,6 +12,7 @@ import 'admin_history_page.dart';
import 'admin_communication_page.dart';
import 'admin_map_page.dart';
import 'admin_amicale_page.dart';
import 'admin_operations_page.dart';
/// Class pour dessiner les petits points blancs sur le fond
class DotsPainter extends CustomPainter {
@@ -126,7 +127,10 @@ class _AdminDashboardPageState extends State<AdminDashboardPage> with WidgetsBin
operationRepository: operationRepository,
);
case _PageType.operations:
return const Scaffold(body: Center(child: Text('Page Opérations')));
return AdminOperationsPage(
operationRepository: operationRepository,
userRepository: userRepository,
);
}
}

View File

@@ -0,0 +1,817 @@
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/operation_model.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/repositories/operation_repository.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/core/utils/api_exception.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/presentation/widgets/operation_form_dialog.dart';
/// Page d'administration des opérations annuelles
/// Cette page est intégrée dans le tableau de bord administrateur
/// FOND TRANSPARENT - le fond dégradé est géré par AdminDashboardPage
class AdminOperationsPage extends StatefulWidget {
final OperationRepository operationRepository;
final UserRepository userRepository;
const AdminOperationsPage({
super.key,
required this.operationRepository,
required this.userRepository,
});
@override
State<AdminOperationsPage> createState() => _AdminOperationsPageState();
}
class _AdminOperationsPageState extends State<AdminOperationsPage> {
late int? _userAmicaleId;
@override
void initState() {
super.initState();
_userAmicaleId = widget.userRepository.getCurrentUser()?.fkEntite;
debugPrint('🔧 AdminOperationsPage initialisée - UserAmicaleId: $_userAmicaleId');
}
void _showCreateOperationDialog() {
showDialog(
context: context,
barrierDismissible: false,
builder: (dialogContext) => OperationFormDialog(
title: 'Créer une nouvelle opération',
operationRepository: widget.operationRepository,
userRepository: widget.userRepository,
onSuccess: () {
// Simple callback pour rafraîchir l'interface
if (mounted) {
setState(() {});
}
},
),
);
}
void _showEditOperationDialog(OperationModel op) {
showDialog(
context: context,
barrierDismissible: false,
builder: (dialogContext) => OperationFormDialog(
title: op.isActive ? 'Modifier l\'opération active : ${op.name}' : 'Modifier l\'opération : ${op.name}',
operation: op,
operationRepository: widget.operationRepository,
userRepository: widget.userRepository,
onSuccess: () {
// Simple callback pour rafraîchir l'interface
if (mounted) {
setState(() {});
}
},
),
);
}
/// Récupère les passages réalisés (fkType != 2) pour une opération
int _getCompletedPassagesCount(int operationId) {
try {
final passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
final completedPassages = passagesBox.values.where((passage) => passage.fkOperation == operationId && passage.fkType != 2).length;
debugPrint('🔍 Passages réalisés pour opération $operationId: $completedPassages');
return completedPassages;
} catch (e) {
debugPrint('❌ Erreur lors du comptage des passages: $e');
return 0;
}
}
void _handleDelete(OperationModel op, List<OperationModel> operations) async {
final currentUser = widget.userRepository.getCurrentUser();
if (currentUser == null) {
ApiException.showError(context, Exception("Utilisateur non connecté"));
return;
}
// Vérifier qu'il reste au moins une opération
if (operations.length <= 1) {
ApiException.showError(context, Exception("Impossible de supprimer la dernière opération"));
return;
}
// Cas 1: Opération inactive - Suppression simple pour role > 1
if (!op.isActive && currentUser.role > 1) {
final confirmed = await _showSimpleDeleteDialog(op);
if (confirmed == true) {
await _performSimpleDelete(op);
}
return;
}
// Cas 2: Opération active avec role = 2 - Vérification des passages
if (op.isActive && currentUser.role == 2) {
final completedPassagesCount = _getCompletedPassagesCount(op.id);
if (completedPassagesCount > 0) {
// Il y a des passages réalisés - Dialog d'avertissement avec confirmation par nom
final confirmed = await _showActiveDeleteWithPassagesDialog(op, completedPassagesCount);
if (confirmed == true) {
await _performActiveDelete(op);
}
} else {
// Pas de passages réalisés - Suppression simple
final confirmed = await _showActiveDeleteDialog(op);
if (confirmed == true) {
await _performActiveDelete(op);
}
}
return;
}
// Cas 3: Role > 2 - Suppression autorisée sans restrictions
if (currentUser.role > 2) {
final confirmed = await _showSimpleDeleteDialog(op);
if (confirmed == true) {
if (op.isActive) {
await _performActiveDelete(op);
} else {
await _performSimpleDelete(op);
}
}
return;
}
// Cas par défaut - Pas d'autorisation
ApiException.showError(context, Exception("Vous n'avez pas les droits pour supprimer cette opération"));
}
/// Dialog simple pour suppression d'opération inactive
Future<bool?> _showSimpleDeleteDialog(OperationModel op) {
return showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Row(
children: [
Icon(Icons.warning, color: Colors.orange),
SizedBox(width: 8),
Text("Confirmer la suppression"),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("Voulez-vous supprimer l'opération \"${op.name}\" ?"),
const SizedBox(height: 8),
const Text(
"Cette action est définitive.",
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.red),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text("Annuler"),
),
ElevatedButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: const Text("Supprimer"),
),
],
),
);
}
/// Dialog pour suppression d'opération active sans passages
Future<bool?> _showActiveDeleteDialog(OperationModel op) {
return showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Row(
children: [
Icon(Icons.warning, color: Colors.orange),
SizedBox(width: 8),
Text("Supprimer l'opération active"),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("Voulez-vous supprimer l'opération active \"${op.name}\" ?"),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.shade200),
),
child: const Row(
children: [
Icon(Icons.info, color: Colors.blue, size: 20),
SizedBox(width: 8),
Expanded(
child: Text(
"Votre dernière opération inactive sera automatiquement réactivée.",
style: TextStyle(color: Colors.blue),
),
),
],
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text("Annuler"),
),
ElevatedButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: const Text("Supprimer"),
),
],
),
);
}
/// Dialog pour suppression d'opération active avec passages réalisés
Future<bool?> _showActiveDeleteWithPassagesDialog(OperationModel op, int passagesCount) {
final TextEditingController nameController = TextEditingController();
return showDialog<bool>(
context: context,
builder: (dialogContext) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: const Row(
children: [
Icon(Icons.error, color: Colors.red),
SizedBox(width: 8),
Text("ATTENTION - Passages réalisés"),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.warning, color: Colors.red, size: 20),
const SizedBox(width: 8),
Text(
"$passagesCount passage(s) réalisé(s) trouvé(s)",
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.red,
),
),
],
),
const SizedBox(height: 8),
const Text(
"La suppression de cette opération active supprimera définitivement tous les passages réalisés !",
style: TextStyle(color: Colors.red),
),
],
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.shade200),
),
child: const Row(
children: [
Icon(Icons.info, color: Colors.blue, size: 20),
SizedBox(width: 8),
Expanded(
child: Text(
"Votre dernière opération inactive sera automatiquement réactivée.",
style: TextStyle(color: Colors.blue),
),
),
],
),
),
const SizedBox(height: 16),
const Text(
"Pour confirmer, saisissez le nom exact de l'opération :",
style: TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
TextField(
controller: nameController,
decoration: InputDecoration(
hintText: op.name,
border: const OutlineInputBorder(),
isDense: true,
),
onChanged: (value) => setState(() {}),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text("Annuler"),
),
ElevatedButton(
onPressed: nameController.text.trim() == op.name.trim() ? () => Navigator.of(dialogContext).pop(true) : null,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: const Text("Supprimer définitivement"),
),
],
),
),
);
}
/// Suppression simple d'opération inactive
Future<void> _performSimpleDelete(OperationModel op) async {
try {
final success = await widget.operationRepository.deleteOperationViaApi(op.id);
if (success && mounted) {
ApiException.showSuccess(context, "Opération supprimée avec succès");
setState(() {});
} else {
throw Exception("Erreur lors de la suppression");
}
} catch (e) {
if (mounted) {
ApiException.showError(context, e);
}
}
}
/// Suppression d'opération active (avec réactivation automatique)
Future<void> _performActiveDelete(OperationModel op) async {
try {
final success = await widget.operationRepository.deleteActiveOperationViaApi(op.id);
if (success && mounted) {
ApiException.showSuccess(context, "Opération active supprimée avec succès. L'opération précédente a été réactivée.");
setState(() {});
} else {
throw Exception("Erreur lors de la suppression");
}
} catch (e) {
if (mounted) {
ApiException.showError(context, e);
}
}
}
void _handleExport(OperationModel operation) async {
try {
// Afficher un indicateur de chargement
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
),
const SizedBox(width: 16),
Text("Export Excel de l'opération \"${operation.name}\" en cours..."),
],
),
duration: const Duration(seconds: 10), // Plus long pour le téléchargement
backgroundColor: Colors.blue,
),
);
// Appeler l'export via le repository
await widget.operationRepository.exportOperationToExcel(operation.id, operation.name);
// Masquer le SnackBar de chargement et afficher le succès
if (mounted) {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
ApiException.showSuccess(context, "Export Excel de l'opération \"${operation.name}\" terminé avec succès !");
}
} catch (e) {
// Masquer le SnackBar de chargement et afficher l'erreur
if (mounted) {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
ApiException.showError(context, e);
}
}
}
String _formatDate(DateTime date) {
return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}';
}
Widget _buildOperationsTable(List<OperationModel> operations) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// En-tête du tableau
_buildTableHeader(theme),
// Corps du tableau
Container(
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(8),
bottomRight: Radius.circular(8),
),
border: Border.all(
color: theme.colorScheme.primary.withOpacity(0.1),
width: 1,
),
),
child: ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: operations.length,
itemBuilder: (context, index) {
final operation = operations[index];
return _buildOperationRow(operation, index % 2 == 1, theme, operations);
},
),
),
],
);
}
Widget _buildTableHeader(ThemeData theme) {
final textStyle = theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
);
return Container(
decoration: BoxDecoration(
color: theme.colorScheme.primary.withOpacity(0.1),
border: Border(
bottom: BorderSide(
color: theme.dividerColor.withOpacity(0.3),
width: 1,
),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: Row(
children: [
// Colonne ID
Expanded(
flex: 1,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Text('ID', style: textStyle, overflow: TextOverflow.ellipsis),
),
),
// Colonne Nom
Expanded(
flex: 4,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Text('Nom de l\'opération', style: textStyle, overflow: TextOverflow.ellipsis),
),
),
// Colonne Date début
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Text('Date début', style: textStyle, overflow: TextOverflow.ellipsis),
),
),
// Colonne Date fin
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Text('Date fin', style: textStyle, overflow: TextOverflow.ellipsis),
),
),
// Colonne Statut
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Text('Statut', style: textStyle, overflow: TextOverflow.ellipsis),
),
),
// Colonne Actions
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Text('Actions', style: textStyle, overflow: TextOverflow.ellipsis),
),
),
],
),
),
);
}
Widget _buildOperationRow(OperationModel operation, bool isAlternate, ThemeData theme, List<OperationModel> allOperations) {
final textStyle = theme.textTheme.bodyMedium;
final backgroundColor = isAlternate ? theme.colorScheme.surface : theme.colorScheme.surface;
final canDelete = allOperations.length > 1; // Peut supprimer seulement s'il y a plus d'une opération
return InkWell(
onTap: operation.isActive ? () => _showEditOperationDialog(operation) : null,
hoverColor: operation.isActive ? theme.colorScheme.primary.withOpacity(0.05) : null,
child: Container(
decoration: BoxDecoration(
color: backgroundColor,
border: Border(
bottom: BorderSide(
color: theme.dividerColor.withOpacity(0.3),
width: 1,
),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: Row(
children: [
// Colonne ID
Expanded(
flex: 1,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Text(
operation.id.toString(),
style: textStyle,
overflow: TextOverflow.ellipsis,
),
),
),
// Colonne Nom
Expanded(
flex: 4,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Row(
children: [
if (operation.isActive) ...[
Icon(
Icons.edit_outlined,
size: 16,
color: theme.colorScheme.primary.withOpacity(0.6),
),
const SizedBox(width: 4),
],
Expanded(
child: Text(
operation.name,
style: textStyle?.copyWith(
color: operation.isActive ? theme.colorScheme.primary : textStyle.color,
fontWeight: operation.isActive ? FontWeight.w600 : textStyle.fontWeight,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
// Colonne Date début
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Text(
_formatDate(operation.dateDebut),
style: textStyle,
overflow: TextOverflow.ellipsis,
),
),
),
// Colonne Date fin
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Text(
_formatDate(operation.dateFin),
style: textStyle,
overflow: TextOverflow.ellipsis,
),
),
),
// Colonne Statut
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
decoration: BoxDecoration(
color: operation.isActive ? Colors.green : Colors.red,
borderRadius: BorderRadius.circular(12.0),
),
child: Text(
operation.isActive ? 'Active' : 'Inactive',
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
),
),
),
),
// Colonne Actions
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
// Bouton Delete - Affiché seulement s'il y a plus d'une opération
if (canDelete)
IconButton(
icon: Icon(
Icons.delete_forever,
color: theme.colorScheme.error,
size: 20,
),
tooltip: 'Supprimer',
onPressed: () => _handleDelete(operation, allOperations),
constraints: const BoxConstraints(
minWidth: 36,
minHeight: 36,
),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
// Bouton Export
IconButton(
icon: Icon(
Icons.download,
color: theme.colorScheme.secondary,
size: 20,
),
tooltip: 'Exporter',
onPressed: () => _handleExport(operation),
constraints: const BoxConstraints(
minWidth: 36,
minHeight: 36,
),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
],
),
),
),
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
debugPrint('🎨 AdminOperationsPage.build() appelée');
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre de la page
Text(
'Gestion des opérations annuelles',
style: theme.textTheme.headlineMedium?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
// Contenu principal avec ValueListenableBuilder
Expanded(
child: ValueListenableBuilder<Box<OperationModel>>(
valueListenable: widget.operationRepository.operationBox.listenable(),
builder: (context, operationBox, child) {
debugPrint('🔄 ValueListenableBuilder - Nombre d\'opérations: ${operationBox.length}');
// Filtrer et trier les opérations
final allOperations = operationBox.values.toList();
allOperations.sort((a, b) => b.id.compareTo(a.id));
final operations = allOperations.take(10).toList(); // Limiter à 10 opérations récentes
debugPrint('📊 Opérations affichées: ${operations.length}');
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header avec bouton d'ajout
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Opérations récentes (${operations.length})',
style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
ElevatedButton.icon(
onPressed: _showCreateOperationDialog,
icon: const Icon(Icons.add),
label: const Text('Nouvelle opération'),
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: Colors.white,
),
),
],
),
const SizedBox(height: 16),
// Tableau des opérations
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: operations.isEmpty
? Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.calendar_today_outlined,
size: 64,
color: theme.colorScheme.primary.withOpacity(0.5),
),
const SizedBox(height: 16),
Text(
"Aucune opération créée",
style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.primary,
),
),
const SizedBox(height: 8),
Text(
"Cliquez sur 'Nouvelle opération' pour commencer",
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
],
),
)
: _buildOperationsTable(operations),
),
const SizedBox(height: 24),
],
);
},
),
),
],
),
);
}
}