reports page

This commit is contained in:
Arseni
2026-02-16 21:05:38 +03:00
parent 11d4b9a608
commit 0eea39fb97
56 changed files with 2227 additions and 501 deletions

View File

@@ -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);
}

View File

@@ -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);
}

View 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,
),
),
],
),
),
],
),
),
),
),
);
},
);
}
}

View File

@@ -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,
];
}

View File

@@ -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%',
),
],
);
}
}

View File

@@ -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';
}

View File

@@ -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),
);
}

View File

@@ -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,
),
),
],
),
),
],
),
);
}
}

View File

@@ -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);
}