feat: Ajout de l'exportation des événements au format CSV avec filtres personnalisables
This commit is contained in:
655
em2rp/lib/views/widgets/data_management/events_export.dart
Normal file
655
em2rp/lib/views/widgets/data_management/events_export.dart
Normal file
@@ -0,0 +1,655 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:em2rp/utils/colors.dart';
|
||||
import 'package:em2rp/models/event_model.dart';
|
||||
import 'package:em2rp/services/data_service.dart';
|
||||
import 'package:em2rp/services/api_service.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:csv/csv.dart';
|
||||
import 'package:universal_io/io.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:em2rp/utils/web_download.dart' as web_download;
|
||||
|
||||
|
||||
class EventsExport extends StatefulWidget {
|
||||
const EventsExport({super.key});
|
||||
|
||||
@override
|
||||
State<EventsExport> createState() => _EventsExportState();
|
||||
}
|
||||
|
||||
class _EventsExportState extends State<EventsExport> {
|
||||
late final DataService _dataService;
|
||||
|
||||
// Filtres
|
||||
DateTime? _startDate;
|
||||
DateTime? _endDate;
|
||||
final List<String> _selectedEventTypeIds = [];
|
||||
final List<String> _selectedStatuses = [];
|
||||
|
||||
// Options disponibles
|
||||
List<Map<String, dynamic>> _eventTypes = [];
|
||||
final List<String> _availableStatuses = [
|
||||
'CONFIRMED',
|
||||
'WAITING_FOR_APPROVAL',
|
||||
'CANCELED',
|
||||
];
|
||||
|
||||
bool _isLoading = false;
|
||||
bool _loadingEventTypes = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_dataService = DataService(FirebaseFunctionsApiService());
|
||||
_loadEventTypes();
|
||||
}
|
||||
|
||||
Future<void> _loadEventTypes() async {
|
||||
setState(() => _loadingEventTypes = true);
|
||||
try {
|
||||
final eventTypesData = await _dataService.getEventTypes();
|
||||
setState(() {
|
||||
_eventTypes = eventTypesData;
|
||||
_loadingEventTypes = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = 'Erreur lors du chargement des types d\'événements: $e';
|
||||
_loadingEventTypes = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String _getStatusLabel(String status) {
|
||||
switch (status) {
|
||||
case 'CONFIRMED':
|
||||
return 'Validé';
|
||||
case 'WAITING_FOR_APPROVAL':
|
||||
return 'En attente';
|
||||
case 'CANCELED':
|
||||
return 'Annulé';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _exportEvents() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
// Récupérer tous les événements via l'API
|
||||
final result = await _dataService.getEvents();
|
||||
final eventsData = result['events'] as List<Map<String, dynamic>>;
|
||||
final usersData = result['users'] as Map<String, dynamic>;
|
||||
|
||||
// Parser les événements
|
||||
List<EventModel> events = [];
|
||||
for (var eventData in eventsData) {
|
||||
try {
|
||||
final event = EventModel.fromMap(eventData, eventData['id'] as String);
|
||||
events.add(event);
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du parsing de l\'événement ${eventData['id']}: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Appliquer les filtres
|
||||
events = _applyFilters(events);
|
||||
|
||||
if (events.isEmpty) {
|
||||
setState(() {
|
||||
_error = 'Aucun événement ne correspond aux critères de filtrage.';
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Générer le CSV
|
||||
final csv = await _generateCSV(events, usersData);
|
||||
|
||||
// Sauvegarder et partager le fichier
|
||||
await _saveAndShareCSV(csv);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Export réussi : ${events.length} événement(s) exporté(s)'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = 'Erreur lors de l\'export: $e';
|
||||
});
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
List<EventModel> _applyFilters(List<EventModel> events) {
|
||||
return events.where((event) {
|
||||
// Filtre par date
|
||||
if (_startDate != null && event.endDateTime.isBefore(_startDate!)) {
|
||||
return false;
|
||||
}
|
||||
if (_endDate != null && event.startDateTime.isAfter(_endDate!)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filtre par type d'événement
|
||||
if (_selectedEventTypeIds.isNotEmpty &&
|
||||
!_selectedEventTypeIds.contains(event.eventTypeId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filtre par statut
|
||||
if (_selectedStatuses.isNotEmpty &&
|
||||
!_selectedStatuses.contains(eventStatusToString(event.status))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Future<String> _generateCSV(
|
||||
List<EventModel> events,
|
||||
Map<String, dynamic> usersData,
|
||||
) async {
|
||||
final List<List<dynamic>> rows = [];
|
||||
|
||||
// En-têtes
|
||||
rows.add([
|
||||
'Titre',
|
||||
'Type d\'événement',
|
||||
'Statut',
|
||||
'Description',
|
||||
'Date début',
|
||||
'Date fin',
|
||||
'Durée montage (min)',
|
||||
'Durée démontage (min)',
|
||||
'Prix de base HT (€)',
|
||||
'Prix de base TTC (€)',
|
||||
'Options',
|
||||
'Prix total HT (€)',
|
||||
'Prix total TTC (€)',
|
||||
'Workforce',
|
||||
'Contact client',
|
||||
'Jauge',
|
||||
]);
|
||||
|
||||
// Récupérer les noms des types d'événements
|
||||
final eventTypesMap = <String, String>{};
|
||||
for (var eventType in _eventTypes) {
|
||||
eventTypesMap[eventType['id']] = eventType['name'];
|
||||
}
|
||||
|
||||
final dateFormat = DateFormat('dd/MM/yyyy HH:mm');
|
||||
|
||||
for (var event in events) {
|
||||
// Calculer le prix total TTC (base + options)
|
||||
final totalTTC = event.basePrice +
|
||||
event.options.fold<num>(
|
||||
0,
|
||||
(sum, opt) {
|
||||
final priceTTC = opt['price'] ?? 0.0;
|
||||
final quantity = opt['quantity'] ?? 1;
|
||||
return sum + (priceTTC * quantity);
|
||||
},
|
||||
);
|
||||
|
||||
// Calculer les prix HT (TVA 20%)
|
||||
final basePriceHT = event.basePrice / 1.20;
|
||||
final totalHT = totalTTC / 1.20;
|
||||
|
||||
// Formatter les options
|
||||
final optionsStr = event.options.isEmpty
|
||||
? 'Aucune'
|
||||
: event.options.map((opt) {
|
||||
final name = opt['name'] ?? '';
|
||||
final code = opt['code'] ?? '';
|
||||
final details = opt['details'] ?? '';
|
||||
final price = opt['price'] ?? 0.0;
|
||||
final quantity = opt['quantity'] ?? 1;
|
||||
|
||||
final optName = code.isNotEmpty ? '$code - $name' : name;
|
||||
final optDetails = details.isNotEmpty ? ' ($details)' : '';
|
||||
final optPrice = (price * quantity).toStringAsFixed(2);
|
||||
|
||||
return '$optName$optDetails: $optPrice €';
|
||||
}).join(' | ');
|
||||
|
||||
// Formatter la workforce
|
||||
final workforceStr = event.workforce.isEmpty
|
||||
? 'Aucun'
|
||||
: event.workforce.map((worker) {
|
||||
if (worker is String) {
|
||||
// UID - récupérer le nom depuis usersData
|
||||
final userData = usersData[worker] as Map<String, dynamic>?;
|
||||
if (userData != null) {
|
||||
final firstName = userData['firstName'] ?? '';
|
||||
final lastName = userData['lastName'] ?? '';
|
||||
return '$firstName $lastName'.trim();
|
||||
}
|
||||
return worker;
|
||||
}
|
||||
return worker.toString();
|
||||
}).join(', ');
|
||||
|
||||
// Formatter le contact
|
||||
final contactParts = <String>[];
|
||||
if (event.contactEmail != null && event.contactEmail!.isNotEmpty) {
|
||||
contactParts.add(event.contactEmail!);
|
||||
}
|
||||
if (event.contactPhone != null && event.contactPhone!.isNotEmpty) {
|
||||
contactParts.add(event.contactPhone!);
|
||||
}
|
||||
final contactStr = contactParts.isEmpty ? 'N/A' : contactParts.join(' | ');
|
||||
|
||||
rows.add([
|
||||
event.name,
|
||||
eventTypesMap[event.eventTypeId] ?? event.eventTypeId,
|
||||
_getStatusLabel(eventStatusToString(event.status)),
|
||||
event.description,
|
||||
dateFormat.format(event.startDateTime),
|
||||
dateFormat.format(event.endDateTime),
|
||||
event.installationTime,
|
||||
event.disassemblyTime,
|
||||
basePriceHT.toStringAsFixed(2),
|
||||
event.basePrice.toStringAsFixed(2),
|
||||
optionsStr,
|
||||
totalHT.toStringAsFixed(2),
|
||||
totalTTC.toStringAsFixed(2),
|
||||
workforceStr,
|
||||
contactStr,
|
||||
event.jauge?.toString() ?? 'N/A',
|
||||
]);
|
||||
}
|
||||
|
||||
return const ListToCsvConverter().convert(rows);
|
||||
}
|
||||
|
||||
Future<void> _saveAndShareCSV(String csvContent) async {
|
||||
final timestamp = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
|
||||
final fileName = 'evenements_export_$timestamp.csv';
|
||||
|
||||
if (kIsWeb) {
|
||||
// Sur le web, télécharger directement avec la fonction conditionnelle
|
||||
web_download.downloadFile(csvContent, fileName);
|
||||
} else {
|
||||
// Sur mobile/desktop, utiliser share_plus
|
||||
final directory = await getTemporaryDirectory();
|
||||
final filePath = '${directory.path}/$fileName';
|
||||
final file = File(filePath);
|
||||
await file.writeAsString(csvContent);
|
||||
|
||||
// ignore: deprecated_member_use
|
||||
await Share.shareXFiles(
|
||||
[XFile(filePath)],
|
||||
subject: 'Export des événements',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _selectDateRange() async {
|
||||
final picked = await showDateRangePicker(
|
||||
context: context,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2030),
|
||||
initialDateRange: _startDate != null && _endDate != null
|
||||
? DateTimeRange(start: _startDate!, end: _endDate!)
|
||||
: null,
|
||||
locale: const Locale('fr', 'FR'),
|
||||
builder: (context, child) {
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
colorScheme: ColorScheme.light(
|
||||
primary: AppColors.rouge,
|
||||
onPrimary: Colors.white,
|
||||
),
|
||||
),
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
_startDate = picked.start;
|
||||
_endDate = picked.end;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _clearDateRange() {
|
||||
setState(() {
|
||||
_startDate = null;
|
||||
_endDate = null;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_loadingEventTypes) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// En-tête
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.file_download, color: AppColors.rouge, size: 32),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Exporter les événements',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.rouge,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Exportez les événements au format CSV avec filtres personnalisables',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Section des filtres
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildFiltersSection(),
|
||||
const SizedBox(height: 32),
|
||||
if (_error != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red[50],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.red),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_error!,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Bouton d'export
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isLoading ? null : _exportEvents,
|
||||
icon: _isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.download),
|
||||
label: Text(
|
||||
_isLoading ? 'Export en cours...' : 'Exporter les événements',
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.rouge,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFiltersSection() {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.filter_list, color: AppColors.rouge),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Filtres',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 24),
|
||||
|
||||
// Filtre par période
|
||||
_buildDateRangeFilter(),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Filtre par type d'événement
|
||||
_buildEventTypeFilter(),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Filtre par statut
|
||||
_buildStatusFilter(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDateRangeFilter() {
|
||||
final dateFormat = DateFormat('dd/MM/yyyy');
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Période',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _selectDateRange,
|
||||
icon: const Icon(Icons.calendar_today),
|
||||
label: Text(
|
||||
_startDate != null && _endDate != null
|
||||
? 'Du ${dateFormat.format(_startDate!)} au ${dateFormat.format(_endDate!)}'
|
||||
: 'Toutes les périodes',
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.rouge,
|
||||
side: BorderSide(color: AppColors.rouge),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_startDate != null && _endDate != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
onPressed: _clearDateRange,
|
||||
icon: const Icon(Icons.clear),
|
||||
color: Colors.grey,
|
||||
tooltip: 'Effacer la période',
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEventTypeFilter() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Types d\'événement',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
// Bouton "Tous"
|
||||
FilterChip(
|
||||
label: const Text('Tous'),
|
||||
selected: _selectedEventTypeIds.isEmpty,
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
_selectedEventTypeIds.clear();
|
||||
}
|
||||
});
|
||||
},
|
||||
selectedColor: AppColors.rouge,
|
||||
labelStyle: TextStyle(
|
||||
color: _selectedEventTypeIds.isEmpty
|
||||
? Colors.white
|
||||
: AppColors.rouge,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
// Chips pour chaque type
|
||||
..._eventTypes.map((eventType) {
|
||||
final isSelected =
|
||||
_selectedEventTypeIds.contains(eventType['id']);
|
||||
return FilterChip(
|
||||
label: Text(eventType['name']),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
_selectedEventTypeIds.add(eventType['id']);
|
||||
} else {
|
||||
_selectedEventTypeIds.remove(eventType['id']);
|
||||
}
|
||||
});
|
||||
},
|
||||
selectedColor: AppColors.rouge,
|
||||
labelStyle: TextStyle(
|
||||
color: isSelected ? Colors.white : AppColors.rouge,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusFilter() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Statut de l\'événement',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
// Bouton "Tous"
|
||||
FilterChip(
|
||||
label: const Text('Tous'),
|
||||
selected: _selectedStatuses.isEmpty,
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
_selectedStatuses.clear();
|
||||
}
|
||||
});
|
||||
},
|
||||
selectedColor: AppColors.rouge,
|
||||
labelStyle: TextStyle(
|
||||
color: _selectedStatuses.isEmpty ? Colors.white : AppColors.rouge,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
// Chips pour chaque statut
|
||||
..._availableStatuses.map((status) {
|
||||
final isSelected = _selectedStatuses.contains(status);
|
||||
return FilterChip(
|
||||
label: Text(_getStatusLabel(status)),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
_selectedStatuses.add(status);
|
||||
} else {
|
||||
_selectedStatuses.remove(status);
|
||||
}
|
||||
});
|
||||
},
|
||||
selectedColor: AppColors.rouge,
|
||||
labelStyle: TextStyle(
|
||||
color: isSelected ? Colors.white : AppColors.rouge,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user