reports page #518

Merged
tech merged 2 commits from SEND053 into main 2026-02-17 09:21:39 +00:00
66 changed files with 2304 additions and 516 deletions

View File

@@ -27,6 +27,17 @@ class PaymentDTO {
this.createdAt, this.createdAt,
}); });
factory PaymentDTO.fromJson(Map<String, dynamic> json) => _$PaymentDTOFromJson(json); factory PaymentDTO.fromJson(Map<String, dynamic> json) =>
_$PaymentDTOFromJson(_normalizeJson(json));
Map<String, dynamic> toJson() => _$PaymentDTOToJson(this); Map<String, dynamic> toJson() => _$PaymentDTOToJson(this);
static Map<String, dynamic> _normalizeJson(Map<String, dynamic> json) {
if (json.containsKey('metadata') || !json.containsKey('meta')) {
return json;
}
final normalized = Map<String, dynamic>.from(json);
normalized['metadata'] = normalized['meta'];
return normalized;
}
} }

View File

@@ -1,13 +1,16 @@
import 'package:pshared/models/wallet/wallet.dart' as domain; import 'package:pshared/models/wallet/wallet.dart' as domain;
import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/models/payment/wallet.dart';
import 'package:pshared/utils/currency.dart'; import 'package:pshared/utils/currency.dart';
import 'package:pshared/utils/money.dart';
extension WalletUiMapper on domain.WalletModel { extension WalletUiMapper on domain.WalletModel {
Wallet toUi() => Wallet( Wallet toUi() => Wallet(
id: walletRef, id: walletRef,
walletUserID: walletRef, walletUserID: walletRef,
balance: double.tryParse(availableMoney?.amount ?? balance?.available?.amount ?? '0') ?? 0, balance: parseMoneyAmount(
availableMoney?.amount ?? balance?.available?.amount,
),
currency: currencyStringToCode(asset.tokenSymbol), currency: currencyStringToCode(asset.tokenSymbol),
calculatedAt: balance?.calculatedAt ?? DateTime.now(), calculatedAt: balance?.calculatedAt ?? DateTime.now(),
depositAddress: depositAddress, depositAddress: depositAddress,

View File

@@ -3,6 +3,7 @@ import 'package:pshared/models/describable.dart';
import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/models/payment/wallet.dart';
import 'package:pshared/models/wallet/chain_asset.dart'; import 'package:pshared/models/wallet/chain_asset.dart';
import 'package:pshared/service/wallet.dart' as shared_wallet_service; import 'package:pshared/service/wallet.dart' as shared_wallet_service;
import 'package:pshared/utils/money.dart';
abstract class WalletsService { abstract class WalletsService {
@@ -29,8 +30,7 @@ class ApiWalletsService implements WalletsService {
organizationRef: organizationRef, organizationRef: organizationRef,
walletRef: walletRef, walletRef: walletRef,
); );
final amount = balance.available?.amount; return parseMoneyAmount(balance.available?.amount);
return amount == null ? 0 : double.tryParse(amount) ?? 0;
} }
@override @override

View File

@@ -77,3 +77,13 @@ IconData iconForCurrencyType(Currency currencyCode) {
return Icons.money; return Icons.money;
} }
} }
String? currencySymbolFromCode(String? code) {
final normalized = code?.trim();
if (normalized == null || normalized.isEmpty) return null;
try {
return currencyCodeToSymbol(currencyStringToCode(normalized.toUpperCase()));
} catch (_) {
return null;
}
}

View File

@@ -0,0 +1,12 @@
import 'package:pshared/models/money.dart';
double parseMoneyAmount(String? raw, {double fallback = 0}) {
final trimmed = raw?.trim();
if (trimmed == null || trimmed.isEmpty) return fallback;
return double.tryParse(trimmed) ?? fallback;
}
extension MoneyAmountX on Money {
double get amountValue => parseMoneyAmount(amount);
}

View File

