reports page #518
@@ -27,6 +27,17 @@ class PaymentDTO {
|
|||||||
this.createdAt,
|
this.createdAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory PaymentDTO.fromJson(Map<String, dynamic> json) => _$PaymentDTOFromJson(json);
|
factory PaymentDTO.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$PaymentDTOFromJson(_normalizeJson(json));
|
||||||
Map<String, dynamic> toJson() => _$PaymentDTOToJson(this);
|
Map<String, dynamic> toJson() => _$PaymentDTOToJson(this);
|
||||||
|
|
||||||
|
static Map<String, dynamic> _normalizeJson(Map<String, dynamic> json) {
|
||||||
|
if (json.containsKey('metadata') || !json.containsKey('meta')) {
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
final normalized = Map<String, dynamic>.from(json);
|
||||||
|
normalized['metadata'] = normalized['meta'];
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import 'package:pshared/models/wallet/wallet.dart' as domain;
|
import 'package:pshared/models/wallet/wallet.dart' as domain;
|
||||||
import 'package:pshared/models/payment/wallet.dart';
|
import 'package:pshared/models/payment/wallet.dart';
|
||||||
import 'package:pshared/utils/currency.dart';
|
import 'package:pshared/utils/currency.dart';
|
||||||
|
import 'package:pshared/utils/money.dart';
|
||||||
|
|
||||||
|
|
||||||
extension WalletUiMapper on domain.WalletModel {
|
extension WalletUiMapper on domain.WalletModel {
|
||||||
Wallet toUi() => Wallet(
|
Wallet toUi() => Wallet(
|
||||||
id: walletRef,
|
id: walletRef,
|
||||||
walletUserID: walletRef,
|
walletUserID: walletRef,
|
||||||
balance: double.tryParse(availableMoney?.amount ?? balance?.available?.amount ?? '0') ?? 0,
|
balance: parseMoneyAmount(
|
||||||
|
availableMoney?.amount ?? balance?.available?.amount,
|
||||||
|
),
|
||||||
currency: currencyStringToCode(asset.tokenSymbol),
|
currency: currencyStringToCode(asset.tokenSymbol),
|
||||||
calculatedAt: balance?.calculatedAt ?? DateTime.now(),
|
calculatedAt: balance?.calculatedAt ?? DateTime.now(),
|
||||||
depositAddress: depositAddress,
|
depositAddress: depositAddress,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:pshared/models/describable.dart';
|
|||||||
import 'package:pshared/models/payment/wallet.dart';
|
import 'package:pshared/models/payment/wallet.dart';
|
||||||
import 'package:pshared/models/wallet/chain_asset.dart';
|
import 'package:pshared/models/wallet/chain_asset.dart';
|
||||||
import 'package:pshared/service/wallet.dart' as shared_wallet_service;
|
import 'package:pshared/service/wallet.dart' as shared_wallet_service;
|
||||||
|
import 'package:pshared/utils/money.dart';
|
||||||
|
|
||||||
|
|
||||||
abstract class WalletsService {
|
abstract class WalletsService {
|
||||||
@@ -29,8 +30,7 @@ class ApiWalletsService implements WalletsService {
|
|||||||
organizationRef: organizationRef,
|
organizationRef: organizationRef,
|
||||||
walletRef: walletRef,
|
walletRef: walletRef,
|
||||||
);
|
);
|
||||||
final amount = balance.available?.amount;
|
return parseMoneyAmount(balance.available?.amount);
|
||||||
return amount == null ? 0 : double.tryParse(amount) ?? 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -77,3 +77,13 @@ IconData iconForCurrencyType(Currency currencyCode) {
|
|||||||
return Icons.money;
|
return Icons.money;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? currencySymbolFromCode(String? code) {
|
||||||
|
final normalized = code?.trim();
|
||||||
|
if (normalized == null || normalized.isEmpty) return null;
|
||||||
|
try {
|
||||||
|
return currencyCodeToSymbol(currencyStringToCode(normalized.toUpperCase()));
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
12
frontend/pshared/lib/utils/money.dart
Normal file
12
frontend/pshared/lib/utils/money.dart
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import 'package:pshared/models/money.dart';
|
||||||
|
|
||||||
|
|
||||||
|
double parseMoneyAmount(String? raw, {double fallback = 0}) {
|
||||||
|
final trimmed = raw?.trim();
|
||||||
|
if (trimmed == null || trimmed.isEmpty) return fallback;
|
||||||
|
return double.tryParse(trimmed) ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MoneyAmountX on Money {
|
||||||
|
double get amountValue => parseMoneyAmount(amount);
|
||||||
|
}
|
||||||
@@ -18,12 +18,14 @@ class PayoutRoutes {
|
|||||||
static const payment = 'payout-payment';
|
static const payment = 'payout-payment';
|
||||||
static const settings = 'payout-settings';
|
static const settings = 'payout-settings';
|
||||||
static const reports = 'payout-reports';
|
static const reports = 'payout-reports';
|
||||||
|
static const reportPayment = 'payout-report-payment';
|
||||||
static const methods = 'payout-methods';
|
static const methods = 'payout-methods';
|
||||||
static const editWallet = 'payout-edit-wallet';
|
static const editWallet = 'payout-edit-wallet';
|
||||||
static const walletTopUp = 'payout-wallet-top-up';
|
static const walletTopUp = 'payout-wallet-top-up';
|
||||||
|
|
||||||
static const paymentTypeQuery = 'paymentType';
|
static const paymentTypeQuery = 'paymentType';
|
||||||
static const returnToQuery = 'returnTo';
|
static const returnToQuery = 'returnTo';
|
||||||
|
static const reportPaymentIdQuery = 'paymentId';
|
||||||
|
|
||||||
static const dashboardPath = '/dashboard';
|
static const dashboardPath = '/dashboard';
|
||||||
static const recipientsPath = '/dashboard/recipients';
|
static const recipientsPath = '/dashboard/recipients';
|
||||||
@@ -32,6 +34,7 @@ class PayoutRoutes {
|
|||||||
static const paymentPath = '/dashboard/payment';
|
static const paymentPath = '/dashboard/payment';
|
||||||
static const settingsPath = '/dashboard/settings';
|
static const settingsPath = '/dashboard/settings';
|
||||||
static const reportsPath = '/dashboard/reports';
|
static const reportsPath = '/dashboard/reports';
|
||||||
|
static const reportPaymentPath = '/dashboard/reports/payment';
|
||||||
static const methodsPath = '/dashboard/methods';
|
static const methodsPath = '/dashboard/methods';
|
||||||
static const editWalletPath = '/dashboard/methods/edit';
|
static const editWalletPath = '/dashboard/methods/edit';
|
||||||
static const walletTopUpPath = '/dashboard/wallet/top-up';
|
static const walletTopUpPath = '/dashboard/wallet/top-up';
|
||||||
@@ -173,6 +176,20 @@ extension PayoutNavigation on BuildContext {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
void goToReportPayment(String paymentId) => goNamed(
|
||||||
|
PayoutRoutes.reportPayment,
|
||||||
|
queryParameters: {
|
||||||
|
PayoutRoutes.reportPaymentIdQuery: paymentId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
void pushToReportPayment(String paymentId) => pushNamed(
|
||||||
|
PayoutRoutes.reportPayment,
|
||||||
|
queryParameters: {
|
||||||
|
PayoutRoutes.reportPaymentIdQuery: paymentId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
void pushToWalletTopUp({PayoutDestination? returnTo}) => pushNamed(
|
void pushToWalletTopUp({PayoutDestination? returnTo}) => pushNamed(
|
||||||
PayoutRoutes.walletTopUp,
|
PayoutRoutes.walletTopUp,
|
||||||
queryParameters: PayoutRoutes.buildQueryParameters(returnTo: returnTo),
|
queryParameters: PayoutRoutes.buildQueryParameters(returnTo: returnTo),
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import 'package:pweb/pages/dashboard/dashboard.dart';
|
|||||||
import 'package:pweb/pages/invitations/page.dart';
|
import 'package:pweb/pages/invitations/page.dart';
|
||||||
import 'package:pweb/pages/payment_methods/page.dart';
|
import 'package:pweb/pages/payment_methods/page.dart';
|
||||||
import 'package:pweb/pages/payout_page/wallet/edit/page.dart';
|
import 'package:pweb/pages/payout_page/wallet/edit/page.dart';
|
||||||
|
import 'package:pweb/pages/report/details/page.dart';
|
||||||
import 'package:pweb/pages/report/page.dart';
|
import 'package:pweb/pages/report/page.dart';
|
||||||
import 'package:pweb/pages/settings/profile/page.dart';
|
import 'package:pweb/pages/settings/profile/page.dart';
|
||||||
import 'package:pweb/pages/wallet_top_up/page.dart';
|
import 'package:pweb/pages/wallet_top_up/page.dart';
|
||||||
@@ -292,6 +293,17 @@ RouteBase payoutShellRoute() => ShellRoute(
|
|||||||
pageBuilder: (_, _) =>
|
pageBuilder: (_, _) =>
|
||||||
const NoTransitionPage(child: OperationHistoryPage()),
|
const NoTransitionPage(child: OperationHistoryPage()),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
name: PayoutRoutes.reportPayment,
|
||||||
|
path: PayoutRoutes.reportPaymentPath,
|
||||||
|
pageBuilder: (_, state) => NoTransitionPage(
|
||||||
|
child: PaymentDetailsPage(
|
||||||
|
paymentId: state.uri.queryParameters[
|
||||||
|
PayoutRoutes.reportPaymentIdQuery] ??
|
||||||
|
'',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
name: PayoutRoutes.methods,
|
name: PayoutRoutes.methods,
|
||||||
path: PayoutRoutes.methodsPath,
|
path: PayoutRoutes.methodsPath,
|
||||||
|
|||||||
44
frontend/pweb/lib/controllers/payout_volumes.dart
Normal file
44
frontend/pweb/lib/controllers/payout_volumes.dart
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class PayoutVolumesController extends ChangeNotifier {
|
||||||
|
DateTimeRange _range;
|
||||||
|
|
||||||
|
PayoutVolumesController({DateTime? now})
|
||||||
|
: _range = _defaultRange(now ?? DateTime.now());
|
||||||
|
|
||||||
|
DateTimeRange get range => _range;
|
||||||
|
|
||||||
|
void setRange(DateTimeRange range) {
|
||||||
|
final normalized = _normalizeRange(range);
|
||||||
|
if (_isSameRange(_range, normalized)) return;
|
||||||
|
_range = normalized;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
static DateTimeRange _defaultRange(DateTime now) {
|
||||||
|
final local = now.toLocal();
|
||||||
|
final start = DateTime(local.year, local.month, 1);
|
||||||
|
final end = DateTime(
|
||||||
|
local.year,
|
||||||
|
local.month,
|
||||||
|
local.day,
|
||||||
|
);
|
||||||
|
return DateTimeRange(start: start, end: end);
|
||||||
|
}
|
||||||
|
|
||||||
|
static DateTimeRange _normalizeRange(DateTimeRange range) {
|
||||||
|
final start = DateTime(range.start.year, range.start.month, range.start.day);
|
||||||
|
final end = DateTime(
|
||||||
|
range.end.year,
|
||||||
|
range.end.month,
|
||||||
|
range.end.day,
|
||||||
|
);
|
||||||
|
return DateTimeRange(start: start, end: end);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _isSameRange(DateTimeRange a, DateTimeRange b) {
|
||||||
|
return a.start.isAtSameMomentAs(b.start) &&
|
||||||
|
a.end.isAtSameMomentAs(b.end);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
frontend/pweb/lib/controllers/recent_payments.dart
Normal file
33
frontend/pweb/lib/controllers/recent_payments.dart
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/models/payment/operation.dart';
|
||||||
|
import 'package:pshared/provider/payment/payments.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/utils/report/operations.dart';
|
||||||
|
import 'package:pweb/utils/report/payment_mapper.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class RecentPaymentsController extends ChangeNotifier {
|
||||||
|
PaymentsProvider? _payments;
|
||||||
|
List<OperationItem> _recent = const [];
|
||||||
|
|
||||||
|
List<OperationItem> get recentOperations => _recent;
|
||||||
|
bool get isLoading => _payments?.isLoading ?? false;
|
||||||
|
Exception? get error => _payments?.error;
|
||||||
|
|
||||||
|
void update(PaymentsProvider provider) {
|
||||||
|
if (!identical(_payments, provider)) {
|
||||||
|
_payments = provider;
|
||||||
|
|
|||||||
|
}
|
||||||
|
_rebuild();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _rebuild() {
|
||||||
|
final operations = (_payments?.payments ?? const [])
|
||||||
|
.map(mapPaymentToOperation)
|
||||||
|
.toList();
|
||||||
|
_recent = sortOperations(operations).take(5).toList();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
104
frontend/pweb/lib/controllers/report_operations.dart
Normal file
104
frontend/pweb/lib/controllers/report_operations.dart
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import 'dart:collection';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/models/payment/operation.dart';
|
||||||
|
import 'package:pshared/models/payment/status.dart';
|
||||||
|
import 'package:pshared/provider/payment/payments.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/utils/report/operations.dart';
|
||||||
|
import 'package:pweb/utils/report/payment_mapper.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class ReportOperationsController extends ChangeNotifier {
|
||||||
|
PaymentsProvider? _payments;
|
||||||
|
DateTimeRange? _selectedRange;
|
||||||
|
final Set<OperationStatus> _selectedStatuses = {};
|
||||||
|
List<OperationItem> _operations = const [];
|
||||||
|
List<OperationItem> _filtered = const [];
|
||||||
|
|
||||||
|
List<OperationItem> get operations => _operations;
|
||||||
|
List<OperationItem> get filteredOperations => _filtered;
|
||||||
|
DateTimeRange? get selectedRange => _selectedRange;
|
||||||
|
Set<OperationStatus> get selectedStatuses =>
|
||||||
|
UnmodifiableSetView(_selectedStatuses);
|
||||||
|
|
||||||
|
bool get isLoading => _payments?.isLoading ?? false;
|
||||||
|
Exception? get error => _payments?.error;
|
||||||
|
|
||||||
|
void update(PaymentsProvider provider) {
|
||||||
|
if (!identical(_payments, provider)) {
|
||||||
|
_payments = provider;
|
||||||
|
tech
commented
то же самое то же самое
|
|||||||
|
}
|
||||||
|
_rebuildOperations();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setRange(DateTimeRange? range) {
|
||||||
|
if (_isSameRange(_selectedRange, range)) return;
|
||||||
|
_selectedRange = range;
|
||||||
|
_rebuildFiltered();
|
||||||
|
}
|
||||||
|
|
||||||
|
void toggleStatus(OperationStatus status) {
|
||||||
|
if (_selectedStatuses.contains(status)) {
|
||||||
|
_selectedStatuses.remove(status);
|
||||||
|
} else {
|
||||||
|
_selectedStatuses.add(status);
|
||||||
|
}
|
||||||
|
_rebuildFiltered();
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearFilters() {
|
||||||
|
if (_selectedRange == null && _selectedStatuses.isEmpty) return;
|
||||||
|
_selectedRange = null;
|
||||||
|
_selectedStatuses.clear();
|
||||||
|
_rebuildFiltered();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refresh() async {
|
||||||
|
await _payments?.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _rebuildOperations() {
|
||||||
|
final items = _payments?.payments ?? const [];
|
||||||
|
_operations = items.map(mapPaymentToOperation).toList();
|
||||||
|
_rebuildFiltered(notify: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _rebuildFiltered({bool notify = true}) {
|
||||||
|
_filtered = _applyFilters(_operations);
|
||||||
|
if (notify) {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<OperationItem> _applyFilters(List<OperationItem> operations) {
|
||||||
|
if (_selectedRange == null && _selectedStatuses.isEmpty) {
|
||||||
|
return sortOperations(operations);
|
||||||
|
}
|
||||||
|
|
||||||
|
final filtered = operations.where((op) {
|
||||||
|
final statusMatch =
|
||||||
|
_selectedStatuses.isEmpty || _selectedStatuses.contains(op.status);
|
||||||
|
final dateMatch = _selectedRange == null ||
|
||||||
|
isUnknownDate(op.date) ||
|
||||||
|
(op.date.isAfter(
|
||||||
|
_selectedRange!.start.subtract(const Duration(seconds: 1)),
|
||||||
|
) &&
|
||||||
|
op.date.isBefore(
|
||||||
|
_selectedRange!.end.add(const Duration(seconds: 1)),
|
||||||
|
));
|
||||||
|
return statusMatch && dateMatch;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return sortOperations(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isSameRange(DateTimeRange? left, DateTimeRange? right) {
|
||||||
|
if (left == null && right == null) return true;
|
||||||
|
if (left == null || right == null) return false;
|
||||||
|
return left.start.isAtSameMomentAs(right.start) &&
|
||||||
|
left.end.isAtSameMomentAs(right.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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 '-';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -382,6 +382,36 @@
|
|||||||
"@commentColumn": {
|
"@commentColumn": {
|
||||||
"description": "Table column header for any comment"
|
"description": "Table column header for any comment"
|
||||||
},
|
},
|
||||||
|
"reportPaymentsEmpty": "No payments yet",
|
||||||
|
"paymentDetailsNotFound": "Payment not found",
|
||||||
|
"paymentDetailsIdentifiers": "Identifiers",
|
||||||
|
"paymentDetailsAmounts": "Amounts",
|
||||||
|
"paymentDetailsFx": "FX quote",
|
||||||
|
"paymentDetailsFailure": "Failure",
|
||||||
|
"paymentDetailsMetadata": "Metadata",
|
||||||
|
"metadataUploadFileName": "Upload file name",
|
||||||
|
"metadataTotalRecipients": "Total Recipients",
|
||||||
|
"paymentIdLabel": "Payment ID",
|
||||||
|
"paymentIdCopied": "Payment ID copied",
|
||||||
|
"idempotencyKeyLabel": "Idempotency key",
|
||||||
|
"quoteIdLabel": "Quote ID",
|
||||||
|
"createdAtLabel": "Created at",
|
||||||
|
"debitAmountLabel": "Debit amount",
|
||||||
|
"debitSettlementAmountLabel": "Debit settlement amount",
|
||||||
|
"expectedSettlementAmountLabel": "Expected settlement amount",
|
||||||
|
"feeTotalLabel": "Total fee",
|
||||||
|
"networkFeeLabel": "Network fee",
|
||||||
|
"fxRateLabel": "Rate",
|
||||||
|
"fxProviderLabel": "Provider",
|
||||||
|
"rateRefLabel": "Rate reference",
|
||||||
|
"quoteRefLabel": "Quote reference",
|
||||||
|
"fxSideLabel": "Side",
|
||||||
|
"fxBaseAmountLabel": "Base amount",
|
||||||
|
"fxQuoteAmountLabel": "Quote amount",
|
||||||
|
"expiresAtLabel": "Expires at",
|
||||||
|
"failureCodeLabel": "Failure code",
|
||||||
|
"failureReasonLabel": "Failure reason",
|
||||||
|
"metadataEmpty": "No metadata",
|
||||||
"paymentConfigTitle": "Where to receive money",
|
"paymentConfigTitle": "Where to receive money",
|
||||||
"paymentConfigSubtitle": "Add multiple methods and choose your primary one.",
|
"paymentConfigSubtitle": "Add multiple methods and choose your primary one.",
|
||||||
"addPaymentMethod": "Add payment method",
|
"addPaymentMethod": "Add payment method",
|
||||||
@@ -517,8 +547,8 @@
|
|||||||
"upload": "Upload",
|
"upload": "Upload",
|
||||||
"changeFile": "Change file",
|
"changeFile": "Change file",
|
||||||
"hintUpload": "Supported format: .CSV · Max size 1 MB",
|
"hintUpload": "Supported format: .CSV · Max size 1 MB",
|
||||||
"uploadHistory": "Upload History",
|
"uploadHistory": "Recent payments",
|
||||||
"viewWholeHistory": "View Whole History",
|
"viewWholeHistory": "Show all payments",
|
||||||
"paymentStatusSuccessful": "Payment Successful",
|
"paymentStatusSuccessful": "Payment Successful",
|
||||||
"paymentStatusProcessing": "Processing",
|
"paymentStatusProcessing": "Processing",
|
||||||
"paymentStatusReserved": "Funds Reserved",
|
"paymentStatusReserved": "Funds Reserved",
|
||||||
|
|||||||
@@ -382,6 +382,36 @@
|
|||||||
"@commentColumn": {
|
"@commentColumn": {
|
||||||
"description": "Заголовок столбца таблицы для комментария"
|
"description": "Заголовок столбца таблицы для комментария"
|
||||||
},
|
},
|
||||||
|
"reportPaymentsEmpty": "Платежей пока нет",
|
||||||
|
"paymentDetailsNotFound": "Платеж не найден",
|
||||||
|
"paymentDetailsIdentifiers": "Идентификаторы",
|
||||||
|
"paymentDetailsAmounts": "Суммы",
|
||||||
|
"paymentDetailsFx": "Курс",
|
||||||
|
"paymentDetailsFailure": "Ошибка",
|
||||||
|
"paymentDetailsMetadata": "Метаданные",
|
||||||
|
"metadataUploadFileName": "Имя файла загрузки",
|
||||||
|
"metadataTotalRecipients": "Всего получателей",
|
||||||
|
"paymentIdLabel": "ID платежа",
|
||||||
|
"paymentIdCopied": "ID платежа скопирован",
|
||||||
|
"idempotencyKeyLabel": "Ключ идемпотентности",
|
||||||
|
"quoteIdLabel": "ID котировки",
|
||||||
|
"createdAtLabel": "Создан",
|
||||||
|
"debitAmountLabel": "Списано",
|
||||||
|
"debitSettlementAmountLabel": "Списано к зачислению",
|
||||||
|
"expectedSettlementAmountLabel": "Ожидаемая сумма зачисления",
|
||||||
|
"feeTotalLabel": "Комиссия",
|
||||||
|
"networkFeeLabel": "Сетевая комиссия",
|
||||||
|
"fxRateLabel": "Курс",
|
||||||
|
"fxProviderLabel": "Провайдер",
|
||||||
|
"rateRefLabel": "Ссылка на курс",
|
||||||
|
"quoteRefLabel": "Ссылка на котировку",
|
||||||
|
"fxSideLabel": "Сторона",
|
||||||
|
"fxBaseAmountLabel": "Базовая сумма",
|
||||||
|
"fxQuoteAmountLabel": "Котируемая сумма",
|
||||||
|
"expiresAtLabel": "Истекает",
|
||||||
|
"failureCodeLabel": "Код ошибки",
|
||||||
|
"failureReasonLabel": "Причина ошибки",
|
||||||
|
"metadataEmpty": "Метаданные отсутствуют",
|
||||||
"paymentConfigTitle": "Куда получать деньги",
|
"paymentConfigTitle": "Куда получать деньги",
|
||||||
"paymentConfigSubtitle": "Добавьте несколько методов и выберите основной.",
|
"paymentConfigSubtitle": "Добавьте несколько методов и выберите основной.",
|
||||||
"addPaymentMethod": "Добавить способ оплаты",
|
"addPaymentMethod": "Добавить способ оплаты",
|
||||||
@@ -517,8 +547,8 @@
|
|||||||
"upload": "Загрузить",
|
"upload": "Загрузить",
|
||||||
"changeFile": "Заменить файл",
|
"changeFile": "Заменить файл",
|
||||||
"hintUpload": "Поддерживаемый формат: .CSV · Макс. размер 1 МБ",
|
"hintUpload": "Поддерживаемый формат: .CSV · Макс. размер 1 МБ",
|
||||||
"uploadHistory": "История загрузок",
|
"uploadHistory": "Последние платежи",
|
||||||
"viewWholeHistory": "Смотреть всю историю",
|
"viewWholeHistory": "Показать все платежи",
|
||||||
"paymentStatusSuccessful": "Платеж успешен",
|
"paymentStatusSuccessful": "Платеж успешен",
|
||||||
"paymentStatusProcessing": "В обработке",
|
"paymentStatusProcessing": "В обработке",
|
||||||
"paymentStatusReserved": "Средства зарезервированы",
|
"paymentStatusReserved": "Средства зарезервированы",
|
||||||
|
|||||||
6
frontend/pweb/lib/models/chart_point.dart
Normal file
6
frontend/pweb/lib/models/chart_point.dart
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
class ChartPoint<T> {
|
||||||
|
final T key;
|
||||||
|
final double value;
|
||||||
|
|
||||||
|
const ChartPoint(this.key, this.value);
|
||||||
|
}
|
||||||
27
frontend/pweb/lib/models/payment_state.dart
Normal file
27
frontend/pweb/lib/models/payment_state.dart
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
enum PaymentState {
|
||||||
|
success,
|
||||||
|
failed,
|
||||||
|
cancelled,
|
||||||
|
processing,
|
||||||
|
unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
PaymentState paymentStateFromRaw(String? raw) {
|
||||||
|
final trimmed = (raw ?? '').trim().toUpperCase();
|
||||||
|
final normalized = trimmed.startsWith('PAYMENT_STATE_')
|
||||||
|
? trimmed.substring('PAYMENT_STATE_'.length)
|
||||||
|
: trimmed;
|
||||||
|
|
||||||
|
switch (normalized) {
|
||||||
|
case 'SUCCESS':
|
||||||
|
return PaymentState.success;
|
||||||
|
case 'FAILED':
|
||||||
|
return PaymentState.failed;
|
||||||
|
case 'CANCELLED':
|
||||||
|
return PaymentState.cancelled;
|
||||||
|
case 'PROCESSING':
|
||||||
|
return PaymentState.processing;
|
||||||
|
default:
|
||||||
|
return PaymentState.unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
|
|||||||
import 'package:pshared/controllers/balance_mask/ledger_accounts.dart';
|
import 'package:pshared/controllers/balance_mask/ledger_accounts.dart';
|
||||||
import 'package:pshared/models/ledger/account.dart';
|
import 'package:pshared/models/ledger/account.dart';
|
||||||
import 'package:pshared/utils/currency.dart';
|
import 'package:pshared/utils/currency.dart';
|
||||||
|
import 'package:pshared/utils/money.dart';
|
||||||
|
|
||||||
import 'package:pweb/pages/dashboard/buttons/balance/config.dart';
|
import 'package:pweb/pages/dashboard/buttons/balance/config.dart';
|
||||||
import 'package:pweb/pages/dashboard/buttons/balance/header.dart';
|
import 'package:pweb/pages/dashboard/buttons/balance/header.dart';
|
||||||
@@ -25,8 +26,8 @@ class LedgerAccountCard extends StatelessWidget {
|
|||||||
final money = account.balance?.balance;
|
final money = account.balance?.balance;
|
||||||
if (money == null) return '--';
|
if (money == null) return '--';
|
||||||
|
|
||||||
final amount = double.tryParse(money.amount);
|
final amount = parseMoneyAmount(money.amount, fallback: double.nan);
|
||||||
if (amount == null) {
|
if (amount.isNaN) {
|
||||||
return '${money.amount} ${money.currency}';
|
return '${money.amount} ${money.currency}';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
|||||||
|
|
||||||
import 'package:pshared/provider/payment/amount.dart';
|
import 'package:pshared/provider/payment/amount.dart';
|
||||||
import 'package:pshared/utils/currency.dart';
|
import 'package:pshared/utils/currency.dart';
|
||||||
|
import 'package:pshared/utils/money.dart';
|
||||||
|
|
||||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
@@ -32,7 +33,13 @@ class _PaymentAmountWidgetState extends State<PaymentAmountWidget> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
double? _parseAmount(String value) => double.tryParse(value.replaceAll(',', '.'));
|
double? _parseAmount(String value) {
|
||||||
|
final parsed = parseMoneyAmount(
|
||||||
|
value.replaceAll(',', '.'),
|
||||||
|
fallback: double.nan,
|
||||||
|
);
|
||||||
|
return parsed.isNaN ? null : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
void _syncTextWithAmount(double amount) {
|
void _syncTextWithAmount(double amount) {
|
||||||
final parsedText = _parseAmount(_controller.text);
|
final parsedText = _parseAmount(_controller.text);
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import 'package:pshared/models/asset.dart';
|
import 'package:pshared/models/asset.dart';
|
||||||
import 'package:pshared/models/money.dart';
|
import 'package:pshared/models/money.dart';
|
||||||
import 'package:pshared/utils/currency.dart';
|
import 'package:pshared/utils/currency.dart';
|
||||||
|
import 'package:pshared/utils/money.dart';
|
||||||
|
|
||||||
import 'package:pweb/controllers/multiple_payouts.dart';
|
import 'package:pweb/controllers/multiple_payouts.dart';
|
||||||
|
|
||||||
|
|
||||||
String moneyLabel(Money? money) {
|
String moneyLabel(Money? money) {
|
||||||
if (money == null) return 'N/A';
|
if (money == null) return 'N/A';
|
||||||
final amount = double.tryParse(money.amount);
|
final amount = parseMoneyAmount(money.amount, fallback: double.nan);
|
||||||
if (amount == null) return '${money.amount} ${money.currency}';
|
if (amount.isNaN) return '${money.amount} ${money.currency}';
|
||||||
try {
|
try {
|
||||||
return assetToString(
|
return assetToString(
|
||||||
Asset(
|
Asset(
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/app/router/payout_routes.dart';
|
||||||
|
import 'package:pweb/widgets/sidebar/destinations.dart';
|
||||||
|
|
||||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
|
||||||
@@ -7,21 +10,30 @@ class UploadHistoryHeader extends StatelessWidget {
|
|||||||
const UploadHistoryHeader({
|
const UploadHistoryHeader({
|
||||||
super.key,
|
super.key,
|
||||||
required this.theme,
|
required this.theme,
|
||||||
required this.l10n,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final ThemeData theme;
|
final ThemeData theme;
|
||||||
final AppLocalizations l10n;
|
|
||||||
|
|
||||||
static const double _smallBox = 5;
|
static const double _smallBox = 5;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final l10 = AppLocalizations.of(context)!;
|
||||||
return Row(
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.history),
|
Row(
|
||||||
const SizedBox(width: _smallBox),
|
children: [
|
||||||
Text(l10n.uploadHistory, style: theme.textTheme.bodyLarge),
|
const Icon(Icons.history),
|
||||||
|
const SizedBox(width: _smallBox),
|
||||||
|
Text(l10.uploadHistory, style: theme.textTheme.bodyLarge),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () => context.goToPayout(PayoutDestination.reports),
|
||||||
|
icon: const Icon(Icons.open_in_new, size: 16),
|
||||||
|
label: Text(l10.viewWholeHistory),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
|
|
||||||
import 'package:pshared/models/payment/payment.dart';
|
|
||||||
|
|
||||||
import 'package:pweb/controllers/upload_history_table.dart';
|
|
||||||
import 'package:pweb/pages/dashboard/payouts/multiple/sections/history/helpers.dart';
|
|
||||||
import 'package:pweb/pages/dashboard/payouts/multiple/sections/history/status_badge.dart';
|
|
||||||
|
|
||||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
|
||||||
|
|
||||||
|
|
||||||
class UploadHistoryTable extends StatelessWidget {
|
|
||||||
const UploadHistoryTable({
|
|
||||||
super.key,
|
|
||||||
required this.items,
|
|
||||||
required this.dateFormat,
|
|
||||||
required this.l10n,
|
|
||||||
});
|
|
||||||
|
|
||||||
final List<Payment> items;
|
|
||||||
final DateFormat dateFormat;
|
|
||||||
final AppLocalizations l10n;
|
|
||||||
|
|
||||||
static const int _maxVisibleItems = 10;
|
|
||||||
static const UploadHistoryTableController _controller =
|
|
||||||
UploadHistoryTableController();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final visibleItems = items.take(_maxVisibleItems).toList(growable: false);
|
|
||||||
|
|
||||||
return DataTable(
|
|
||||||
columns: [
|
|
||||||
DataColumn(label: Text(l10n.fileNameColumn)),
|
|
||||||
DataColumn(label: Text(l10n.rowsColumn)),
|
|
||||||
DataColumn(label: Text(l10n.dateColumn)),
|
|
||||||
DataColumn(label: Text(l10n.amountColumn)),
|
|
||||||
DataColumn(label: Text(l10n.statusColumn)),
|
|
||||||
],
|
|
||||||
rows: visibleItems.map((payment) {
|
|
||||||
final metadata = payment.metadata;
|
|
||||||
final status = statusView(l10n, payment.state);
|
|
||||||
final fileName = metadata?['upload_filename'];
|
|
||||||
final fileNameText =
|
|
||||||
(fileName == null || fileName.isEmpty) ? '-' : fileName;
|
|
||||||
final rows = metadata?['upload_rows'];
|
|
||||||
final rowsText = (rows == null || rows.isEmpty) ? '-' : rows;
|
|
||||||
final createdAt = payment.createdAt;
|
|
||||||
final dateText = createdAt == null
|
|
||||||
? '-'
|
|
||||||
: dateFormat.format(createdAt.toLocal());
|
|
||||||
final amountText = _controller.amountText(payment);
|
|
||||||
|
|
||||||
return DataRow(
|
|
||||||
cells: [
|
|
||||||
DataCell(Text(fileNameText)),
|
|
||||||
DataCell(Text(rowsText)),
|
|
||||||
DataCell(Text(dateText)),
|
|
||||||
DataCell(Text(amountText)),
|
|
||||||
DataCell(HistoryStatusBadge(statusView: status)),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/models/payment/operation.dart';
|
||||||
import 'package:pshared/provider/payment/payments.dart';
|
import 'package:pshared/provider/payment/payments.dart';
|
||||||
|
|
||||||
import 'package:pweb/pages/dashboard/payouts/multiple/sections/history/header.dart';
|
import 'package:pweb/pages/dashboard/payouts/multiple/sections/history/header.dart';
|
||||||
import 'package:pweb/pages/dashboard/payouts/multiple/sections/history/table.dart';
|
import 'package:pweb/controllers/recent_payments.dart';
|
||||||
|
import 'package:pweb/pages/report/cards/column.dart';
|
||||||
|
import 'package:pweb/utils/report/payment_mapper.dart';
|
||||||
|
import 'package:pweb/app/router/payout_routes.dart';
|
||||||
|
|
||||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
@@ -17,61 +19,49 @@ class UploadHistorySection extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final provider = context.watch<PaymentsProvider>();
|
return ChangeNotifierProxyProvider<PaymentsProvider, RecentPaymentsController>(
|
||||||
final theme = Theme.of(context);
|
create: (_) => RecentPaymentsController(),
|
||||||
final l10 = AppLocalizations.of(context)!;
|
update: (_, payments, controller) => controller!..update(payments),
|
||||||
final dateFormat = DateFormat.yMMMd().add_Hm();
|
child: const _RecentPaymentsView(),
|
||||||
|
);
|
||||||
if (provider.isLoading) {
|
}
|
||||||
return const Center(child: CircularProgressIndicator());
|
}
|
||||||
}
|
|
||||||
if (provider.error != null) {
|
class _RecentPaymentsView extends StatelessWidget {
|
||||||
return Text(
|
const _RecentPaymentsView();
|
||||||
l10.notificationError(provider.error ?? l10.noErrorInformation),
|
|
||||||
);
|
void _openPaymentDetails(BuildContext context, OperationItem operation) {
|
||||||
}
|
final paymentId = paymentIdFromOperation(operation);
|
||||||
final items = List.of(provider.payments);
|
if (paymentId == null) return;
|
||||||
items.sort((a, b) {
|
context.pushToReportPayment(paymentId);
|
||||||
final left = a.createdAt;
|
}
|
||||||
final right = b.createdAt;
|
|
||||||
if (left == null && right == null) return 0;
|
@override
|
||||||
if (left == null) return 1;
|
Widget build(BuildContext context) {
|
||||||
if (right == null) return -1;
|
final theme = Theme.of(context);
|
||||||
return right.compareTo(left);
|
final l10 = AppLocalizations.of(context)!;
|
||||||
});
|
return Consumer<RecentPaymentsController>(
|
||||||
|
builder: (context, controller, child) {
|
||||||
return Column(
|
if (controller.isLoading) {
|
||||||
children: [
|
return const Center(child: CircularProgressIndicator());
|
||||||
UploadHistoryHeader(theme: theme, l10n: l10),
|
}
|
||||||
const SizedBox(height: 8),
|
if (controller.error != null) {
|
||||||
if (items.isEmpty)
|
return Text(
|
||||||
Align(
|
l10.notificationError(controller.error ?? l10.noErrorInformation),
|
||||||
alignment: Alignment.centerLeft,
|
);
|
||||||
child: Text(
|
}
|
||||||
l10.walletHistoryEmpty,
|
|
||||||
style: theme.textTheme.bodyMedium,
|
return Column(
|
||||||
),
|
children: [
|
||||||
)
|
UploadHistoryHeader(theme: theme),
|
||||||
else ...[
|
const SizedBox(height: 8),
|
||||||
UploadHistoryTable(
|
OperationsCardsColumn(
|
||||||
items: items,
|
operations: controller.recentOperations,
|
||||||
dateFormat: dateFormat,
|
onTap: (operation) => _openPaymentDetails(context, operation),
|
||||||
l10n: l10,
|
),
|
||||||
),
|
],
|
||||||
//TODO redirect to Reports page
|
);
|
||||||
// if (hasMore) ...[
|
},
|
||||||
// const SizedBox(height: 8),
|
|
||||||
// Align(
|
|
||||||
// alignment: Alignment.centerLeft,
|
|
||||||
// child: TextButton.icon(
|
|
||||||
// onPressed: () => context.goNamed(PayoutRoutes.reports),
|
|
||||||
// icon: const Icon(Icons.open_in_new, size: 16),
|
|
||||||
// label: Text(l10.viewWholeHistory),
|
|
||||||
// ),
|
|
||||||
// ),
|
|
||||||
// ],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
43
frontend/pweb/lib/pages/report/cards/column.dart
Normal file
43
frontend/pweb/lib/pages/report/cards/column.dart
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/models/payment/operation.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
import 'package:pweb/pages/report/cards/items.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class OperationsCardsColumn extends StatelessWidget {
|
||||||
|
final List<OperationItem> operations;
|
||||||
|
final ValueChanged<OperationItem>? onTap;
|
||||||
|
|
||||||
|
const OperationsCardsColumn({
|
||||||
|
super.key,
|
||||||
|
required this.operations,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final loc = AppLocalizations.of(context)!;
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final items = buildOperationCardItems(
|
||||||
|
context,
|
||||||
|
operations,
|
||||||
|
onTap: onTap,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (operations.isEmpty) {
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
loc.reportPaymentsEmpty,
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: items,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
74
frontend/pweb/lib/pages/report/cards/items.dart
Normal file
74
frontend/pweb/lib/pages/report/cards/items.dart
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/models/payment/operation.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/pages/report/cards/operation_card.dart';
|
||||||
|
import 'package:pweb/utils/report/format.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
|
||||||
|
List<Widget> buildOperationCardItems(
|
||||||
|
BuildContext context,
|
||||||
|
List<OperationItem> operations, {
|
||||||
|
ValueChanged<OperationItem>? onTap,
|
||||||
|
}) {
|
||||||
|
final loc = AppLocalizations.of(context)!;
|
||||||
|
final items = <Widget>[];
|
||||||
|
String? currentKey;
|
||||||
|
|
||||||
|
for (final operation in operations) {
|
||||||
|
final dateKey = _dateKey(operation.date);
|
||||||
|
if (dateKey != currentKey) {
|
||||||
|
if (items.isNotEmpty) {
|
||||||
|
items.add(const SizedBox(height: 16));
|
||||||
|
}
|
||||||
|
items.add(_DateHeader(
|
||||||
|
label: _dateLabel(context, operation.date, loc),
|
||||||
|
));
|
||||||
|
items.add(const SizedBox(height: 8));
|
||||||
|
currentKey = dateKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.add(OperationCard(
|
||||||
|
operation: operation,
|
||||||
|
onTap: onTap,
|
||||||
|
));
|
||||||
|
items.add(const SizedBox(height: 12));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.isNotEmpty) {
|
||||||
|
items.removeLast();
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _dateKey(DateTime date) {
|
||||||
|
if (date.millisecondsSinceEpoch == 0) return 'unknown';
|
||||||
|
final local = date.toLocal();
|
||||||
|
final normalized = DateTime(local.year, local.month, local.day);
|
||||||
|
return normalized.toIso8601String();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _dateLabel(BuildContext context, DateTime date, AppLocalizations loc) {
|
||||||
|
if (date.millisecondsSinceEpoch == 0) return loc.unknown;
|
||||||
|
return formatLongDate(context, date);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DateHeader extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const _DateHeader({required this.label});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Text(
|
||||||
|
label,
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
frontend/pweb/lib/pages/report/cards/list.dart
Normal file
44
frontend/pweb/lib/pages/report/cards/list.dart
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/models/payment/operation.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/pages/report/cards/items.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class OperationsCardsList extends StatelessWidget {
|
||||||
|
final List<OperationItem> operations;
|
||||||
|
final ValueChanged<OperationItem>? onTap;
|
||||||
|
|
||||||
|
const OperationsCardsList({
|
||||||
|
super.key,
|
||||||
|
required this.operations,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final loc = AppLocalizations.of(context)!;
|
||||||
|
final items = buildOperationCardItems(
|
||||||
|
context,
|
||||||
|
operations,
|
||||||
|
onTap: onTap,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Expanded(
|
||||||
|
child: operations.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Text(
|
||||||
|
loc.reportPaymentsEmpty,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
itemCount: items.length,
|
||||||
|
itemBuilder: (context, index) => items[index],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
87
frontend/pweb/lib/pages/report/cards/operation_card.dart
Normal file
87
frontend/pweb/lib/pages/report/cards/operation_card.dart
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/models/payment/operation.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/pages/report/cards/operation_card_utils.dart';
|
||||||
|
import 'package:pweb/pages/report/cards/operation_info_row.dart';
|
||||||
|
import 'package:pweb/pages/report/table/badge.dart';
|
||||||
|
import 'package:pweb/utils/report/format.dart';
|
||||||
|
import 'package:pweb/utils/report/payment_mapper.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class OperationCard extends StatelessWidget {
|
||||||
|
final OperationItem operation;
|
||||||
|
final ValueChanged<OperationItem>? onTap;
|
||||||
|
|
||||||
|
const OperationCard({
|
||||||
|
super.key,
|
||||||
|
required this.operation,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final loc = AppLocalizations.of(context)!;
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final canOpen = onTap != null && paymentIdFromOperation(operation) != null;
|
||||||
|
final amountLabel = formatAmount(operation.amount, operation.currency);
|
||||||
|
final toAmountLabel = formatAmount(operation.toAmount, operation.toCurrency);
|
||||||
|
final showToAmount = shouldShowToAmount(operation);
|
||||||
|
final timeLabel = formatOperationTime(context, operation.date);
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
side: BorderSide(color: theme.dividerColor.withAlpha(25)),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: canOpen ? () => onTap?.call(operation) : null,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
OperationStatusBadge(status: operation.status),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
timeLabel,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
amountLabel,
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showToAmount)
|
||||||
|
Text(
|
||||||
|
loc.recipientWillReceive(toAmountLabel),
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if ((operation.fileName ?? '').trim().isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
OperationInfoRow(
|
||||||
|
icon: Icons.description,
|
||||||
|
value: operation.fileName!,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/models/payment/operation.dart';
|
||||||
|
|
||||||
|
|
||||||
|
bool shouldShowToAmount(OperationItem operation) {
|
||||||
|
if (operation.toCurrency.trim().isEmpty) return false;
|
||||||
|
if (operation.currency.trim().isEmpty) return true;
|
||||||
|
if (operation.currency != operation.toCurrency) return true;
|
||||||
|
return (operation.toAmount - operation.amount).abs() > 0.0001;
|
||||||
|
}
|
||||||
|
|
||||||
|
String formatOperationTime(BuildContext context, DateTime date) {
|
||||||
|
if (date.millisecondsSinceEpoch == 0) return '-';
|
||||||
|
return TimeOfDay.fromDateTime(date.toLocal()).format(context);
|
||||||
|
}
|
||||||
36
frontend/pweb/lib/pages/report/cards/operation_info_row.dart
Normal file
36
frontend/pweb/lib/pages/report/cards/operation_info_row.dart
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class OperationInfoRow extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
const OperationInfoRow({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
size: 16,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
import 'package:syncfusion_flutter_charts/charts.dart' hide ChartPoint;
|
||||||
|
|
||||||
import 'package:pshared/models/payment/operation.dart';
|
import 'package:pshared/models/payment/operation.dart';
|
||||||
|
|
||||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
import 'package:pweb/models/chart_point.dart';
|
||||||
|
|
||||||
|
|
||||||
class PayoutDistributionChart extends StatelessWidget {
|
class PayoutDistributionChart extends StatelessWidget {
|
||||||
@@ -25,7 +26,7 @@ class PayoutDistributionChart extends StatelessWidget {
|
|||||||
|
|
||||||
// 2) Build chart data
|
// 2) Build chart data
|
||||||
final data = sums.entries
|
final data = sums.entries
|
||||||
.map((e) => _ChartData(e.key, e.value))
|
.map((e) => ChartPoint<String>(e.key, e.value))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
// 3) Build a simple horizontal legend
|
// 3) Build a simple horizontal legend
|
||||||
@@ -42,7 +43,7 @@ class PayoutDistributionChart extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Icon(Icons.circle, size: 10, color: palette[i % palette.length]),
|
Icon(Icons.circle, size: 10, color: palette[i % palette.length]),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(data[i].label, style: Theme.of(context).textTheme.bodySmall),
|
Text(data[i].key, style: Theme.of(context).textTheme.bodySmall),
|
||||||
if (i < data.length - 1) const SizedBox(width: 12),
|
if (i < data.length - 1) const SizedBox(width: 12),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -62,10 +63,10 @@ class PayoutDistributionChart extends StatelessWidget {
|
|||||||
child: SfCircularChart(
|
child: SfCircularChart(
|
||||||
legend: Legend(isVisible: false),
|
legend: Legend(isVisible: false),
|
||||||
tooltipBehavior: TooltipBehavior(enable: true),
|
tooltipBehavior: TooltipBehavior(enable: true),
|
||||||
series: <PieSeries<_ChartData, String>>[
|
series: <PieSeries<ChartPoint<String>, String>>[
|
||||||
PieSeries<_ChartData, String>(
|
PieSeries<ChartPoint<String>, String>(
|
||||||
dataSource: data,
|
dataSource: data,
|
||||||
xValueMapper: (d, _) => d.label,
|
xValueMapper: (d, _) => d.key,
|
||||||
yValueMapper: (d, _) => d.value,
|
yValueMapper: (d, _) => d.value,
|
||||||
dataLabelMapper: (d, _) =>
|
dataLabelMapper: (d, _) =>
|
||||||
'${(d.value / sums.values.fold(0, (a, b) => a + b) * 100).toStringAsFixed(1)}%',
|
'${(d.value / sums.values.fold(0, (a, b) => a + b) * 100).toStringAsFixed(1)}%',
|
||||||
@@ -95,9 +96,3 @@ class PayoutDistributionChart extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ChartData {
|
|
||||||
final String label;
|
|
||||||
final double value;
|
|
||||||
_ChartData(this.label, this.value);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/models/payment/operation.dart';
|
||||||
|
import 'package:pshared/models/payment/status.dart';
|
||||||
|
|
||||||
|
|
||||||
|
List<CurrencyTotal> aggregateCurrencyTotals(
|
||||||
|
List<OperationItem> operations,
|
||||||
|
DateTimeRange range,
|
||||||
|
) {
|
||||||
|
final totals = <String, double>{};
|
||||||
|
for (final operation in operations) {
|
||||||
|
if (operation.status != OperationStatus.success) continue;
|
||||||
|
if (_isUnknownDate(operation.date)) continue;
|
||||||
|
if (operation.date.isBefore(range.start) ||
|
||||||
|
operation.date.isAfter(range.end)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final currency = _normalizeCurrency(operation.currency);
|
||||||
|
totals[currency] = (totals[currency] ?? 0) + operation.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
final list = totals.entries
|
||||||
|
.map((entry) => CurrencyTotal(entry.key, entry.value))
|
||||||
|
.toList();
|
||||||
|
list.sort((a, b) => a.currency.compareTo(b.currency));
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _normalizeCurrency(String raw) {
|
||||||
|
final trimmed = raw.trim();
|
||||||
|
return trimmed.isEmpty ? '-' : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isUnknownDate(DateTime date) => date.millisecondsSinceEpoch == 0;
|
||||||
|
|
||||||
|
class CurrencyTotal {
|
||||||
|
final String currency;
|
||||||
|
final double amount;
|
||||||
|
|
||||||
|
const CurrencyTotal(this.currency, this.amount);
|
||||||
|
}
|
||||||
126
frontend/pweb/lib/pages/report/charts/payout_volumes/chart.dart
Normal file
126
frontend/pweb/lib/pages/report/charts/payout_volumes/chart.dart
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/models/payment/operation.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/controllers/payout_volumes.dart';
|
||||||
|
import 'package:pweb/pages/report/charts/payout_volumes/aggregator.dart';
|
||||||
|
import 'package:pweb/pages/report/charts/payout_volumes/pie_chart.dart';
|
||||||
|
import 'package:pweb/pages/report/charts/payout_volumes/range_label.dart';
|
||||||
|
import 'package:pweb/pages/report/charts/payout_volumes/range_picker.dart';
|
||||||
|
import 'package:pweb/pages/report/charts/payout_volumes/totals_list.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class PayoutVolumesChart extends StatelessWidget {
|
||||||
|
final List<OperationItem> operations;
|
||||||
|
|
||||||
|
const PayoutVolumesChart({super.key, required this.operations});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ChangeNotifierProvider(
|
||||||
|
create: (_) => PayoutVolumesController(),
|
||||||
|
child: _PayoutVolumesChartBody(operations: operations),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PayoutVolumesChartBody extends StatelessWidget {
|
||||||
|
final List<OperationItem> operations;
|
||||||
|
|
||||||
|
const _PayoutVolumesChartBody({required this.operations});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Consumer<PayoutVolumesController>(
|
||||||
|
builder: (context, controller, child) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final range = controller.range;
|
||||||
|
|
||||||
|
final totals = aggregateCurrencyTotals(operations, range);
|
||||||
|
final rangeLabel = formatRangeLabel(context, range);
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: 200,
|
||||||
|
child: Card(
|
||||||
|
margin: const EdgeInsets.all(16),
|
||||||
|
elevation: 2,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
onTap: () => pickPayoutVolumesRange(context, controller),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l10n.debitAmountLabel,
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
rangeLabel,
|
||||||
|
style: theme.textTheme.labelMedium,
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
maxLines: 2,
|
||||||
|
softWrap: true,
|
||||||
|
overflow: TextOverflow.clip,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Icon(
|
||||||
|
Icons.date_range_outlined,
|
||||||
|
size: 16,
|
||||||
|
color: theme.iconTheme.color?.withAlpha(160),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Expanded(
|
||||||
|
child: totals.isEmpty
|
||||||
|
? Center(child: Text(l10n.noPayouts))
|
||||||
|
: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: PayoutTotalsList(
|
||||||
|
totals: totals,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: PayoutVolumesPieChart(
|
||||||
|
totals: totals,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
|
||||||
|
List<Color> payoutVolumesPalette(ThemeData theme) {
|
||||||
|
return [
|
||||||
|
theme.colorScheme.primary,
|
||||||
|
theme.colorScheme.secondary,
|
||||||
|
theme.colorScheme.tertiary,
|
||||||
|
theme.colorScheme.primaryContainer,
|
||||||
|
theme.colorScheme.secondaryContainer,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/pages/report/charts/payout_volumes/aggregator.dart';
|
||||||
|
import 'package:pweb/pages/report/charts/payout_volumes/palette.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class PayoutVolumesPieChart extends StatelessWidget {
|
||||||
|
final List<CurrencyTotal> totals;
|
||||||
|
|
||||||
|
const PayoutVolumesPieChart({super.key, required this.totals});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final palette = payoutVolumesPalette(theme);
|
||||||
|
|
||||||
|
return SfCircularChart(
|
||||||
|
legend: Legend(isVisible: false),
|
||||||
|
tooltipBehavior: TooltipBehavior(enable: true),
|
||||||
|
series: <PieSeries<CurrencyTotal, String>>[
|
||||||
|
PieSeries<CurrencyTotal, String>(
|
||||||
|
dataSource: totals,
|
||||||
|
xValueMapper: (item, _) => item.currency,
|
||||||
|
yValueMapper: (item, _) => item.amount,
|
||||||
|
dataLabelMapper: (item, _) => item.currency,
|
||||||
|
dataLabelSettings: const DataLabelSettings(
|
||||||
|
isVisible: true,
|
||||||
|
labelPosition: ChartDataLabelPosition.inside,
|
||||||
|
),
|
||||||
|
pointColorMapper: (item, index) =>
|
||||||
|
palette[index % palette.length],
|
||||||
|
radius: '100%',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
|
||||||
|
String formatRangeLabel(BuildContext context, DateTimeRange range) {
|
||||||
|
final start = _formatShortDate(context, range.start);
|
||||||
|
final end = _formatShortDate(context, range.end);
|
||||||
|
return '$start –\n$end';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatShortDate(BuildContext context, DateTime date) {
|
||||||
|
final locale = Localizations.localeOf(context).toString();
|
||||||
|
final day = DateFormat('d', locale).format(date);
|
||||||
|
final month = DateFormat('MMM', locale).format(date);
|
||||||
|
final year = DateFormat('y', locale).format(date);
|
||||||
|
return '$day $month $year';
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/controllers/payout_volumes.dart';
|
||||||
|
|
||||||
|
|
||||||
|
Future<void> pickPayoutVolumesRange(
|
||||||
|
BuildContext context,
|
||||||
|
PayoutVolumesController controller,
|
||||||
|
) async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final initial = _dateOnlyRange(controller.range);
|
||||||
|
final picked = await showDateRangePicker(
|
||||||
|
context: context,
|
||||||
|
firstDate: DateTime(2000),
|
||||||
|
lastDate: DateUtils.dateOnly(now).add(const Duration(days: 1)),
|
||||||
|
initialDateRange: initial,
|
||||||
|
);
|
||||||
|
if (picked != null) {
|
||||||
|
controller.setRange(picked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTimeRange _dateOnlyRange(DateTimeRange range) {
|
||||||
|
return DateTimeRange(
|
||||||
|
start: DateUtils.dateOnly(range.start),
|
||||||
|
end: DateUtils.dateOnly(range.end),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/pages/report/charts/payout_volumes/aggregator.dart';
|
||||||
|
import 'package:pweb/pages/report/charts/payout_volumes/palette.dart';
|
||||||
|
import 'package:pweb/utils/report/format.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class PayoutTotalsList extends StatelessWidget {
|
||||||
|
final List<CurrencyTotal> totals;
|
||||||
|
|
||||||
|
const PayoutTotalsList({super.key, required this.totals});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final palette = payoutVolumesPalette(theme);
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
for (var index = 0; index < totals.length; index++)
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: index == totals.length - 1 ? 0 : 8,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.circle,
|
||||||
|
size: 10,
|
||||||
|
color: palette[index % palette.length],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
totals[index].currency,
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
formatAmount(
|
||||||
|
totals[index].amount,
|
||||||
|
totals[index].currency,
|
||||||
|
),
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,13 @@ import 'dart:math';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
import 'package:syncfusion_flutter_charts/charts.dart' hide ChartPoint;
|
||||||
|
|
||||||
import 'package:pshared/models/payment/status.dart';
|
import 'package:pshared/models/payment/status.dart';
|
||||||
import 'package:pshared/models/payment/operation.dart';
|
import 'package:pshared/models/payment/operation.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/models/chart_point.dart';
|
||||||
|
|
||||||
|
|
||||||
class StatusChart extends StatelessWidget {
|
class StatusChart extends StatelessWidget {
|
||||||
final List<OperationItem> operations;
|
final List<OperationItem> operations;
|
||||||
@@ -21,9 +23,9 @@ class StatusChart extends StatelessWidget {
|
|||||||
counts[op.status] = (counts[op.status] ?? 0) + 1;
|
counts[op.status] = (counts[op.status] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
final items = counts.entries
|
final items = counts.entries
|
||||||
.map((e) => _ChartData(e.key, e.value.toDouble()))
|
.map((e) => ChartPoint<OperationStatus>(e.key, e.value.toDouble()))
|
||||||
.toList();
|
.toList();
|
||||||
final maxCount = items.map((e) => e.count.toInt()).fold<int>(0, max);
|
final maxCount = items.map((e) => e.value.toInt()).fold<int>(0, max);
|
||||||
|
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final barColor = theme.colorScheme.secondary;
|
final barColor = theme.colorScheme.secondary;
|
||||||
@@ -66,11 +68,11 @@ class StatusChart extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// ─── Bar series with tooltip enabled ───────────────
|
// ─── Bar series with tooltip enabled ───────────────
|
||||||
series: <ColumnSeries<_ChartData, String>>[
|
series: <ColumnSeries<ChartPoint<OperationStatus>, String>>[
|
||||||
ColumnSeries<_ChartData, String>(
|
ColumnSeries<ChartPoint<OperationStatus>, String>(
|
||||||
dataSource: items,
|
dataSource: items,
|
||||||
xValueMapper: (d, _) => d.status.localized(context),
|
xValueMapper: (d, _) => d.key.localized(context),
|
||||||
yValueMapper: (d, _) => d.count,
|
yValueMapper: (d, _) => d.value,
|
||||||
color: barColor,
|
color: barColor,
|
||||||
width: 0.6,
|
width: 0.6,
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||||
@@ -83,9 +85,3 @@ class StatusChart extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ChartData {
|
|
||||||
final OperationStatus status;
|
|
||||||
final double count;
|
|
||||||
_ChartData(this.status, this.count);
|
|
||||||
}
|
|
||||||
|
|||||||
47
frontend/pweb/lib/pages/report/details/content.dart
Normal file
47
frontend/pweb/lib/pages/report/details/content.dart
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/models/payment/payment.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/pages/report/details/header.dart';
|
||||||
|
import 'package:pweb/pages/report/details/sections.dart';
|
||||||
|
import 'package:pweb/pages/report/details/summary_card/widget.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentDetailsContent extends StatelessWidget {
|
||||||
|
final Payment payment;
|
||||||
|
final VoidCallback onBack;
|
||||||
|
final VoidCallback? onDownloadAct;
|
||||||
|
|
||||||
|
const PaymentDetailsContent({
|
||||||
|
super.key,
|
||||||
|
required this.payment,
|
||||||
|
required this.onBack,
|
||||||
|
this.onDownloadAct,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final loc = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
PaymentDetailsHeader(
|
||||||
|
title: loc.paymentInfo,
|
||||||
|
onBack: onBack,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
PaymentSummaryCard(
|
||||||
|
payment: payment,
|
||||||
|
onDownloadAct: onDownloadAct,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
PaymentDetailsSections(payment: payment),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
frontend/pweb/lib/pages/report/details/header.dart
Normal file
43
frontend/pweb/lib/pages/report/details/header.dart
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentDetailsHeader extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final VoidCallback onBack;
|
||||||
|
|
||||||
|
const PaymentDetailsHeader({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.onBack,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: onBack,
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
tooltip: AppLocalizations.of(context)!.back,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
87
frontend/pweb/lib/pages/report/details/page.dart
Normal file
87
frontend/pweb/lib/pages/report/details/page.dart
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/models/payment/payment.dart';
|
||||||
|
import 'package:pshared/models/payment/status.dart';
|
||||||
|
import 'package:pshared/provider/payment/payments.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/app/router/payout_routes.dart';
|
||||||
|
import 'package:pweb/pages/report/details/content.dart';
|
||||||
|
import 'package:pweb/pages/report/details/states/error.dart';
|
||||||
|
import 'package:pweb/pages/report/details/states/not_found.dart';
|
||||||
|
import 'package:pweb/utils/report/download_act.dart';
|
||||||
|
import 'package:pweb/utils/report/payment_mapper.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentDetailsPage extends StatelessWidget {
|
||||||
|
final String paymentId;
|
||||||
|
|
||||||
|
const PaymentDetailsPage({
|
||||||
|
super.key,
|
||||||
|
required this.paymentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Consumer<PaymentsProvider>(
|
||||||
|
builder: (context, provider, child) {
|
||||||
|
final loc = AppLocalizations.of(context)!;
|
||||||
|
if (provider.isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider.error != null) {
|
||||||
|
return PaymentDetailsError(
|
||||||
|
message: provider.error?.toString() ?? loc.noErrorInformation,
|
||||||
|
onRetry: () => provider.refresh(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final payment = _findPayment(provider.payments, paymentId);
|
||||||
|
if (payment == null) {
|
||||||
|
return PaymentDetailsNotFound(onBack: () => _handleBack(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
final status = statusFromPayment(payment);
|
||||||
|
final paymentRef = payment.paymentRef ?? '';
|
||||||
|
final canDownload = status == OperationStatus.success &&
|
||||||
|
paymentRef.trim().isNotEmpty;
|
||||||
|
|
||||||
|
return PaymentDetailsContent(
|
||||||
|
payment: payment,
|
||||||
|
onBack: () => _handleBack(context),
|
||||||
|
onDownloadAct: canDownload
|
||||||
|
? () => downloadPaymentAct(context, paymentRef)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Payment? _findPayment(List<Payment> payments, String paymentId) {
|
||||||
|
final trimmed = paymentId.trim();
|
||||||
|
if (trimmed.isEmpty) return null;
|
||||||
|
for (final payment in payments) {
|
||||||
|
if (payment.paymentRef == trimmed) return payment;
|
||||||
|
if (payment.idempotencyKey == trimmed) return payment;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleBack(BuildContext context) {
|
||||||
|
final router = GoRouter.of(context);
|
||||||
|
if (router.canPop()) {
|
||||||
|
context.pop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.go(PayoutRoutes.reportsPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
frontend/pweb/lib/pages/report/details/row.dart
Normal file
71
frontend/pweb/lib/pages/report/details/row.dart
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class DetailRow extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final bool multiline;
|
||||||
|
final bool monospaced;
|
||||||
|
|
||||||
|
const DetailRow({
|
||||||
|
super.key,
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
this.multiline = false,
|
||||||
|
this.monospaced = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final valueStyle = monospaced
|
||||||
|
? theme.textTheme.bodyMedium?.copyWith(fontFamily: 'monospace')
|
||||||
|
: theme.textTheme.bodyMedium;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final isNarrow = constraints.maxWidth < 250;
|
||||||
|
if (isNarrow) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
SelectableText(value, style: valueStyle),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: multiline
|
||||||
|
? CrossAxisAlignment.start
|
||||||
|
: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: SelectableText(value, style: valueStyle),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
frontend/pweb/lib/pages/report/details/section.dart
Normal file
41
frontend/pweb/lib/pages/report/details/section.dart
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class DetailsSection extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final List<Widget> children;
|
||||||
|
|
||||||
|
const DetailsSection({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.children,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
side: BorderSide(color: theme.dividerColor.withAlpha(25)),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
...children,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
frontend/pweb/lib/pages/report/details/sections.dart
Normal file
36
frontend/pweb/lib/pages/report/details/sections.dart
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/models/payment/payment.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/pages/report/details/sections/fx.dart';
|
||||||
|
import 'package:pweb/pages/report/details/sections/metadata.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentDetailsSections extends StatelessWidget {
|
||||||
|
final Payment payment;
|
||||||
|
|
||||||
|
const PaymentDetailsSections({
|
||||||
|
super.key,
|
||||||
|
required this.payment,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final hasFx = _hasFxQuote(payment);
|
||||||
|
if (!hasFx) {
|
||||||
|
return PaymentMetadataSection(payment: payment);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(child: PaymentFxSection(payment: payment)),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(child: PaymentMetadataSection(payment: payment)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _hasFxQuote(Payment payment) => payment.lastQuote?.fxQuote != null;
|
||||||
|
|
||||||
|
}
|
||||||
70
frontend/pweb/lib/pages/report/details/sections/fx.dart
Normal file
70
frontend/pweb/lib/pages/report/details/sections/fx.dart
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/models/payment/payment.dart';
|
||||||
|
import 'package:pshared/models/payment/fx/quote.dart';
|
||||||
|
import 'package:pshared/utils/currency.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/pages/report/details/section.dart';
|
||||||
|
import 'package:pweb/pages/report/details/sections/rows.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentFxSection extends StatelessWidget {
|
||||||
|
final Payment payment;
|
||||||
|
|
||||||
|
const PaymentFxSection({
|
||||||
|
super.key,
|
||||||
|
required this.payment,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final loc = AppLocalizations.of(context)!;
|
||||||
|
final fx = payment.lastQuote?.fxQuote;
|
||||||
|
final rows = buildDetailRows([
|
||||||
|
DetailValue(
|
||||||
|
label: loc.fxRateLabel,
|
||||||
|
value: _formatRate(fx),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return DetailsSection(
|
||||||
|
title: loc.paymentDetailsFx,
|
||||||
|
children: rows,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _formatRate(FxQuote? fx) {
|
||||||
|
if (fx == null) return null;
|
||||||
|
final price = fx.price?.trim();
|
||||||
|
if (price == null || price.isEmpty) return null;
|
||||||
|
|
||||||
|
final base = _firstNonEmpty([
|
||||||
|
currencySymbolFromCode(fx.baseCurrency),
|
||||||
|
currencySymbolFromCode(fx.baseAmount?.currency),
|
||||||
|
fx.baseCurrency,
|
||||||
|
fx.baseAmount?.currency,
|
||||||
|
]);
|
||||||
|
final quote = _firstNonEmpty([
|
||||||
|
currencySymbolFromCode(fx.quoteCurrency),
|
||||||
|
currencySymbolFromCode(fx.quoteAmount?.currency),
|
||||||
|
fx.quoteCurrency,
|
||||||
|
fx.quoteAmount?.currency,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (base == null || quote == null) {
|
||||||
|
return price;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '1 $base = $price $quote';
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _firstNonEmpty(List<String?> values) {
|
||||||
|
for (final value in values) {
|
||||||
|
final trimmed = value?.trim();
|
||||||
|
if (trimmed != null && trimmed.isNotEmpty) return trimmed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/models/payment/payment.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/pages/report/details/row.dart';
|
||||||
|
import 'package:pweb/pages/report/details/section.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentMetadataSection extends StatelessWidget {
|
||||||
|
final Payment payment;
|
||||||
|
|
||||||
|
const PaymentMetadataSection({
|
||||||
|
super.key,
|
||||||
|
required this.payment,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final loc = AppLocalizations.of(context)!;
|
||||||
|
final metadata = payment.metadata ?? const {};
|
||||||
|
const allowedKeys = {'upload_filename', 'upload_rows'};
|
||||||
|
final filtered = Map<String, String>.fromEntries(
|
||||||
|
metadata.entries.where((entry) => allowedKeys.contains(entry.key)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (filtered.isEmpty) {
|
||||||
|
return DetailsSection(
|
||||||
|
title: loc.paymentDetailsMetadata,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
loc.metadataEmpty,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final entries = filtered.entries.toList()
|
||||||
|
..sort((a, b) => a.key.compareTo(b.key));
|
||||||
|
|
||||||
|
return DetailsSection(
|
||||||
|
title: loc.paymentDetailsMetadata,
|
||||||
|
children: entries
|
||||||
|
.map(
|
||||||
|
(entry) => DetailRow(
|
||||||
|
label: _metadataLabel(loc, entry.key),
|
||||||
|
value: entry.value,
|
||||||
|
monospaced: true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _metadataLabel(AppLocalizations loc, String key) {
|
||||||
|
switch (key) {
|
||||||
|
case 'upload_filename':
|
||||||
|
return loc.metadataUploadFileName;
|
||||||
|
case 'upload_rows':
|
||||||
|
return loc.metadataTotalRecipients;
|
||||||
|
default:
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
31
frontend/pweb/lib/pages/report/details/sections/rows.dart
Normal file
31
frontend/pweb/lib/pages/report/details/sections/rows.dart
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import 'package:pweb/pages/report/details/row.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class DetailValue {
|
||||||
|
final String label;
|
||||||
|
final String? value;
|
||||||
|
final bool multiline;
|
||||||
|
final bool monospaced;
|
||||||
|
|
||||||
|
const DetailValue({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
this.multiline = false,
|
||||||
|
this.monospaced = false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
List<DetailRow> buildDetailRows(List<DetailValue> values) {
|
||||||
|
return values
|
||||||
|
.where((item) {
|
||||||
|
final value = item.value?.trim();
|
||||||
|
return value != null && value.isNotEmpty && value != '-';
|
||||||
|
})
|
||||||
|
.map((item) => DetailRow(
|
||||||
|
label: item.label,
|
||||||
|
value: item.value!.trim(),
|
||||||
|
multiline: item.multiline,
|
||||||
|
monospaced: item.monospaced,
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
34
frontend/pweb/lib/pages/report/details/states/error.dart
Normal file
34
frontend/pweb/lib/pages/report/details/states/error.dart
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentDetailsError extends StatelessWidget {
|
||||||
|
final String message;
|
||||||
|
final VoidCallback onRetry;
|
||||||
|
|
||||||
|
const PaymentDetailsError({
|
||||||
|
super.key,
|
||||||
|
required this.message,
|
||||||
|
required this.onRetry,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final loc = AppLocalizations.of(context)!;
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(loc.notificationError(message)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: onRetry,
|
||||||
|
child: Text(loc.retry),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
frontend/pweb/lib/pages/report/details/states/not_found.dart
Normal file
35
frontend/pweb/lib/pages/report/details/states/not_found.dart
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentDetailsNotFound extends StatelessWidget {
|
||||||
|
final VoidCallback onBack;
|
||||||
|
|
||||||
|
const PaymentDetailsNotFound({
|
||||||
|
super.key,
|
||||||
|
required this.onBack,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final loc = AppLocalizations.of(context)!;
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
loc.paymentDetailsNotFound,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: onBack,
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
label: Text(loc.back),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class AmountHeadline extends StatelessWidget {
|
||||||
|
final String amount;
|
||||||
|
final String currency;
|
||||||
|
|
||||||
|
const AmountHeadline({
|
||||||
|
required this.amount,
|
||||||
|
required this.currency,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final amountStyle = theme.textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
);
|
||||||
|
final currencyStyle = theme.textTheme.titleLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currency.isEmpty) {
|
||||||
|
return Text(amount, style: amountStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(amount, style: amountStyle),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(currency, style: currencyStyle),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class CopyableId extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final VoidCallback onCopy;
|
||||||
|
|
||||||
|
const CopyableId({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.onCopy,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final labelStyle = theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
);
|
||||||
|
final valueStyle = theme.textTheme.labelLarge?.copyWith(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
);
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: onCopy,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest.withAlpha(120),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: theme.dividerColor.withAlpha(40)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(label, style: labelStyle),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 320),
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: valueStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Icon(
|
||||||
|
Icons.copy_outlined,
|
||||||
|
size: 16,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class InfoLine extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String text;
|
||||||
|
final bool muted;
|
||||||
|
|
||||||
|
const InfoLine({
|
||||||
|
required this.icon,
|
||||||
|
required this.text,
|
||||||
|
this.muted = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final color = muted
|
||||||
|
? theme.colorScheme.onSurfaceVariant
|
||||||
|
: theme.colorScheme.onSurface;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 18, color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(color: color),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
124
frontend/pweb/lib/pages/report/details/summary_card/widget.dart
Normal file
124
frontend/pweb/lib/pages/report/details/summary_card/widget.dart
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/models/payment/payment.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/pages/report/details/summary_card/amount_headline.dart';
|
||||||
|
import 'package:pweb/pages/report/details/summary_card/copy_id.dart';
|
||||||
|
import 'package:pweb/pages/report/details/summary_card/info_line.dart';
|
||||||
|
import 'package:pweb/pages/report/table/badge.dart';
|
||||||
|
import 'package:pweb/utils/report/amount_parts.dart';
|
||||||
|
import 'package:pweb/utils/report/format.dart';
|
||||||
|
import 'package:pweb/utils/report/payment_mapper.dart';
|
||||||
|
import 'package:pweb/utils/clipboard.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentSummaryCard extends StatelessWidget {
|
||||||
|
final Payment payment;
|
||||||
|
final VoidCallback? onDownloadAct;
|
||||||
|
|
||||||
|
const PaymentSummaryCard({
|
||||||
|
super.key,
|
||||||
|
required this.payment,
|
||||||
|
this.onDownloadAct,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final loc = AppLocalizations.of(context)!;
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final status = statusFromPayment(payment);
|
||||||
|
final dateLabel = formatDateLabel(context, resolvePaymentDate(payment));
|
||||||
|
|
||||||
|
final primaryAmount = payment.lastQuote?.debitAmount ??
|
||||||
|
payment.lastQuote?.expectedSettlementAmount;
|
||||||
|
final toAmount = payment.lastQuote?.expectedSettlementAmount;
|
||||||
|
final fee = payment.lastQuote?.expectedFeeTotal ??
|
||||||
|
payment.lastQuote?.networkFee?.networkFee;
|
||||||
|
|
||||||
|
final amountLabel = formatMoney(primaryAmount);
|
||||||
|
final toAmountLabel = formatMoney(toAmount);
|
||||||
|
final feeLabel = formatMoney(fee);
|
||||||
|
final paymentRef = (payment.paymentRef ?? '').trim();
|
||||||
|
|
||||||
|
final showToAmount = toAmountLabel != '-' && toAmountLabel != amountLabel;
|
||||||
|
final showPaymentId = paymentRef.isNotEmpty;
|
||||||
|
final amountParts = splitAmount(amountLabel);
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
side: BorderSide(color: theme.dividerColor.withAlpha(25)),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
OperationStatusBadge(status: status),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
dateLabel,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
AmountHeadline(
|
||||||
|
amount: amountParts.amount,
|
||||||
|
currency: amountParts.currency,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
if (amountLabel != '-')
|
||||||
|
InfoLine(
|
||||||
|
icon: Icons.send_outlined,
|
||||||
|
text: loc.sentAmount(amountLabel),
|
||||||
|
),
|
||||||
|
if (showToAmount && toAmountLabel != '-')
|
||||||
|
InfoLine(
|
||||||
|
icon: Icons.south_east,
|
||||||
|
text: loc.recipientWillReceive(toAmountLabel),
|
||||||
|
),
|
||||||
|
if (feeLabel != '-')
|
||||||
|
InfoLine(
|
||||||
|
icon: Icons.receipt_long_outlined,
|
||||||
|
text: loc.fee(feeLabel),
|
||||||
|
muted: true,
|
||||||
|
),
|
||||||
|
if (onDownloadAct != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: onDownloadAct,
|
||||||
|
icon: const Icon(Icons.download),
|
||||||
|
label: Text(loc.downloadAct),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (showPaymentId) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Divider(color: theme.dividerColor.withAlpha(35), height: 1),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: CopyableId(
|
||||||
|
label: loc.paymentIdLabel,
|
||||||
|
value: paymentRef,
|
||||||
|
onCopy: () => copyToClipboard(
|
||||||
|
context,
|
||||||
|
paymentRef,
|
||||||
|
loc.paymentIdCopied,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,45 +3,41 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'package:pshared/models/payment/operation.dart';
|
import 'package:pshared/models/payment/operation.dart';
|
||||||
import 'package:pshared/models/payment/payment.dart';
|
|
||||||
import 'package:pshared/models/payment/status.dart';
|
|
||||||
import 'package:pshared/provider/payment/payments.dart';
|
import 'package:pshared/provider/payment/payments.dart';
|
||||||
|
|
||||||
import 'package:pweb/pages/report/charts/distribution.dart';
|
import 'package:pweb/pages/report/cards/list.dart';
|
||||||
|
import 'package:pweb/pages/report/charts/payout_volumes/chart.dart';
|
||||||
import 'package:pweb/pages/report/charts/status.dart';
|
import 'package:pweb/pages/report/charts/status.dart';
|
||||||
|
import 'package:pweb/controllers/report_operations.dart';
|
||||||
import 'package:pweb/pages/report/table/filters.dart';
|
import 'package:pweb/pages/report/table/filters.dart';
|
||||||
import 'package:pweb/pages/report/table/widget.dart';
|
import 'package:pweb/utils/report/payment_mapper.dart';
|
||||||
|
import 'package:pweb/app/router/payout_routes.dart';
|
||||||
|
|
||||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
|
||||||
class OperationHistoryPage extends StatefulWidget {
|
class OperationHistoryPage extends StatelessWidget {
|
||||||
const OperationHistoryPage({super.key});
|
const OperationHistoryPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<OperationHistoryPage> createState() => _OperationHistoryPageState();
|
Widget build(BuildContext context) {
|
||||||
|
return ChangeNotifierProxyProvider<PaymentsProvider, ReportOperationsController>(
|
||||||
|
create: (_) => ReportOperationsController(),
|
||||||
|
update: (_, payments, controller) => controller!..update(payments),
|
||||||
|
child: const _OperationHistoryView(),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _OperationHistoryPageState extends State<OperationHistoryPage> {
|
class _OperationHistoryView extends StatelessWidget {
|
||||||
DateTimeRange? _pendingRange;
|
const _OperationHistoryView();
|
||||||
DateTimeRange? _appliedRange;
|
|
||||||
final Set<OperationStatus> _pendingStatuses = {};
|
|
||||||
Set<OperationStatus> _appliedStatuses = {};
|
|
||||||
|
|
||||||
@override
|
Future<void> _pickRange(
|
||||||
void initState() {
|
BuildContext context,
|
||||||
super.initState();
|
ReportOperationsController controller,
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
) async {
|
||||||
final provider = context.read<PaymentsProvider>();
|
|
||||||
if (!provider.isReady && !provider.isLoading) {
|
|
||||||
provider.refresh();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _pickRange() async {
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final initial = _pendingRange ??
|
final initial = controller.selectedRange ??
|
||||||
DateTimeRange(
|
DateTimeRange(
|
||||||
start: now.subtract(const Duration(days: 30)),
|
start: now.subtract(const Duration(days: 30)),
|
||||||
end: now,
|
end: now,
|
||||||
@@ -55,147 +51,36 @@ class _OperationHistoryPageState extends State<OperationHistoryPage> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (picked != null) {
|
if (picked != null) {
|
||||||
setState(() {
|
controller.setRange(picked);
|
||||||
_pendingRange = picked;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _toggleStatus(OperationStatus status) {
|
void _openPaymentDetails(BuildContext context, OperationItem operation) {
|
||||||
setState(() {
|
final paymentId = paymentIdFromOperation(operation);
|
||||||
if (_pendingStatuses.contains(status)) {
|
if (paymentId == null) return;
|
||||||
_pendingStatuses.remove(status);
|
|
||||||
} else {
|
context.pushToReportPayment(paymentId);
|
||||||
_pendingStatuses.add(status);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _applyFilters() {
|
|
||||||
setState(() {
|
|
||||||
_appliedRange = _pendingRange;
|
|
||||||
_appliedStatuses = {..._pendingStatuses};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
List<OperationItem> _mapPayments(List<Payment> payments) {
|
|
||||||
return payments.map(_mapPayment).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
OperationItem _mapPayment(Payment payment) {
|
|
||||||
final debit = payment.lastQuote?.debitAmount;
|
|
||||||
final settlement = payment.lastQuote?.expectedSettlementAmount;
|
|
||||||
final amountMoney = debit ?? settlement;
|
|
||||||
|
|
||||||
final amount = _parseAmount(amountMoney?.amount);
|
|
||||||
final currency = amountMoney?.currency ?? '';
|
|
||||||
final toAmount = settlement == null ? amount : _parseAmount(settlement.amount);
|
|
||||||
final toCurrency = settlement?.currency ?? currency;
|
|
||||||
|
|
||||||
final payId = _firstNonEmpty([payment.paymentRef, payment.idempotencyKey]) ?? '-';
|
|
||||||
final name = _firstNonEmpty([payment.lastQuote?.quoteRef, payment.paymentRef, payment.idempotencyKey]) ?? '-';
|
|
||||||
final comment = _firstNonEmpty([payment.failureReason, payment.failureCode, payment.state]) ?? '';
|
|
||||||
|
|
||||||
return OperationItem(
|
|
||||||
status: _statusFromPaymentState(payment.state),
|
|
||||||
fileName: null,
|
|
||||||
amount: amount,
|
|
||||||
currency: currency,
|
|
||||||
toAmount: toAmount,
|
|
||||||
toCurrency: toCurrency,
|
|
||||||
payId: payId,
|
|
||||||
paymentRef: payment.paymentRef,
|
|
||||||
cardNumber: null,
|
|
||||||
name: name,
|
|
||||||
date: _resolvePaymentDate(payment),
|
|
||||||
comment: comment,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<OperationItem> _filterOperations(List<OperationItem> operations) {
|
|
||||||
if (_appliedRange == null && _appliedStatuses.isEmpty) {
|
|
||||||
return operations;
|
|
||||||
}
|
|
||||||
|
|
||||||
return operations.where((op) {
|
|
||||||
final statusMatch =
|
|
||||||
_appliedStatuses.isEmpty || _appliedStatuses.contains(op.status);
|
|
||||||
|
|
||||||
final dateMatch = _appliedRange == null ||
|
|
||||||
_isUnknownDate(op.date) ||
|
|
||||||
(op.date.isAfter(_appliedRange!.start.subtract(const Duration(seconds: 1))) &&
|
|
||||||
op.date.isBefore(_appliedRange!.end.add(const Duration(seconds: 1))));
|
|
||||||
|
|
||||||
return statusMatch && dateMatch;
|
|
||||||
}).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
OperationStatus _statusFromPaymentState(String? raw) {
|
|
||||||
final state = raw?.trim().toLowerCase();
|
|
||||||
|
|
||||||
switch (state) {
|
|
||||||
case 'accepted':
|
|
||||||
case 'funds_reserved':
|
|
||||||
case 'submitted':
|
|
||||||
case 'unspecified':
|
|
||||||
case null:
|
|
||||||
return OperationStatus.processing;
|
|
||||||
|
|
||||||
case 'settled':
|
|
||||||
case 'success':
|
|
||||||
return OperationStatus.success;
|
|
||||||
|
|
||||||
case 'failed':
|
|
||||||
case 'cancelled':
|
|
||||||
return OperationStatus.error;
|
|
||||||
|
|
||||||
default:
|
|
||||||
// Future-proof: any new backend state is treated as processing
|
|
||||||
return OperationStatus.processing;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DateTime _resolvePaymentDate(Payment payment) {
|
|
||||||
final expiresAt = payment.lastQuote?.fxQuote?.expiresAtUnixMs;
|
|
||||||
if (expiresAt != null && expiresAt > 0) {
|
|
||||||
return DateTime.fromMillisecondsSinceEpoch(expiresAt, isUtc: true);
|
|
||||||
}
|
|
||||||
return DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
double _parseAmount(String? amount) {
|
|
||||||
if (amount == null || amount.trim().isEmpty) return 0;
|
|
||||||
return double.tryParse(amount) ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
String? _firstNonEmpty(List<String?> values) {
|
|
||||||
for (final value in values) {
|
|
||||||
final trimmed = value?.trim();
|
|
||||||
if (trimmed != null && trimmed.isNotEmpty) return trimmed;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _isUnknownDate(DateTime date) => date.millisecondsSinceEpoch == 0;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final loc = AppLocalizations.of(context)!;
|
final loc = AppLocalizations.of(context)!;
|
||||||
return Consumer<PaymentsProvider>(
|
return Consumer<ReportOperationsController>(
|
||||||
builder: (context, provider, child) {
|
builder: (context, controller, child) {
|
||||||
if (provider.isLoading) {
|
if (controller.isLoading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider.error != null) {
|
if (controller.error != null) {
|
||||||
final message = provider.error?.toString() ?? loc.noErrorInformation;
|
final message =
|
||||||
|
controller.error?.toString() ?? loc.noErrorInformation;
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(loc.notificationError(message)),
|
Text(loc.notificationError(message)),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () => provider.refresh(),
|
onPressed: () => controller.refresh(),
|
||||||
child: Text(loc.retry),
|
child: Text(loc.retry),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -203,11 +88,8 @@ class _OperationHistoryPageState extends State<OperationHistoryPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final operations = _mapPayments(provider.payments);
|
final operations = controller.operations;
|
||||||
final filteredOperations = _filterOperations(operations);
|
final filteredOperations = controller.filteredOperations;
|
||||||
final hasFileName = operations.any(
|
|
||||||
(operation) => (operation.fileName ?? '').trim().isNotEmpty,
|
|
||||||
);
|
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
@@ -215,6 +97,7 @@ class _OperationHistoryPageState extends State<OperationHistoryPage> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
spacing: 16,
|
spacing: 16,
|
||||||
children: [
|
children: [
|
||||||
|
//TODO Make charts more useful and re-enable
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 200,
|
height: 200,
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -224,7 +107,7 @@ class _OperationHistoryPageState extends State<OperationHistoryPage> {
|
|||||||
child: StatusChart(operations: operations),
|
child: StatusChart(operations: operations),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: PayoutDistributionChart(
|
child: PayoutVolumesChart(
|
||||||
operations: operations,
|
operations: operations,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -232,15 +115,15 @@ class _OperationHistoryPageState extends State<OperationHistoryPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
OperationFilters(
|
OperationFilters(
|
||||||
selectedRange: _pendingRange,
|
selectedRange: controller.selectedRange,
|
||||||
selectedStatuses: _pendingStatuses,
|
selectedStatuses: controller.selectedStatuses,
|
||||||
onPickRange: _pickRange,
|
onPickRange: () => _pickRange(context, controller),
|
||||||
onToggleStatus: _toggleStatus,
|
onToggleStatus: controller.toggleStatus,
|
||||||
onApply: _applyFilters,
|
onClear: controller.clearFilters,
|
||||||
),
|
),
|
||||||
OperationsTable(
|
OperationsCardsList(
|
||||||
operations: filteredOperations,
|
operations: filteredOperations,
|
||||||
showFileNameColumn: hasFileName,
|
onTap: (operation) => _openPaymentDetails(context, operation),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import 'package:badges/badges.dart' as badges;
|
|||||||
|
|
||||||
import 'package:pshared/models/payment/status.dart';
|
import 'package:pshared/models/payment/status.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/utils/payment/status_view.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
|
||||||
class OperationStatusBadge extends StatelessWidget {
|
class OperationStatusBadge extends StatelessWidget {
|
||||||
final OperationStatus status;
|
final OperationStatus status;
|
||||||
@@ -11,15 +15,8 @@ class OperationStatusBadge extends StatelessWidget {
|
|||||||
const OperationStatusBadge({super.key, required this.status});
|
const OperationStatusBadge({super.key, required this.status});
|
||||||
|
|
||||||
Color _badgeColor(BuildContext context) {
|
Color _badgeColor(BuildContext context) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
switch (status) {
|
return operationStatusView(l10n, status).color;
|
||||||
case OperationStatus.processing:
|
|
||||||
return scheme.primary;
|
|
||||||
case OperationStatus.success:
|
|
||||||
return scheme.secondary;
|
|
||||||
case OperationStatus.error:
|
|
||||||
return scheme.error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Color _textColor(Color background) {
|
Color _textColor(Color background) {
|
||||||
|
|||||||
@@ -1,70 +1,80 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:badges/badges.dart' as badges; // Make sure to add badges package in pubspec.yaml
|
|
||||||
import 'package:pshared/models/payment/status.dart';
|
import 'package:pshared/models/payment/status.dart';
|
||||||
import 'package:pshared/utils/localization.dart';
|
import 'package:pshared/utils/localization.dart';
|
||||||
|
|
||||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
|
||||||
class OperationFilters extends StatelessWidget {
|
class OperationFilters extends StatelessWidget {
|
||||||
final DateTimeRange? selectedRange;
|
final DateTimeRange? selectedRange;
|
||||||
final Set<OperationStatus> selectedStatuses;
|
final Set<OperationStatus> selectedStatuses;
|
||||||
final VoidCallback onPickRange;
|
final VoidCallback onPickRange;
|
||||||
final VoidCallback onApply;
|
|
||||||
final ValueChanged<OperationStatus> onToggleStatus;
|
final ValueChanged<OperationStatus> onToggleStatus;
|
||||||
|
final VoidCallback onClear;
|
||||||
|
|
||||||
const OperationFilters({
|
const OperationFilters({
|
||||||
super.key,
|
super.key,
|
||||||
required this.selectedRange,
|
required this.selectedRange,
|
||||||
required this.selectedStatuses,
|
required this.selectedStatuses,
|
||||||
required this.onPickRange,
|
required this.onPickRange,
|
||||||
required this.onApply,
|
|
||||||
required this.onToggleStatus,
|
required this.onToggleStatus,
|
||||||
|
required this.onClear,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final hasActive = selectedRange != null || selectedStatuses.isNotEmpty;
|
||||||
|
final periodLabel = selectedRange == null
|
||||||
|
? l10n.selectPeriod
|
||||||
|
: '${dateToLocalFormat(context, selectedRange!.start)} – ${dateToLocalFormat(context, selectedRange!.end)}';
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.all(16),
|
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
elevation: 2,
|
elevation: 0,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Row(
|
||||||
l10n.filters,
|
children: [
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
Text(
|
||||||
),
|
l10n.filters,
|
||||||
const SizedBox(height: 12),
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
GestureDetector(
|
fontWeight: FontWeight.w600,
|
||||||
onTap: onPickRange,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.date_range_outlined, color: Theme.of(context).primaryColor),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
selectedRange == null
|
|
||||||
? l10n.selectPeriod
|
|
||||||
: '${dateToLocalFormat(context, selectedRange!.start)} – ${dateToLocalFormat(context, selectedRange!.end)}',
|
|
||||||
style: TextStyle(
|
|
||||||
color: selectedRange == null
|
|
||||||
? Colors.grey
|
|
||||||
: Colors.black87,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Icon(Icons.keyboard_arrow_down, color: Colors.grey),
|
),
|
||||||
],
|
const Spacer(),
|
||||||
|
if (hasActive)
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: onClear,
|
||||||
|
icon: const Icon(Icons.close, size: 16),
|
||||||
|
label: Text(l10n.reset),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: onPickRange,
|
||||||
|
icon: const Icon(Icons.date_range_outlined, size: 18),
|
||||||
|
label: Text(
|
||||||
|
periodLabel,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 12),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 12,
|
spacing: 10,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: const [
|
children: const [
|
||||||
OperationStatus.success,
|
OperationStatus.success,
|
||||||
@@ -73,51 +83,28 @@ class OperationFilters extends StatelessWidget {
|
|||||||
].map((status) {
|
].map((status) {
|
||||||
final label = status.localized(context);
|
final label = status.localized(context);
|
||||||
final isSelected = selectedStatuses.contains(status);
|
final isSelected = selectedStatuses.contains(status);
|
||||||
return GestureDetector(
|
return FilterChip(
|
||||||
onTap: () => onToggleStatus(status),
|
label: Text(l10n.status(label)),
|
||||||
child: badges.Badge(
|
selected: isSelected,
|
||||||
badgeAnimation: badges.BadgeAnimation.fade(),
|
onSelected: (_) => onToggleStatus(status),
|
||||||
badgeStyle: badges.BadgeStyle(
|
selectedColor: theme.colorScheme.primaryContainer,
|
||||||
shape: badges.BadgeShape.square,
|
checkmarkColor: theme.colorScheme.onPrimaryContainer,
|
||||||
badgeColor: isSelected
|
labelStyle: theme.textTheme.bodySmall?.copyWith(
|
||||||
? Theme.of(context).primaryColor
|
color: isSelected
|
||||||
: Colors.grey.shade300,
|
? theme.colorScheme.onPrimaryContainer
|
||||||
borderRadius: BorderRadius.circular(8),
|
: theme.colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
badgeContent: Padding(
|
shape: RoundedRectangleBorder(
|
||||||
padding: const EdgeInsets.symmetric(
|
borderRadius: BorderRadius.circular(12),
|
||||||
horizontal: 8,
|
),
|
||||||
vertical: 4,
|
side: BorderSide(
|
||||||
),
|
color: isSelected
|
||||||
child: Text(
|
? theme.colorScheme.primaryContainer
|
||||||
l10n.status(label),
|
: theme.dividerColor.withAlpha(60),
|
||||||
style: TextStyle(
|
|
||||||
color: isSelected ? Colors.white : Colors.black87,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: onApply,
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 24,
|
|
||||||
vertical: 12,
|
|
||||||
),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Text(l10n.apply),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
|
|
||||||
import 'package:pshared/models/payment/operation.dart';
|
import 'package:pshared/models/payment/operation.dart';
|
||||||
import 'package:pshared/models/payment/status.dart';
|
import 'package:pshared/models/payment/status.dart';
|
||||||
import 'package:pshared/provider/organizations.dart';
|
|
||||||
import 'package:pshared/service/payment/documents.dart';
|
|
||||||
import 'package:pshared/utils/currency.dart';
|
import 'package:pshared/utils/currency.dart';
|
||||||
|
|
||||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
|
||||||
import 'package:pweb/pages/report/table/badge.dart';
|
import 'package:pweb/pages/report/table/badge.dart';
|
||||||
import 'package:pweb/utils/download.dart';
|
import 'package:pweb/utils/report/download_act.dart';
|
||||||
import 'package:pweb/utils/error/snackbar.dart';
|
|
||||||
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
|
||||||
class OperationRow {
|
class OperationRow {
|
||||||
@@ -29,7 +25,7 @@ class OperationRow {
|
|||||||
|
|
||||||
final documentCell = canDownload
|
final documentCell = canDownload
|
||||||
? TextButton.icon(
|
? TextButton.icon(
|
||||||
onPressed: () => _downloadAct(context, op),
|
onPressed: () => downloadPaymentAct(context, op.paymentRef ?? ''),
|
||||||
icon: const Icon(Icons.download),
|
icon: const Icon(Icons.download),
|
||||||
label: Text(loc.downloadAct),
|
label: Text(loc.downloadAct),
|
||||||
)
|
)
|
||||||
@@ -47,28 +43,4 @@ class OperationRow {
|
|||||||
DataCell(Text(op.comment)),
|
DataCell(Text(op.comment)),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> _downloadAct(BuildContext context, OperationItem op) async {
|
|
||||||
final organizations = context.read<OrganizationsProvider>();
|
|
||||||
if (!organizations.isOrganizationSet) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final paymentRef = (op.paymentRef ?? '').trim();
|
|
||||||
if (paymentRef.isEmpty) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final loc = AppLocalizations.of(context)!;
|
|
||||||
await executeActionWithNotification(
|
|
||||||
context: context,
|
|
||||||
action: () async {
|
|
||||||
final file = await PaymentDocumentsService.getAct(
|
|
||||||
organizations.current.id,
|
|
||||||
paymentRef,
|
|
||||||
);
|
|
||||||
await downloadFile(file);
|
|
||||||
},
|
|
||||||
errorMessage: loc.downloadActError,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:pshared/provider/payment/multiple/provider.dart';
|
|||||||
import 'package:pshared/provider/payment/multiple/quotation.dart';
|
import 'package:pshared/provider/payment/multiple/quotation.dart';
|
||||||
import 'package:pshared/provider/payment/payments.dart';
|
import 'package:pshared/provider/payment/payments.dart';
|
||||||
import 'package:pshared/utils/currency.dart';
|
import 'package:pshared/utils/currency.dart';
|
||||||
|
import 'package:pshared/utils/money.dart';
|
||||||
|
|
||||||
import 'package:pweb/models/multiple_payouts/csv_row.dart';
|
import 'package:pweb/models/multiple_payouts/csv_row.dart';
|
||||||
import 'package:pweb/models/multiple_payouts/state.dart';
|
import 'package:pweb/models/multiple_payouts/state.dart';
|
||||||
@@ -93,8 +94,8 @@ class MultiplePayoutsProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
double total = 0;
|
double total = 0;
|
||||||
for (final row in _rows) {
|
for (final row in _rows) {
|
||||||
final value = double.tryParse(row.amount);
|
final value = parseMoneyAmount(row.amount, fallback: double.nan);
|
||||||
if (value == null) return null;
|
if (value.isNaN) return null;
|
||||||
total += value;
|
total += value;
|
||||||
}
|
}
|
||||||
return Money(amount: amountToString(total), currency: currency);
|
return Money(amount: amountToString(total), currency: currency);
|
||||||
@@ -121,10 +122,10 @@ class MultiplePayoutsProvider extends ChangeNotifier {
|
|||||||
final fee = aggregateFeeAmountFor(sourceWallet);
|
final fee = aggregateFeeAmountFor(sourceWallet);
|
||||||
if (debit == null || fee == null) return null;
|
if (debit == null || fee == null) return null;
|
||||||
|
|
||||||
final debitValue = double.tryParse(debit.amount);
|
final debitValue = parseMoneyAmount(debit.amount, fallback: double.nan);
|
||||||
final feeValue = double.tryParse(fee.amount);
|
final feeValue = parseMoneyAmount(fee.amount, fallback: double.nan);
|
||||||
if (debit.currency.toUpperCase() != fee.currency.toUpperCase()) return null;
|
if (debit.currency.toUpperCase() != fee.currency.toUpperCase()) return null;
|
||||||
if (debitValue == null || feeValue == null || debitValue <= 0) return null;
|
if (debitValue.isNaN || feeValue.isNaN || debitValue <= 0) return null;
|
||||||
return (feeValue / debitValue) * 100;
|
return (feeValue / debitValue) * 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'package:pshared/utils/money.dart';
|
||||||
|
|
||||||
import 'package:pweb/models/multiple_payouts/csv_row.dart';
|
import 'package:pweb/models/multiple_payouts/csv_row.dart';
|
||||||
|
|
||||||
|
|
||||||
@@ -79,8 +81,8 @@ class MultipleCsvParser {
|
|||||||
throw FormatException('CSV row ${i + 1}: amount is required');
|
throw FormatException('CSV row ${i + 1}: amount is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
final parsedAmount = double.tryParse(amount);
|
final parsedAmount = parseMoneyAmount(amount, fallback: double.nan);
|
||||||
if (parsedAmount == null || parsedAmount <= 0) {
|
if (parsedAmount.isNaN || parsedAmount <= 0) {
|
||||||
throw FormatException(
|
throw FormatException(
|
||||||
'CSV row ${i + 1}: amount must be greater than 0',
|
'CSV row ${i + 1}: amount must be greater than 0',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/models/payment/status.dart';
|
||||||
|
|
||||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
|
||||||
@@ -19,13 +21,13 @@ StatusView statusView(AppLocalizations l10n, String? raw) {
|
|||||||
|
|
||||||
switch (normalized) {
|
switch (normalized) {
|
||||||
case 'SETTLED':
|
case 'SETTLED':
|
||||||
return StatusView(l10n.paymentStatusPending, Colors.yellow);
|
return StatusView(l10n.paymentStatusPending, Colors.orange);
|
||||||
case 'SUCCESS':
|
case 'SUCCESS':
|
||||||
return StatusView(l10n.paymentStatusSuccessful, Colors.green);
|
return StatusView(l10n.paymentStatusSuccessful, Colors.green);
|
||||||
case 'FUNDS_RESERVED':
|
case 'FUNDS_RESERVED':
|
||||||
return StatusView(l10n.paymentStatusReserved, Colors.blue);
|
return StatusView(l10n.paymentStatusReserved, Colors.blue);
|
||||||
case 'ACCEPTED':
|
case 'ACCEPTED':
|
||||||
return StatusView(l10n.paymentStatusProcessing, Colors.yellow);
|
return StatusView(l10n.paymentStatusProcessing, Colors.orange);
|
||||||
case 'SUBMITTED':
|
case 'SUBMITTED':
|
||||||
return StatusView(l10n.paymentStatusProcessing, Colors.blue);
|
return StatusView(l10n.paymentStatusProcessing, Colors.blue);
|
||||||
case 'FAILED':
|
case 'FAILED':
|
||||||
@@ -38,3 +40,17 @@ StatusView statusView(AppLocalizations l10n, String? raw) {
|
|||||||
return StatusView(l10n.paymentStatusPending, Colors.grey);
|
return StatusView(l10n.paymentStatusPending, Colors.grey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
StatusView operationStatusView(
|
||||||
|
AppLocalizations l10n,
|
||||||
|
OperationStatus status,
|
||||||
|
) {
|
||||||
|
switch (status) {
|
||||||
|
case OperationStatus.success:
|
||||||
|
return statusView(l10n, 'SUCCESS');
|
||||||
|
case OperationStatus.error:
|
||||||
|
return statusView(l10n, 'FAILED');
|
||||||
|
case OperationStatus.processing:
|
||||||
|
return statusView(l10n, 'ACCEPTED');
|
||||||
|
}
|
||||||
|
}
|
||||||
22
frontend/pweb/lib/utils/report/amount_parts.dart
Normal file
22
frontend/pweb/lib/utils/report/amount_parts.dart
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
AmountParts splitAmount(String value) {
|
||||||
|
final trimmed = value.trim();
|
||||||
|
if (trimmed.isEmpty || trimmed == '-') {
|
||||||
|
return const AmountParts(amount: '-', currency: '');
|
||||||
|
}
|
||||||
|
final parts = trimmed.split(' ');
|
||||||
|
if (parts.length < 2) {
|
||||||
|
return AmountParts(amount: trimmed, currency: '');
|
||||||
|
}
|
||||||
|
final currency = parts.removeLast();
|
||||||
|
return AmountParts(amount: parts.join(' '), currency: currency);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AmountParts {
|
||||||
|
final String amount;
|
||||||
|
final String currency;
|
||||||
|
|
||||||
|
const AmountParts({
|
||||||
|
required this.amount,
|
||||||
|
required this.currency,
|
||||||
|
});
|
||||||
|
}
|
||||||
36
frontend/pweb/lib/utils/report/download_act.dart
Normal file
36
frontend/pweb/lib/utils/report/download_act.dart
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/provider/organizations.dart';
|
||||||
|
import 'package:pshared/service/payment/documents.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/utils/download.dart';
|
||||||
|
import 'package:pweb/utils/error/snackbar.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
|
||||||
|
Future<void> downloadPaymentAct(BuildContext context, String paymentRef) async {
|
||||||
|
final organizations = context.read<OrganizationsProvider>();
|
||||||
|
if (!organizations.isOrganizationSet) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final trimmed = paymentRef.trim();
|
||||||
|
if (trimmed.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final loc = AppLocalizations.of(context)!;
|
||||||
|
await executeActionWithNotification(
|
||||||
|
context: context,
|
||||||
|
action: () async {
|
||||||
|
final file = await PaymentDocumentsService.getAct(
|
||||||
|
organizations.current.id,
|
||||||
|
trimmed,
|
||||||
|
);
|
||||||
|
await downloadFile(file);
|
||||||
|
},
|
||||||
|
errorMessage: loc.downloadActError,
|
||||||
|
);
|
||||||
|
}
|
||||||
42
frontend/pweb/lib/utils/report/format.dart
Normal file
42
frontend/pweb/lib/utils/report/format.dart
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/models/money.dart';
|
||||||
|
import 'package:pshared/utils/currency.dart';
|
||||||
|
import 'package:pshared/utils/localization.dart';
|
||||||
|
|
||||||
|
|
||||||
|
String formatMoney(Money? money, {String fallback = '-'}) {
|
||||||
|
if (money == null) return fallback;
|
||||||
|
final amount = money.amount.trim();
|
||||||
|
if (amount.isEmpty) return fallback;
|
||||||
|
final symbol = currencySymbolFromCode(money.currency);
|
||||||
|
final suffix = symbol ?? money.currency;
|
||||||
|
if (suffix.trim().isEmpty) return amount;
|
||||||
|
return '$amount $suffix';
|
||||||
|
}
|
||||||
|
|
||||||
|
String formatAmount(double amount, String currency, {String fallback = '-'}) {
|
||||||
|
final trimmed = currency.trim();
|
||||||
|
if (trimmed.isEmpty) return amountToString(amount);
|
||||||
|
final symbol = currencySymbolFromCode(trimmed);
|
||||||
|
final suffix = symbol ?? trimmed;
|
||||||
|
return '${amountToString(amount)} $suffix';
|
||||||
|
}
|
||||||
|
|
||||||
|
String formatDateLabel(BuildContext context, DateTime? date, {String fallback = '-'}) {
|
||||||
|
if (date == null || date.millisecondsSinceEpoch == 0) return fallback;
|
||||||
|
return dateTimeToLocalFormat(context, date.toLocal());
|
||||||
|
}
|
||||||
|
|
||||||
|
String formatLongDate(BuildContext context, DateTime? date, {String fallback = '-'}) {
|
||||||
|
if (date == null || date.millisecondsSinceEpoch == 0) return fallback;
|
||||||
|
final locale = Localizations.localeOf(context).toString();
|
||||||
|
final formatter = DateFormat('d MMMM y', locale);
|
||||||
|
return formatter.format(date.toLocal());
|
||||||
|
}
|
||||||
|
|
||||||
|
String collapseWhitespace(String value) {
|
||||||
|
return value.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||||
|
}
|
||||||
23
frontend/pweb/lib/utils/report/operations.dart
Normal file
23
frontend/pweb/lib/utils/report/operations.dart
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import 'package:pshared/models/payment/operation.dart';
|
||||||
|
|
||||||
|
|
||||||
|
List<OperationItem> sortOperations(List<OperationItem> operations) {
|
||||||
|
final sorted = List<OperationItem>.from(operations);
|
||||||
|
sorted.sort((a, b) {
|
||||||
|
final aTime = a.date.millisecondsSinceEpoch;
|
||||||
|
final bTime = b.date.millisecondsSinceEpoch;
|
||||||
|
final aUnknown = isUnknownDate(a.date);
|
||||||
|
final bUnknown = isUnknownDate(b.date);
|
||||||
|
|
||||||
|
if (aUnknown != bUnknown) {
|
||||||
|
return aUnknown ? 1 : -1;
|
||||||
|
}
|
||||||
|
if (aTime != bTime) {
|
||||||
|
return bTime.compareTo(aTime);
|
||||||
|
}
|
||||||
|
return a.payId.compareTo(b.payId);
|
||||||
|
});
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isUnknownDate(DateTime date) => date.millisecondsSinceEpoch == 0;
|
||||||
110
frontend/pweb/lib/utils/report/payment_mapper.dart
Normal file
110
frontend/pweb/lib/utils/report/payment_mapper.dart
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import 'package:pshared/models/payment/operation.dart';
|
||||||
|
import 'package:pshared/models/payment/payment.dart';
|
||||||
|
import 'package:pshared/models/payment/status.dart';
|
||||||
|
import 'package:pshared/utils/money.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/models/payment_state.dart';
|
||||||
|
|
||||||
|
|
||||||
|
OperationItem mapPaymentToOperation(Payment payment) {
|
||||||
|
final debit = payment.lastQuote?.debitAmount;
|
||||||
|
final settlement = payment.lastQuote?.expectedSettlementAmount;
|
||||||
|
final amountMoney = debit ?? settlement;
|
||||||
|
|
||||||
|
final amount = parseMoneyAmount(amountMoney?.amount);
|
||||||
|
final currency = amountMoney?.currency ?? '';
|
||||||
|
final toAmount = settlement == null
|
||||||
|
? amount
|
||||||
|
: parseMoneyAmount(settlement.amount);
|
||||||
|
final toCurrency = settlement?.currency ?? currency;
|
||||||
|
|
||||||
|
final payId = _firstNonEmpty([
|
||||||
|
payment.paymentRef,
|
||||||
|
payment.idempotencyKey,
|
||||||
|
]) ??
|
||||||
|
'-';
|
||||||
|
final name = _firstNonEmpty([
|
||||||
|
payment.lastQuote?.quoteRef,
|
||||||
|
payment.paymentRef,
|
||||||
|
payment.idempotencyKey,
|
||||||
|
]) ??
|
||||||
|
'-';
|
||||||
|
final comment = _firstNonEmpty([
|
||||||
|
payment.failureReason,
|
||||||
|
payment.failureCode,
|
||||||
|
payment.state,
|
||||||
|
]) ??
|
||||||
|
'';
|
||||||
|
|
||||||
|
return OperationItem(
|
||||||
|
status: statusFromPayment(payment),
|
||||||
|
fileName: _extractFileName(payment.metadata),
|
||||||
|
amount: amount,
|
||||||
|
currency: currency,
|
||||||
|
toAmount: toAmount,
|
||||||
|
toCurrency: toCurrency,
|
||||||
|
payId: payId,
|
||||||
|
paymentRef: payment.paymentRef,
|
||||||
|
cardNumber: null,
|
||||||
|
name: name,
|
||||||
|
date: resolvePaymentDate(payment),
|
||||||
|
comment: comment,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
OperationStatus statusFromPayment(Payment payment) {
|
||||||
|
final state = paymentStateFromRaw(payment.state);
|
||||||
|
switch (state) {
|
||||||
|
case PaymentState.success:
|
||||||
|
return OperationStatus.success;
|
||||||
|
case PaymentState.failed:
|
||||||
|
case PaymentState.cancelled:
|
||||||
|
return OperationStatus.error;
|
||||||
|
case PaymentState.processing:
|
||||||
|
case PaymentState.unknown:
|
||||||
|
return OperationStatus.processing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime resolvePaymentDate(Payment payment) {
|
||||||
|
final createdAt = payment.createdAt;
|
||||||
|
if (createdAt != null) return createdAt.toLocal();
|
||||||
|
|
||||||
|
final expiresAt = payment.lastQuote?.fxQuote?.expiresAtUnixMs;
|
||||||
|
if (expiresAt != null && expiresAt > 0) {
|
||||||
|
return DateTime.fromMillisecondsSinceEpoch(expiresAt, isUtc: true).toLocal();
|
||||||
|
}
|
||||||
|
|
||||||
|
return DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? paymentIdFromOperation(OperationItem operation) {
|
||||||
|
final candidates = [
|
||||||
|
operation.paymentRef,
|
||||||
|
operation.payId,
|
||||||
|
];
|
||||||
|
for (final candidate in candidates) {
|
||||||
|
final trimmed = candidate?.trim();
|
||||||
|
if (trimmed != null && trimmed.isNotEmpty && trimmed != '-') {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _extractFileName(Map<String, String>? metadata) {
|
||||||
|
if (metadata == null || metadata.isEmpty) return null;
|
||||||
|
return _firstNonEmpty([
|
||||||
|
metadata['upload_filename'],
|
||||||
|
metadata['upload_file_name'],
|
||||||
|
metadata['filename'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _firstNonEmpty(List<String?> values) {
|
||||||
|
for (final value in values) {
|
||||||
|
final trimmed = value?.trim();
|
||||||
|
tech
commented
по идее это должен быть просто enum с mapper'ом. по идее это должен быть просто enum с mapper'ом.
|
|||||||
|
if (trimmed != null && trimmed.isNotEmpty) return trimmed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
37
frontend/pweb/lib/utils/report/utils/format.dart
Normal file
37
frontend/pweb/lib/utils/report/utils/format.dart
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/models/money.dart';
|
||||||
|
import 'package:pshared/utils/currency.dart';
|
||||||
|
import 'package:pshared/utils/localization.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
|
||||||
|
String formatMoney(Money? money, {String fallback = '-'}) {
|
||||||
|
final amount = money?.amount.trim();
|
||||||
|
if (amount == null || amount.isEmpty) return fallback;
|
||||||
|
return '$amount ${money!.currency}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String formatAmount(double amount, String currency, {String fallback = '-'}) {
|
||||||
|
final trimmed = currency.trim();
|
||||||
|
if (trimmed.isEmpty) return amountToString(amount);
|
||||||
|
final symbol = currencySymbolFromCode(trimmed);
|
||||||
|
final suffix = symbol ?? trimmed;
|
||||||
|
return '${amountToString(amount)} $suffix';
|
||||||
|
}
|
||||||
|
|
||||||
|
String formatDateLabel(BuildContext context, DateTime? date, {String fallback = '-'}) {
|
||||||
|
if (date == null || date.millisecondsSinceEpoch == 0) return fallback;
|
||||||
|
return dateTimeToLocalFormat(context, date.toLocal());
|
||||||
|
}
|
||||||
|
|
||||||
|
String formatLongDate(BuildContext context, DateTime? date, {String fallback = '-'}) {
|
||||||
|
if (date == null || date.millisecondsSinceEpoch == 0) return fallback;
|
||||||
|
final locale = Localizations.localeOf(context).toString();
|
||||||
|
final formatter = DateFormat('d MMMM y', locale);
|
||||||
|
return formatter.format(date.toLocal());
|
||||||
|
}
|
||||||
|
|
||||||
|
String collapseWhitespace(String value) {
|
||||||
|
return value.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||||
|
}
|
||||||
@@ -45,8 +45,8 @@ class PayoutSidebar extends StatelessWidget {
|
|||||||
PayoutDestination.dashboard,
|
PayoutDestination.dashboard,
|
||||||
PayoutDestination.recipients,
|
PayoutDestination.recipients,
|
||||||
PayoutDestination.invitations,
|
PayoutDestination.invitations,
|
||||||
|
PayoutDestination.reports,
|
||||||
// PayoutDestination.methods,
|
// PayoutDestination.methods,
|
||||||
// PayoutDestination.reports,
|
|
||||||
// PayoutDestination.organizationSettings,
|
// PayoutDestination.organizationSettings,
|
||||||
//TODO Add when ready
|
//TODO Add when ready
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user
по идее должно работать без ручного управления подпиской. update должен вызываться при любом обновлении провайдера. Соответственно, можно просто из update делать rebuild. Так не работает?