reports page
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart' hide ChartPoint;
|
||||
|
||||
import 'package:pshared/models/payment/operation.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
import 'package:pweb/models/chart_point.dart';
|
||||
|
||||
|
||||
class PayoutDistributionChart extends StatelessWidget {
|
||||
@@ -25,7 +26,7 @@ class PayoutDistributionChart extends StatelessWidget {
|
||||
|
||||
// 2) Build chart data
|
||||
final data = sums.entries
|
||||
.map((e) => _ChartData(e.key, e.value))
|
||||
.map((e) => ChartPoint<String>(e.key, e.value))
|
||||
.toList();
|
||||
|
||||
// 3) Build a simple horizontal legend
|
||||
@@ -42,7 +43,7 @@ class PayoutDistributionChart extends StatelessWidget {
|
||||
children: [
|
||||
Icon(Icons.circle, size: 10, color: palette[i % palette.length]),
|
||||
const SizedBox(width: 4),
|
||||
Text(data[i].label, style: Theme.of(context).textTheme.bodySmall),
|
||||
Text(data[i].key, style: Theme.of(context).textTheme.bodySmall),
|
||||
if (i < data.length - 1) const SizedBox(width: 12),
|
||||
],
|
||||
);
|
||||
@@ -62,10 +63,10 @@ class PayoutDistributionChart extends StatelessWidget {
|
||||
child: SfCircularChart(
|
||||
legend: Legend(isVisible: false),
|
||||
tooltipBehavior: TooltipBehavior(enable: true),
|
||||
series: <PieSeries<_ChartData, String>>[
|
||||
PieSeries<_ChartData, String>(
|
||||
series: <PieSeries<ChartPoint<String>, String>>[
|
||||
PieSeries<ChartPoint<String>, String>(
|
||||
dataSource: data,
|
||||
xValueMapper: (d, _) => d.label,
|
||||
xValueMapper: (d, _) => d.key,
|
||||
yValueMapper: (d, _) => d.value,
|
||||
dataLabelMapper: (d, _) =>
|
||||
'${(d.value / sums.values.fold(0, (a, b) => a + b) * 100).toStringAsFixed(1)}%',
|
||||
@@ -95,9 +96,3 @@ class PayoutDistributionChart extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChartData {
|
||||
final String label;
|
||||
final double value;
|
||||
_ChartData(this.label, this.value);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/models/payment/operation.dart';
|
||||
import 'package:pshared/models/payment/status.dart';
|
||||
|
||||
|
||||
List<CurrencyTotal> aggregateCurrencyTotals(
|
||||
List<OperationItem> operations,
|
||||
DateTimeRange range,
|
||||
) {
|
||||
final totals = <String, double>{};
|
||||
for (final operation in operations) {
|
||||
if (operation.status != OperationStatus.success) continue;
|
||||
if (_isUnknownDate(operation.date)) continue;
|
||||
if (operation.date.isBefore(range.start) ||
|
||||
operation.date.isAfter(range.end)) {
|
||||
continue;
|
||||
}
|
||||
final currency = _normalizeCurrency(operation.currency);
|
||||
totals[currency] = (totals[currency] ?? 0) + operation.amount;
|
||||
}
|
||||
|
||||
final list = totals.entries
|
||||
.map((entry) => CurrencyTotal(entry.key, entry.value))
|
||||
.toList();
|
||||
list.sort((a, b) => a.currency.compareTo(b.currency));
|
||||
return list;
|
||||
}
|
||||
|
||||
String _normalizeCurrency(String raw) {
|
||||
final trimmed = raw.trim();
|
||||
return trimmed.isEmpty ? '-' : trimmed;
|
||||
}
|
||||
|
||||
bool _isUnknownDate(DateTime date) => date.millisecondsSinceEpoch == 0;
|
||||
|
||||
class CurrencyTotal {
|
||||
final String currency;
|
||||
final double amount;
|
||||
|
||||
const CurrencyTotal(this.currency, this.amount);
|
||||
}
|
||||
126
frontend/pweb/lib/pages/report/charts/payout_volumes/chart.dart
Normal file
126
frontend/pweb/lib/pages/report/charts/payout_volumes/chart.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pshared/models/payment/operation.dart';
|
||||
|
||||
import 'package:pweb/controllers/payout_volumes.dart';
|
||||
import 'package:pweb/pages/report/charts/payout_volumes/aggregator.dart';
|
||||
import 'package:pweb/pages/report/charts/payout_volumes/pie_chart.dart';
|
||||
import 'package:pweb/pages/report/charts/payout_volumes/range_label.dart';
|
||||
import 'package:pweb/pages/report/charts/payout_volumes/range_picker.dart';
|
||||
import 'package:pweb/pages/report/charts/payout_volumes/totals_list.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class PayoutVolumesChart extends StatelessWidget {
|
||||
final List<OperationItem> operations;
|
||||
|
||||
const PayoutVolumesChart({super.key, required this.operations});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProvider(
|
||||
create: (_) => PayoutVolumesController(),
|
||||
child: _PayoutVolumesChartBody(operations: operations),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PayoutVolumesChartBody extends StatelessWidget {
|
||||
final List<OperationItem> operations;
|
||||
|
||||
const _PayoutVolumesChartBody({required this.operations});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<PayoutVolumesController>(
|
||||
builder: (context, controller, child) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final theme = Theme.of(context);
|
||||
final range = controller.range;
|
||||
|
||||
final totals = aggregateCurrencyTotals(operations, range);
|
||||
final rangeLabel = formatRangeLabel(context, range);
|
||||
|
||||
return SizedBox(
|
||||
height: 200,
|
||||
child: Card(
|
||||
margin: const EdgeInsets.all(16),
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () => pickPayoutVolumesRange(context, controller),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
l10n.debitAmountLabel,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
rangeLabel,
|
||||
style: theme.textTheme.labelMedium,
|
||||
textAlign: TextAlign.right,
|
||||
maxLines: 2,
|
||||
softWrap: true,
|
||||
overflow: TextOverflow.clip,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Icon(
|
||||
Icons.date_range_outlined,
|
||||
size: 16,
|
||||
color: theme.iconTheme.color?.withAlpha(160),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: totals.isEmpty
|
||||
? Center(child: Text(l10n.noPayouts))
|
||||
: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: PayoutTotalsList(
|
||||
totals: totals,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: PayoutVolumesPieChart(
|
||||
totals: totals,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
||||
List<Color> payoutVolumesPalette(ThemeData theme) {
|
||||
return [
|
||||
theme.colorScheme.primary,
|
||||
theme.colorScheme.secondary,
|
||||
theme.colorScheme.tertiary,
|
||||
theme.colorScheme.primaryContainer,
|
||||
theme.colorScheme.secondaryContainer,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
|
||||
import 'package:pweb/pages/report/charts/payout_volumes/aggregator.dart';
|
||||
import 'package:pweb/pages/report/charts/payout_volumes/palette.dart';
|
||||
|
||||
|
||||
class PayoutVolumesPieChart extends StatelessWidget {
|
||||
final List<CurrencyTotal> totals;
|
||||
|
||||
const PayoutVolumesPieChart({super.key, required this.totals});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final palette = payoutVolumesPalette(theme);
|
||||
|
||||
return SfCircularChart(
|
||||
legend: Legend(isVisible: false),
|
||||
tooltipBehavior: TooltipBehavior(enable: true),
|
||||
series: <PieSeries<CurrencyTotal, String>>[
|
||||
PieSeries<CurrencyTotal, String>(
|
||||
dataSource: totals,
|
||||
xValueMapper: (item, _) => item.currency,
|
||||
yValueMapper: (item, _) => item.amount,
|
||||
dataLabelMapper: (item, _) => item.currency,
|
||||
dataLabelSettings: const DataLabelSettings(
|
||||
isVisible: true,
|
||||
labelPosition: ChartDataLabelPosition.inside,
|
||||
),
|
||||
pointColorMapper: (item, index) =>
|
||||
palette[index % palette.length],
|
||||
radius: '100%',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
|
||||
String formatRangeLabel(BuildContext context, DateTimeRange range) {
|
||||
final start = _formatShortDate(context, range.start);
|
||||
final end = _formatShortDate(context, range.end);
|
||||
return '$start –\n$end';
|
||||
}
|
||||
|
||||
String _formatShortDate(BuildContext context, DateTime date) {
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
final day = DateFormat('d', locale).format(date);
|
||||
final month = DateFormat('MMM', locale).format(date);
|
||||
final year = DateFormat('y', locale).format(date);
|
||||
return '$day $month $year';
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pweb/controllers/payout_volumes.dart';
|
||||
|
||||
|
||||
Future<void> pickPayoutVolumesRange(
|
||||
BuildContext context,
|
||||
PayoutVolumesController controller,
|
||||
) async {
|
||||
final now = DateTime.now();
|
||||
final initial = _dateOnlyRange(controller.range);
|
||||
final picked = await showDateRangePicker(
|
||||
context: context,
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateUtils.dateOnly(now).add(const Duration(days: 1)),
|
||||
initialDateRange: initial,
|
||||
);
|
||||
if (picked != null) {
|
||||
controller.setRange(picked);
|
||||
}
|
||||
}
|
||||
|
||||
DateTimeRange _dateOnlyRange(DateTimeRange range) {
|
||||
return DateTimeRange(
|
||||
start: DateUtils.dateOnly(range.start),
|
||||
end: DateUtils.dateOnly(range.end),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pweb/pages/report/charts/payout_volumes/aggregator.dart';
|
||||
import 'package:pweb/pages/report/charts/payout_volumes/palette.dart';
|
||||
import 'package:pweb/utils/report/format.dart';
|
||||
|
||||
|
||||
class PayoutTotalsList extends StatelessWidget {
|
||||
final List<CurrencyTotal> totals;
|
||||
|
||||
const PayoutTotalsList({super.key, required this.totals});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final palette = payoutVolumesPalette(theme);
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (var index = 0; index < totals.length; index++)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: index == totals.length - 1 ? 0 : 8,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.circle,
|
||||
size: 10,
|
||||
color: palette[index % palette.length],
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
totals[index].currency,
|
||||
style: theme.textTheme.bodySmall,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
formatAmount(
|
||||
totals[index].amount,
|
||||
totals[index].currency,
|
||||
),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,13 @@ import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart' hide ChartPoint;
|
||||
|
||||
import 'package:pshared/models/payment/status.dart';
|
||||
import 'package:pshared/models/payment/operation.dart';
|
||||
|
||||
import 'package:pweb/models/chart_point.dart';
|
||||
|
||||
|
||||
class StatusChart extends StatelessWidget {
|
||||
final List<OperationItem> operations;
|
||||
@@ -21,9 +23,9 @@ class StatusChart extends StatelessWidget {
|
||||
counts[op.status] = (counts[op.status] ?? 0) + 1;
|
||||
}
|
||||
final items = counts.entries
|
||||
.map((e) => _ChartData(e.key, e.value.toDouble()))
|
||||
.map((e) => ChartPoint<OperationStatus>(e.key, e.value.toDouble()))
|
||||
.toList();
|
||||
final maxCount = items.map((e) => e.count.toInt()).fold<int>(0, max);
|
||||
final maxCount = items.map((e) => e.value.toInt()).fold<int>(0, max);
|
||||
|
||||
final theme = Theme.of(context);
|
||||
final barColor = theme.colorScheme.secondary;
|
||||
@@ -66,11 +68,11 @@ class StatusChart extends StatelessWidget {
|
||||
),
|
||||
|
||||
// ─── Bar series with tooltip enabled ───────────────
|
||||
series: <ColumnSeries<_ChartData, String>>[
|
||||
ColumnSeries<_ChartData, String>(
|
||||
series: <ColumnSeries<ChartPoint<OperationStatus>, String>>[
|
||||
ColumnSeries<ChartPoint<OperationStatus>, String>(
|
||||
dataSource: items,
|
||||
xValueMapper: (d, _) => d.status.localized(context),
|
||||
yValueMapper: (d, _) => d.count,
|
||||
xValueMapper: (d, _) => d.key.localized(context),
|
||||
yValueMapper: (d, _) => d.value,
|
||||
color: barColor,
|
||||
width: 0.6,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
@@ -83,9 +85,3 @@ class StatusChart extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChartData {
|
||||
final OperationStatus status;
|
||||
final double count;
|
||||
_ChartData(this.status, this.count);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user