@@ -18,12 +18,14 @@ class PayoutRoutes {
static const payment = 'payout-payment'; static const payment = 'payout-payment';
static const settings = 'payout-settings'; static const settings = 'payout-settings';
static const reports = 'payout-reports'; static const reports = 'payout-reports';
static const reportPayment = 'payout-report-payment';
static const methods = 'payout-methods'; static const methods = 'payout-methods';
static const editWallet = 'payout-edit-wallet'; static const editWallet = 'payout-edit-wallet';
static const walletTopUp = 'payout-wallet-top-up'; static const walletTopUp = 'payout-wallet-top-up';
static const paymentTypeQuery = 'paymentType'; static const paymentTypeQuery = 'paymentType';
static const returnToQuery = 'returnTo'; static const returnToQuery = 'returnTo';
static const reportPaymentIdQuery = 'paymentId';
static const dashboardPath = '/dashboard'; static const dashboardPath = '/dashboard';
static const recipientsPath = '/dashboard/recipients'; static const recipientsPath = '/dashboard/recipients';
@@ -32,6 +34,7 @@ class PayoutRoutes {
static const paymentPath = '/dashboard/payment'; static const paymentPath = '/dashboard/payment';
static const settingsPath = '/dashboard/settings'; static const settingsPath = '/dashboard/settings';
static const reportsPath = '/dashboard/reports'; static const reportsPath = '/dashboard/reports';
static const reportPaymentPath = '/dashboard/reports/payment';
static const methodsPath = '/dashboard/methods'; static const methodsPath = '/dashboard/methods';
static const editWalletPath = '/dashboard/methods/edit'; static const editWalletPath = '/dashboard/methods/edit';
static const walletTopUpPath = '/dashboard/wallet/top-up'; static const walletTopUpPath = '/dashboard/wallet/top-up';
@@ -173,6 +176,20 @@ extension PayoutNavigation on BuildContext {
), ),
); );
void goToReportPayment(String paymentId) => goNamed(
PayoutRoutes.reportPayment,
queryParameters: {
PayoutRoutes.reportPaymentIdQuery: paymentId,
},
);
void pushToReportPayment(String paymentId) => pushNamed(
PayoutRoutes.reportPayment,
queryParameters: {
PayoutRoutes.reportPaymentIdQuery: paymentId,
},
);
void pushToWalletTopUp({PayoutDestination? returnTo}) => pushNamed( void pushToWalletTopUp({PayoutDestination? returnTo}) => pushNamed(
PayoutRoutes.walletTopUp, PayoutRoutes.walletTopUp,
queryParameters: PayoutRoutes.buildQueryParameters(returnTo: returnTo), queryParameters: PayoutRoutes.buildQueryParameters(returnTo: returnTo),

View File

@@ -33,6 +33,7 @@ import 'package:pweb/pages/dashboard/dashboard.dart';
import 'package:pweb/pages/invitations/page.dart'; import 'package:pweb/pages/invitations/page.dart';
import 'package:pweb/pages/payment_methods/page.dart'; import 'package:pweb/pages/payment_methods/page.dart';
import 'package:pweb/pages/payout_page/wallet/edit/page.dart'; import 'package:pweb/pages/payout_page/wallet/edit/page.dart';
import 'package:pweb/pages/report/details/page.dart';
import 'package:pweb/pages/report/page.dart'; import 'package:pweb/pages/report/page.dart';
import 'package:pweb/pages/settings/profile/page.dart'; import 'package:pweb/pages/settings/profile/page.dart';
import 'package:pweb/pages/wallet_top_up/page.dart'; import 'package:pweb/pages/wallet_top_up/page.dart';
@@ -292,6 +293,17 @@ RouteBase payoutShellRoute() => ShellRoute(
pageBuilder: (_, _) => pageBuilder: (_, _) =>
const NoTransitionPage(child: OperationHistoryPage()), const NoTransitionPage(child: OperationHistoryPage()),
), ),
GoRoute(
name: PayoutRoutes.reportPayment,
path: PayoutRoutes.reportPaymentPath,
pageBuilder: (_, state) => NoTransitionPage(
child: PaymentDetailsPage(
paymentId: state.uri.queryParameters[
PayoutRoutes.reportPaymentIdQuery] ??
'',
),
),
),
GoRoute( GoRoute(
name: PayoutRoutes.methods, name: PayoutRoutes.methods,
path: PayoutRoutes.methodsPath, path: PayoutRoutes.methodsPath,

View File

@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
class PayoutVolumesController extends ChangeNotifier {
DateTimeRange _range;
PayoutVolumesController({DateTime? now})
: _range = _defaultRange(now ?? DateTime.now());
DateTimeRange get range => _range;
void setRange(DateTimeRange range) {
final normalized = _normalizeRange(range);
if (_isSameRange(_range, normalized)) return;
_range = normalized;
notifyListeners();
}
static DateTimeRange _defaultRange(DateTime now) {
final local = now.toLocal();
final start = DateTime(local.year, local.month, 1);
final end = DateTime(
local.year,
local.month,
local.day,
);
return DateTimeRange(start: start, end: end);
}
static DateTimeRange _normalizeRange(DateTimeRange range) {
final start = DateTime(range.start.year, range.start.month, range.start.day);
final end = DateTime(
range.end.year,
range.end.month,
range.end.day,
);
return DateTimeRange(start: start, end: end);
}
static bool _isSameRange(DateTimeRange a, DateTimeRange b) {
return a.start.isAtSameMomentAs(b.start) &&
a.end.isAtSameMomentAs(b.end);
}
}

View File

@@ -0,0 +1,33 @@
import 'package:flutter/foundation.dart';
import 'package:pshared/models/payment/operation.dart';
import 'package:pshared/provider/payment/payments.dart';
import 'package:pweb/utils/report/operations.dart';
import 'package:pweb/utils/report/payment_mapper.dart';
class RecentPaymentsController extends ChangeNotifier {
PaymentsProvider? _payments;
List<OperationItem> _recent = const [];
List<OperationItem> get recentOperations => _recent;
bool get isLoading => _payments?.isLoading ?? false;
Exception? get error => _payments?.error;
void update(PaymentsProvider provider) {
if (!identical(_payments, provider)) {
_payments = provider;
Review

по идее должно работать без ручного управления подпиской. update должен вызываться при любом обновлении провайдера. Соответственно, можно просто из update делать rebuild. Так не работает?

по идее должно работать без ручного управления подпиской. update должен вызываться при любом обновлении провайдера. Соответственно, можно просто из update делать rebuild. Так не работает?
}
_rebuild();
}
void _rebuild() {
final operations = (_payments?.payments ?? const [])
.map(mapPaymentToOperation)
.toList();
_recent = sortOperations(operations).take(5).toList();
notifyListeners();
}
}

View File

@@ -0,0 +1,104 @@
import 'dart:collection';
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/operation.dart';
import 'package:pshared/models/payment/status.dart';
import 'package:pshared/provider/payment/payments.dart';
import 'package:pweb/utils/report/operations.dart';
import 'package:pweb/utils/report/payment_mapper.dart';
class ReportOperationsController extends ChangeNotifier {
PaymentsProvider? _payments;
DateTimeRange? _selectedRange;
final Set<OperationStatus> _selectedStatuses = {};
List<OperationItem> _operations = const [];
List<OperationItem> _filtered = const [];
List<OperationItem> get operations => _operations;
List<OperationItem> get filteredOperations => _filtered;
DateTimeRange? get selectedRange => _selectedRange;
Set<OperationStatus> get selectedStatuses =>
UnmodifiableSetView(_selectedStatuses);
bool get isLoading => _payments?.isLoading ?? false;
Exception? get error => _payments?.error;
void update(PaymentsProvider provider) {
if (!identical(_payments, provider)) {
_payments = provider;
Review

то же самое

то же самое
}
_rebuildOperations();
}
void setRange(DateTimeRange? range) {
if (_isSameRange(_selectedRange, range)) return;
_selectedRange = range;
_rebuildFiltered();
}
void toggleStatus(OperationStatus status) {
if (_selectedStatuses.contains(status)) {
_selectedStatuses.remove(status);
} else {
_selectedStatuses.add(status);
}
_rebuildFiltered();
}
void clearFilters() {
if (_selectedRange == null && _selectedStatuses.isEmpty) return;
_selectedRange = null;
_selectedStatuses.clear();
_rebuildFiltered();
}
Future<void> refresh() async {
await _payments?.refresh();
}
void _rebuildOperations() {
final items = _payments?.payments ?? const [];
_operations = items.map(mapPaymentToOperation).toList();
_rebuildFiltered(notify: true);
}
void _rebuildFiltered({bool notify = true}) {
_filtered = _applyFilters(_operations);
if (notify) {
notifyListeners();
}
}
List<OperationItem> _applyFilters(List<OperationItem> operations) {
if (_selectedRange == null && _selectedStatuses.isEmpty) {
return sortOperations(operations);
}
final filtered = operations.where((op) {
final statusMatch =
_selectedStatuses.isEmpty || _selectedStatuses.contains(op.status);
final dateMatch = _selectedRange == null ||
isUnknownDate(op.date) ||
(op.date.isAfter(
_selectedRange!.start.subtract(const Duration(seconds: 1)),
) &&
op.date.isBefore(
_selectedRange!.end.add(const Duration(seconds: 1)),
));
return statusMatch && dateMatch;
}).toList();
return sortOperations(filtered);
}
bool _isSameRange(DateTimeRange? left, DateTimeRange? right) {
if (left == null && right == null) return true;
if (left == null || right == null) return false;
return left.start.isAtSameMomentAs(right.start) &&
left.end.isAtSameMomentAs(right.end);
}
}

View File

@@ -1,20 +0,0 @@
import 'package:pshared/models/payment/payment.dart';
class UploadHistoryTableController {
const UploadHistoryTableController();
String amountText(Payment payment) {
final receivedAmount = payment.lastQuote?.expectedSettlementAmount;
if (receivedAmount != null) {
return '${receivedAmount.amount} ${receivedAmount.currency}';
}
final fallbackAmount = payment.lastQuote?.debitAmount;
if (fallbackAmount != null) {
return '${fallbackAmount.amount} ${fallbackAmount.currency}';
}
return '-';
}
}

View File

@@ -382,6 +382,36 @@
"@commentColumn": { "@commentColumn": {
"description": "Table column header for any comment" "description": "Table column header for any comment"
}, },
"reportPaymentsEmpty": "No payments yet",
"paymentDetailsNotFound": "Payment not found",
"paymentDetailsIdentifiers": "Identifiers",
"paymentDetailsAmounts": "Amounts",
"paymentDetailsFx": "FX quote",
"paymentDetailsFailure": "Failure",
"paymentDetailsMetadata": "Metadata",
"metadataUploadFileName": "Upload file name",
"metadataTotalRecipients": "Total Recipients",
"paymentIdLabel": "Payment ID",
"paymentIdCopied": "Payment ID copied",
"idempotencyKeyLabel": "Idempotency key",
"quoteIdLabel": "Quote ID",
"createdAtLabel": "Created at",
"debitAmountLabel": "Debit amount",
"debitSettlementAmountLabel": "Debit settlement amount",
"expectedSettlementAmountLabel": "Expected settlement amount",
"feeTotalLabel": "Total fee",
"networkFeeLabel": "Network fee",
"fxRateLabel": "Rate",
"fxProviderLabel": "Provider",
"rateRefLabel": "Rate reference",
"quoteRefLabel": "Quote reference",
"fxSideLabel": "Side",
"fxBaseAmountLabel": "Base amount",
"fxQuoteAmountLabel": "Quote amount",
"expiresAtLabel": "Expires at",
"failureCodeLabel": "Failure code",
"failureReasonLabel": "Failure reason",
"metadataEmpty": "No metadata",
"paymentConfigTitle": "Where to receive money", "paymentConfigTitle": "Where to receive money",
"paymentConfigSubtitle": "Add multiple methods and choose your primary one.", "paymentConfigSubtitle": "Add multiple methods and choose your primary one.",
"addPaymentMethod": "Add payment method", "addPaymentMethod": "Add payment method",
@@ -517,8 +547,8 @@
"upload": "Upload", "upload": "Upload",
"changeFile": "Change file", "changeFile": "Change file",
"hintUpload": "Supported format: .CSV · Max size 1 MB", "hintUpload": "Supported format: .CSV · Max size 1 MB",
"uploadHistory": "Upload History", "uploadHistory": "Recent payments",
"viewWholeHistory": "View Whole History", "viewWholeHistory": "Show all payments",
"paymentStatusSuccessful": "Payment Successful", "paymentStatusSuccessful": "Payment Successful",
"paymentStatusProcessing": "Processing", "paymentStatusProcessing": "Processing",
"paymentStatusReserved": "Funds Reserved", "paymentStatusReserved": "Funds Reserved",

View File

@@ -382,6 +382,36 @@
"@commentColumn": { "@commentColumn": {
"description": "Заголовок столбца таблицы для комментария" "description": "Заголовок столбца таблицы для комментария"
}, },
"reportPaymentsEmpty": "Платежей пока нет",
"paymentDetailsNotFound": "Платеж не найден",
"paymentDetailsIdentifiers": "Идентификаторы",
"paymentDetailsAmounts": "Суммы",
"paymentDetailsFx": "Курс",
"paymentDetailsFailure": "Ошибка",
"paymentDetailsMetadata": "Метаданные",
"metadataUploadFileName": "Имя файла загрузки",
"metadataTotalRecipients": "Всего получателей",
"paymentIdLabel": "ID платежа",
"paymentIdCopied": "ID платежа скопирован",
"idempotencyKeyLabel": "Ключ идемпотентности",
"quoteIdLabel": "ID котировки",
"createdAtLabel": "Создан",
"debitAmountLabel": "Списано",
"debitSettlementAmountLabel": "Списано к зачислению",
"expectedSettlementAmountLabel": "Ожидаемая сумма зачисления",
"feeTotalLabel": "Комиссия",
"networkFeeLabel": "Сетевая комиссия",
"fxRateLabel": "Курс",
"fxProviderLabel": "Провайдер",
"rateRefLabel": "Ссылка на курс",
"quoteRefLabel": "Ссылка на котировку",
"fxSideLabel": "Сторона",
"fxBaseAmountLabel": "Базовая сумма",
"fxQuoteAmountLabel": "Котируемая сумма",
"expiresAtLabel": "Истекает",
"failureCodeLabel": "Код ошибки",
"failureReasonLabel": "Причина ошибки",
"metadataEmpty": "Метаданные отсутствуют",
"paymentConfigTitle": "Куда получать деньги", "paymentConfigTitle": "Куда получать деньги",
"paymentConfigSubtitle": "Добавьте несколько методов и выберите основной.", "paymentConfigSubtitle": "Добавьте несколько методов и выберите основной.",
"addPaymentMethod": "Добавить способ оплаты", "addPaymentMethod": "Добавить способ оплаты",
@@ -517,8 +547,8 @@
"upload": "Загрузить", "upload": "Загрузить",
"changeFile": "Заменить файл", "changeFile": "Заменить файл",
"hintUpload": "Поддерживаемый формат: .CSV · Макс. размер 1 МБ", "hintUpload": "Поддерживаемый формат: .CSV · Макс. размер 1 МБ",
"uploadHistory": "История загрузок", "uploadHistory": "Последние платежи",
"viewWholeHistory": "Смотреть всю историю", "viewWholeHistory": "Показать все платежи",
"paymentStatusSuccessful": "Платеж успешен", "paymentStatusSuccessful": "Платеж успешен",
"paymentStatusProcessing": "В обработке", "paymentStatusProcessing": "В обработке",
"paymentStatusReserved": "Средства зарезервированы", "paymentStatusReserved": "Средства зарезервированы",

View File

@@ -0,0 +1,6 @@
class ChartPoint<T> {
final T key;
final double value;
const ChartPoint(this.key, this.value);
}

View File

@@ -0,0 +1,27 @@
enum PaymentState {
success,
failed,
cancelled,
processing,
unknown,
}
PaymentState paymentStateFromRaw(String? raw) {
final trimmed = (raw ?? '').trim().toUpperCase();
final normalized = trimmed.startsWith('PAYMENT_STATE_')
? trimmed.substring('PAYMENT_STATE_'.length)
: trimmed;
switch (normalized) {
case 'SUCCESS':
return PaymentState.success;
case 'FAILED':
return PaymentState.failed;
case 'CANCELLED':
return PaymentState.cancelled;
case 'PROCESSING':
return PaymentState.processing;
default:
return PaymentState.unknown;
}
}

View File

@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
import 'package:pshared/controllers/balance_mask/ledger_accounts.dart'; import 'package:pshared/controllers/balance_mask/ledger_accounts.dart';
import 'package:pshared/models/ledger/account.dart'; import 'package:pshared/models/ledger/account.dart';
import 'package:pshared/utils/currency.dart'; import 'package:pshared/utils/currency.dart';
import 'package:pshared/utils/money.dart';
import 'package:pweb/pages/dashboard/buttons/balance/config.dart'; import 'package:pweb/pages/dashboard/buttons/balance/config.dart';
import 'package:pweb/pages/dashboard/buttons/balance/header.dart'; import 'package:pweb/pages/dashboard/buttons/balance/header.dart';
@@ -25,8 +26,8 @@ class LedgerAccountCard extends StatelessWidget {
final money = account.balance?.balance; final money = account.balance?.balance;
if (money == null) return '--'; if (money == null) return '--';
final amount = double.tryParse(money.amount); final amount = parseMoneyAmount(money.amount, fallback: double.nan);
if (amount == null) { if (amount.isNaN) {
return '${money.amount} ${money.currency}'; return '${money.amount} ${money.currency}';
} }

View File

@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import 'package:pshared/provider/payment/amount.dart'; import 'package:pshared/provider/payment/amount.dart';
import 'package:pshared/utils/currency.dart'; import 'package:pshared/utils/currency.dart';
import 'package:pshared/utils/money.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -32,7 +33,13 @@ class _PaymentAmountWidgetState extends State<PaymentAmountWidget> {
super.dispose(); super.dispose();
} }
double? _parseAmount(String value) => double.tryParse(value.replaceAll(',', '.')); double? _parseAmount(String value) {
final parsed = parseMoneyAmount(
value.replaceAll(',', '.'),
fallback: double.nan,
);
return parsed.isNaN ? null : parsed;
}
void _syncTextWithAmount(double amount) { void _syncTextWithAmount(double amount) {
final parsedText = _parseAmount(_controller.text); final parsedText = _parseAmount(_controller.text);

View File

@@ -1,14 +1,15 @@
import 'package:pshared/models/asset.dart'; import 'package:pshared/models/asset.dart';
import 'package:pshared/models/money.dart'; import 'package:pshared/models/money.dart';
import 'package:pshared/utils/currency.dart'; import 'package:pshared/utils/currency.dart';
import 'package:pshared/utils/money.dart';
import 'package:pweb/controllers/multiple_payouts.dart'; import 'package:pweb/controllers/multiple_payouts.dart';
String moneyLabel(Money? money) { String moneyLabel(Money? money) {
if (money == null) return 'N/A'; if (money == null) return 'N/A';
final amount = double.tryParse(money.amount); final amount = parseMoneyAmount(money.amount, fallback: double.nan);
if (amount == null) return '${money.amount} ${money.currency}'; if (amount.isNaN) return '${money.amount} ${money.currency}';
try { try {
return assetToString( return assetToString(
Asset( Asset(

View File

@@ -1,5 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:pweb/app/router/payout_routes.dart';
import 'package:pweb/widgets/sidebar/destinations.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -7,21 +10,30 @@ class UploadHistoryHeader extends StatelessWidget {
const UploadHistoryHeader({ const UploadHistoryHeader({
super.key, super.key,
required this.theme, required this.theme,
required this.l10n,
}); });
final ThemeData theme; final ThemeData theme;
final AppLocalizations l10n;
static const double _smallBox = 5; static const double _smallBox = 5;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10 = AppLocalizations.of(context)!;
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Icon(Icons.history), Row(
const SizedBox(width: _smallBox), children: [
Text(l10n.uploadHistory, style: theme.textTheme.bodyLarge), const Icon(Icons.history),
const SizedBox(width: _smallBox),
Text(l10.uploadHistory, style: theme.textTheme.bodyLarge),
],
),
TextButton.icon(
onPressed: () => context.goToPayout(PayoutDestination.reports),
icon: const Icon(Icons.open_in_new, size: 16),
label: Text(l10.viewWholeHistory),
),
], ],
); );
} }

View File

@@ -1,37 +0,0 @@
import 'package:flutter/material.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/sections/history/helpers.dart';
class HistoryStatusBadge extends StatelessWidget {
const HistoryStatusBadge({
super.key,
required this.statusView,
});
final StatusView statusView;
static const double _radius = 6;
static const double _statusBgOpacity = 0.12;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: statusView.color.withValues(alpha: _statusBgOpacity),
borderRadius: BorderRadius.circular(_radius),
),
child: Text(
statusView.label,
style: TextStyle(
color: statusView.color,
fontWeight: FontWeight.w600,
),
),
);
}
}

View File

@@ -1,68 +0,0 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:pshared/models/payment/payment.dart';
import 'package:pweb/controllers/upload_history_table.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/sections/history/helpers.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/sections/history/status_badge.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class UploadHistoryTable extends StatelessWidget {
const UploadHistoryTable({
super.key,
required this.items,
required this.dateFormat,
required this.l10n,
});
final List<Payment> items;
final DateFormat dateFormat;
final AppLocalizations l10n;
static const int _maxVisibleItems = 10;
static const UploadHistoryTableController _controller =
UploadHistoryTableController();
@override
Widget build(BuildContext context) {
final visibleItems = items.take(_maxVisibleItems).toList(growable: false);
return DataTable(
columns: [
DataColumn(label: Text(l10n.fileNameColumn)),
DataColumn(label: Text(l10n.rowsColumn)),
DataColumn(label: Text(l10n.dateColumn)),
DataColumn(label: Text(l10n.amountColumn)),
DataColumn(label: Text(l10n.statusColumn)),
],
rows: visibleItems.map((payment) {
final metadata = payment.metadata;
final status = statusView(l10n, payment.state);
final fileName = metadata?['upload_filename'];
final fileNameText =
(fileName == null || fileName.isEmpty) ? '-' : fileName;
final rows = metadata?['upload_rows'];
final rowsText = (rows == null || rows.isEmpty) ? '-' : rows;
final createdAt = payment.createdAt;
final dateText = createdAt == null
? '-'
: dateFormat.format(createdAt.toLocal());
final amountText = _controller.amountText(payment);
return DataRow(
cells: [
DataCell(Text(fileNameText)),
DataCell(Text(rowsText)),
DataCell(Text(dateText)),
DataCell(Text(amountText)),
DataCell(HistoryStatusBadge(statusView: status)),
],
);
}).toList(),
);
}
}

View File

@@ -1,13 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/models/payment/operation.dart';
import 'package:pshared/provider/payment/payments.dart'; import 'package:pshared/provider/payment/payments.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/sections/history/header.dart'; import 'package:pweb/pages/dashboard/payouts/multiple/sections/history/header.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/sections/history/table.dart'; import 'package:pweb/controllers/recent_payments.dart';
import 'package:pweb/pages/report/cards/column.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'; import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -17,61 +19,49 @@ class UploadHistorySection extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final provider = context.watch<PaymentsProvider>(); return ChangeNotifierProxyProvider<PaymentsProvider, RecentPaymentsController>(
final theme = Theme.of(context); create: (_) => RecentPaymentsController(),
final l10 = AppLocalizations.of(context)!; update: (_, payments, controller) => controller!..update(payments),
final dateFormat = DateFormat.yMMMd().add_Hm(); child: const _RecentPaymentsView(),
);
if (provider.isLoading) { }
return const Center(child: CircularProgressIndicator()); }
}
if (provider.error != null) { class _RecentPaymentsView extends StatelessWidget {
return Text( const _RecentPaymentsView();
l10.notificationError(provider.error ?? l10.noErrorInformation),
); void _openPaymentDetails(BuildContext context, OperationItem operation) {
} final paymentId = paymentIdFromOperation(operation);
final items = List.of(provider.payments); if (paymentId == null) return;
items.sort((a, b) { context.pushToReportPayment(paymentId);
final left = a.createdAt; }
final right = b.createdAt;
if (left == null && right == null) return 0; @override
if (left == null) return 1; Widget build(BuildContext context) {
if (right == null) return -1; final theme = Theme.of(context);
return right.compareTo(left); final l10 = AppLocalizations.of(context)!;
}); return Consumer<RecentPaymentsController>(
builder: (context, controller, child) {
return Column( if (controller.isLoading) {
children: [ return const Center(child: CircularProgressIndicator());
UploadHistoryHeader(theme: theme, l10n: l10), }
const SizedBox(height: 8), if (controller.error != null) {
if (items.isEmpty) return Text(
Align( l10.notificationError(controller.error ?? l10.noErrorInformation),
alignment: Alignment.centerLeft, );
child: Text( }
l10.walletHistoryEmpty,
style: theme.textTheme.bodyMedium, return Column(
), children: [
) UploadHistoryHeader(theme: theme),
else ...[ const SizedBox(height: 8),
UploadHistoryTable( OperationsCardsColumn(
items: items, operations: controller.recentOperations,
dateFormat: dateFormat, onTap: (operation) => _openPaymentDetails(context, operation),
l10n: l10, ),
), ],
//TODO redirect to Reports page );
// if (hasMore) ...[ },
// const SizedBox(height: 8),
// Align(
// alignment: Alignment.centerLeft,
// child: TextButton.icon(
// onPressed: () => context.goNamed(PayoutRoutes.reports),
// icon: const Icon(Icons.open_in_new, size: 16),
// label: Text(l10.viewWholeHistory),
// ),
// ),
// ],
],
],
); );
} }
} }

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

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

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

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

