diff --git a/frontend/pshared/lib/data/dto/payment/payment.dart b/frontend/pshared/lib/data/dto/payment/payment.dart index 5d9352f9..8cb38541 100644 --- a/frontend/pshared/lib/data/dto/payment/payment.dart +++ b/frontend/pshared/lib/data/dto/payment/payment.dart @@ -27,6 +27,17 @@ class PaymentDTO { this.createdAt, }); - factory PaymentDTO.fromJson(Map json) => _$PaymentDTOFromJson(json); + factory PaymentDTO.fromJson(Map json) => + _$PaymentDTOFromJson(_normalizeJson(json)); Map toJson() => _$PaymentDTOToJson(this); + + static Map _normalizeJson(Map json) { + if (json.containsKey('metadata') || !json.containsKey('meta')) { + return json; + } + + final normalized = Map.from(json); + normalized['metadata'] = normalized['meta']; + return normalized; + } } diff --git a/frontend/pshared/lib/data/mapper/wallet/ui.dart b/frontend/pshared/lib/data/mapper/wallet/ui.dart index c674d6c6..e1e27aec 100644 --- a/frontend/pshared/lib/data/mapper/wallet/ui.dart +++ b/frontend/pshared/lib/data/mapper/wallet/ui.dart @@ -1,13 +1,16 @@ import 'package:pshared/models/wallet/wallet.dart' as domain; import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/utils/currency.dart'; +import 'package:pshared/utils/money.dart'; extension WalletUiMapper on domain.WalletModel { Wallet toUi() => Wallet( id: walletRef, walletUserID: walletRef, - balance: double.tryParse(availableMoney?.amount ?? balance?.available?.amount ?? '0') ?? 0, + balance: parseMoneyAmount( + availableMoney?.amount ?? balance?.available?.amount, + ), currency: currencyStringToCode(asset.tokenSymbol), calculatedAt: balance?.calculatedAt ?? DateTime.now(), depositAddress: depositAddress, diff --git a/frontend/pshared/lib/service/payment/wallets.dart b/frontend/pshared/lib/service/payment/wallets.dart index ba43f16f..f1a42853 100644 --- a/frontend/pshared/lib/service/payment/wallets.dart +++ b/frontend/pshared/lib/service/payment/wallets.dart @@ -3,6 +3,7 @@ import 'package:pshared/models/describable.dart'; import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/models/wallet/chain_asset.dart'; import 'package:pshared/service/wallet.dart' as shared_wallet_service; +import 'package:pshared/utils/money.dart'; abstract class WalletsService { @@ -29,8 +30,7 @@ class ApiWalletsService implements WalletsService { organizationRef: organizationRef, walletRef: walletRef, ); - final amount = balance.available?.amount; - return amount == null ? 0 : double.tryParse(amount) ?? 0; + return parseMoneyAmount(balance.available?.amount); } @override diff --git a/frontend/pshared/lib/utils/currency.dart b/frontend/pshared/lib/utils/currency.dart index ae46218b..26f5c722 100644 --- a/frontend/pshared/lib/utils/currency.dart +++ b/frontend/pshared/lib/utils/currency.dart @@ -76,4 +76,14 @@ IconData iconForCurrencyType(Currency currencyCode) { case Currency.usdc: return Icons.money; } -} \ No newline at end of file +} + +String? currencySymbolFromCode(String? code) { + final normalized = code?.trim(); + if (normalized == null || normalized.isEmpty) return null; + try { + return currencyCodeToSymbol(currencyStringToCode(normalized.toUpperCase())); + } catch (_) { + return null; + } +} diff --git a/frontend/pshared/lib/utils/money.dart b/frontend/pshared/lib/utils/money.dart new file mode 100644 index 00000000..925f5eb5 --- /dev/null +++ b/frontend/pshared/lib/utils/money.dart @@ -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); +} diff --git a/frontend/pweb/lib/app/router/payout_routes.dart b/frontend/pweb/lib/app/router/payout_routes.dart index 508acdd5..bb27809f 100644 --- a/frontend/pweb/lib/app/router/payout_routes.dart +++ b/frontend/pweb/lib/app/router/payout_routes.dart @@ -18,12 +18,14 @@ class PayoutRoutes { static const payment = 'payout-payment'; static const settings = 'payout-settings'; static const reports = 'payout-reports'; + static const reportPayment = 'payout-report-payment'; static const methods = 'payout-methods'; static const editWallet = 'payout-edit-wallet'; static const walletTopUp = 'payout-wallet-top-up'; static const paymentTypeQuery = 'paymentType'; static const returnToQuery = 'returnTo'; + static const reportPaymentIdQuery = 'paymentId'; static const dashboardPath = '/dashboard'; static const recipientsPath = '/dashboard/recipients'; @@ -32,6 +34,7 @@ class PayoutRoutes { static const paymentPath = '/dashboard/payment'; static const settingsPath = '/dashboard/settings'; static const reportsPath = '/dashboard/reports'; + static const reportPaymentPath = '/dashboard/reports/payment'; static const methodsPath = '/dashboard/methods'; static const editWalletPath = '/dashboard/methods/edit'; 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( PayoutRoutes.walletTopUp, queryParameters: PayoutRoutes.buildQueryParameters(returnTo: returnTo), diff --git a/frontend/pweb/lib/app/router/payout_shell.dart b/frontend/pweb/lib/app/router/payout_shell.dart index 282a6440..175ed265 100644 --- a/frontend/pweb/lib/app/router/payout_shell.dart +++ b/frontend/pweb/lib/app/router/payout_shell.dart @@ -33,6 +33,7 @@ import 'package:pweb/pages/dashboard/dashboard.dart'; import 'package:pweb/pages/invitations/page.dart'; import 'package:pweb/pages/payment_methods/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/settings/profile/page.dart'; import 'package:pweb/pages/wallet_top_up/page.dart'; @@ -292,6 +293,17 @@ RouteBase payoutShellRoute() => ShellRoute( pageBuilder: (_, _) => const NoTransitionPage(child: OperationHistoryPage()), ), + GoRoute( + name: PayoutRoutes.reportPayment, + path: PayoutRoutes.reportPaymentPath, + pageBuilder: (_, state) => NoTransitionPage( + child: PaymentDetailsPage( + paymentId: state.uri.queryParameters[ + PayoutRoutes.reportPaymentIdQuery] ?? + '', + ), + ), + ), GoRoute( name: PayoutRoutes.methods, path: PayoutRoutes.methodsPath, diff --git a/frontend/pweb/lib/controllers/payout_volumes.dart b/frontend/pweb/lib/controllers/payout_volumes.dart new file mode 100644 index 00000000..99dc77c2 --- /dev/null +++ b/frontend/pweb/lib/controllers/payout_volumes.dart @@ -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); + } +} diff --git a/frontend/pweb/lib/controllers/recent_payments.dart b/frontend/pweb/lib/controllers/recent_payments.dart new file mode 100644 index 00000000..72497a65 --- /dev/null +++ b/frontend/pweb/lib/controllers/recent_payments.dart @@ -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 _recent = const []; + + List get recentOperations => _recent; + bool get isLoading => _payments?.isLoading ?? false; + Exception? get error => _payments?.error; + + void update(PaymentsProvider provider) { + if (!identical(_payments, provider)) { + _payments = provider; + } + _rebuild(); + } + + void _rebuild() { + final operations = (_payments?.payments ?? const []) + .map(mapPaymentToOperation) + .toList(); + _recent = sortOperations(operations).take(5).toList(); + notifyListeners(); + } + +} diff --git a/frontend/pweb/lib/controllers/report_operations.dart b/frontend/pweb/lib/controllers/report_operations.dart new file mode 100644 index 00000000..8da4fe84 --- /dev/null +++ b/frontend/pweb/lib/controllers/report_operations.dart @@ -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 _selectedStatuses = {}; + List _operations = const []; + List _filtered = const []; + + List get operations => _operations; + List get filteredOperations => _filtered; + DateTimeRange? get selectedRange => _selectedRange; + Set 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; + } + _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 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 _applyFilters(List 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); + } + +} diff --git a/frontend/pweb/lib/controllers/upload_history_table.dart b/frontend/pweb/lib/controllers/upload_history_table.dart deleted file mode 100644 index 80fa714b..00000000 --- a/frontend/pweb/lib/controllers/upload_history_table.dart +++ /dev/null @@ -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 '-'; - } -} diff --git a/frontend/pweb/lib/l10n/en.arb b/frontend/pweb/lib/l10n/en.arb index d8715c48..f275a79b 100644 --- a/frontend/pweb/lib/l10n/en.arb +++ b/frontend/pweb/lib/l10n/en.arb @@ -382,6 +382,36 @@ "@commentColumn": { "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", "paymentConfigSubtitle": "Add multiple methods and choose your primary one.", "addPaymentMethod": "Add payment method", @@ -517,8 +547,8 @@ "upload": "Upload", "changeFile": "Change file", "hintUpload": "Supported format: .CSV · Max size 1 MB", - "uploadHistory": "Upload History", - "viewWholeHistory": "View Whole History", + "uploadHistory": "Recent payments", + "viewWholeHistory": "Show all payments", "paymentStatusSuccessful": "Payment Successful", "paymentStatusProcessing": "Processing", "paymentStatusReserved": "Funds Reserved", diff --git a/frontend/pweb/lib/l10n/ru.arb b/frontend/pweb/lib/l10n/ru.arb index 72c2ae01..a94bcb30 100644 --- a/frontend/pweb/lib/l10n/ru.arb +++ b/frontend/pweb/lib/l10n/ru.arb @@ -382,6 +382,36 @@ "@commentColumn": { "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": "Куда получать деньги", "paymentConfigSubtitle": "Добавьте несколько методов и выберите основной.", "addPaymentMethod": "Добавить способ оплаты", @@ -517,8 +547,8 @@ "upload": "Загрузить", "changeFile": "Заменить файл", "hintUpload": "Поддерживаемый формат: .CSV · Макс. размер 1 МБ", - "uploadHistory": "История загрузок", - "viewWholeHistory": "Смотреть всю историю", + "uploadHistory": "Последние платежи", + "viewWholeHistory": "Показать все платежи", "paymentStatusSuccessful": "Платеж успешен", "paymentStatusProcessing": "В обработке", "paymentStatusReserved": "Средства зарезервированы", diff --git a/frontend/pweb/lib/models/chart_point.dart b/frontend/pweb/lib/models/chart_point.dart new file mode 100644 index 00000000..3432bf3d --- /dev/null +++ b/frontend/pweb/lib/models/chart_point.dart @@ -0,0 +1,6 @@ +class ChartPoint { + final T key; + final double value; + + const ChartPoint(this.key, this.value); +} diff --git a/frontend/pweb/lib/models/payment_state.dart b/frontend/pweb/lib/models/payment_state.dart new file mode 100644 index 00000000..814abf3c --- /dev/null +++ b/frontend/pweb/lib/models/payment_state.dart @@ -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; + } +} diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/ledger.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/ledger.dart index 980d2b3c..269b3539 100644 --- a/frontend/pweb/lib/pages/dashboard/buttons/balance/ledger.dart +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/ledger.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import 'package:pshared/controllers/balance_mask/ledger_accounts.dart'; import 'package:pshared/models/ledger/account.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/header.dart'; @@ -25,8 +26,8 @@ class LedgerAccountCard extends StatelessWidget { final money = account.balance?.balance; if (money == null) return '--'; - final amount = double.tryParse(money.amount); - if (amount == null) { + final amount = parseMoneyAmount(money.amount, fallback: double.nan); + if (amount.isNaN) { return '${money.amount} ${money.currency}'; } diff --git a/frontend/pweb/lib/pages/dashboard/payouts/amount.dart b/frontend/pweb/lib/pages/dashboard/payouts/amount.dart index 3e2b2b27..c50053de 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/amount.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/amount.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import 'package:pshared/provider/payment/amount.dart'; import 'package:pshared/utils/currency.dart'; +import 'package:pshared/utils/money.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -32,7 +33,13 @@ class _PaymentAmountWidgetState extends State { 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) { final parsedText = _parseAmount(_controller.text); diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/helpers.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/helpers.dart index 84e455c8..6aafc898 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/helpers.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/helpers.dart @@ -1,14 +1,15 @@ import 'package:pshared/models/asset.dart'; import 'package:pshared/models/money.dart'; import 'package:pshared/utils/currency.dart'; +import 'package:pshared/utils/money.dart'; import 'package:pweb/controllers/multiple_payouts.dart'; String moneyLabel(Money? money) { if (money == null) return 'N/A'; - final amount = double.tryParse(money.amount); - if (amount == null) return '${money.amount} ${money.currency}'; + final amount = parseMoneyAmount(money.amount, fallback: double.nan); + if (amount.isNaN) return '${money.amount} ${money.currency}'; try { return assetToString( Asset( diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/header.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/header.dart index 4ccfe2db..f9a6b86a 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/header.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/header.dart @@ -1,5 +1,8 @@ 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'; @@ -7,21 +10,30 @@ class UploadHistoryHeader extends StatelessWidget { const UploadHistoryHeader({ super.key, required this.theme, - required this.l10n, }); final ThemeData theme; - final AppLocalizations l10n; static const double _smallBox = 5; @override Widget build(BuildContext context) { + final l10 = AppLocalizations.of(context)!; return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Icon(Icons.history), - const SizedBox(width: _smallBox), - Text(l10n.uploadHistory, style: theme.textTheme.bodyLarge), + Row( + children: [ + 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), + ), ], ); } diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/status_badge.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/status_badge.dart deleted file mode 100644 index 9b7bd341..00000000 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/status_badge.dart +++ /dev/null @@ -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, - ), - ), - ); - } -} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/table.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/table.dart deleted file mode 100644 index c2572751..00000000 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/table.dart +++ /dev/null @@ -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 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(), - ); - } -} diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/widget.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/widget.dart index 010908f0..f3662fcf 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/widget.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/widget.dart @@ -1,13 +1,15 @@ import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; - import 'package:provider/provider.dart'; +import 'package:pshared/models/payment/operation.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/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'; @@ -17,61 +19,49 @@ class UploadHistorySection extends StatelessWidget { @override Widget build(BuildContext context) { - final provider = context.watch(); - final theme = Theme.of(context); - final l10 = AppLocalizations.of(context)!; - final dateFormat = DateFormat.yMMMd().add_Hm(); - - if (provider.isLoading) { - return const Center(child: CircularProgressIndicator()); - } - if (provider.error != null) { - return Text( - l10.notificationError(provider.error ?? l10.noErrorInformation), - ); - } - final items = List.of(provider.payments); - items.sort((a, b) { - final left = a.createdAt; - final right = b.createdAt; - if (left == null && right == null) return 0; - if (left == null) return 1; - if (right == null) return -1; - return right.compareTo(left); - }); - - return Column( - children: [ - UploadHistoryHeader(theme: theme, l10n: l10), - const SizedBox(height: 8), - if (items.isEmpty) - Align( - alignment: Alignment.centerLeft, - child: Text( - l10.walletHistoryEmpty, - style: theme.textTheme.bodyMedium, - ), - ) - else ...[ - UploadHistoryTable( - items: items, - dateFormat: dateFormat, - 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), - // ), - // ), - // ], - ], - ], + return ChangeNotifierProxyProvider( + create: (_) => RecentPaymentsController(), + update: (_, payments, controller) => controller!..update(payments), + child: const _RecentPaymentsView(), + ); + } +} + +class _RecentPaymentsView extends StatelessWidget { + const _RecentPaymentsView(); + + void _openPaymentDetails(BuildContext context, OperationItem operation) { + final paymentId = paymentIdFromOperation(operation); + if (paymentId == null) return; + context.pushToReportPayment(paymentId); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10 = AppLocalizations.of(context)!; + return Consumer( + builder: (context, controller, child) { + if (controller.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (controller.error != null) { + return Text( + l10.notificationError(controller.error ?? l10.noErrorInformation), + ); + } + + return Column( + children: [ + UploadHistoryHeader(theme: theme), + const SizedBox(height: 8), + OperationsCardsColumn( + operations: controller.recentOperations, + onTap: (operation) => _openPaymentDetails(context, operation), + ), + ], + ); + }, ); } } diff --git a/frontend/pweb/lib/pages/report/cards/column.dart b/frontend/pweb/lib/pages/report/cards/column.dart new file mode 100644 index 00000000..cb672f9c --- /dev/null +++ b/frontend/pweb/lib/pages/report/cards/column.dart @@ -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 operations; + final ValueChanged? 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, + ); + } +} diff --git a/frontend/pweb/lib/pages/report/cards/items.dart b/frontend/pweb/lib/pages/report/cards/items.dart new file mode 100644 index 00000000..32347333 --- /dev/null +++ b/frontend/pweb/lib/pages/report/cards/items.dart @@ -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 buildOperationCardItems( + BuildContext context, + List operations, { + ValueChanged? onTap, +}) { + final loc = AppLocalizations.of(context)!; + final items = []; + 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, + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/report/cards/list.dart b/frontend/pweb/lib/pages/report/cards/list.dart new file mode 100644 index 00000000..0c10c234 --- /dev/null +++ b/frontend/pweb/lib/pages/report/cards/list.dart @@ -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 operations; + final ValueChanged? 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], + ), + ); + } + +} diff --git a/frontend/pweb/lib/pages/report/cards/operation_card.dart b/frontend/pweb/lib/pages/report/cards/operation_card.dart new file mode 100644 index 00000000..3b57c68f --- /dev/null +++ b/frontend/pweb/lib/pages/report/cards/operation_card.dart @@ -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? 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!, + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/report/cards/operation_card_utils.dart b/frontend/pweb/lib/pages/report/cards/operation_card_utils.dart new file mode 100644 index 00000000..1ddb222c --- /dev/null +++ b/frontend/pweb/lib/pages/report/cards/operation_card_utils.dart @@ -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); +} diff --git a/frontend/pweb/lib/pages/report/cards/operation_info_row.dart b/frontend/pweb/lib/pages/report/cards/operation_info_row.dart new file mode 100644 index 00000000..1ef8edde --- /dev/null +++ b/frontend/pweb/lib/pages/report/cards/operation_info_row.dart @@ -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, + ), + ), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/report/charts/distribution.dart b/frontend/pweb/lib/pages/report/charts/distribution.dart index db016b3a..68ec0a55 100644 --- a/frontend/pweb/lib/pages/report/charts/distribution.dart +++ b/frontend/pweb/lib/pages/report/charts/distribution.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; -import 'package:syncfusion_flutter_charts/charts.dart'; +import 'package:syncfusion_flutter_charts/charts.dart' hide ChartPoint; import 'package:pshared/models/payment/operation.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; +import 'package:pweb/models/chart_point.dart'; class PayoutDistributionChart extends StatelessWidget { @@ -25,7 +26,7 @@ class PayoutDistributionChart extends StatelessWidget { // 2) Build chart data final data = sums.entries - .map((e) => _ChartData(e.key, e.value)) + .map((e) => ChartPoint(e.key, e.value)) .toList(); // 3) Build a simple horizontal legend @@ -42,7 +43,7 @@ class PayoutDistributionChart extends StatelessWidget { children: [ Icon(Icons.circle, size: 10, color: palette[i % palette.length]), const SizedBox(width: 4), - Text(data[i].label, style: Theme.of(context).textTheme.bodySmall), + Text(data[i].key, style: Theme.of(context).textTheme.bodySmall), if (i < data.length - 1) const SizedBox(width: 12), ], ); @@ -62,10 +63,10 @@ class PayoutDistributionChart extends StatelessWidget { child: SfCircularChart( legend: Legend(isVisible: false), tooltipBehavior: TooltipBehavior(enable: true), - series: >[ - PieSeries<_ChartData, String>( + series: , String>>[ + PieSeries, String>( dataSource: data, - xValueMapper: (d, _) => d.label, + xValueMapper: (d, _) => d.key, yValueMapper: (d, _) => d.value, dataLabelMapper: (d, _) => '${(d.value / sums.values.fold(0, (a, b) => a + b) * 100).toStringAsFixed(1)}%', @@ -95,9 +96,3 @@ class PayoutDistributionChart extends StatelessWidget { ); } } - -class _ChartData { - final String label; - final double value; - _ChartData(this.label, this.value); -} diff --git a/frontend/pweb/lib/pages/report/charts/payout_volumes/aggregator.dart b/frontend/pweb/lib/pages/report/charts/payout_volumes/aggregator.dart new file mode 100644 index 00000000..cc9b066b --- /dev/null +++ b/frontend/pweb/lib/pages/report/charts/payout_volumes/aggregator.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/operation.dart'; +import 'package:pshared/models/payment/status.dart'; + + +List aggregateCurrencyTotals( + List operations, + DateTimeRange range, +) { + final totals = {}; + 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); +} diff --git a/frontend/pweb/lib/pages/report/charts/payout_volumes/chart.dart b/frontend/pweb/lib/pages/report/charts/payout_volumes/chart.dart new file mode 100644 index 00000000..78b808e4 --- /dev/null +++ b/frontend/pweb/lib/pages/report/charts/payout_volumes/chart.dart @@ -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 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 operations; + + const _PayoutVolumesChartBody({required this.operations}); + + @override + Widget build(BuildContext context) { + return Consumer( + 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, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/frontend/pweb/lib/pages/report/charts/payout_volumes/palette.dart b/frontend/pweb/lib/pages/report/charts/payout_volumes/palette.dart new file mode 100644 index 00000000..82b7eef6 --- /dev/null +++ b/frontend/pweb/lib/pages/report/charts/payout_volumes/palette.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; + + +List payoutVolumesPalette(ThemeData theme) { + return [ + theme.colorScheme.primary, + theme.colorScheme.secondary, + theme.colorScheme.tertiary, + theme.colorScheme.primaryContainer, + theme.colorScheme.secondaryContainer, + ]; +} diff --git a/frontend/pweb/lib/pages/report/charts/payout_volumes/pie_chart.dart b/frontend/pweb/lib/pages/report/charts/payout_volumes/pie_chart.dart new file mode 100644 index 00000000..55c3ab95 --- /dev/null +++ b/frontend/pweb/lib/pages/report/charts/payout_volumes/pie_chart.dart @@ -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 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( + 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%', + ), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/report/charts/payout_volumes/range_label.dart b/frontend/pweb/lib/pages/report/charts/payout_volumes/range_label.dart new file mode 100644 index 00000000..a1145b59 --- /dev/null +++ b/frontend/pweb/lib/pages/report/charts/payout_volumes/range_label.dart @@ -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'; +} diff --git a/frontend/pweb/lib/pages/report/charts/payout_volumes/range_picker.dart b/frontend/pweb/lib/pages/report/charts/payout_volumes/range_picker.dart new file mode 100644 index 00000000..ba02d155 --- /dev/null +++ b/frontend/pweb/lib/pages/report/charts/payout_volumes/range_picker.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/controllers/payout_volumes.dart'; + + +Future 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), + ); +} diff --git a/frontend/pweb/lib/pages/report/charts/payout_volumes/totals_list.dart b/frontend/pweb/lib/pages/report/charts/payout_volumes/totals_list.dart new file mode 100644 index 00000000..38abb52e --- /dev/null +++ b/frontend/pweb/lib/pages/report/charts/payout_volumes/totals_list.dart @@ -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 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, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/report/charts/status.dart b/frontend/pweb/lib/pages/report/charts/status.dart index 27ea708c..c96e0503 100644 --- a/frontend/pweb/lib/pages/report/charts/status.dart +++ b/frontend/pweb/lib/pages/report/charts/status.dart @@ -2,11 +2,13 @@ import 'dart:math'; import 'package:flutter/material.dart'; -import 'package:syncfusion_flutter_charts/charts.dart'; +import 'package:syncfusion_flutter_charts/charts.dart' hide ChartPoint; import 'package:pshared/models/payment/status.dart'; import 'package:pshared/models/payment/operation.dart'; +import 'package:pweb/models/chart_point.dart'; + class StatusChart extends StatelessWidget { final List operations; @@ -21,9 +23,9 @@ class StatusChart extends StatelessWidget { counts[op.status] = (counts[op.status] ?? 0) + 1; } final items = counts.entries - .map((e) => _ChartData(e.key, e.value.toDouble())) + .map((e) => ChartPoint(e.key, e.value.toDouble())) .toList(); - final maxCount = items.map((e) => e.count.toInt()).fold(0, max); + final maxCount = items.map((e) => e.value.toInt()).fold(0, max); final theme = Theme.of(context); final barColor = theme.colorScheme.secondary; @@ -66,11 +68,11 @@ class StatusChart extends StatelessWidget { ), // ─── Bar series with tooltip enabled ─────────────── - series: >[ - ColumnSeries<_ChartData, String>( + series: , String>>[ + ColumnSeries, String>( dataSource: items, - xValueMapper: (d, _) => d.status.localized(context), - yValueMapper: (d, _) => d.count, + xValueMapper: (d, _) => d.key.localized(context), + yValueMapper: (d, _) => d.value, color: barColor, width: 0.6, borderRadius: const BorderRadius.all(Radius.circular(4)), @@ -83,9 +85,3 @@ class StatusChart extends StatelessWidget { ); } } - -class _ChartData { - final OperationStatus status; - final double count; - _ChartData(this.status, this.count); -} diff --git a/frontend/pweb/lib/pages/report/details/content.dart b/frontend/pweb/lib/pages/report/details/content.dart new file mode 100644 index 00000000..d40dd0a8 --- /dev/null +++ b/frontend/pweb/lib/pages/report/details/content.dart @@ -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), + ], + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/report/details/header.dart b/frontend/pweb/lib/pages/report/details/header.dart new file mode 100644 index 00000000..9916bdf6 --- /dev/null +++ b/frontend/pweb/lib/pages/report/details/header.dart @@ -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, + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/report/details/page.dart b/frontend/pweb/lib/pages/report/details/page.dart new file mode 100644 index 00000000..bd73d28d --- /dev/null +++ b/frontend/pweb/lib/pages/report/details/page.dart @@ -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( + 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 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); + } +} diff --git a/frontend/pweb/lib/pages/report/details/row.dart b/frontend/pweb/lib/pages/report/details/row.dart new file mode 100644 index 00000000..95c9c602 --- /dev/null +++ b/frontend/pweb/lib/pages/report/details/row.dart @@ -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), + ), + ], + ); + }, + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/report/details/section.dart b/frontend/pweb/lib/pages/report/details/section.dart new file mode 100644 index 00000000..7dace1f7 --- /dev/null +++ b/frontend/pweb/lib/pages/report/details/section.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + + +class DetailsSection extends StatelessWidget { + final String title; + final List 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, + ], + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/report/details/sections.dart b/frontend/pweb/lib/pages/report/details/sections.dart new file mode 100644 index 00000000..a4419f86 --- /dev/null +++ b/frontend/pweb/lib/pages/report/details/sections.dart @@ -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; + +} diff --git a/frontend/pweb/lib/pages/report/details/sections/fx.dart b/frontend/pweb/lib/pages/report/details/sections/fx.dart new file mode 100644 index 00000000..f56527bd --- /dev/null +++ b/frontend/pweb/lib/pages/report/details/sections/fx.dart @@ -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 values) { + for (final value in values) { + final trimmed = value?.trim(); + if (trimmed != null && trimmed.isNotEmpty) return trimmed; + } + return null; + } +} diff --git a/frontend/pweb/lib/pages/report/details/sections/metadata.dart b/frontend/pweb/lib/pages/report/details/sections/metadata.dart new file mode 100644 index 00000000..63a48dc7 --- /dev/null +++ b/frontend/pweb/lib/pages/report/details/sections/metadata.dart @@ -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.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; + } +} diff --git a/frontend/pweb/lib/pages/report/details/sections/rows.dart b/frontend/pweb/lib/pages/report/details/sections/rows.dart new file mode 100644 index 00000000..d544b69e --- /dev/null +++ b/frontend/pweb/lib/pages/report/details/sections/rows.dart @@ -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 buildDetailRows(List 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(); +} diff --git a/frontend/pweb/lib/pages/report/details/states/error.dart b/frontend/pweb/lib/pages/report/details/states/error.dart new file mode 100644 index 00000000..24da05a5 --- /dev/null +++ b/frontend/pweb/lib/pages/report/details/states/error.dart @@ -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), + ), + ], + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/report/details/states/not_found.dart b/frontend/pweb/lib/pages/report/details/states/not_found.dart new file mode 100644 index 00000000..cb3e1992 --- /dev/null +++ b/frontend/pweb/lib/pages/report/details/states/not_found.dart @@ -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), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/report/details/summary_card/amount_headline.dart b/frontend/pweb/lib/pages/report/details/summary_card/amount_headline.dart new file mode 100644 index 00000000..e512d6ae --- /dev/null +++ b/frontend/pweb/lib/pages/report/details/summary_card/amount_headline.dart @@ -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), + ], + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/report/details/summary_card/copy_id.dart b/frontend/pweb/lib/pages/report/details/summary_card/copy_id.dart new file mode 100644 index 00000000..169f8e41 --- /dev/null +++ b/frontend/pweb/lib/pages/report/details/summary_card/copy_id.dart @@ -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, + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/report/details/summary_card/info_line.dart b/frontend/pweb/lib/pages/report/details/summary_card/info_line.dart new file mode 100644 index 00000000..2adcc926 --- /dev/null +++ b/frontend/pweb/lib/pages/report/details/summary_card/info_line.dart @@ -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), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/report/details/summary_card/widget.dart b/frontend/pweb/lib/pages/report/details/summary_card/widget.dart new file mode 100644 index 00000000..cd7cf05c --- /dev/null +++ b/frontend/pweb/lib/pages/report/details/summary_card/widget.dart @@ -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, + ), + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/report/page.dart b/frontend/pweb/lib/pages/report/page.dart index 1a2c3ca9..51b209ae 100644 --- a/frontend/pweb/lib/pages/report/page.dart +++ b/frontend/pweb/lib/pages/report/page.dart @@ -3,199 +3,84 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:pshared/models/payment/operation.dart'; -import 'package:pshared/models/payment/payment.dart'; -import 'package:pshared/models/payment/status.dart'; import 'package:pshared/provider/payment/payments.dart'; -import 'package:pweb/pages/report/charts/distribution.dart'; +import 'package:pweb/pages/report/cards/list.dart'; +import 'package:pweb/pages/report/charts/payout_volumes/chart.dart'; import 'package:pweb/pages/report/charts/status.dart'; +import 'package:pweb/controllers/report_operations.dart'; import 'package:pweb/pages/report/table/filters.dart'; -import 'package:pweb/pages/report/table/widget.dart'; +import 'package:pweb/utils/report/payment_mapper.dart'; +import 'package:pweb/app/router/payout_routes.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; -class OperationHistoryPage extends StatefulWidget { +class OperationHistoryPage extends StatelessWidget { const OperationHistoryPage({super.key}); @override - State createState() => _OperationHistoryPageState(); + Widget build(BuildContext context) { + return ChangeNotifierProxyProvider( + create: (_) => ReportOperationsController(), + update: (_, payments, controller) => controller!..update(payments), + child: const _OperationHistoryView(), + ); + } } -class _OperationHistoryPageState extends State { - DateTimeRange? _pendingRange; - DateTimeRange? _appliedRange; - final Set _pendingStatuses = {}; - Set _appliedStatuses = {}; +class _OperationHistoryView extends StatelessWidget { + const _OperationHistoryView(); - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - final provider = context.read(); - if (!provider.isReady && !provider.isLoading) { - provider.refresh(); - } - }); - } - - Future _pickRange() async { + Future _pickRange( + BuildContext context, + ReportOperationsController controller, + ) async { final now = DateTime.now(); - final initial = _pendingRange ?? + final initial = controller.selectedRange ?? DateTimeRange( start: now.subtract(const Duration(days: 30)), end: now, ); - + final picked = await showDateRangePicker( context: context, firstDate: DateTime(2000), lastDate: now.add(const Duration(days: 1)), initialDateRange: initial, ); - + if (picked != null) { - setState(() { - _pendingRange = picked; - }); + controller.setRange(picked); } } - void _toggleStatus(OperationStatus status) { - setState(() { - if (_pendingStatuses.contains(status)) { - _pendingStatuses.remove(status); - } else { - _pendingStatuses.add(status); - } - }); + void _openPaymentDetails(BuildContext context, OperationItem operation) { + final paymentId = paymentIdFromOperation(operation); + if (paymentId == null) return; + + context.pushToReportPayment(paymentId); } - void _applyFilters() { - setState(() { - _appliedRange = _pendingRange; - _appliedStatuses = {..._pendingStatuses}; - }); - } - - List _mapPayments(List 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 _filterOperations(List 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 values) { - for (final value in values) { - final trimmed = value?.trim(); - if (trimmed != null && trimmed.isNotEmpty) return trimmed; - } - return null; - } - - bool _isUnknownDate(DateTime date) => date.millisecondsSinceEpoch == 0; - @override Widget build(BuildContext context) { final loc = AppLocalizations.of(context)!; - return Consumer( - builder: (context, provider, child) { - if (provider.isLoading) { + return Consumer( + builder: (context, controller, child) { + if (controller.isLoading) { return const Center(child: CircularProgressIndicator()); } - - if (provider.error != null) { - final message = provider.error?.toString() ?? loc.noErrorInformation; + + if (controller.error != null) { + final message = + controller.error?.toString() ?? loc.noErrorInformation; return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text(loc.notificationError(message)), ElevatedButton( - onPressed: () => provider.refresh(), + onPressed: () => controller.refresh(), child: Text(loc.retry), ), ], @@ -203,18 +88,16 @@ class _OperationHistoryPageState extends State { ); } - final operations = _mapPayments(provider.payments); - final filteredOperations = _filterOperations(operations); - final hasFileName = operations.any( - (operation) => (operation.fileName ?? '').trim().isNotEmpty, - ); - + final operations = controller.operations; + final filteredOperations = controller.filteredOperations; + return Padding( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, spacing: 16, children: [ + //TODO Make charts more useful and re-enable SizedBox( height: 200, child: Row( @@ -224,7 +107,7 @@ class _OperationHistoryPageState extends State { child: StatusChart(operations: operations), ), Expanded( - child: PayoutDistributionChart( + child: PayoutVolumesChart( operations: operations, ), ), @@ -232,15 +115,15 @@ class _OperationHistoryPageState extends State { ), ), OperationFilters( - selectedRange: _pendingRange, - selectedStatuses: _pendingStatuses, - onPickRange: _pickRange, - onToggleStatus: _toggleStatus, - onApply: _applyFilters, + selectedRange: controller.selectedRange, + selectedStatuses: controller.selectedStatuses, + onPickRange: () => _pickRange(context, controller), + onToggleStatus: controller.toggleStatus, + onClear: controller.clearFilters, ), - OperationsTable( + OperationsCardsList( operations: filteredOperations, - showFileNameColumn: hasFileName, + onTap: (operation) => _openPaymentDetails(context, operation), ), ], ), diff --git a/frontend/pweb/lib/pages/report/table/badge.dart b/frontend/pweb/lib/pages/report/table/badge.dart index 960d905a..0ca4ea93 100644 --- a/frontend/pweb/lib/pages/report/table/badge.dart +++ b/frontend/pweb/lib/pages/report/table/badge.dart @@ -4,6 +4,10 @@ import 'package:badges/badges.dart' as badges; import 'package:pshared/models/payment/status.dart'; +import 'package:pweb/utils/payment/status_view.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + class OperationStatusBadge extends StatelessWidget { final OperationStatus status; @@ -11,15 +15,8 @@ class OperationStatusBadge extends StatelessWidget { const OperationStatusBadge({super.key, required this.status}); Color _badgeColor(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - switch (status) { - case OperationStatus.processing: - return scheme.primary; - case OperationStatus.success: - return scheme.secondary; - case OperationStatus.error: - return scheme.error; - } + final l10n = AppLocalizations.of(context)!; + return operationStatusView(l10n, status).color; } Color _textColor(Color background) { @@ -52,4 +49,4 @@ class OperationStatusBadge extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/frontend/pweb/lib/pages/report/table/filters.dart b/frontend/pweb/lib/pages/report/table/filters.dart index 1545b85f..5390b284 100644 --- a/frontend/pweb/lib/pages/report/table/filters.dart +++ b/frontend/pweb/lib/pages/report/table/filters.dart @@ -1,70 +1,80 @@ import 'package:flutter/material.dart'; -import 'package:badges/badges.dart' as badges; // Make sure to add badges package in pubspec.yaml import 'package:pshared/models/payment/status.dart'; import 'package:pshared/utils/localization.dart'; + import 'package:pweb/generated/i18n/app_localizations.dart'; + class OperationFilters extends StatelessWidget { final DateTimeRange? selectedRange; final Set selectedStatuses; final VoidCallback onPickRange; - final VoidCallback onApply; final ValueChanged onToggleStatus; + final VoidCallback onClear; const OperationFilters({ super.key, required this.selectedRange, required this.selectedStatuses, required this.onPickRange, - required this.onApply, required this.onToggleStatus, + required this.onClear, }); @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; + final theme = Theme.of(context); + final hasActive = selectedRange != null || selectedStatuses.isNotEmpty; + final periodLabel = selectedRange == null + ? l10n.selectPeriod + : '${dateToLocalFormat(context, selectedRange!.start)} – ${dateToLocalFormat(context, selectedRange!.end)}'; return Card( - margin: const EdgeInsets.all(16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), - elevation: 2, + elevation: 0, child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - l10n.filters, - style: Theme.of(context).textTheme.bodyLarge, - ), - const SizedBox(height: 12), - GestureDetector( - onTap: onPickRange, - child: Row( - children: [ - Icon(Icons.date_range_outlined, color: Theme.of(context).primaryColor), - const SizedBox(width: 8), - Expanded( - child: Text( - selectedRange == null - ? l10n.selectPeriod - : '${dateToLocalFormat(context, selectedRange!.start)} – ${dateToLocalFormat(context, selectedRange!.end)}', - style: TextStyle( - color: selectedRange == null - ? Colors.grey - : Colors.black87, - ), - ), + Row( + children: [ + Text( + l10n.filters, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, ), - Icon(Icons.keyboard_arrow_down, color: Colors.grey), - ], + ), + const Spacer(), + if (hasActive) + TextButton.icon( + onPressed: onClear, + icon: const Icon(Icons.close, size: 16), + label: Text(l10n.reset), + ), + ], + ), + const SizedBox(height: 10), + OutlinedButton.icon( + onPressed: onPickRange, + icon: const Icon(Icons.date_range_outlined, size: 18), + label: Text( + periodLabel, + overflow: TextOverflow.ellipsis, + ), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), ), ), - const SizedBox(height: 16), + const SizedBox(height: 12), Wrap( - spacing: 12, + spacing: 10, runSpacing: 8, children: const [ OperationStatus.success, @@ -73,51 +83,28 @@ class OperationFilters extends StatelessWidget { ].map((status) { final label = status.localized(context); final isSelected = selectedStatuses.contains(status); - return GestureDetector( - onTap: () => onToggleStatus(status), - child: badges.Badge( - badgeAnimation: badges.BadgeAnimation.fade(), - badgeStyle: badges.BadgeStyle( - shape: badges.BadgeShape.square, - badgeColor: isSelected - ? Theme.of(context).primaryColor - : Colors.grey.shade300, - borderRadius: BorderRadius.circular(8), - ), - badgeContent: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - child: Text( - l10n.status(label), - style: TextStyle( - color: isSelected ? Colors.white : Colors.black87, - fontSize: 14, - ), - ), - ), + return FilterChip( + label: Text(l10n.status(label)), + selected: isSelected, + onSelected: (_) => onToggleStatus(status), + selectedColor: theme.colorScheme.primaryContainer, + checkmarkColor: theme.colorScheme.onPrimaryContainer, + labelStyle: theme.textTheme.bodySmall?.copyWith( + color: isSelected + ? theme.colorScheme.onPrimaryContainer + : theme.colorScheme.onSurface, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + side: BorderSide( + color: isSelected + ? theme.colorScheme.primaryContainer + : theme.dividerColor.withAlpha(60), ), ); }).toList(), ), - const SizedBox(height: 24), - Align( - alignment: Alignment.centerRight, - child: ElevatedButton( - onPressed: onApply, - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 24, - vertical: 12, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - child: Text(l10n.apply), - ), - ), ], ), ), diff --git a/frontend/pweb/lib/pages/report/table/row.dart b/frontend/pweb/lib/pages/report/table/row.dart index a2a91313..a8797bc5 100644 --- a/frontend/pweb/lib/pages/report/table/row.dart +++ b/frontend/pweb/lib/pages/report/table/row.dart @@ -1,17 +1,13 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - import 'package:pshared/models/payment/operation.dart'; import 'package:pshared/models/payment/status.dart'; -import 'package:pshared/provider/organizations.dart'; -import 'package:pshared/service/payment/documents.dart'; import 'package:pshared/utils/currency.dart'; -import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/pages/report/table/badge.dart'; -import 'package:pweb/utils/download.dart'; -import 'package:pweb/utils/error/snackbar.dart'; +import 'package:pweb/utils/report/download_act.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; class OperationRow { @@ -29,7 +25,7 @@ class OperationRow { final documentCell = canDownload ? TextButton.icon( - onPressed: () => _downloadAct(context, op), + onPressed: () => downloadPaymentAct(context, op.paymentRef ?? ''), icon: const Icon(Icons.download), label: Text(loc.downloadAct), ) @@ -47,28 +43,4 @@ class OperationRow { DataCell(Text(op.comment)), ]); } - - static Future _downloadAct(BuildContext context, OperationItem op) async { - final organizations = context.read(); - 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, - ); - } } diff --git a/frontend/pweb/lib/providers/multiple_payouts.dart b/frontend/pweb/lib/providers/multiple_payouts.dart index 4f376eea..46f489e5 100644 --- a/frontend/pweb/lib/providers/multiple_payouts.dart +++ b/frontend/pweb/lib/providers/multiple_payouts.dart @@ -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/payments.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/state.dart'; @@ -93,8 +94,8 @@ class MultiplePayoutsProvider extends ChangeNotifier { double total = 0; for (final row in _rows) { - final value = double.tryParse(row.amount); - if (value == null) return null; + final value = parseMoneyAmount(row.amount, fallback: double.nan); + if (value.isNaN) return null; total += value; } return Money(amount: amountToString(total), currency: currency); @@ -121,10 +122,10 @@ class MultiplePayoutsProvider extends ChangeNotifier { final fee = aggregateFeeAmountFor(sourceWallet); if (debit == null || fee == null) return null; - final debitValue = double.tryParse(debit.amount); - final feeValue = double.tryParse(fee.amount); + final debitValue = parseMoneyAmount(debit.amount, fallback: double.nan); + final feeValue = parseMoneyAmount(fee.amount, fallback: double.nan); 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; } diff --git a/frontend/pweb/lib/utils/payment/multiple_csv_parser.dart b/frontend/pweb/lib/utils/payment/multiple_csv_parser.dart index f5c180c0..1281eca0 100644 --- a/frontend/pweb/lib/utils/payment/multiple_csv_parser.dart +++ b/frontend/pweb/lib/utils/payment/multiple_csv_parser.dart @@ -1,3 +1,5 @@ +import 'package:pshared/utils/money.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'); } - final parsedAmount = double.tryParse(amount); - if (parsedAmount == null || parsedAmount <= 0) { + final parsedAmount = parseMoneyAmount(amount, fallback: double.nan); + if (parsedAmount.isNaN || parsedAmount <= 0) { throw FormatException( 'CSV row ${i + 1}: amount must be greater than 0', ); diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/helpers.dart b/frontend/pweb/lib/utils/payment/status_view.dart similarity index 67% rename from frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/helpers.dart rename to frontend/pweb/lib/utils/payment/status_view.dart index 98ccde5a..eb660e51 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/history/helpers.dart +++ b/frontend/pweb/lib/utils/payment/status_view.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:pshared/models/payment/status.dart'; + import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -19,13 +21,13 @@ StatusView statusView(AppLocalizations l10n, String? raw) { switch (normalized) { case 'SETTLED': - return StatusView(l10n.paymentStatusPending, Colors.yellow); + return StatusView(l10n.paymentStatusPending, Colors.orange); case 'SUCCESS': return StatusView(l10n.paymentStatusSuccessful, Colors.green); case 'FUNDS_RESERVED': return StatusView(l10n.paymentStatusReserved, Colors.blue); case 'ACCEPTED': - return StatusView(l10n.paymentStatusProcessing, Colors.yellow); + return StatusView(l10n.paymentStatusProcessing, Colors.orange); case 'SUBMITTED': return StatusView(l10n.paymentStatusProcessing, Colors.blue); case 'FAILED': @@ -38,3 +40,17 @@ StatusView statusView(AppLocalizations l10n, String? raw) { 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'); + } +} diff --git a/frontend/pweb/lib/utils/report/amount_parts.dart b/frontend/pweb/lib/utils/report/amount_parts.dart new file mode 100644 index 00000000..05371746 --- /dev/null +++ b/frontend/pweb/lib/utils/report/amount_parts.dart @@ -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, + }); +} diff --git a/frontend/pweb/lib/utils/report/download_act.dart b/frontend/pweb/lib/utils/report/download_act.dart new file mode 100644 index 00000000..27930664 --- /dev/null +++ b/frontend/pweb/lib/utils/report/download_act.dart @@ -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 downloadPaymentAct(BuildContext context, String paymentRef) async { + final organizations = context.read(); + 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, + ); +} diff --git a/frontend/pweb/lib/utils/report/format.dart b/frontend/pweb/lib/utils/report/format.dart new file mode 100644 index 00000000..4cde3fc9 --- /dev/null +++ b/frontend/pweb/lib/utils/report/format.dart @@ -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(); +} diff --git a/frontend/pweb/lib/utils/report/operations.dart b/frontend/pweb/lib/utils/report/operations.dart new file mode 100644 index 00000000..08fe5f08 --- /dev/null +++ b/frontend/pweb/lib/utils/report/operations.dart @@ -0,0 +1,23 @@ +import 'package:pshared/models/payment/operation.dart'; + + +List sortOperations(List operations) { + final sorted = List.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; diff --git a/frontend/pweb/lib/utils/report/payment_mapper.dart b/frontend/pweb/lib/utils/report/payment_mapper.dart new file mode 100644 index 00000000..8300b115 --- /dev/null +++ b/frontend/pweb/lib/utils/report/payment_mapper.dart @@ -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? metadata) { + if (metadata == null || metadata.isEmpty) return null; + return _firstNonEmpty([ + metadata['upload_filename'], + metadata['upload_file_name'], + metadata['filename'], + ]); +} + +String? _firstNonEmpty(List values) { + for (final value in values) { + final trimmed = value?.trim(); + if (trimmed != null && trimmed.isNotEmpty) return trimmed; + } + return null; +} diff --git a/frontend/pweb/lib/utils/report/utils/format.dart b/frontend/pweb/lib/utils/report/utils/format.dart new file mode 100644 index 00000000..f89d1428 --- /dev/null +++ b/frontend/pweb/lib/utils/report/utils/format.dart @@ -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(); +} diff --git a/frontend/pweb/lib/widgets/sidebar/sidebar.dart b/frontend/pweb/lib/widgets/sidebar/sidebar.dart index 19af0f71..83e7882e 100644 --- a/frontend/pweb/lib/widgets/sidebar/sidebar.dart +++ b/frontend/pweb/lib/widgets/sidebar/sidebar.dart @@ -45,8 +45,8 @@ class PayoutSidebar extends StatelessWidget { PayoutDestination.dashboard, PayoutDestination.recipients, PayoutDestination.invitations, + PayoutDestination.reports, // PayoutDestination.methods, - // PayoutDestination.reports, // PayoutDestination.organizationSettings, //TODO Add when ready ];