- Corrige l'erreur SQL 'Unknown column fk_operation in users' - L'opération active est récupérée depuis operations.chk_active = 1 - Jointure avec users pour filtrer par entité de l'admin créateur - Query: SELECT o.id FROM operations o INNER JOIN users u ON u.fk_entite = o.fk_entite WHERE u.id = ? AND o.chk_active = 1
1136 lines
35 KiB
Dart
Executable File
1136 lines
35 KiB
Dart
Executable File
import 'package:flutter/material.dart';
|
|
import 'package:geosector_app/core/repositories/user_repository.dart';
|
|
import 'package:geosector_app/core/repositories/amicale_repository.dart';
|
|
import 'package:geosector_app/core/services/event_stats_service.dart';
|
|
import 'package:geosector_app/core/services/current_user_service.dart';
|
|
import 'package:geosector_app/core/data/models/event_stats_model.dart';
|
|
import 'package:geosector_app/core/data/models/amicale_model.dart';
|
|
import 'package:geosector_app/core/utils/api_exception.dart';
|
|
import 'package:intl/intl.dart';
|
|
|
|
/// Mode de sélection de la période
|
|
enum DateSelectionMode {
|
|
/// Une seule date
|
|
singleDay,
|
|
/// Plage de dates personnalisée
|
|
dateRange,
|
|
/// Derniers X jours
|
|
lastDays,
|
|
}
|
|
|
|
/// Page d'administration des connexions et événements.
|
|
///
|
|
/// Affiche les statistiques de connexion et d'activité :
|
|
/// - Pour les admins amicale : données de leur amicale uniquement
|
|
/// - Pour les super-admins : données globales avec filtre par amicale
|
|
class AdminConnexionsPage extends StatefulWidget {
|
|
final UserRepository userRepository;
|
|
final AmicaleRepository amicaleRepository;
|
|
|
|
const AdminConnexionsPage({
|
|
super.key,
|
|
required this.userRepository,
|
|
required this.amicaleRepository,
|
|
});
|
|
|
|
@override
|
|
State<AdminConnexionsPage> createState() => _AdminConnexionsPageState();
|
|
}
|
|
|
|
class _AdminConnexionsPageState extends State<AdminConnexionsPage> {
|
|
final _eventStatsService = EventStatsService.instance;
|
|
final _dateFormat = DateFormat('dd/MM/yyyy');
|
|
final _timeFormat = DateFormat('HH:mm');
|
|
|
|
// État de chargement
|
|
bool _isLoading = true;
|
|
String? _error;
|
|
|
|
// Données
|
|
EventSummary? _summary;
|
|
DailyStats? _dailyStats;
|
|
EventDetails? _details;
|
|
|
|
// Mode de sélection de période
|
|
DateSelectionMode _selectionMode = DateSelectionMode.lastDays;
|
|
|
|
// Pour mode "Jour unique"
|
|
DateTime _singleDate = DateTime.now();
|
|
|
|
// Pour mode "Période personnalisée"
|
|
DateTime _startDate = DateTime.now().subtract(const Duration(days: 6));
|
|
DateTime _endDate = DateTime.now();
|
|
|
|
// Pour mode "Derniers jours"
|
|
int _lastDays = 7;
|
|
|
|
// Filtre par type d'événement
|
|
String? _selectedEventType;
|
|
|
|
// Filtre par entité (super-admin uniquement)
|
|
int? _selectedEntityId;
|
|
List<AmicaleModel> _amicales = [];
|
|
bool _isSuperAdmin = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_isSuperAdmin = CurrentUserService.instance.isSuperAdmin;
|
|
if (_isSuperAdmin) {
|
|
_loadAmicales();
|
|
}
|
|
_loadData();
|
|
}
|
|
|
|
/// Charge la liste des amicales pour le filtre super-admin
|
|
void _loadAmicales() {
|
|
try {
|
|
_amicales = widget.amicaleRepository.getAllAmicales();
|
|
// Trier par nom
|
|
_amicales.sort((a, b) => a.name.compareTo(b.name));
|
|
} catch (e) {
|
|
debugPrint('Erreur chargement amicales: $e');
|
|
}
|
|
}
|
|
|
|
/// Calcule les dates de début et fin selon le mode sélectionné
|
|
(DateTime, DateTime) _getDateRange() {
|
|
switch (_selectionMode) {
|
|
case DateSelectionMode.singleDay:
|
|
return (_singleDate, _singleDate);
|
|
case DateSelectionMode.dateRange:
|
|
return (_startDate, _endDate);
|
|
case DateSelectionMode.lastDays:
|
|
final end = DateTime.now();
|
|
final start = end.subtract(Duration(days: _lastDays - 1));
|
|
return (start, end);
|
|
}
|
|
}
|
|
|
|
/// Vérifie si on affiche une période (plusieurs jours)
|
|
bool get _isMultipleDays {
|
|
final (start, end) = _getDateRange();
|
|
return !start.isAtSameMomentAs(end) &&
|
|
(end.difference(start).inDays > 0 || _selectionMode != DateSelectionMode.singleDay);
|
|
}
|
|
|
|
Future<void> _loadData() async {
|
|
setState(() {
|
|
_isLoading = true;
|
|
_error = null;
|
|
});
|
|
|
|
try {
|
|
final (startDate, endDate) = _getDateRange();
|
|
|
|
// Charger les données selon le mode
|
|
if (_selectionMode == DateSelectionMode.singleDay) {
|
|
// Mode jour unique : résumé + détails du jour
|
|
final results = await Future.wait([
|
|
_eventStatsService.getSummary(
|
|
date: _singleDate,
|
|
entityId: _selectedEntityId,
|
|
),
|
|
_eventStatsService.getDetails(
|
|
date: _singleDate,
|
|
event: _selectedEventType,
|
|
entityId: _selectedEntityId,
|
|
limit: 50,
|
|
),
|
|
]);
|
|
|
|
setState(() {
|
|
_summary = results[0] as EventSummary;
|
|
_dailyStats = null; // Pas de graphique en mode jour unique
|
|
_details = results[1] as EventDetails;
|
|
_isLoading = false;
|
|
});
|
|
} else {
|
|
// Mode période : stats quotidiennes + détails du dernier jour
|
|
final results = await Future.wait([
|
|
_eventStatsService.getDailyStats(
|
|
from: startDate,
|
|
to: endDate,
|
|
entityId: _selectedEntityId,
|
|
),
|
|
_eventStatsService.getDetails(
|
|
date: endDate,
|
|
event: _selectedEventType,
|
|
entityId: _selectedEntityId,
|
|
limit: 50,
|
|
),
|
|
]);
|
|
|
|
final dailyStats = results[0] as DailyStats;
|
|
|
|
// Calculer le résumé agrégé depuis les stats quotidiennes
|
|
setState(() {
|
|
_dailyStats = dailyStats;
|
|
_summary = _computeAggregatedSummary(dailyStats);
|
|
_details = results[1] as EventDetails;
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
setState(() {
|
|
_error = e is ApiException ? e.message : 'Erreur lors du chargement des données';
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Calcule un résumé agrégé à partir des stats quotidiennes
|
|
EventSummary _computeAggregatedSummary(DailyStats dailyStats) {
|
|
int authSuccess = 0;
|
|
int authFailed = 0;
|
|
int authLogout = 0;
|
|
int passagesCreated = 0;
|
|
double passagesAmount = 0;
|
|
int stripeSuccess = 0;
|
|
double stripeAmount = 0;
|
|
int totalEvents = 0;
|
|
final uniqueUsersSet = <int>{};
|
|
|
|
for (final day in dailyStats.days) {
|
|
totalEvents += day.totalCount;
|
|
|
|
// Auth events
|
|
if (day.events.containsKey('login_success')) {
|
|
authSuccess += day.events['login_success']!.count;
|
|
}
|
|
if (day.events.containsKey('login_failed')) {
|
|
authFailed += day.events['login_failed']!.count;
|
|
}
|
|
if (day.events.containsKey('logout')) {
|
|
authLogout += day.events['logout']!.count;
|
|
}
|
|
|
|
// Passages
|
|
if (day.events.containsKey('passage_created')) {
|
|
passagesCreated += day.events['passage_created']!.count;
|
|
passagesAmount += day.events['passage_created']!.sumAmount;
|
|
}
|
|
|
|
// Stripe
|
|
if (day.events.containsKey('stripe_payment_success')) {
|
|
stripeSuccess += day.events['stripe_payment_success']!.count;
|
|
stripeAmount += day.events['stripe_payment_success']!.sumAmount;
|
|
}
|
|
}
|
|
|
|
return EventSummary(
|
|
date: dailyStats.to,
|
|
stats: DayStats(
|
|
auth: AuthStats(success: authSuccess, failed: authFailed, logout: authLogout),
|
|
passages: PassageStats(created: passagesCreated, updated: 0, deleted: 0, amount: passagesAmount),
|
|
users: const UserStats(created: 0, updated: 0, deleted: 0),
|
|
sectors: const SectorStats(created: 0, updated: 0, deleted: 0),
|
|
stripe: StripeStats(created: 0, success: stripeSuccess, failed: 0, cancelled: 0, amount: stripeAmount),
|
|
),
|
|
totals: DayTotals(events: totalEvents, uniqueUsers: uniqueUsersSet.length),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final size = MediaQuery.of(context).size;
|
|
final isMobile = size.width <= 600;
|
|
|
|
return SafeArea(
|
|
child: RefreshIndicator(
|
|
onRefresh: _loadData,
|
|
child: SingleChildScrollView(
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|
padding: EdgeInsets.all(isMobile ? 12 : 16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Titre
|
|
_buildTitle(theme),
|
|
const SizedBox(height: 16),
|
|
|
|
// Filtres de période
|
|
_buildPeriodFilters(theme, isMobile),
|
|
const SizedBox(height: 16),
|
|
|
|
// Contenu principal
|
|
if (_isLoading)
|
|
const Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(48),
|
|
child: CircularProgressIndicator(),
|
|
),
|
|
)
|
|
else if (_error != null)
|
|
_buildErrorCard(theme)
|
|
else ...[
|
|
// Cards de résumé
|
|
_buildSummaryCards(theme, isMobile),
|
|
const SizedBox(height: 24),
|
|
|
|
// Graphique d'évolution (seulement si période > 1 jour)
|
|
if (_isMultipleDays) ...[
|
|
_buildChartSection(theme, isMobile),
|
|
const SizedBox(height: 24),
|
|
],
|
|
|
|
// Tableau des événements détaillés
|
|
_buildDetailsTable(theme, isMobile),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildTitle(ThemeData theme) {
|
|
return Text(
|
|
'Connexions et activité',
|
|
style: theme.textTheme.headlineSmall?.copyWith(
|
|
color: theme.colorScheme.primary,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPeriodFilters(ThemeData theme, bool isMobile) {
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Sélecteur de mode
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: [
|
|
Text(
|
|
'Période :',
|
|
style: theme.textTheme.titleSmall?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
SegmentedButton<DateSelectionMode>(
|
|
segments: const [
|
|
ButtonSegment(
|
|
value: DateSelectionMode.singleDay,
|
|
label: Text('Jour'),
|
|
icon: Icon(Icons.today, size: 18),
|
|
),
|
|
ButtonSegment(
|
|
value: DateSelectionMode.dateRange,
|
|
label: Text('Période'),
|
|
icon: Icon(Icons.date_range, size: 18),
|
|
),
|
|
ButtonSegment(
|
|
value: DateSelectionMode.lastDays,
|
|
label: Text('Derniers jours'),
|
|
icon: Icon(Icons.history, size: 18),
|
|
),
|
|
],
|
|
selected: {_selectionMode},
|
|
onSelectionChanged: (values) {
|
|
setState(() => _selectionMode = values.first);
|
|
_loadData();
|
|
},
|
|
style: const ButtonStyle(
|
|
visualDensity: VisualDensity.compact,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
|
|
// Contrôles spécifiques selon le mode
|
|
Wrap(
|
|
spacing: 12,
|
|
runSpacing: 12,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: [
|
|
// Sélecteurs de date selon le mode
|
|
..._buildDateSelectors(theme),
|
|
|
|
// Séparateur vertical
|
|
if (!isMobile)
|
|
Container(
|
|
height: 36,
|
|
width: 1,
|
|
color: theme.colorScheme.outlineVariant,
|
|
),
|
|
|
|
// Filtre par type d'événement
|
|
_buildEventTypeFilter(theme),
|
|
|
|
// Filtre par entité (super-admin uniquement)
|
|
if (_isSuperAdmin) ...[
|
|
if (!isMobile)
|
|
Container(
|
|
height: 36,
|
|
width: 1,
|
|
color: theme.colorScheme.outlineVariant,
|
|
),
|
|
_buildEntityFilter(theme),
|
|
],
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
List<Widget> _buildDateSelectors(ThemeData theme) {
|
|
switch (_selectionMode) {
|
|
case DateSelectionMode.singleDay:
|
|
return [
|
|
_buildDateButton(
|
|
theme: theme,
|
|
label: 'Date',
|
|
date: _singleDate,
|
|
onTap: () => _selectDate(
|
|
initialDate: _singleDate,
|
|
onSelected: (date) {
|
|
setState(() => _singleDate = date);
|
|
_loadData();
|
|
},
|
|
),
|
|
),
|
|
];
|
|
|
|
case DateSelectionMode.dateRange:
|
|
return [
|
|
_buildDateButton(
|
|
theme: theme,
|
|
label: 'Du',
|
|
date: _startDate,
|
|
onTap: () => _selectDate(
|
|
initialDate: _startDate,
|
|
lastDate: _endDate,
|
|
onSelected: (date) {
|
|
setState(() => _startDate = date);
|
|
_loadData();
|
|
},
|
|
),
|
|
),
|
|
const Icon(Icons.arrow_forward, size: 16),
|
|
_buildDateButton(
|
|
theme: theme,
|
|
label: 'Au',
|
|
date: _endDate,
|
|
onTap: () => _selectDate(
|
|
initialDate: _endDate,
|
|
firstDate: _startDate,
|
|
onSelected: (date) {
|
|
setState(() => _endDate = date);
|
|
_loadData();
|
|
},
|
|
),
|
|
),
|
|
];
|
|
|
|
case DateSelectionMode.lastDays:
|
|
return [
|
|
SegmentedButton<int>(
|
|
segments: const [
|
|
ButtonSegment(value: 7, label: Text('7 jours')),
|
|
ButtonSegment(value: 14, label: Text('14 jours')),
|
|
ButtonSegment(value: 21, label: Text('21 jours')),
|
|
ButtonSegment(value: 30, label: Text('30 jours')),
|
|
],
|
|
selected: {_lastDays},
|
|
onSelectionChanged: (values) {
|
|
setState(() => _lastDays = values.first);
|
|
_loadData();
|
|
},
|
|
style: const ButtonStyle(
|
|
visualDensity: VisualDensity.compact,
|
|
),
|
|
),
|
|
];
|
|
}
|
|
}
|
|
|
|
Widget _buildDateButton({
|
|
required ThemeData theme,
|
|
required String label,
|
|
required DateTime date,
|
|
required VoidCallback onTap,
|
|
}) {
|
|
return InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: theme.colorScheme.outline),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
'$label : ',
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
Icon(Icons.calendar_today, size: 16, color: theme.colorScheme.primary),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
_dateFormat.format(date),
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _selectDate({
|
|
required DateTime initialDate,
|
|
DateTime? firstDate,
|
|
DateTime? lastDate,
|
|
required void Function(DateTime) onSelected,
|
|
}) async {
|
|
final picked = await showDatePicker(
|
|
context: context,
|
|
initialDate: initialDate,
|
|
firstDate: firstDate ?? DateTime(2024),
|
|
lastDate: lastDate ?? DateTime.now(),
|
|
locale: const Locale('fr', 'FR'),
|
|
);
|
|
if (picked != null) {
|
|
onSelected(picked);
|
|
}
|
|
}
|
|
|
|
Widget _buildEventTypeFilter(ThemeData theme) {
|
|
return DropdownButtonHideUnderline(
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: theme.colorScheme.outline),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: DropdownButton<String?>(
|
|
value: _selectedEventType,
|
|
hint: const Text('Tous les événements'),
|
|
items: const [
|
|
DropdownMenuItem(value: null, child: Text('Tous')),
|
|
DropdownMenuItem(value: 'login_success', child: Text('Connexions')),
|
|
DropdownMenuItem(value: 'login_failed', child: Text('Échecs connexion')),
|
|
DropdownMenuItem(value: 'passage_created', child: Text('Passages créés')),
|
|
DropdownMenuItem(value: 'stripe_payment_success', child: Text('Paiements Stripe')),
|
|
],
|
|
onChanged: (value) {
|
|
setState(() => _selectedEventType = value);
|
|
_loadData();
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildEntityFilter(ThemeData theme) {
|
|
return DropdownButtonHideUnderline(
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: theme.colorScheme.outline),
|
|
borderRadius: BorderRadius.circular(8),
|
|
color: _selectedEntityId != null
|
|
? theme.colorScheme.primaryContainer.withOpacity(0.3)
|
|
: null,
|
|
),
|
|
child: DropdownButton<int?>(
|
|
value: _selectedEntityId,
|
|
hint: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.business, size: 16, color: theme.colorScheme.primary),
|
|
const SizedBox(width: 6),
|
|
const Text('Toutes les amicales'),
|
|
],
|
|
),
|
|
selectedItemBuilder: (context) {
|
|
return [
|
|
// Item pour "Toutes"
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.business, size: 16, color: theme.colorScheme.primary),
|
|
const SizedBox(width: 6),
|
|
const Text('Toutes les amicales'),
|
|
],
|
|
),
|
|
// Items pour chaque amicale
|
|
..._amicales.map((amicale) => Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.business, size: 16, color: theme.colorScheme.primary),
|
|
const SizedBox(width: 6),
|
|
ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 150),
|
|
child: Text(
|
|
amicale.name,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
)),
|
|
];
|
|
},
|
|
items: [
|
|
const DropdownMenuItem<int?>(
|
|
value: null,
|
|
child: Text('Toutes les amicales'),
|
|
),
|
|
..._amicales.map((amicale) => DropdownMenuItem<int?>(
|
|
value: amicale.id,
|
|
child: Text(
|
|
amicale.name,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
)),
|
|
],
|
|
onChanged: (value) {
|
|
setState(() => _selectedEntityId = value);
|
|
_loadData();
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildErrorCard(ThemeData theme) {
|
|
return Card(
|
|
color: theme.colorScheme.errorContainer,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.error_outline, color: theme.colorScheme.error),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
_error!,
|
|
style: TextStyle(color: theme.colorScheme.onErrorContainer),
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: _loadData,
|
|
child: const Text('Réessayer'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSummaryCards(ThemeData theme, bool isMobile) {
|
|
if (_summary == null) return const SizedBox.shrink();
|
|
|
|
final stats = _summary!.stats;
|
|
final totals = _summary!.totals;
|
|
final (startDate, endDate) = _getDateRange();
|
|
|
|
// Libellé de la période
|
|
final periodLabel = _selectionMode == DateSelectionMode.singleDay
|
|
? _dateFormat.format(_singleDate)
|
|
: '${_dateFormat.format(startDate)} - ${_dateFormat.format(endDate)}';
|
|
|
|
// Disposition responsive : 2 colonnes sur mobile, 4 sur desktop
|
|
final crossAxisCount = isMobile ? 2 : 4;
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Période affichée
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
child: Text(
|
|
'Résumé : $periodLabel',
|
|
style: theme.textTheme.titleSmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
GridView.count(
|
|
crossAxisCount: crossAxisCount,
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
mainAxisSpacing: 12,
|
|
crossAxisSpacing: 12,
|
|
childAspectRatio: isMobile ? 1.3 : 1.8,
|
|
children: [
|
|
// Connexions
|
|
_buildStatCard(
|
|
theme: theme,
|
|
icon: Icons.login,
|
|
iconColor: Colors.green,
|
|
title: 'Connexions',
|
|
value: stats.auth.success.toString(),
|
|
subtitle: '${stats.auth.failed} échecs',
|
|
),
|
|
|
|
// Passages
|
|
_buildStatCard(
|
|
theme: theme,
|
|
icon: Icons.receipt_long,
|
|
iconColor: Colors.blue,
|
|
title: 'Passages',
|
|
value: stats.passages.created.toString(),
|
|
subtitle: '${_formatAmount(stats.passages.amount)} collectés',
|
|
),
|
|
|
|
// Paiements Stripe
|
|
_buildStatCard(
|
|
theme: theme,
|
|
icon: Icons.credit_card,
|
|
iconColor: Colors.purple,
|
|
title: 'Paiements',
|
|
value: stats.stripe.success.toString(),
|
|
subtitle: _formatAmount(stats.stripe.amount),
|
|
),
|
|
|
|
// Utilisateurs actifs
|
|
_buildStatCard(
|
|
theme: theme,
|
|
icon: Icons.people,
|
|
iconColor: Colors.orange,
|
|
title: 'Événements',
|
|
value: totals.events.toString(),
|
|
subtitle: '${totals.uniqueUsers} utilisateurs',
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildStatCard({
|
|
required ThemeData theme,
|
|
required IconData icon,
|
|
required Color iconColor,
|
|
required String title,
|
|
required String value,
|
|
required String subtitle,
|
|
}) {
|
|
return Card(
|
|
elevation: 2,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: iconColor.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Icon(icon, color: iconColor, size: 20),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
title,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
Text(
|
|
value,
|
|
style: theme.textTheme.headlineMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
color: theme.colorScheme.onSurface,
|
|
),
|
|
),
|
|
Text(
|
|
subtitle,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildChartSection(ThemeData theme, bool isMobile) {
|
|
if (_dailyStats == null || _dailyStats!.days.isEmpty) {
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Center(
|
|
child: Text(
|
|
'Aucune donnée pour cette période',
|
|
style: theme.textTheme.bodyLarge?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
final (startDate, endDate) = _getDateRange();
|
|
final daysDiff = endDate.difference(startDate).inDays + 1;
|
|
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Évolution sur $daysDiff jours',
|
|
style: theme.textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
SizedBox(
|
|
height: isMobile ? 200 : 250,
|
|
child: _buildSimpleBarChart(theme),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSimpleBarChart(ThemeData theme) {
|
|
final days = _dailyStats!.days;
|
|
if (days.isEmpty) return const SizedBox.shrink();
|
|
|
|
// Trouver le max pour le scaling
|
|
final maxCount = days.map((d) => d.totalCount).reduce((a, b) => a > b ? a : b);
|
|
final scale = maxCount > 0 ? 1.0 / maxCount : 1.0;
|
|
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final barWidth = (constraints.maxWidth - (days.length - 1) * 4) / days.length;
|
|
final clampedBarWidth = barWidth.clamp(8.0, 40.0);
|
|
|
|
return Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: days.map((day) {
|
|
final height = (constraints.maxHeight - 30) * day.totalCount * scale;
|
|
|
|
return Tooltip(
|
|
message: '${_dateFormat.format(day.date)}\n${day.totalCount} événements',
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
Container(
|
|
width: clampedBarWidth,
|
|
height: height.clamp(4.0, constraints.maxHeight - 30),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.primary,
|
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(4)),
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
SizedBox(
|
|
width: clampedBarWidth + 8,
|
|
child: Text(
|
|
DateFormat('dd').format(day.date),
|
|
style: theme.textTheme.labelSmall,
|
|
textAlign: TextAlign.center,
|
|
overflow: TextOverflow.clip,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}).toList(),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildDetailsTable(ThemeData theme, bool isMobile) {
|
|
final (_, endDate) = _getDateRange();
|
|
final detailsDateLabel = _selectionMode == DateSelectionMode.singleDay
|
|
? _dateFormat.format(_singleDate)
|
|
: _dateFormat.format(endDate);
|
|
|
|
if (_details == null || _details!.events.isEmpty) {
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Center(
|
|
child: Text(
|
|
'Aucun événement pour le $detailsDateLabel',
|
|
style: theme.textTheme.bodyLarge?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Détail des événements',
|
|
style: theme.textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
Text(
|
|
detailsDateLabel,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
Text(
|
|
'${_details!.pagination.total} événements',
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Table responsive
|
|
if (isMobile)
|
|
_buildMobileEventsList(theme)
|
|
else
|
|
_buildDesktopEventsTable(theme),
|
|
|
|
// Pagination
|
|
if (_details!.pagination.hasMore)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 16),
|
|
child: Center(
|
|
child: TextButton.icon(
|
|
onPressed: _loadMoreDetails,
|
|
icon: const Icon(Icons.expand_more),
|
|
label: const Text('Charger plus'),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMobileEventsList(ThemeData theme) {
|
|
return ListView.separated(
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
itemCount: _details!.events.length,
|
|
separatorBuilder: (_, __) => const Divider(height: 1),
|
|
itemBuilder: (context, index) {
|
|
final event = _details!.events[index];
|
|
return ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: _buildEventIcon(event.event),
|
|
title: Text(
|
|
EventTypes.getLabel(event.event),
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
subtitle: Text(
|
|
'${event.username ?? 'Anonyme'} - ${_timeFormat.format(event.timestamp)}',
|
|
style: theme.textTheme.bodySmall,
|
|
),
|
|
trailing: event.platform != null
|
|
? _buildPlatformChip(event.platform!, theme)
|
|
: null,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildDesktopEventsTable(ThemeData theme) {
|
|
return SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: DataTable(
|
|
headingRowHeight: 40,
|
|
dataRowMinHeight: 40,
|
|
dataRowMaxHeight: 50,
|
|
columnSpacing: 24,
|
|
columns: const [
|
|
DataColumn(label: Text('Heure')),
|
|
DataColumn(label: Text('Type')),
|
|
DataColumn(label: Text('Utilisateur')),
|
|
DataColumn(label: Text('Plateforme')),
|
|
DataColumn(label: Text('IP')),
|
|
DataColumn(label: Text('Détails')),
|
|
],
|
|
rows: _details!.events.map((event) {
|
|
return DataRow(
|
|
cells: [
|
|
DataCell(Text(_timeFormat.format(event.timestamp))),
|
|
DataCell(Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_buildEventIcon(event.event),
|
|
const SizedBox(width: 8),
|
|
Text(EventTypes.getLabel(event.event)),
|
|
],
|
|
)),
|
|
DataCell(Text(event.username ?? 'Anonyme')),
|
|
DataCell(event.platform != null
|
|
? _buildPlatformChip(event.platform!, theme)
|
|
: const Text('-')),
|
|
DataCell(Text(event.ip ?? '-')),
|
|
DataCell(Text(event.reason ?? '-')),
|
|
],
|
|
);
|
|
}).toList(),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildEventIcon(String eventType) {
|
|
IconData icon;
|
|
Color color;
|
|
|
|
switch (eventType) {
|
|
case 'login_success':
|
|
icon = Icons.login;
|
|
color = Colors.green;
|
|
break;
|
|
case 'login_failed':
|
|
icon = Icons.error_outline;
|
|
color = Colors.red;
|
|
break;
|
|
case 'logout':
|
|
icon = Icons.logout;
|
|
color = Colors.orange;
|
|
break;
|
|
case 'passage_created':
|
|
icon = Icons.add_circle_outline;
|
|
color = Colors.blue;
|
|
break;
|
|
case 'passage_updated':
|
|
icon = Icons.edit;
|
|
color = Colors.teal;
|
|
break;
|
|
case 'passage_deleted':
|
|
icon = Icons.delete_outline;
|
|
color = Colors.red;
|
|
break;
|
|
case 'stripe_payment_success':
|
|
icon = Icons.credit_card;
|
|
color = Colors.green;
|
|
break;
|
|
case 'stripe_payment_failed':
|
|
icon = Icons.credit_card_off;
|
|
color = Colors.red;
|
|
break;
|
|
default:
|
|
icon = Icons.circle;
|
|
color = Colors.grey;
|
|
}
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.all(6),
|
|
decoration: BoxDecoration(
|
|
color: color.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Icon(icon, size: 16, color: color),
|
|
);
|
|
}
|
|
|
|
Widget _buildPlatformChip(String platform, ThemeData theme) {
|
|
IconData icon;
|
|
switch (platform.toLowerCase()) {
|
|
case 'ios':
|
|
icon = Icons.phone_iphone;
|
|
break;
|
|
case 'android':
|
|
icon = Icons.phone_android;
|
|
break;
|
|
case 'web':
|
|
icon = Icons.computer;
|
|
break;
|
|
default:
|
|
icon = Icons.devices;
|
|
}
|
|
|
|
return Chip(
|
|
avatar: Icon(icon, size: 14),
|
|
label: Text(
|
|
platform.toUpperCase(),
|
|
style: theme.textTheme.labelSmall,
|
|
),
|
|
padding: EdgeInsets.zero,
|
|
visualDensity: VisualDensity.compact,
|
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
);
|
|
}
|
|
|
|
Future<void> _loadMoreDetails() async {
|
|
if (_details == null || !_details!.pagination.hasMore) return;
|
|
|
|
final (_, endDate) = _getDateRange();
|
|
final detailsDate = _selectionMode == DateSelectionMode.singleDay
|
|
? _singleDate
|
|
: endDate;
|
|
|
|
try {
|
|
final moreDetails = await _eventStatsService.getDetails(
|
|
date: detailsDate,
|
|
event: _selectedEventType,
|
|
entityId: _selectedEntityId,
|
|
limit: 50,
|
|
offset: _details!.events.length,
|
|
);
|
|
|
|
setState(() {
|
|
_details = EventDetails(
|
|
date: _details!.date,
|
|
events: [..._details!.events, ...moreDetails.events],
|
|
pagination: moreDetails.pagination,
|
|
);
|
|
});
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ApiException.showError(context, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
String _formatAmount(double amount) {
|
|
return NumberFormat.currency(locale: 'fr_FR', symbol: '€').format(amount);
|
|
}
|
|
}
|