View File

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

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

View File

@@ -1,10 +1,11 @@
import 'package:flutter/material.dart'; 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:pshared/models/payment/operation.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
import 'package:pweb/models/chart_point.dart';
class PayoutDistributionChart extends StatelessWidget { class PayoutDistributionChart extends StatelessWidget {
@@ -25,7 +26,7 @@ class PayoutDistributionChart extends StatelessWidget {
// 2) Build chart data // 2) Build chart data
final data = sums.entries final data = sums.entries
.map((e) => _ChartData(e.key, e.value)) .map((e) => ChartPoint<String>(e.key, e.value))
.toList(); .toList();
// 3) Build a simple horizontal legend // 3) Build a simple horizontal legend
@@ -42,7 +43,7 @@ class PayoutDistributionChart extends StatelessWidget {
children: [ children: [
Icon(Icons.circle, size: 10, color: palette[i % palette.length]), Icon(Icons.circle, size: 10, color: palette[i % palette.length]),
const SizedBox(width: 4), 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), if (i < data.length - 1) const SizedBox(width: 12),
], ],
); );
@@ -62,10 +63,10 @@ class PayoutDistributionChart extends StatelessWidget {
child: SfCircularChart( child: SfCircularChart(
legend: Legend(isVisible: false), legend: Legend(isVisible: false),
tooltipBehavior: TooltipBehavior(enable: true), tooltipBehavior: TooltipBehavior(enable: true),
series: <PieSeries<_ChartData, String>>[ series: <PieSeries<ChartPoint<String>, String>>[
PieSeries<_ChartData, String>( PieSeries<ChartPoint<String>, String>(
dataSource: data, dataSource: data,
xValueMapper: (d, _) => d.label, xValueMapper: (d, _) => d.key,
yValueMapper: (d, _) => d.value, yValueMapper: (d, _) => d.value,
dataLabelMapper: (d, _) => dataLabelMapper: (d, _) =>
'${(d.value / sums.values.fold(0, (a, b) => a + b) * 100).toStringAsFixed(1)}%', '${(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: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/status.dart';
import 'package:pshared/models/payment/operation.dart'; import 'package:pshared/models/payment/operation.dart';
import 'package:pweb/models/chart_point.dart';
class StatusChart extends StatelessWidget { class StatusChart extends StatelessWidget {
final List<OperationItem> operations; final List<OperationItem> operations;
@@ -21,9 +23,9 @@ class StatusChart extends StatelessWidget {
counts[op.status] = (counts[op.status] ?? 0) + 1; counts[op.status] = (counts[op.status] ?? 0) + 1;
} }
final items = counts.entries final items = counts.entries
.map((e) => _ChartData(e.key, e.value.toDouble())) .map((e) => ChartPoint<OperationStatus>(e.key, e.value.toDouble()))
.toList(); .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 theme = Theme.of(context);
final barColor = theme.colorScheme.secondary; final barColor = theme.colorScheme.secondary;
@@ -66,11 +68,11 @@ class StatusChart extends StatelessWidget {
), ),
// ─── Bar series with tooltip enabled ─────────────── // ─── Bar series with tooltip enabled ───────────────
series: <ColumnSeries<_ChartData, String>>[ series: <ColumnSeries<ChartPoint<OperationStatus>, String>>[
ColumnSeries<_ChartData, String>( ColumnSeries<ChartPoint<OperationStatus>, String>(
dataSource: items, dataSource: items,
xValueMapper: (d, _) => d.status.localized(context), xValueMapper: (d, _) => d.key.localized(context),
yValueMapper: (d, _) => d.count, yValueMapper: (d, _) => d.value,
color: barColor, color: barColor,
width: 0.6, width: 0.6,
borderRadius: const BorderRadius.all(Radius.circular(4)), 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);
}

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

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

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

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

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

View 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;
}

View 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;
}
}

View File

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

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

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

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

View File

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

View File

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

View File

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

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

View File

@@ -3,45 +3,41 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/models/payment/operation.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: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/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/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'; import 'package:pweb/generated/i18n/app_localizations.dart';
class OperationHistoryPage extends StatefulWidget { class OperationHistoryPage extends StatelessWidget {
const OperationHistoryPage({super.key}); const OperationHistoryPage({super.key});
@override @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> { class _OperationHistoryView extends StatelessWidget {
DateTimeRange? _pendingRange; const _OperationHistoryView();
DateTimeRange? _appliedRange;
final Set<OperationStatus> _pendingStatuses = {};
Set<OperationStatus> _appliedStatuses = {};
@override Future<void> _pickRange(
void initState() { BuildContext context,
super.initState(); ReportOperationsController controller,
WidgetsBinding.instance.addPostFrameCallback((_) { ) async {
final provider = context.read<PaymentsProvider>();
if (!provider.isReady && !provider.isLoading) {
provider.refresh();
}
});
}
Future<void> _pickRange() async {
final now = DateTime.now(); final now = DateTime.now();
final initial = _pendingRange ?? final initial = controller.selectedRange ??
DateTimeRange( DateTimeRange(
start: now.subtract(const Duration(days: 30)), start: now.subtract(const Duration(days: 30)),
end: now, end: now,
@@ -55,147 +51,36 @@ class _OperationHistoryPageState extends State<OperationHistoryPage> {
); );
if (picked != null) { if (picked != null) {
setState(() { controller.setRange(picked);
_pendingRange = picked;
});
} }
} }
void _toggleStatus(OperationStatus status) { void _openPaymentDetails(BuildContext context, OperationItem operation) {
setState(() { final paymentId = paymentIdFromOperation(operation);
if (_pendingStatuses.contains(status)) { if (paymentId == null) return;
_pendingStatuses.remove(status);
} else { context.pushToReportPayment(paymentId);
_pendingStatuses.add(status);
}
});
} }
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!; final loc = AppLocalizations.of(context)!;
return Consumer<PaymentsProvider>( return Consumer<ReportOperationsController>(
builder: (context, provider, child) { builder: (context, controller, child) {
if (provider.isLoading) { if (controller.isLoading) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (provider.error != null) { if (controller.error != null) {
final message = provider.error?.toString() ?? loc.noErrorInformation; final message =
controller.error?.toString() ?? loc.noErrorInformation;
return Center( return Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text(loc.notificationError(message)), Text(loc.notificationError(message)),
ElevatedButton( ElevatedButton(
onPressed: () => provider.refresh(), onPressed: () => controller.refresh(),
child: Text(loc.retry), child: Text(loc.retry),
), ),
], ],
@@ -203,11 +88,8 @@ class _OperationHistoryPageState extends State<OperationHistoryPage> {
); );
} }
final operations = _mapPayments(provider.payments); final operations = controller.operations;
final filteredOperations = _filterOperations(operations); final filteredOperations = controller.filteredOperations;
final hasFileName = operations.any(
(operation) => (operation.fileName ?? '').trim().isNotEmpty,
);
return Padding( return Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
@@ -215,6 +97,7 @@ class _OperationHistoryPageState extends State<OperationHistoryPage> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 16, spacing: 16,
children: [ children: [
//TODO Make charts more useful and re-enable
SizedBox( SizedBox(
height: 200, height: 200,
child: Row( child: Row(
@@ -224,7 +107,7 @@ class _OperationHistoryPageState extends State<OperationHistoryPage> {
child: StatusChart(operations: operations), child: StatusChart(operations: operations),
), ),
Expanded( Expanded(
child: PayoutDistributionChart( child: PayoutVolumesChart(
operations: operations, operations: operations,
), ),
), ),
@@ -232,15 +115,15 @@ class _OperationHistoryPageState extends State<OperationHistoryPage> {
), ),
), ),
OperationFilters( OperationFilters(
selectedRange: _pendingRange, selectedRange: controller.selectedRange,
selectedStatuses: _pendingStatuses, selectedStatuses: controller.selectedStatuses,
onPickRange: _pickRange, onPickRange: () => _pickRange(context, controller),
onToggleStatus: _toggleStatus, onToggleStatus: controller.toggleStatus,
onApply: _applyFilters, onClear: controller.clearFilters,
), ),
OperationsTable( OperationsCardsList(
operations: filteredOperations, operations: filteredOperations,
showFileNameColumn: hasFileName, onTap: (operation) => _openPaymentDetails(context, operation),
), ),
], ],
), ),

View File

@@ -4,6 +4,10 @@ import 'package:badges/badges.dart' as badges;
import 'package:pshared/models/payment/status.dart'; 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 { class OperationStatusBadge extends StatelessWidget {
final OperationStatus status; final OperationStatus status;
@@ -11,15 +15,8 @@ class OperationStatusBadge extends StatelessWidget {
const OperationStatusBadge({super.key, required this.status}); const OperationStatusBadge({super.key, required this.status});
Color _badgeColor(BuildContext context) { Color _badgeColor(BuildContext context) {
final scheme = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!;
switch (status) { return operationStatusView(l10n, status).color;
case OperationStatus.processing:
return scheme.primary;
case OperationStatus.success:
return scheme.secondary;
case OperationStatus.error:
return scheme.error;
}
} }
Color _textColor(Color background) { Color _textColor(Color background) {

View File

@@ -1,70 +1,80 @@
import 'package:flutter/material.dart'; 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/models/payment/status.dart';
import 'package:pshared/utils/localization.dart'; import 'package:pshared/utils/localization.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
class OperationFilters extends StatelessWidget { class OperationFilters extends StatelessWidget {
final DateTimeRange? selectedRange; final DateTimeRange? selectedRange;
final Set<OperationStatus> selectedStatuses; final Set<OperationStatus> selectedStatuses;
final VoidCallback onPickRange; final VoidCallback onPickRange;
final VoidCallback onApply;
final ValueChanged<OperationStatus> onToggleStatus; final ValueChanged<OperationStatus> onToggleStatus;
final VoidCallback onClear;
const OperationFilters({ const OperationFilters({
super.key, super.key,
required this.selectedRange, required this.selectedRange,
required this.selectedStatuses, required this.selectedStatuses,
required this.onPickRange, required this.onPickRange,
required this.onApply,
required this.onToggleStatus, required this.onToggleStatus,
required this.onClear,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(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( return Card(
margin: const EdgeInsets.all(16),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
elevation: 2, elevation: 0,
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Row(
l10n.filters, children: [
style: Theme.of(context).textTheme.bodyLarge, Text(
), l10n.filters,
const SizedBox(height: 12), style: theme.textTheme.titleSmall?.copyWith(
GestureDetector( fontWeight: FontWeight.w600,
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,
),
),
), ),
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( Wrap(
spacing: 12, spacing: 10,
runSpacing: 8, runSpacing: 8,
children: const [ children: const [
OperationStatus.success, OperationStatus.success,
@@ -73,51 +83,28 @@ class OperationFilters extends StatelessWidget {
].map((status) { ].map((status) {
final label = status.localized(context); final label = status.localized(context);
final isSelected = selectedStatuses.contains(status); final isSelected = selectedStatuses.contains(status);
return GestureDetector( return FilterChip(
onTap: () => onToggleStatus(status), label: Text(l10n.status(label)),
child: badges.Badge( selected: isSelected,
badgeAnimation: badges.BadgeAnimation.fade(), onSelected: (_) => onToggleStatus(status),
badgeStyle: badges.BadgeStyle( selectedColor: theme.colorScheme.primaryContainer,
shape: badges.BadgeShape.square, checkmarkColor: theme.colorScheme.onPrimaryContainer,
badgeColor: isSelected labelStyle: theme.textTheme.bodySmall?.copyWith(
? Theme.of(context).primaryColor color: isSelected
: Colors.grey.shade300, ? theme.colorScheme.onPrimaryContainer
borderRadius: BorderRadius.circular(8), : theme.colorScheme.onSurface,
), ),
badgeContent: Padding( shape: RoundedRectangleBorder(
padding: const EdgeInsets.symmetric( borderRadius: BorderRadius.circular(12),
horizontal: 8, ),
vertical: 4, side: BorderSide(
), color: isSelected
child: Text( ? theme.colorScheme.primaryContainer
l10n.status(label), : theme.dividerColor.withAlpha(60),
style: TextStyle(
color: isSelected ? Colors.white : Colors.black87,
fontSize: 14,
),
),
),
), ),
); );
}).toList(), }).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),
),
),
], ],
), ),
), ),

View File

@@ -1,17 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/payment/operation.dart'; import 'package:pshared/models/payment/operation.dart';
import 'package:pshared/models/payment/status.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:pshared/utils/currency.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
import 'package:pweb/pages/report/table/badge.dart'; import 'package:pweb/pages/report/table/badge.dart';
import 'package:pweb/utils/download.dart'; import 'package:pweb/utils/report/download_act.dart';
import 'package:pweb/utils/error/snackbar.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class OperationRow { class OperationRow {
@@ -29,7 +25,7 @@ class OperationRow {
final documentCell = canDownload final documentCell = canDownload
? TextButton.icon( ? TextButton.icon(
onPressed: () => _downloadAct(context, op), onPressed: () => downloadPaymentAct(context, op.paymentRef ?? ''),
icon: const Icon(Icons.download), icon: const Icon(Icons.download),
label: Text(loc.downloadAct), label: Text(loc.downloadAct),
) )
@@ -47,28 +43,4 @@ class OperationRow {
DataCell(Text(op.comment)), 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,
);
}
} }

View File

@@ -8,6 +8,7 @@ import 'package:pshared/provider/payment/multiple/provider.dart';
import 'package:pshared/provider/payment/multiple/quotation.dart'; import 'package:pshared/provider/payment/multiple/quotation.dart';
import 'package:pshared/provider/payment/payments.dart'; import 'package:pshared/provider/payment/payments.dart';
import 'package:pshared/utils/currency.dart'; import 'package:pshared/utils/currency.dart';
import 'package:pshared/utils/money.dart';
import 'package:pweb/models/multiple_payouts/csv_row.dart'; import 'package:pweb/models/multiple_payouts/csv_row.dart';
import 'package:pweb/models/multiple_payouts/state.dart'; import 'package:pweb/models/multiple_payouts/state.dart';
@@ -93,8 +94,8 @@ class MultiplePayoutsProvider extends ChangeNotifier {
double total = 0; double total = 0;
for (final row in _rows) { for (final row in _rows) {
final value = double.tryParse(row.amount); final value = parseMoneyAmount(row.amount, fallback: double.nan);
if (value == null) return null; if (value.isNaN) return null;
total += value; total += value;
} }
return Money(amount: amountToString(total), currency: currency); return Money(amount: amountToString(total), currency: currency);
@@ -121,10 +122,10 @@ class MultiplePayoutsProvider extends ChangeNotifier {
final fee = aggregateFeeAmountFor(sourceWallet); final fee = aggregateFeeAmountFor(sourceWallet);
if (debit == null || fee == null) return null; if (debit == null || fee == null) return null;
final debitValue = double.tryParse(debit.amount); final debitValue = parseMoneyAmount(debit.amount, fallback: double.nan);
final feeValue = double.tryParse(fee.amount); final feeValue = parseMoneyAmount(fee.amount, fallback: double.nan);
if (debit.currency.toUpperCase() != fee.currency.toUpperCase()) return null; if (debit.currency.toUpperCase() != fee.currency.toUpperCase()) return null;
if (debitValue == null || feeValue == null || debitValue <= 0) return null; if (debitValue.isNaN || feeValue.isNaN || debitValue <= 0) return null;
return (feeValue / debitValue) * 100; return (feeValue / debitValue) * 100;
} }

View File

@@ -1,3 +1,5 @@
import 'package:pshared/utils/money.dart';
import 'package:pweb/models/multiple_payouts/csv_row.dart'; import 'package:pweb/models/multiple_payouts/csv_row.dart';
@@ -79,8 +81,8 @@ class MultipleCsvParser {
throw FormatException('CSV row ${i + 1}: amount is required'); throw FormatException('CSV row ${i + 1}: amount is required');
} }
final parsedAmount = double.tryParse(amount); final parsedAmount = parseMoneyAmount(amount, fallback: double.nan);
if (parsedAmount == null || parsedAmount <= 0) { if (parsedAmount.isNaN || parsedAmount <= 0) {
throw FormatException( throw FormatException(
'CSV row ${i + 1}: amount must be greater than 0', 'CSV row ${i + 1}: amount must be greater than 0',
); );

View File

@@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:pshared/models/payment/status.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -19,13 +21,13 @@ StatusView statusView(AppLocalizations l10n, String? raw) {
switch (normalized) { switch (normalized) {
case 'SETTLED': case 'SETTLED':
return StatusView(l10n.paymentStatusPending, Colors.yellow); return StatusView(l10n.paymentStatusPending, Colors.orange);
case 'SUCCESS': case 'SUCCESS':
return StatusView(l10n.paymentStatusSuccessful, Colors.green); return StatusView(l10n.paymentStatusSuccessful, Colors.green);
case 'FUNDS_RESERVED': case 'FUNDS_RESERVED':
return StatusView(l10n.paymentStatusReserved, Colors.blue); return StatusView(l10n.paymentStatusReserved, Colors.blue);
case 'ACCEPTED': case 'ACCEPTED':
return StatusView(l10n.paymentStatusProcessing, Colors.yellow); return StatusView(l10n.paymentStatusProcessing, Colors.orange);
case 'SUBMITTED': case 'SUBMITTED':
return StatusView(l10n.paymentStatusProcessing, Colors.blue); return StatusView(l10n.paymentStatusProcessing, Colors.blue);
case 'FAILED': case 'FAILED':
@@ -38,3 +40,17 @@ StatusView statusView(AppLocalizations l10n, String? raw) {
return StatusView(l10n.paymentStatusPending, Colors.grey); return StatusView(l10n.paymentStatusPending, Colors.grey);
} }
} }
StatusView operationStatusView(
AppLocalizations l10n,
OperationStatus status,
) {
switch (status) {
case OperationStatus.success:
return statusView(l10n, 'SUCCESS');
case OperationStatus.error:
return statusView(l10n, 'FAILED');
case OperationStatus.processing:
return statusView(l10n, 'ACCEPTED');
}
}

View File

@@ -0,0 +1,22 @@
AmountParts splitAmount(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty || trimmed == '-') {
return const AmountParts(amount: '-', currency: '');
}
final parts = trimmed.split(' ');
if (parts.length < 2) {
return AmountParts(amount: trimmed, currency: '');
}
final currency = parts.removeLast();
return AmountParts(amount: parts.join(' '), currency: currency);
}
class AmountParts {
final String amount;
final String currency;
const AmountParts({
required this.amount,
required this.currency,
});
}

View File

@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/organizations.dart';
import 'package:pshared/service/payment/documents.dart';
import 'package:pweb/utils/download.dart';
import 'package:pweb/utils/error/snackbar.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
Future<void> downloadPaymentAct(BuildContext context, String paymentRef) async {
final organizations = context.read<OrganizationsProvider>();
if (!organizations.isOrganizationSet) {
return;
}
final trimmed = paymentRef.trim();
if (trimmed.isEmpty) {
return;
}
final loc = AppLocalizations.of(context)!;
await executeActionWithNotification(
context: context,
action: () async {
final file = await PaymentDocumentsService.getAct(
organizations.current.id,
trimmed,
);
await downloadFile(file);
},
errorMessage: loc.downloadActError,
);
}

View File

@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:pshared/models/money.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pshared/utils/localization.dart';
String formatMoney(Money? money, {String fallback = '-'}) {
if (money == null) return fallback;
final amount = money.amount.trim();
if (amount.isEmpty) return fallback;
final symbol = currencySymbolFromCode(money.currency);
final suffix = symbol ?? money.currency;
if (suffix.trim().isEmpty) return amount;
return '$amount $suffix';
}
String formatAmount(double amount, String currency, {String fallback = '-'}) {
final trimmed = currency.trim();
if (trimmed.isEmpty) return amountToString(amount);
final symbol = currencySymbolFromCode(trimmed);
final suffix = symbol ?? trimmed;
return '${amountToString(amount)} $suffix';
}
String formatDateLabel(BuildContext context, DateTime? date, {String fallback = '-'}) {
if (date == null || date.millisecondsSinceEpoch == 0) return fallback;
return dateTimeToLocalFormat(context, date.toLocal());
}
String formatLongDate(BuildContext context, DateTime? date, {String fallback = '-'}) {
if (date == null || date.millisecondsSinceEpoch == 0) return fallback;
final locale = Localizations.localeOf(context).toString();
final formatter = DateFormat('d MMMM y', locale);
return formatter.format(date.toLocal());
}
String collapseWhitespace(String value) {
return value.replaceAll(RegExp(r'\s+'), ' ').trim();
}

View File

@@ -0,0 +1,23 @@
import 'package:pshared/models/payment/operation.dart';
List<OperationItem> sortOperations(List<OperationItem> operations) {
final sorted = List<OperationItem>.from(operations);
sorted.sort((a, b) {
final aTime = a.date.millisecondsSinceEpoch;
final bTime = b.date.millisecondsSinceEpoch;
final aUnknown = isUnknownDate(a.date);
final bUnknown = isUnknownDate(b.date);
if (aUnknown != bUnknown) {
return aUnknown ? 1 : -1;
}
if (aTime != bTime) {
return bTime.compareTo(aTime);
}
return a.payId.compareTo(b.payId);
});
return sorted;
}
bool isUnknownDate(DateTime date) => date.millisecondsSinceEpoch == 0;

View File

@@ -0,0 +1,110 @@
import 'package:pshared/models/payment/operation.dart';
import 'package:pshared/models/payment/payment.dart';
import 'package:pshared/models/payment/status.dart';
import 'package:pshared/utils/money.dart';
import 'package:pweb/models/payment_state.dart';
OperationItem mapPaymentToOperation(Payment payment) {
final debit = payment.lastQuote?.debitAmount;
final settlement = payment.lastQuote?.expectedSettlementAmount;
final amountMoney = debit ?? settlement;
final amount = parseMoneyAmount(amountMoney?.amount);
final currency = amountMoney?.currency ?? '';
final toAmount = settlement == null
? amount
: parseMoneyAmount(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: statusFromPayment(payment),
fileName: _extractFileName(payment.metadata),
amount: amount,
currency: currency,
toAmount: toAmount,
toCurrency: toCurrency,
payId: payId,
paymentRef: payment.paymentRef,
cardNumber: null,
name: name,
date: resolvePaymentDate(payment),
comment: comment,
);
}
OperationStatus statusFromPayment(Payment payment) {
final state = paymentStateFromRaw(payment.state);
switch (state) {
case PaymentState.success:
return OperationStatus.success;
case PaymentState.failed:
case PaymentState.cancelled:
return OperationStatus.error;
case PaymentState.processing:
case PaymentState.unknown:
return OperationStatus.processing;
}
}
DateTime resolvePaymentDate(Payment payment) {
final createdAt = payment.createdAt;
if (createdAt != null) return createdAt.toLocal();
final expiresAt = payment.lastQuote?.fxQuote?.expiresAtUnixMs;
if (expiresAt != null && expiresAt > 0) {
return DateTime.fromMillisecondsSinceEpoch(expiresAt, isUtc: true).toLocal();
}
return DateTime.fromMillisecondsSinceEpoch(0);
}
String? paymentIdFromOperation(OperationItem operation) {
final candidates = [
operation.paymentRef,
operation.payId,
];
for (final candidate in candidates) {
final trimmed = candidate?.trim();
if (trimmed != null && trimmed.isNotEmpty && trimmed != '-') {
return trimmed;
}
}
return null;
}
String? _extractFileName(Map<String, String>? metadata) {
if (metadata == null || metadata.isEmpty) return null;
return _firstNonEmpty([
metadata['upload_filename'],
metadata['upload_file_name'],
metadata['filename'],
]);
}
String? _firstNonEmpty(List<String?> values) {
for (final value in values) {
final trimmed = value?.trim();
Review

по идее это должен быть просто enum с mapper'ом.

по идее это должен быть просто enum с mapper'ом.
if (trimmed != null && trimmed.isNotEmpty) return trimmed;
}
return null;
}

View File

@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/money.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pshared/utils/localization.dart';
import 'package:intl/intl.dart';
String formatMoney(Money? money, {String fallback = '-'}) {
final amount = money?.amount.trim();
if (amount == null || amount.isEmpty) return fallback;
return '$amount ${money!.currency}';
}
String formatAmount(double amount, String currency, {String fallback = '-'}) {
final trimmed = currency.trim();
if (trimmed.isEmpty) return amountToString(amount);
final symbol = currencySymbolFromCode(trimmed);
final suffix = symbol ?? trimmed;
return '${amountToString(amount)} $suffix';
}
String formatDateLabel(BuildContext context, DateTime? date, {String fallback = '-'}) {
if (date == null || date.millisecondsSinceEpoch == 0) return fallback;
return dateTimeToLocalFormat(context, date.toLocal());
}
String formatLongDate(BuildContext context, DateTime? date, {String fallback = '-'}) {
if (date == null || date.millisecondsSinceEpoch == 0) return fallback;
final locale = Localizations.localeOf(context).toString();
final formatter = DateFormat('d MMMM y', locale);
return formatter.format(date.toLocal());
}
String collapseWhitespace(String value) {
return value.replaceAll(RegExp(r'\s+'), ' ').trim();
}

View File

@@ -45,8 +45,8 @@ class PayoutSidebar extends StatelessWidget {
PayoutDestination.dashboard, PayoutDestination.dashboard,
PayoutDestination.recipients, PayoutDestination.recipients,
PayoutDestination.invitations, PayoutDestination.invitations,
PayoutDestination.reports,
// PayoutDestination.methods, // PayoutDestination.methods,
// PayoutDestination.reports,
// PayoutDestination.organizationSettings, // PayoutDestination.organizationSettings,
//TODO Add when ready //TODO Add when ready
]; ];