reports page
This commit is contained in:
43
frontend/pweb/lib/pages/report/cards/column.dart
Normal file
43
frontend/pweb/lib/pages/report/cards/column.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/models/payment/operation.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
import 'package:pweb/pages/report/cards/items.dart';
|
||||
|
||||
|
||||
class OperationsCardsColumn extends StatelessWidget {
|
||||
final List<OperationItem> operations;
|
||||
final ValueChanged<OperationItem>? onTap;
|
||||
|
||||
const OperationsCardsColumn({
|
||||
super.key,
|
||||
required this.operations,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
final theme = Theme.of(context);
|
||||
final items = buildOperationCardItems(
|
||||
context,
|
||||
operations,
|
||||
onTap: onTap,
|
||||
);
|
||||
|
||||
if (operations.isEmpty) {
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
loc.reportPaymentsEmpty,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: items,
|
||||
);
|
||||
}
|
||||
}
|
||||
74
frontend/pweb/lib/pages/report/cards/items.dart
Normal file
74
frontend/pweb/lib/pages/report/cards/items.dart
Normal file
@@ -0,0 +1,74 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/models/payment/operation.dart';
|
||||
|
||||
import 'package:pweb/pages/report/cards/operation_card.dart';
|
||||
import 'package:pweb/utils/report/format.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
List<Widget> buildOperationCardItems(
|
||||
BuildContext context,
|
||||
List<OperationItem> operations, {
|
||||
ValueChanged<OperationItem>? onTap,
|
||||
}) {
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
final items = <Widget>[];
|
||||
String? currentKey;
|
||||
|
||||
for (final operation in operations) {
|
||||
final dateKey = _dateKey(operation.date);
|
||||
if (dateKey != currentKey) {
|
||||
if (items.isNotEmpty) {
|
||||
items.add(const SizedBox(height: 16));
|
||||
}
|
||||
items.add(_DateHeader(
|
||||
label: _dateLabel(context, operation.date, loc),
|
||||
));
|
||||
items.add(const SizedBox(height: 8));
|
||||
currentKey = dateKey;
|
||||
}
|
||||
|
||||
items.add(OperationCard(
|
||||
operation: operation,
|
||||
onTap: onTap,
|
||||
));
|
||||
items.add(const SizedBox(height: 12));
|
||||
}
|
||||
|
||||
if (items.isNotEmpty) {
|
||||
items.removeLast();
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
String _dateKey(DateTime date) {
|
||||
if (date.millisecondsSinceEpoch == 0) return 'unknown';
|
||||
final local = date.toLocal();
|
||||
final normalized = DateTime(local.year, local.month, local.day);
|
||||
return normalized.toIso8601String();
|
||||
}
|
||||
|
||||
String _dateLabel(BuildContext context, DateTime date, AppLocalizations loc) {
|
||||
if (date.millisecondsSinceEpoch == 0) return loc.unknown;
|
||||
return formatLongDate(context, date);
|
||||
}
|
||||
|
||||
class _DateHeader extends StatelessWidget {
|
||||
final String label;
|
||||
|
||||
const _DateHeader({required this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Text(
|
||||
label,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
44
frontend/pweb/lib/pages/report/cards/list.dart
Normal file
44
frontend/pweb/lib/pages/report/cards/list.dart
Normal file
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/models/payment/operation.dart';
|
||||
|
||||
import 'package:pweb/pages/report/cards/items.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class OperationsCardsList extends StatelessWidget {
|
||||
final List<OperationItem> operations;
|
||||
final ValueChanged<OperationItem>? onTap;
|
||||
|
||||
const OperationsCardsList({
|
||||
super.key,
|
||||
required this.operations,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
final items = buildOperationCardItems(
|
||||
context,
|
||||
operations,
|
||||
onTap: onTap,
|
||||
);
|
||||
|
||||
return Expanded(
|
||||
child: operations.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
loc.reportPaymentsEmpty,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) => items[index],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
87
frontend/pweb/lib/pages/report/cards/operation_card.dart
Normal file
87
frontend/pweb/lib/pages/report/cards/operation_card.dart
Normal file
@@ -0,0 +1,87 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/models/payment/operation.dart';
|
||||
|
||||
import 'package:pweb/pages/report/cards/operation_card_utils.dart';
|
||||
import 'package:pweb/pages/report/cards/operation_info_row.dart';
|
||||
import 'package:pweb/pages/report/table/badge.dart';
|
||||
import 'package:pweb/utils/report/format.dart';
|
||||
import 'package:pweb/utils/report/payment_mapper.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class OperationCard extends StatelessWidget {
|
||||
final OperationItem operation;
|
||||
final ValueChanged<OperationItem>? onTap;
|
||||
|
||||
const OperationCard({
|
||||
super.key,
|
||||
required this.operation,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
final theme = Theme.of(context);
|
||||
final canOpen = onTap != null && paymentIdFromOperation(operation) != null;
|
||||
final amountLabel = formatAmount(operation.amount, operation.currency);
|
||||
final toAmountLabel = formatAmount(operation.toAmount, operation.toCurrency);
|
||||
final showToAmount = shouldShowToAmount(operation);
|
||||
final timeLabel = formatOperationTime(context, operation.date);
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
side: BorderSide(color: theme.dividerColor.withAlpha(25)),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: canOpen ? () => onTap?.call(operation) : null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
OperationStatusBadge(status: operation.status),
|
||||
const Spacer(),
|
||||
Text(
|
||||
timeLabel,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
amountLabel,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (showToAmount)
|
||||
Text(
|
||||
loc.recipientWillReceive(toAmountLabel),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if ((operation.fileName ?? '').trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
OperationInfoRow(
|
||||
icon: Icons.description,
|
||||
value: operation.fileName!,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/models/payment/operation.dart';
|
||||
|
||||
|
||||
bool shouldShowToAmount(OperationItem operation) {
|
||||
if (operation.toCurrency.trim().isEmpty) return false;
|
||||
if (operation.currency.trim().isEmpty) return true;
|
||||
if (operation.currency != operation.toCurrency) return true;
|
||||
return (operation.toAmount - operation.amount).abs() > 0.0001;
|
||||
}
|
||||
|
||||
String formatOperationTime(BuildContext context, DateTime date) {
|
||||
if (date.millisecondsSinceEpoch == 0) return '-';
|
||||
return TimeOfDay.fromDateTime(date.toLocal()).format(context);
|
||||
}
|
||||
36
frontend/pweb/lib/pages/report/cards/operation_info_row.dart
Normal file
36
frontend/pweb/lib/pages/report/cards/operation_info_row.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
||||
class OperationInfoRow extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String value;
|
||||
|
||||
const OperationInfoRow({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
47
frontend/pweb/lib/pages/report/details/content.dart
Normal file
47
frontend/pweb/lib/pages/report/details/content.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/models/payment/payment.dart';
|
||||
|
||||
import 'package:pweb/pages/report/details/header.dart';
|
||||
import 'package:pweb/pages/report/details/sections.dart';
|
||||
import 'package:pweb/pages/report/details/summary_card/widget.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class PaymentDetailsContent extends StatelessWidget {
|
||||
final Payment payment;
|
||||
final VoidCallback onBack;
|
||||
final VoidCallback? onDownloadAct;
|
||||
|
||||
const PaymentDetailsContent({
|
||||
super.key,
|
||||
required this.payment,
|
||||
required this.onBack,
|
||||
this.onDownloadAct,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
PaymentDetailsHeader(
|
||||
title: loc.paymentInfo,
|
||||
onBack: onBack,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
PaymentSummaryCard(
|
||||
payment: payment,
|
||||
onDownloadAct: onDownloadAct,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
PaymentDetailsSections(payment: payment),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
43
frontend/pweb/lib/pages/report/details/header.dart
Normal file
43
frontend/pweb/lib/pages/report/details/header.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class PaymentDetailsHeader extends StatelessWidget {
|
||||
final String title;
|
||||
final VoidCallback onBack;
|
||||
|
||||
const PaymentDetailsHeader({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.onBack,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: onBack,
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
tooltip: AppLocalizations.of(context)!.back,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
87
frontend/pweb/lib/pages/report/details/page.dart
Normal file
87
frontend/pweb/lib/pages/report/details/page.dart
Normal file
@@ -0,0 +1,87 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pshared/models/payment/payment.dart';
|
||||
import 'package:pshared/models/payment/status.dart';
|
||||
import 'package:pshared/provider/payment/payments.dart';
|
||||
|
||||
import 'package:pweb/app/router/payout_routes.dart';
|
||||
import 'package:pweb/pages/report/details/content.dart';
|
||||
import 'package:pweb/pages/report/details/states/error.dart';
|
||||
import 'package:pweb/pages/report/details/states/not_found.dart';
|
||||
import 'package:pweb/utils/report/download_act.dart';
|
||||
import 'package:pweb/utils/report/payment_mapper.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class PaymentDetailsPage extends StatelessWidget {
|
||||
final String paymentId;
|
||||
|
||||
const PaymentDetailsPage({
|
||||
super.key,
|
||||
required this.paymentId,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Consumer<PaymentsProvider>(
|
||||
builder: (context, provider, child) {
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
if (provider.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (provider.error != null) {
|
||||
return PaymentDetailsError(
|
||||
message: provider.error?.toString() ?? loc.noErrorInformation,
|
||||
onRetry: () => provider.refresh(),
|
||||
);
|
||||
}
|
||||
|
||||
final payment = _findPayment(provider.payments, paymentId);
|
||||
if (payment == null) {
|
||||
return PaymentDetailsNotFound(onBack: () => _handleBack(context));
|
||||
}
|
||||
|
||||
final status = statusFromPayment(payment);
|
||||
final paymentRef = payment.paymentRef ?? '';
|
||||
final canDownload = status == OperationStatus.success &&
|
||||
paymentRef.trim().isNotEmpty;
|
||||
|
||||
return PaymentDetailsContent(
|
||||
payment: payment,
|
||||
onBack: () => _handleBack(context),
|
||||
onDownloadAct: canDownload
|
||||
? () => downloadPaymentAct(context, paymentRef)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Payment? _findPayment(List<Payment> payments, String paymentId) {
|
||||
final trimmed = paymentId.trim();
|
||||
if (trimmed.isEmpty) return null;
|
||||
for (final payment in payments) {
|
||||
if (payment.paymentRef == trimmed) return payment;
|
||||
if (payment.idempotencyKey == trimmed) return payment;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _handleBack(BuildContext context) {
|
||||
final router = GoRouter.of(context);
|
||||
if (router.canPop()) {
|
||||
context.pop();
|
||||
return;
|
||||
}
|
||||
context.go(PayoutRoutes.reportsPath);
|
||||
}
|
||||
}
|
||||
71
frontend/pweb/lib/pages/report/details/row.dart
Normal file
71
frontend/pweb/lib/pages/report/details/row.dart
Normal file
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
||||
class DetailRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final bool multiline;
|
||||
final bool monospaced;
|
||||
|
||||
const DetailRow({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.multiline = false,
|
||||
this.monospaced = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final valueStyle = monospaced
|
||||
? theme.textTheme.bodyMedium?.copyWith(fontFamily: 'monospace')
|
||||
: theme.textTheme.bodyMedium;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isNarrow = constraints.maxWidth < 250;
|
||||
if (isNarrow) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
SelectableText(value, style: valueStyle),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: multiline
|
||||
? CrossAxisAlignment.start
|
||||
: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: SelectableText(value, style: valueStyle),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
41
frontend/pweb/lib/pages/report/details/section.dart
Normal file
41
frontend/pweb/lib/pages/report/details/section.dart
Normal file
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
||||
class DetailsSection extends StatelessWidget {
|
||||
final String title;
|
||||
final List<Widget> children;
|
||||
|
||||
const DetailsSection({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.children,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
side: BorderSide(color: theme.dividerColor.withAlpha(25)),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
36
frontend/pweb/lib/pages/report/details/sections.dart
Normal file
36
frontend/pweb/lib/pages/report/details/sections.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/models/payment/payment.dart';
|
||||
|
||||
import 'package:pweb/pages/report/details/sections/fx.dart';
|
||||
import 'package:pweb/pages/report/details/sections/metadata.dart';
|
||||
|
||||
|
||||
class PaymentDetailsSections extends StatelessWidget {
|
||||
final Payment payment;
|
||||
|
||||
const PaymentDetailsSections({
|
||||
super.key,
|
||||
required this.payment,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasFx = _hasFxQuote(payment);
|
||||
if (!hasFx) {
|
||||
return PaymentMetadataSection(payment: payment);
|
||||
}
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: PaymentFxSection(payment: payment)),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(child: PaymentMetadataSection(payment: payment)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
bool _hasFxQuote(Payment payment) => payment.lastQuote?.fxQuote != null;
|
||||
|
||||
}
|
||||
70
frontend/pweb/lib/pages/report/details/sections/fx.dart
Normal file
70
frontend/pweb/lib/pages/report/details/sections/fx.dart
Normal file
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/models/payment/payment.dart';
|
||||
import 'package:pshared/models/payment/fx/quote.dart';
|
||||
import 'package:pshared/utils/currency.dart';
|
||||
|
||||
import 'package:pweb/pages/report/details/section.dart';
|
||||
import 'package:pweb/pages/report/details/sections/rows.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class PaymentFxSection extends StatelessWidget {
|
||||
final Payment payment;
|
||||
|
||||
const PaymentFxSection({
|
||||
super.key,
|
||||
required this.payment,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
final fx = payment.lastQuote?.fxQuote;
|
||||
final rows = buildDetailRows([
|
||||
DetailValue(
|
||||
label: loc.fxRateLabel,
|
||||
value: _formatRate(fx),
|
||||
),
|
||||
]);
|
||||
|
||||
return DetailsSection(
|
||||
title: loc.paymentDetailsFx,
|
||||
children: rows,
|
||||
);
|
||||
}
|
||||
|
||||
String? _formatRate(FxQuote? fx) {
|
||||
if (fx == null) return null;
|
||||
final price = fx.price?.trim();
|
||||
if (price == null || price.isEmpty) return null;
|
||||
|
||||
final base = _firstNonEmpty([
|
||||
currencySymbolFromCode(fx.baseCurrency),
|
||||
currencySymbolFromCode(fx.baseAmount?.currency),
|
||||
fx.baseCurrency,
|
||||
fx.baseAmount?.currency,
|
||||
]);
|
||||
final quote = _firstNonEmpty([
|
||||
currencySymbolFromCode(fx.quoteCurrency),
|
||||
currencySymbolFromCode(fx.quoteAmount?.currency),
|
||||
fx.quoteCurrency,
|
||||
fx.quoteAmount?.currency,
|
||||
]);
|
||||
|
||||
if (base == null || quote == null) {
|
||||
return price;
|
||||
}
|
||||
|
||||
return '1 $base = $price $quote';
|
||||
}
|
||||
|
||||
String? _firstNonEmpty(List<String?> values) {
|
||||
for (final value in values) {
|
||||
final trimmed = value?.trim();
|
||||
if (trimmed != null && trimmed.isNotEmpty) return trimmed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/models/payment/payment.dart';
|
||||
|
||||
import 'package:pweb/pages/report/details/row.dart';
|
||||
import 'package:pweb/pages/report/details/section.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class PaymentMetadataSection extends StatelessWidget {
|
||||
final Payment payment;
|
||||
|
||||
const PaymentMetadataSection({
|
||||
super.key,
|
||||
required this.payment,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
final metadata = payment.metadata ?? const {};
|
||||
const allowedKeys = {'upload_filename', 'upload_rows'};
|
||||
final filtered = Map<String, String>.fromEntries(
|
||||
metadata.entries.where((entry) => allowedKeys.contains(entry.key)),
|
||||
);
|
||||
|
||||
if (filtered.isEmpty) {
|
||||
return DetailsSection(
|
||||
title: loc.paymentDetailsMetadata,
|
||||
children: [
|
||||
Text(
|
||||
loc.metadataEmpty,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final entries = filtered.entries.toList()
|
||||
..sort((a, b) => a.key.compareTo(b.key));
|
||||
|
||||
return DetailsSection(
|
||||
title: loc.paymentDetailsMetadata,
|
||||
children: entries
|
||||
.map(
|
||||
(entry) => DetailRow(
|
||||
label: _metadataLabel(loc, entry.key),
|
||||
value: entry.value,
|
||||
monospaced: true,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _metadataLabel(AppLocalizations loc, String key) {
|
||||
switch (key) {
|
||||
case 'upload_filename':
|
||||
return loc.metadataUploadFileName;
|
||||
case 'upload_rows':
|
||||
return loc.metadataTotalRecipients;
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
}
|
||||
31
frontend/pweb/lib/pages/report/details/sections/rows.dart
Normal file
31
frontend/pweb/lib/pages/report/details/sections/rows.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
import 'package:pweb/pages/report/details/row.dart';
|
||||
|
||||
|
||||
class DetailValue {
|
||||
final String label;
|
||||
final String? value;
|
||||
final bool multiline;
|
||||
final bool monospaced;
|
||||
|
||||
const DetailValue({
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.multiline = false,
|
||||
this.monospaced = false,
|
||||
});
|
||||
}
|
||||
|
||||
List<DetailRow> buildDetailRows(List<DetailValue> values) {
|
||||
return values
|
||||
.where((item) {
|
||||
final value = item.value?.trim();
|
||||
return value != null && value.isNotEmpty && value != '-';
|
||||
})
|
||||
.map((item) => DetailRow(
|
||||
label: item.label,
|
||||
value: item.value!.trim(),
|
||||
multiline: item.multiline,
|
||||
monospaced: item.monospaced,
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
34
frontend/pweb/lib/pages/report/details/states/error.dart
Normal file
34
frontend/pweb/lib/pages/report/details/states/error.dart
Normal file
@@ -0,0 +1,34 @@
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class PaymentDetailsError extends StatelessWidget {
|
||||
final String message;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
const PaymentDetailsError({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.onRetry,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(loc.notificationError(message)),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: onRetry,
|
||||
child: Text(loc.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
35
frontend/pweb/lib/pages/report/details/states/not_found.dart
Normal file
35
frontend/pweb/lib/pages/report/details/states/not_found.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class PaymentDetailsNotFound extends StatelessWidget {
|
||||
final VoidCallback onBack;
|
||||
|
||||
const PaymentDetailsNotFound({
|
||||
super.key,
|
||||
required this.onBack,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
loc.paymentDetailsNotFound,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onBack,
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
label: Text(loc.back),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
||||
class AmountHeadline extends StatelessWidget {
|
||||
final String amount;
|
||||
final String currency;
|
||||
|
||||
const AmountHeadline({
|
||||
required this.amount,
|
||||
required this.currency,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final amountStyle = theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
);
|
||||
final currencyStyle = theme.textTheme.titleLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
);
|
||||
|
||||
if (currency.isEmpty) {
|
||||
return Text(amount, style: amountStyle);
|
||||
}
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(amount, style: amountStyle),
|
||||
const SizedBox(width: 4),
|
||||
Text(currency, style: currencyStyle),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
||||
class CopyableId extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final VoidCallback onCopy;
|
||||
|
||||
const CopyableId({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.onCopy,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final labelStyle = theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
);
|
||||
final valueStyle = theme.textTheme.labelLarge?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
);
|
||||
|
||||
return InkWell(
|
||||
onTap: onCopy,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest.withAlpha(120),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: theme.dividerColor.withAlpha(40)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(label, style: labelStyle),
|
||||
const SizedBox(width: 8),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 320),
|
||||
child: Text(
|
||||
value,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: valueStyle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.copy_outlined,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
||||
class InfoLine extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String text;
|
||||
final bool muted;
|
||||
|
||||
const InfoLine({
|
||||
required this.icon,
|
||||
required this.text,
|
||||
this.muted = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final color = muted
|
||||
? theme.colorScheme.onSurfaceVariant
|
||||
: theme.colorScheme.onSurface;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: theme.colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: color),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
124
frontend/pweb/lib/pages/report/details/summary_card/widget.dart
Normal file
124
frontend/pweb/lib/pages/report/details/summary_card/widget.dart
Normal file
@@ -0,0 +1,124 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/models/payment/payment.dart';
|
||||
|
||||
import 'package:pweb/pages/report/details/summary_card/amount_headline.dart';
|
||||
import 'package:pweb/pages/report/details/summary_card/copy_id.dart';
|
||||
import 'package:pweb/pages/report/details/summary_card/info_line.dart';
|
||||
import 'package:pweb/pages/report/table/badge.dart';
|
||||
import 'package:pweb/utils/report/amount_parts.dart';
|
||||
import 'package:pweb/utils/report/format.dart';
|
||||
import 'package:pweb/utils/report/payment_mapper.dart';
|
||||
import 'package:pweb/utils/clipboard.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class PaymentSummaryCard extends StatelessWidget {
|
||||
final Payment payment;
|
||||
final VoidCallback? onDownloadAct;
|
||||
|
||||
const PaymentSummaryCard({
|
||||
super.key,
|
||||
required this.payment,
|
||||
this.onDownloadAct,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
final theme = Theme.of(context);
|
||||
final status = statusFromPayment(payment);
|
||||
final dateLabel = formatDateLabel(context, resolvePaymentDate(payment));
|
||||
|
||||
final primaryAmount = payment.lastQuote?.debitAmount ??
|
||||
payment.lastQuote?.expectedSettlementAmount;
|
||||
final toAmount = payment.lastQuote?.expectedSettlementAmount;
|
||||
final fee = payment.lastQuote?.expectedFeeTotal ??
|
||||
payment.lastQuote?.networkFee?.networkFee;
|
||||
|
||||
final amountLabel = formatMoney(primaryAmount);
|
||||
final toAmountLabel = formatMoney(toAmount);
|
||||
final feeLabel = formatMoney(fee);
|
||||
final paymentRef = (payment.paymentRef ?? '').trim();
|
||||
|
||||
final showToAmount = toAmountLabel != '-' && toAmountLabel != amountLabel;
|
||||
final showPaymentId = paymentRef.isNotEmpty;
|
||||
final amountParts = splitAmount(amountLabel);
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
side: BorderSide(color: theme.dividerColor.withAlpha(25)),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
OperationStatusBadge(status: status),
|
||||
const Spacer(),
|
||||
Text(
|
||||
dateLabel,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
AmountHeadline(
|
||||
amount: amountParts.amount,
|
||||
currency: amountParts.currency,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
if (amountLabel != '-')
|
||||
InfoLine(
|
||||
icon: Icons.send_outlined,
|
||||
text: loc.sentAmount(amountLabel),
|
||||
),
|
||||
if (showToAmount && toAmountLabel != '-')
|
||||
InfoLine(
|
||||
icon: Icons.south_east,
|
||||
text: loc.recipientWillReceive(toAmountLabel),
|
||||
),
|
||||
if (feeLabel != '-')
|
||||
InfoLine(
|
||||
icon: Icons.receipt_long_outlined,
|
||||
text: loc.fee(feeLabel),
|
||||
muted: true,
|
||||
),
|
||||
if (onDownloadAct != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onDownloadAct,
|
||||
icon: const Icon(Icons.download),
|
||||
label: Text(loc.downloadAct),
|
||||
),
|
||||
],
|
||||
if (showPaymentId) ...[
|
||||
const SizedBox(height: 16),
|
||||
Divider(color: theme.dividerColor.withAlpha(35), height: 1),
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: CopyableId(
|
||||
label: loc.paymentIdLabel,
|
||||
value: paymentRef,
|
||||
onCopy: () => copyToClipboard(
|
||||
context,
|
||||
paymentRef,
|
||||
loc.paymentIdCopied,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,199 +3,84 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pshared/models/payment/operation.dart';
|
||||
import 'package:pshared/models/payment/payment.dart';
|
||||
import 'package:pshared/models/payment/status.dart';
|
||||
import 'package:pshared/provider/payment/payments.dart';
|
||||
|
||||
import 'package:pweb/pages/report/charts/distribution.dart';
|
||||
import 'package:pweb/pages/report/cards/list.dart';
|
||||
import 'package:pweb/pages/report/charts/payout_volumes/chart.dart';
|
||||
import 'package:pweb/pages/report/charts/status.dart';
|
||||
import 'package:pweb/controllers/report_operations.dart';
|
||||
import 'package:pweb/pages/report/table/filters.dart';
|
||||
import 'package:pweb/pages/report/table/widget.dart';
|
||||
import 'package:pweb/utils/report/payment_mapper.dart';
|
||||
import 'package:pweb/app/router/payout_routes.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class OperationHistoryPage extends StatefulWidget {
|
||||
class OperationHistoryPage extends StatelessWidget {
|
||||
const OperationHistoryPage({super.key});
|
||||
|
||||
@override
|
||||
State<OperationHistoryPage> createState() => _OperationHistoryPageState();
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProxyProvider<PaymentsProvider, ReportOperationsController>(
|
||||
create: (_) => ReportOperationsController(),
|
||||
update: (_, payments, controller) => controller!..update(payments),
|
||||
child: const _OperationHistoryView(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OperationHistoryPageState extends State<OperationHistoryPage> {
|
||||
DateTimeRange? _pendingRange;
|
||||
DateTimeRange? _appliedRange;
|
||||
final Set<OperationStatus> _pendingStatuses = {};
|
||||
Set<OperationStatus> _appliedStatuses = {};
|
||||
class _OperationHistoryView extends StatelessWidget {
|
||||
const _OperationHistoryView();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final provider = context.read<PaymentsProvider>();
|
||||
if (!provider.isReady && !provider.isLoading) {
|
||||
provider.refresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pickRange() async {
|
||||
Future<void> _pickRange(
|
||||
BuildContext context,
|
||||
ReportOperationsController controller,
|
||||
) async {
|
||||
final now = DateTime.now();
|
||||
final initial = _pendingRange ??
|
||||
final initial = controller.selectedRange ??
|
||||
DateTimeRange(
|
||||
start: now.subtract(const Duration(days: 30)),
|
||||
end: now,
|
||||
);
|
||||
|
||||
|
||||
final picked = await showDateRangePicker(
|
||||
context: context,
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: now.add(const Duration(days: 1)),
|
||||
initialDateRange: initial,
|
||||
);
|
||||
|
||||
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
_pendingRange = picked;
|
||||
});
|
||||
controller.setRange(picked);
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleStatus(OperationStatus status) {
|
||||
setState(() {
|
||||
if (_pendingStatuses.contains(status)) {
|
||||
_pendingStatuses.remove(status);
|
||||
} else {
|
||||
_pendingStatuses.add(status);
|
||||
}
|
||||
});
|
||||
void _openPaymentDetails(BuildContext context, OperationItem operation) {
|
||||
final paymentId = paymentIdFromOperation(operation);
|
||||
if (paymentId == null) return;
|
||||
|
||||
context.pushToReportPayment(paymentId);
|
||||
}
|
||||
|
||||
void _applyFilters() {
|
||||
setState(() {
|
||||
_appliedRange = _pendingRange;
|
||||
_appliedStatuses = {..._pendingStatuses};
|
||||
});
|
||||
}
|
||||
|
||||
List<OperationItem> _mapPayments(List<Payment> payments) {
|
||||
return payments.map(_mapPayment).toList();
|
||||
}
|
||||
|
||||
OperationItem _mapPayment(Payment payment) {
|
||||
final debit = payment.lastQuote?.debitAmount;
|
||||
final settlement = payment.lastQuote?.expectedSettlementAmount;
|
||||
final amountMoney = debit ?? settlement;
|
||||
|
||||
final amount = _parseAmount(amountMoney?.amount);
|
||||
final currency = amountMoney?.currency ?? '';
|
||||
final toAmount = settlement == null ? amount : _parseAmount(settlement.amount);
|
||||
final toCurrency = settlement?.currency ?? currency;
|
||||
|
||||
final payId = _firstNonEmpty([payment.paymentRef, payment.idempotencyKey]) ?? '-';
|
||||
final name = _firstNonEmpty([payment.lastQuote?.quoteRef, payment.paymentRef, payment.idempotencyKey]) ?? '-';
|
||||
final comment = _firstNonEmpty([payment.failureReason, payment.failureCode, payment.state]) ?? '';
|
||||
|
||||
return OperationItem(
|
||||
status: _statusFromPaymentState(payment.state),
|
||||
fileName: null,
|
||||
amount: amount,
|
||||
currency: currency,
|
||||
toAmount: toAmount,
|
||||
toCurrency: toCurrency,
|
||||
payId: payId,
|
||||
paymentRef: payment.paymentRef,
|
||||
cardNumber: null,
|
||||
name: name,
|
||||
date: _resolvePaymentDate(payment),
|
||||
comment: comment,
|
||||
);
|
||||
}
|
||||
|
||||
List<OperationItem> _filterOperations(List<OperationItem> operations) {
|
||||
if (_appliedRange == null && _appliedStatuses.isEmpty) {
|
||||
return operations;
|
||||
}
|
||||
|
||||
return operations.where((op) {
|
||||
final statusMatch =
|
||||
_appliedStatuses.isEmpty || _appliedStatuses.contains(op.status);
|
||||
|
||||
final dateMatch = _appliedRange == null ||
|
||||
_isUnknownDate(op.date) ||
|
||||
(op.date.isAfter(_appliedRange!.start.subtract(const Duration(seconds: 1))) &&
|
||||
op.date.isBefore(_appliedRange!.end.add(const Duration(seconds: 1))));
|
||||
|
||||
return statusMatch && dateMatch;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
OperationStatus _statusFromPaymentState(String? raw) {
|
||||
final state = raw?.trim().toLowerCase();
|
||||
|
||||
switch (state) {
|
||||
case 'accepted':
|
||||
case 'funds_reserved':
|
||||
case 'submitted':
|
||||
case 'unspecified':
|
||||
case null:
|
||||
return OperationStatus.processing;
|
||||
|
||||
case 'settled':
|
||||
case 'success':
|
||||
return OperationStatus.success;
|
||||
|
||||
case 'failed':
|
||||
case 'cancelled':
|
||||
return OperationStatus.error;
|
||||
|
||||
default:
|
||||
// Future-proof: any new backend state is treated as processing
|
||||
return OperationStatus.processing;
|
||||
}
|
||||
}
|
||||
|
||||
DateTime _resolvePaymentDate(Payment payment) {
|
||||
final expiresAt = payment.lastQuote?.fxQuote?.expiresAtUnixMs;
|
||||
if (expiresAt != null && expiresAt > 0) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(expiresAt, isUtc: true);
|
||||
}
|
||||
return DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
|
||||
}
|
||||
|
||||
double _parseAmount(String? amount) {
|
||||
if (amount == null || amount.trim().isEmpty) return 0;
|
||||
return double.tryParse(amount) ?? 0;
|
||||
}
|
||||
|
||||
String? _firstNonEmpty(List<String?> values) {
|
||||
for (final value in values) {
|
||||
final trimmed = value?.trim();
|
||||
if (trimmed != null && trimmed.isNotEmpty) return trimmed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool _isUnknownDate(DateTime date) => date.millisecondsSinceEpoch == 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
return Consumer<PaymentsProvider>(
|
||||
builder: (context, provider, child) {
|
||||
if (provider.isLoading) {
|
||||
return Consumer<ReportOperationsController>(
|
||||
builder: (context, controller, child) {
|
||||
if (controller.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (provider.error != null) {
|
||||
final message = provider.error?.toString() ?? loc.noErrorInformation;
|
||||
|
||||
if (controller.error != null) {
|
||||
final message =
|
||||
controller.error?.toString() ?? loc.noErrorInformation;
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(loc.notificationError(message)),
|
||||
ElevatedButton(
|
||||
onPressed: () => provider.refresh(),
|
||||
onPressed: () => controller.refresh(),
|
||||
child: Text(loc.retry),
|
||||
),
|
||||
],
|
||||
@@ -203,18 +88,16 @@ class _OperationHistoryPageState extends State<OperationHistoryPage> {
|
||||
);
|
||||
}
|
||||
|
||||
final operations = _mapPayments(provider.payments);
|
||||
final filteredOperations = _filterOperations(operations);
|
||||
final hasFileName = operations.any(
|
||||
(operation) => (operation.fileName ?? '').trim().isNotEmpty,
|
||||
);
|
||||
|
||||
final operations = controller.operations;
|
||||
final filteredOperations = controller.filteredOperations;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
spacing: 16,
|
||||
children: [
|
||||
//TODO Make charts more useful and re-enable
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: Row(
|
||||
@@ -224,7 +107,7 @@ class _OperationHistoryPageState extends State<OperationHistoryPage> {
|
||||
child: StatusChart(operations: operations),
|
||||
),
|
||||
Expanded(
|
||||
child: PayoutDistributionChart(
|
||||
child: PayoutVolumesChart(
|
||||
operations: operations,
|
||||
),
|
||||
),
|
||||
@@ -232,15 +115,15 @@ class _OperationHistoryPageState extends State<OperationHistoryPage> {
|
||||
),
|
||||
),
|
||||
OperationFilters(
|
||||
selectedRange: _pendingRange,
|
||||
selectedStatuses: _pendingStatuses,
|
||||
onPickRange: _pickRange,
|
||||
onToggleStatus: _toggleStatus,
|
||||
onApply: _applyFilters,
|
||||
selectedRange: controller.selectedRange,
|
||||
selectedStatuses: controller.selectedStatuses,
|
||||
onPickRange: () => _pickRange(context, controller),
|
||||
onToggleStatus: controller.toggleStatus,
|
||||
onClear: controller.clearFilters,
|
||||
),
|
||||
OperationsTable(
|
||||
OperationsCardsList(
|
||||
operations: filteredOperations,
|
||||
showFileNameColumn: hasFileName,
|
||||
onTap: (operation) => _openPaymentDetails(context, operation),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -4,6 +4,10 @@ import 'package:badges/badges.dart' as badges;
|
||||
|
||||
import 'package:pshared/models/payment/status.dart';
|
||||
|
||||
import 'package:pweb/utils/payment/status_view.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class OperationStatusBadge extends StatelessWidget {
|
||||
final OperationStatus status;
|
||||
@@ -11,15 +15,8 @@ class OperationStatusBadge extends StatelessWidget {
|
||||
const OperationStatusBadge({super.key, required this.status});
|
||||
|
||||
Color _badgeColor(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
switch (status) {
|
||||
case OperationStatus.processing:
|
||||
return scheme.primary;
|
||||
case OperationStatus.success:
|
||||
return scheme.secondary;
|
||||
case OperationStatus.error:
|
||||
return scheme.error;
|
||||
}
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return operationStatusView(l10n, status).color;
|
||||
}
|
||||
|
||||
Color _textColor(Color background) {
|
||||
@@ -52,4 +49,4 @@ class OperationStatusBadge extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,70 +1,80 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:badges/badges.dart' as badges; // Make sure to add badges package in pubspec.yaml
|
||||
import 'package:pshared/models/payment/status.dart';
|
||||
import 'package:pshared/utils/localization.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class OperationFilters extends StatelessWidget {
|
||||
final DateTimeRange? selectedRange;
|
||||
final Set<OperationStatus> selectedStatuses;
|
||||
final VoidCallback onPickRange;
|
||||
final VoidCallback onApply;
|
||||
final ValueChanged<OperationStatus> onToggleStatus;
|
||||
final VoidCallback onClear;
|
||||
|
||||
const OperationFilters({
|
||||
super.key,
|
||||
required this.selectedRange,
|
||||
required this.selectedStatuses,
|
||||
required this.onPickRange,
|
||||
required this.onApply,
|
||||
required this.onToggleStatus,
|
||||
required this.onClear,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final theme = Theme.of(context);
|
||||
final hasActive = selectedRange != null || selectedStatuses.isNotEmpty;
|
||||
final periodLabel = selectedRange == null
|
||||
? l10n.selectPeriod
|
||||
: '${dateToLocalFormat(context, selectedRange!.start)} – ${dateToLocalFormat(context, selectedRange!.end)}';
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
elevation: 2,
|
||||
elevation: 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.filters,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
GestureDetector(
|
||||
onTap: onPickRange,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.date_range_outlined, color: Theme.of(context).primaryColor),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
selectedRange == null
|
||||
? l10n.selectPeriod
|
||||
: '${dateToLocalFormat(context, selectedRange!.start)} – ${dateToLocalFormat(context, selectedRange!.end)}',
|
||||
style: TextStyle(
|
||||
color: selectedRange == null
|
||||
? Colors.grey
|
||||
: Colors.black87,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
l10n.filters,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
Icon(Icons.keyboard_arrow_down, color: Colors.grey),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
if (hasActive)
|
||||
TextButton.icon(
|
||||
onPressed: onClear,
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
label: Text(l10n.reset),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onPickRange,
|
||||
icon: const Icon(Icons.date_range_outlined, size: 18),
|
||||
label: Text(
|
||||
periodLabel,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
spacing: 10,
|
||||
runSpacing: 8,
|
||||
children: const [
|
||||
OperationStatus.success,
|
||||
@@ -73,51 +83,28 @@ class OperationFilters extends StatelessWidget {
|
||||
].map((status) {
|
||||
final label = status.localized(context);
|
||||
final isSelected = selectedStatuses.contains(status);
|
||||
return GestureDetector(
|
||||
onTap: () => onToggleStatus(status),
|
||||
child: badges.Badge(
|
||||
badgeAnimation: badges.BadgeAnimation.fade(),
|
||||
badgeStyle: badges.BadgeStyle(
|
||||
shape: badges.BadgeShape.square,
|
||||
badgeColor: isSelected
|
||||
? Theme.of(context).primaryColor
|
||||
: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
badgeContent: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
child: Text(
|
||||
l10n.status(label),
|
||||
style: TextStyle(
|
||||
color: isSelected ? Colors.white : Colors.black87,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
return FilterChip(
|
||||
label: Text(l10n.status(label)),
|
||||
selected: isSelected,
|
||||
onSelected: (_) => onToggleStatus(status),
|
||||
selectedColor: theme.colorScheme.primaryContainer,
|
||||
checkmarkColor: theme.colorScheme.onPrimaryContainer,
|
||||
labelStyle: theme.textTheme.bodySmall?.copyWith(
|
||||
color: isSelected
|
||||
? theme.colorScheme.onPrimaryContainer
|
||||
: theme.colorScheme.onSurface,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
side: BorderSide(
|
||||
color: isSelected
|
||||
? theme.colorScheme.primaryContainer
|
||||
: theme.dividerColor.withAlpha(60),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: ElevatedButton(
|
||||
onPressed: onApply,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Text(l10n.apply),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pshared/models/payment/operation.dart';
|
||||
import 'package:pshared/models/payment/status.dart';
|
||||
import 'package:pshared/provider/organizations.dart';
|
||||
import 'package:pshared/service/payment/documents.dart';
|
||||
import 'package:pshared/utils/currency.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
import 'package:pweb/pages/report/table/badge.dart';
|
||||
import 'package:pweb/utils/download.dart';
|
||||
import 'package:pweb/utils/error/snackbar.dart';
|
||||
import 'package:pweb/utils/report/download_act.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class OperationRow {
|
||||
@@ -29,7 +25,7 @@ class OperationRow {
|
||||
|
||||
final documentCell = canDownload
|
||||
? TextButton.icon(
|
||||
onPressed: () => _downloadAct(context, op),
|
||||
onPressed: () => downloadPaymentAct(context, op.paymentRef ?? ''),
|
||||
icon: const Icon(Icons.download),
|
||||
label: Text(loc.downloadAct),
|
||||
)
|
||||
@@ -47,28 +43,4 @@ class OperationRow {
|
||||
DataCell(Text(op.comment)),
|
||||
]);
|
||||
}
|
||||
|
||||
static Future<void> _downloadAct(BuildContext context, OperationItem op) async {
|
||||
final organizations = context.read<OrganizationsProvider>();
|
||||
if (!organizations.isOrganizationSet) {
|
||||
return;
|
||||
}
|
||||
final paymentRef = (op.paymentRef ?? '').trim();
|
||||
if (paymentRef.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
await executeActionWithNotification(
|
||||
context: context,
|
||||
action: () async {
|
||||
final file = await PaymentDocumentsService.getAct(
|
||||
organizations.current.id,
|
||||
paymentRef,
|
||||
);
|
||||
await downloadFile(file);
|
||||
},
|
||||
errorMessage: loc.downloadActError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user