feat: mise à jour v1.1.17 et ajout du tableau de bord des statistiques d'événements
- Mise à jour de la version de l'application à `1.1.17` dans `app_version.dart` et `version.json`. - Création d'un module complet de statistiques (`EventStatisticsPage`, `EventStatisticsService`, `EventStatisticsTab`) permettant de filtrer et visualiser les KPI d'événements (montants HT/TTC, panier moyen, répartition par type, top options). - Ajout d'une entrée "Statistiques événements" dans le menu latéral (`MainDrawer`) protégée par la permission `generate_reports`. - Migration exclusive vers Google Cloud TTS dans `SmartTextToSpeechService` et suppression de `TextToSpeechService` (Web Speech API native) pour garantir une compatibilité maximale sur tous les navigateurs. - Mise à jour des dépendances dans `pubspec.yaml` (`google_fonts`, `flutter_secure_storage`, `mobile_scanner`, `flutter_local_notifications`). - Migration du code d'export ICS vers `package:web` pour remplacer l'utilisation de `dart:html` obsolète. - Mise à jour du `CHANGELOG.md` documentant les statistiques et l'évolution du service de synthèse vocale.
This commit is contained in:
@@ -0,0 +1,715 @@
|
||||
import 'package:em2rp/models/event_model.dart';
|
||||
import 'package:em2rp/models/event_statistics_models.dart';
|
||||
import 'package:em2rp/providers/local_user_provider.dart';
|
||||
import 'package:em2rp/services/api_service.dart';
|
||||
import 'package:em2rp/services/data_service.dart';
|
||||
import 'package:em2rp/services/event_statistics_service.dart';
|
||||
import 'package:em2rp/utils/colors.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
enum _AmountDisplayMode { ht, ttc }
|
||||
|
||||
enum _DatePreset { currentMonth, previousMonth, currentYear, previousYear }
|
||||
|
||||
class EventStatisticsTab extends StatefulWidget {
|
||||
const EventStatisticsTab({super.key});
|
||||
|
||||
@override
|
||||
State<EventStatisticsTab> createState() => _EventStatisticsTabState();
|
||||
}
|
||||
|
||||
class _EventStatisticsTabState extends State<EventStatisticsTab> {
|
||||
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||
final EventStatisticsService _statisticsService =
|
||||
const EventStatisticsService();
|
||||
|
||||
final NumberFormat _currencyFormat =
|
||||
NumberFormat.currency(locale: 'fr_FR', symbol: 'EUR ');
|
||||
final NumberFormat _percentFormat = NumberFormat.percentPattern('fr_FR');
|
||||
|
||||
DateTimeRange _selectedPeriod = _initialPeriod();
|
||||
final Set<String> _selectedEventTypeIds = {};
|
||||
final Set<EventStatus> _selectedStatuses = {
|
||||
EventStatus.confirmed,
|
||||
EventStatus.waitingForApproval,
|
||||
};
|
||||
_AmountDisplayMode _amountDisplayMode = _AmountDisplayMode.ht;
|
||||
|
||||
bool _isLoading = true;
|
||||
String? _errorMessage;
|
||||
|
||||
List<EventModel> _events = [];
|
||||
Map<String, String> _eventTypeNames = {};
|
||||
EventStatisticsSummary _summary = EventStatisticsSummary.empty;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadStatistics();
|
||||
}
|
||||
|
||||
static DateTimeRange _initialPeriod() {
|
||||
final now = DateTime.now();
|
||||
return DateTimeRange(
|
||||
start: DateTime(now.year, now.month, 1),
|
||||
end: DateTime(now.year, now.month + 1, 0, 23, 59, 59),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadStatistics() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final localUserProvider =
|
||||
Provider.of<LocalUserProvider>(context, listen: false);
|
||||
final userId = localUserProvider.uid;
|
||||
|
||||
final results = await Future.wait([
|
||||
_dataService.getEvents(userId: userId),
|
||||
_dataService.getEventTypes(),
|
||||
]);
|
||||
|
||||
final eventsResult = results[0] as Map<String, dynamic>;
|
||||
final eventTypesResult = results[1] as List<Map<String, dynamic>>;
|
||||
final eventsData = eventsResult['events'] as List<Map<String, dynamic>>;
|
||||
|
||||
final parsedEvents = <EventModel>[];
|
||||
for (final eventData in eventsData) {
|
||||
try {
|
||||
parsedEvents
|
||||
.add(EventModel.fromMap(eventData, eventData['id'] as String));
|
||||
} catch (_) {
|
||||
// Ignore malformed rows and continue to keep the dashboard available.
|
||||
}
|
||||
}
|
||||
|
||||
final eventTypeNames = <String, String>{};
|
||||
for (final eventType in eventTypesResult) {
|
||||
final id = (eventType['id'] ?? '').toString();
|
||||
if (id.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
eventTypeNames[id] = (eventType['name'] ?? id).toString();
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_events = parsedEvents;
|
||||
_eventTypeNames = eventTypeNames;
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
_rebuildSummary();
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage = 'Erreur lors du chargement des statistiques: $error';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _rebuildSummary() {
|
||||
final filter = EventStatisticsFilter(
|
||||
period: _selectedPeriod,
|
||||
eventTypeIds: _selectedEventTypeIds,
|
||||
includeCanceled: _selectedStatuses.contains(EventStatus.canceled),
|
||||
selectedStatuses: _selectedStatuses,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_summary = _statisticsService.buildSummary(
|
||||
events: _events,
|
||||
filter: filter,
|
||||
eventTypeNames: _eventTypeNames,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _selectDateRange() async {
|
||||
final selectedRange = await showDateRangePicker(
|
||||
context: context,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2035),
|
||||
initialDateRange: _selectedPeriod,
|
||||
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 ?? const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (selectedRange == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_selectedPeriod = DateTimeRange(
|
||||
start: selectedRange.start,
|
||||
end: DateTime(
|
||||
selectedRange.end.year,
|
||||
selectedRange.end.month,
|
||||
selectedRange.end.day,
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
_rebuildSummary();
|
||||
}
|
||||
|
||||
void _resetFilters() {
|
||||
setState(() {
|
||||
_selectedPeriod = _initialPeriod();
|
||||
_selectedEventTypeIds.clear();
|
||||
_selectedStatuses.clear();
|
||||
_selectedStatuses.addAll({
|
||||
EventStatus.confirmed,
|
||||
EventStatus.waitingForApproval,
|
||||
});
|
||||
_amountDisplayMode = _AmountDisplayMode.ht;
|
||||
});
|
||||
_rebuildSummary();
|
||||
}
|
||||
|
||||
String _formatCurrency(double value) => _currencyFormat.format(value);
|
||||
|
||||
String _formatPercent(double value) => _percentFormat.format(value);
|
||||
|
||||
String get _amountUnitLabel =>
|
||||
_amountDisplayMode == _AmountDisplayMode.ht ? 'HT' : 'TTC';
|
||||
|
||||
double _toDisplayAmount(double htAmount) {
|
||||
if (_amountDisplayMode == _AmountDisplayMode.ttc) {
|
||||
return htAmount * 1.2;
|
||||
}
|
||||
return htAmount;
|
||||
}
|
||||
|
||||
String _formatAmount(double htAmount) =>
|
||||
_formatCurrency(_toDisplayAmount(htAmount));
|
||||
|
||||
String _presetLabel(_DatePreset preset) {
|
||||
switch (preset) {
|
||||
case _DatePreset.currentMonth:
|
||||
return 'Ce mois-ci';
|
||||
case _DatePreset.previousMonth:
|
||||
return 'Mois dernier';
|
||||
case _DatePreset.currentYear:
|
||||
return 'Cette année';
|
||||
case _DatePreset.previousYear:
|
||||
return 'Année dernière';
|
||||
}
|
||||
}
|
||||
|
||||
DateTimeRange _rangeForMonth(int year, int month) {
|
||||
return DateTimeRange(
|
||||
start: DateTime(year, month, 1),
|
||||
end: DateTime(year, month + 1, 0, 23, 59, 59),
|
||||
);
|
||||
}
|
||||
|
||||
DateTimeRange _rangeForYear(int year) {
|
||||
return DateTimeRange(
|
||||
start: DateTime(year, 1, 1),
|
||||
end: DateTime(year, 12, 31, 23, 59, 59),
|
||||
);
|
||||
}
|
||||
|
||||
DateTimeRange _rangeForPreset(_DatePreset preset) {
|
||||
final now = DateTime.now();
|
||||
|
||||
switch (preset) {
|
||||
case _DatePreset.currentMonth:
|
||||
return _rangeForMonth(now.year, now.month);
|
||||
case _DatePreset.previousMonth:
|
||||
return _rangeForMonth(now.year, now.month - 1);
|
||||
case _DatePreset.currentYear:
|
||||
return _rangeForYear(now.year);
|
||||
case _DatePreset.previousYear:
|
||||
return _rangeForYear(now.year - 1);
|
||||
}
|
||||
}
|
||||
|
||||
bool _isPresetSelected(_DatePreset preset) {
|
||||
final presetRange = _rangeForPreset(preset);
|
||||
return _selectedPeriod.start == presetRange.start &&
|
||||
_selectedPeriod.end == presetRange.end;
|
||||
}
|
||||
|
||||
void _applyDatePreset(_DatePreset preset) {
|
||||
setState(() {
|
||||
_selectedPeriod = _rangeForPreset(preset);
|
||||
});
|
||||
_rebuildSummary();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (_errorMessage != null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_errorMessage!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _loadStatistics,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Réessayer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _loadStatistics,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_buildFiltersCard(),
|
||||
const SizedBox(height: 16),
|
||||
_buildSummaryCards(),
|
||||
const SizedBox(height: 16),
|
||||
_buildByTypeSection(),
|
||||
const SizedBox(height: 16),
|
||||
_buildTopOptionsSection(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFiltersCard() {
|
||||
final dateFormat = DateFormat('dd/MM/yyyy');
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.filter_alt, color: AppColors.rouge),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Filtres',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
ToggleButtons(
|
||||
isSelected: [
|
||||
_amountDisplayMode == _AmountDisplayMode.ht,
|
||||
_amountDisplayMode == _AmountDisplayMode.ttc,
|
||||
],
|
||||
onPressed: (index) {
|
||||
setState(() {
|
||||
_amountDisplayMode = index == 0
|
||||
? _AmountDisplayMode.ht
|
||||
: _AmountDisplayMode.ttc;
|
||||
});
|
||||
},
|
||||
children: const [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text('HT'),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text('TTC'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TextButton.icon(
|
||||
onPressed: _resetFilters,
|
||||
icon: const Icon(Icons.restart_alt),
|
||||
label: const Text('Réinitialiser'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: _selectDateRange,
|
||||
icon: const Icon(Icons.date_range),
|
||||
label: Text(
|
||||
'${dateFormat.format(_selectedPeriod.start)} - ${dateFormat.format(_selectedPeriod.end)}',
|
||||
),
|
||||
),
|
||||
..._DatePreset.values.map(
|
||||
(preset) => ChoiceChip(
|
||||
label: Text(_presetLabel(preset)),
|
||||
selected: _isPresetSelected(preset),
|
||||
onSelected: (_) => _applyDatePreset(preset),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Statuts d\'événements',
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
FilterChip(
|
||||
label: const Text('Validés'),
|
||||
selected: _selectedStatuses.contains(EventStatus.confirmed),
|
||||
onSelected: (value) {
|
||||
setState(() {
|
||||
if (value) {
|
||||
_selectedStatuses.add(EventStatus.confirmed);
|
||||
} else {
|
||||
_selectedStatuses.remove(EventStatus.confirmed);
|
||||
}
|
||||
});
|
||||
_rebuildSummary();
|
||||
},
|
||||
),
|
||||
FilterChip(
|
||||
label: const Text('En attente'),
|
||||
selected: _selectedStatuses.contains(EventStatus.waitingForApproval),
|
||||
onSelected: (value) {
|
||||
setState(() {
|
||||
if (value) {
|
||||
_selectedStatuses.add(EventStatus.waitingForApproval);
|
||||
} else {
|
||||
_selectedStatuses.remove(EventStatus.waitingForApproval);
|
||||
}
|
||||
});
|
||||
_rebuildSummary();
|
||||
},
|
||||
),
|
||||
FilterChip(
|
||||
label: const Text('Annulés'),
|
||||
selected: _selectedStatuses.contains(EventStatus.canceled),
|
||||
onSelected: (value) {
|
||||
setState(() {
|
||||
if (value) {
|
||||
_selectedStatuses.add(EventStatus.canceled);
|
||||
} else {
|
||||
_selectedStatuses.remove(EventStatus.canceled);
|
||||
}
|
||||
});
|
||||
_rebuildSummary();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_eventTypeNames.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Types d\'événements',
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _eventTypeNames.entries.map((entry) {
|
||||
final selected = _selectedEventTypeIds.contains(entry.key);
|
||||
return FilterChip(
|
||||
label: Text(entry.value),
|
||||
selected: selected,
|
||||
onSelected: (value) {
|
||||
setState(() {
|
||||
if (value) {
|
||||
_selectedEventTypeIds.add(entry.key);
|
||||
} else {
|
||||
_selectedEventTypeIds.remove(entry.key);
|
||||
}
|
||||
});
|
||||
_rebuildSummary();
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummaryCards() {
|
||||
final metrics = <_MetricConfig>[
|
||||
_MetricConfig(
|
||||
title: 'Total événements',
|
||||
value: _summary.totalEvents.toString(),
|
||||
subtitle: 'Sur la période sélectionnée',
|
||||
icon: Icons.event,
|
||||
),
|
||||
_MetricConfig(
|
||||
title: 'Montant total',
|
||||
value: _formatAmount(_summary.totalAmount),
|
||||
subtitle: 'Base + options ($_amountUnitLabel)',
|
||||
icon: Icons.account_balance_wallet,
|
||||
),
|
||||
_MetricConfig(
|
||||
title: 'Montant validé',
|
||||
value: _formatAmount(_summary.validatedAmount),
|
||||
subtitle: '${_summary.validatedEvents} événement(s) ($_amountUnitLabel)',
|
||||
icon: Icons.verified,
|
||||
),
|
||||
_MetricConfig(
|
||||
title: 'Montant non validé',
|
||||
value: _formatAmount(_summary.pendingAmount),
|
||||
subtitle: '${_summary.pendingEvents} événement(s) ($_amountUnitLabel)',
|
||||
icon: Icons.hourglass_top,
|
||||
),
|
||||
_MetricConfig(
|
||||
title: 'Montant annulé',
|
||||
value: _formatAmount(_summary.canceledAmount),
|
||||
subtitle: '${_summary.canceledEvents} événement(s) ($_amountUnitLabel)',
|
||||
icon: Icons.cancel,
|
||||
),
|
||||
_MetricConfig(
|
||||
title: 'Panier moyen',
|
||||
value: _formatAmount(_summary.averageAmount),
|
||||
subtitle: 'Par événement ($_amountUnitLabel)',
|
||||
icon: Icons.trending_up,
|
||||
),
|
||||
_MetricConfig(
|
||||
title: 'Panier médian',
|
||||
value: _formatAmount(_summary.medianAmount),
|
||||
subtitle: 'Par événement ($_amountUnitLabel)',
|
||||
icon: Icons.timeline,
|
||||
),
|
||||
_MetricConfig(
|
||||
title: 'Pourcentage de validation',
|
||||
value: _formatPercent(_summary.validationRate),
|
||||
subtitle:
|
||||
'${_summary.validatedEvents} validés sur ${_summary.totalEvents}',
|
||||
icon: Icons.pie_chart,
|
||||
),
|
||||
_MetricConfig(
|
||||
title: 'Base vs options',
|
||||
value:
|
||||
'${_formatPercent(_summary.baseContributionRate)} / ${_formatPercent(_summary.optionsContributionRate)}',
|
||||
subtitle:
|
||||
'Base: ${_formatAmount(_summary.baseAmount)} - Options: ${_formatAmount(_summary.optionsAmount)}',
|
||||
icon: Icons.stacked_bar_chart,
|
||||
),
|
||||
];
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'KPI période',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: metrics
|
||||
.map(
|
||||
(metric) => _buildMetricCard(
|
||||
title: metric.title,
|
||||
value: metric.value,
|
||||
subtitle: metric.subtitle,
|
||||
icon: metric.icon,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMetricCard({
|
||||
required String title,
|
||||
required String value,
|
||||
required String subtitle,
|
||||
required IconData icon,
|
||||
}) {
|
||||
return SizedBox(
|
||||
width: 280,
|
||||
child: Card(
|
||||
elevation: 1,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, color: AppColors.rouge),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
value,
|
||||
style:
|
||||
const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(color: Colors.grey.shade700),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildByTypeSection() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Répartition par type d\'événement',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_summary.byEventType.isEmpty)
|
||||
const Text(
|
||||
'Aucune donnée pour la période et les filtres sélectionnés.')
|
||||
else
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: DataTable(
|
||||
columns: [
|
||||
const DataColumn(label: Text('Type')),
|
||||
const DataColumn(label: Text('Nb')),
|
||||
const DataColumn(label: Text('Validé')),
|
||||
const DataColumn(label: Text('Non validé')),
|
||||
const DataColumn(label: Text('Annulé')),
|
||||
DataColumn(label: Text('Total $_amountUnitLabel')),
|
||||
],
|
||||
rows: _summary.byEventType
|
||||
.map(
|
||||
(row) => DataRow(
|
||||
cells: [
|
||||
DataCell(Text(row.eventTypeName)),
|
||||
DataCell(Text(row.totalEvents.toString())),
|
||||
DataCell(Text(_formatAmount(row.validatedAmount))),
|
||||
DataCell(Text(_formatAmount(row.pendingAmount))),
|
||||
DataCell(Text(_formatAmount(row.canceledAmount))),
|
||||
DataCell(Text(_formatAmount(row.totalAmount))),
|
||||
],
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTopOptionsSection() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Top options',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_summary.topOptions.isEmpty)
|
||||
const Text('Aucune option valorisée sur la période sélectionnée.')
|
||||
else
|
||||
..._summary.topOptions.map(
|
||||
(option) => ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.add_chart, color: AppColors.rouge),
|
||||
title: Text(option.optionLabel),
|
||||
subtitle: Text(
|
||||
'Validées ${option.validatedUsageCount} fois',
|
||||
),
|
||||
trailing: Text(
|
||||
_formatAmount(option.totalAmount),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetricConfig {
|
||||
final String title;
|
||||
final String value;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
|
||||
const _MetricConfig({
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.subtitle,
|
||||
required this.icon,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user