reports page #518

Merged
tech merged 2 commits from SEND053 into main 2026-02-17 09:21:39 +00:00
56 changed files with 2227 additions and 501 deletions
Showing only changes of commit 0eea39fb97 - Show all commits

View File

@@ -27,6 +27,17 @@ class PaymentDTO {
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);
static Map<String, dynamic> _normalizeJson(Map<String, dynamic> json) {
if (json.containsKey('metadata') || !json.containsKey('meta')) {
return json;
}
final normalized = Map<String, dynamic>.from(json);
normalized['metadata'] = normalized['meta'];
return normalized;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,44 @@
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?.removeListener(_onPaymentsChanged);
Review

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

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

View File

@@ -0,0 +1,115 @@
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?.removeListener(_onPaymentsChanged);
Review

то же самое

то же самое
_payments = provider;
_payments?.addListener(_onPaymentsChanged);
}
_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 _onPaymentsChanged() {
_rebuildOperations();
}
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);
}
@override
void dispose() {
_payments?.removeListener(_onPaymentsChanged);
super.dispose();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/operation.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
import 'package:pweb/pages/report/cards/items.dart';
class OperationsCardsColumn extends StatelessWidget {
final List<OperationItem> operations;
final ValueChanged<OperationItem>? onTap;
const OperationsCardsColumn({
super.key,
required this.operations,
this.onTap,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final items = buildOperationCardItems(
context,
operations,
onTap: onTap,
);
if (operations.isEmpty) {
return Align(
alignment: Alignment.centerLeft,
child: Text(
loc.reportPaymentsEmpty,
style: theme.textTheme.bodyMedium,
),
);
}
return Column(
children: items,
);
}
}

View File

@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/operation.dart';
import 'package:pweb/pages/report/cards/operation_card.dart';
import 'package:pweb/utils/report/format.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
List<Widget> buildOperationCardItems(
BuildContext context,
List<OperationItem> operations, {
ValueChanged<OperationItem>? onTap,
}) {
final loc = AppLocalizations.of(context)!;
final items = <Widget>[];
String? currentKey;
for (final operation in operations) {
final dateKey = _dateKey(operation.date);
if (dateKey != currentKey) {
if (items.isNotEmpty) {
items.add(const SizedBox(height: 16));
}
items.add(_DateHeader(
label: _dateLabel(context, operation.date, loc),
));
items.add(const SizedBox(height: 8));
currentKey = dateKey;
}
items.add(OperationCard(
operation: operation,
onTap: onTap,
));
items.add(const SizedBox(height: 12));
}
if (items.isNotEmpty) {
items.removeLast();
}
return items;
}
String _dateKey(DateTime date) {
if (date.millisecondsSinceEpoch == 0) return 'unknown';
final local = date.toLocal();
final normalized = DateTime(local.year, local.month, local.day);
return normalized.toIso8601String();
}
String _dateLabel(BuildContext context, DateTime date, AppLocalizations loc) {
if (date.millisecondsSinceEpoch == 0) return loc.unknown;
return formatLongDate(context, date);
}
class _DateHeader extends StatelessWidget {
final String label;
const _DateHeader({required this.label});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Text(
label,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
);
}
}

View File

@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/operation.dart';
import 'package:pweb/pages/report/cards/items.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class OperationsCardsList extends StatelessWidget {
final List<OperationItem> operations;
final ValueChanged<OperationItem>? onTap;
const OperationsCardsList({
super.key,
required this.operations,
this.onTap,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final items = buildOperationCardItems(
context,
operations,
onTap: onTap,
);
return Expanded(
child: operations.isEmpty
? Center(
child: Text(
loc.reportPaymentsEmpty,
style: Theme.of(context).textTheme.bodyMedium,
),
)
: ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => items[index],
),
);
}
}

View File

@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/operation.dart';
import 'package:pweb/pages/report/cards/operation_card_utils.dart';
import 'package:pweb/pages/report/cards/operation_info_row.dart';
import 'package:pweb/pages/report/table/badge.dart';
import 'package:pweb/utils/report/format.dart';
import 'package:pweb/utils/report/payment_mapper.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class OperationCard extends StatelessWidget {
final OperationItem operation;
final ValueChanged<OperationItem>? onTap;
const OperationCard({
super.key,
required this.operation,
this.onTap,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final canOpen = onTap != null && paymentIdFromOperation(operation) != null;
final amountLabel = formatAmount(operation.amount, operation.currency);
final toAmountLabel = formatAmount(operation.toAmount, operation.toCurrency);
final showToAmount = shouldShowToAmount(operation);
final timeLabel = formatOperationTime(context, operation.date);
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
side: BorderSide(color: theme.dividerColor.withAlpha(25)),
borderRadius: BorderRadius.circular(16),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: canOpen ? () => onTap?.call(operation) : null,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
OperationStatusBadge(status: operation.status),
const Spacer(),
Text(
timeLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 12),
Text(
amountLabel,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
if (showToAmount)
Text(
loc.recipientWillReceive(toAmountLabel),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
if ((operation.fileName ?? '').trim().isNotEmpty) ...[
const SizedBox(height: 6),
OperationInfoRow(
icon: Icons.description,
value: operation.fileName!,
),
],
],
),
),
),
);
}
}

View File

@@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/operation.dart';
bool shouldShowToAmount(OperationItem operation) {
if (operation.toCurrency.trim().isEmpty) return false;
if (operation.currency.trim().isEmpty) return true;
if (operation.currency != operation.toCurrency) return true;
return (operation.toAmount - operation.amount).abs() > 0.0001;
}
String formatOperationTime(BuildContext context, DateTime date) {
if (date.millisecondsSinceEpoch == 0) return '-';
return TimeOfDay.fromDateTime(date.toLocal()).format(context);
}

View File

@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
class OperationInfoRow extends StatelessWidget {
final IconData icon;
final String value;
const OperationInfoRow({
super.key,
required this.icon,
required this.value,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: [
Icon(
icon,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 6),
Expanded(
child: Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium,
),
),
],
);
}
}

View File

@@ -1,10 +1,11 @@
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
import 'package:syncfusion_flutter_charts/charts.dart' hide ChartPoint;
import 'package:pshared/models/payment/operation.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
import 'package:pweb/models/chart_point.dart';
class PayoutDistributionChart extends StatelessWidget {
@@ -25,7 +26,7 @@ class PayoutDistributionChart extends StatelessWidget {
// 2) Build chart data
final data = sums.entries
.map((e) => _ChartData(e.key, e.value))
.map((e) => ChartPoint<String>(e.key, e.value))
.toList();
// 3) Build a simple horizontal legend
@@ -42,7 +43,7 @@ class PayoutDistributionChart extends StatelessWidget {
children: [
Icon(Icons.circle, size: 10, color: palette[i % palette.length]),
const SizedBox(width: 4),
Text(data[i].label, style: Theme.of(context).textTheme.bodySmall),
Text(data[i].key, style: Theme.of(context).textTheme.bodySmall),
if (i < data.length - 1) const SizedBox(width: 12),
],
);
@@ -62,10 +63,10 @@ class PayoutDistributionChart extends StatelessWidget {
child: SfCircularChart(
legend: Legend(isVisible: false),
tooltipBehavior: TooltipBehavior(enable: true),
series: <PieSeries<_ChartData, String>>[
PieSeries<_ChartData, String>(
series: <PieSeries<ChartPoint<String>, String>>[
PieSeries<ChartPoint<String>, String>(
dataSource: data,
xValueMapper: (d, _) => d.label,
xValueMapper: (d, _) => d.key,
yValueMapper: (d, _) => d.value,
dataLabelMapper: (d, _) =>
'${(d.value / sums.values.fold(0, (a, b) => a + b) * 100).toStringAsFixed(1)}%',
@@ -95,9 +96,3 @@ class PayoutDistributionChart extends StatelessWidget {
);
}
}
class _ChartData {
final String label;
final double value;
_ChartData(this.label, this.value);
}

View File

@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/operation.dart';
import 'package:pshared/models/payment/status.dart';
List<CurrencyTotal> aggregateCurrencyTotals(
List<OperationItem> operations,
DateTimeRange range,
) {
final totals = <String, double>{};
for (final operation in operations) {
if (operation.status != OperationStatus.success) continue;
if (_isUnknownDate(operation.date)) continue;
if (operation.date.isBefore(range.start) ||
operation.date.isAfter(range.end)) {
continue;
}
final currency = _normalizeCurrency(operation.currency);
totals[currency] = (totals[currency] ?? 0) + operation.amount;
}
final list = totals.entries
.map((entry) => CurrencyTotal(entry.key, entry.value))
.toList();
list.sort((a, b) => a.currency.compareTo(b.currency));
return list;
}
String _normalizeCurrency(String raw) {
final trimmed = raw.trim();
return trimmed.isEmpty ? '-' : trimmed;
}
bool _isUnknownDate(DateTime date) => date.millisecondsSinceEpoch == 0;
class CurrencyTotal {
final String currency;
final double amount;
const CurrencyTotal(this.currency, this.amount);
}

View File

@@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/payment/operation.dart';
import 'package:pweb/controllers/payout_volumes.dart';
import 'package:pweb/pages/report/charts/payout_volumes/aggregator.dart';
import 'package:pweb/pages/report/charts/payout_volumes/pie_chart.dart';
import 'package:pweb/pages/report/charts/payout_volumes/range_label.dart';
import 'package:pweb/pages/report/charts/payout_volumes/range_picker.dart';
import 'package:pweb/pages/report/charts/payout_volumes/totals_list.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PayoutVolumesChart extends StatelessWidget {
final List<OperationItem> operations;
const PayoutVolumesChart({super.key, required this.operations});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => PayoutVolumesController(),
child: _PayoutVolumesChartBody(operations: operations),
);
}
}
class _PayoutVolumesChartBody extends StatelessWidget {
final List<OperationItem> operations;
const _PayoutVolumesChartBody({required this.operations});
@override
Widget build(BuildContext context) {
return Consumer<PayoutVolumesController>(
builder: (context, controller, child) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final range = controller.range;
final totals = aggregateCurrencyTotals(operations, range);
final rangeLabel = formatRangeLabel(context, range);
return SizedBox(
height: 200,
child: Card(
margin: const EdgeInsets.all(16),
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => pickPayoutVolumesRange(context, controller),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Text(
l10n.debitAmountLabel,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const Spacer(),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Flexible(
child: Text(
rangeLabel,
style: theme.textTheme.labelMedium,
textAlign: TextAlign.right,
maxLines: 2,
softWrap: true,
overflow: TextOverflow.clip,
),
),
const SizedBox(width: 6),
Icon(
Icons.date_range_outlined,
size: 16,
color: theme.iconTheme.color?.withAlpha(160),
),
],
),
),
],
),
const SizedBox(height: 8),
Expanded(
child: totals.isEmpty
? Center(child: Text(l10n.noPayouts))
: Row(
children: [
Expanded(
child: PayoutTotalsList(
totals: totals,
),
),
const SizedBox(width: 12),
Expanded(
child: PayoutVolumesPieChart(
totals: totals,
),
),
],
),
),
],
),
),
),
),
);
},
);
}
}

View File

@@ -0,0 +1,12 @@
import 'package:flutter/material.dart';
List<Color> payoutVolumesPalette(ThemeData theme) {
return [
theme.colorScheme.primary,
theme.colorScheme.secondary,
theme.colorScheme.tertiary,
theme.colorScheme.primaryContainer,
theme.colorScheme.secondaryContainer,
];
}

View File

@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
import 'package:pweb/pages/report/charts/payout_volumes/aggregator.dart';
import 'package:pweb/pages/report/charts/payout_volumes/palette.dart';
class PayoutVolumesPieChart extends StatelessWidget {
final List<CurrencyTotal> totals;
const PayoutVolumesPieChart({super.key, required this.totals});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final palette = payoutVolumesPalette(theme);
return SfCircularChart(
legend: Legend(isVisible: false),
tooltipBehavior: TooltipBehavior(enable: true),
series: <PieSeries<CurrencyTotal, String>>[
PieSeries<CurrencyTotal, String>(
dataSource: totals,
xValueMapper: (item, _) => item.currency,
yValueMapper: (item, _) => item.amount,
dataLabelMapper: (item, _) => item.currency,
dataLabelSettings: const DataLabelSettings(
isVisible: true,
labelPosition: ChartDataLabelPosition.inside,
),
pointColorMapper: (item, index) =>
palette[index % palette.length],
radius: '100%',
),
],
);
}
}

View File

@@ -0,0 +1,18 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
String formatRangeLabel(BuildContext context, DateTimeRange range) {
final start = _formatShortDate(context, range.start);
final end = _formatShortDate(context, range.end);
return '$start \n$end';
}
String _formatShortDate(BuildContext context, DateTime date) {
final locale = Localizations.localeOf(context).toString();
final day = DateFormat('d', locale).format(date);
final month = DateFormat('MMM', locale).format(date);
final year = DateFormat('y', locale).format(date);
return '$day $month $year';
}

View File

@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
import 'package:pweb/controllers/payout_volumes.dart';
Future<void> pickPayoutVolumesRange(
BuildContext context,
PayoutVolumesController controller,
) async {
final now = DateTime.now();
final initial = _dateOnlyRange(controller.range);
final picked = await showDateRangePicker(
context: context,
firstDate: DateTime(2000),
lastDate: DateUtils.dateOnly(now).add(const Duration(days: 1)),
initialDateRange: initial,
);
if (picked != null) {
controller.setRange(picked);
}
}
DateTimeRange _dateOnlyRange(DateTimeRange range) {
return DateTimeRange(
start: DateUtils.dateOnly(range.start),
end: DateUtils.dateOnly(range.end),
);
}

View File

@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:pweb/pages/report/charts/payout_volumes/aggregator.dart';
import 'package:pweb/pages/report/charts/payout_volumes/palette.dart';
import 'package:pweb/utils/report/format.dart';
class PayoutTotalsList extends StatelessWidget {
final List<CurrencyTotal> totals;
const PayoutTotalsList({super.key, required this.totals});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final palette = payoutVolumesPalette(theme);
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var index = 0; index < totals.length; index++)
Padding(
padding: EdgeInsets.only(
bottom: index == totals.length - 1 ? 0 : 8,
),
child: Row(
children: [
Icon(
Icons.circle,
size: 10,
color: palette[index % palette.length],
),
const SizedBox(width: 6),
Expanded(
child: Text(
totals[index].currency,
style: theme.textTheme.bodySmall,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Text(
formatAmount(
totals[index].amount,
totals[index].currency,
),
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
);
}
}

View File

@@ -2,11 +2,13 @@ import 'dart:math';
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
import 'package:syncfusion_flutter_charts/charts.dart' hide ChartPoint;
import 'package:pshared/models/payment/status.dart';
import 'package:pshared/models/payment/operation.dart';
import 'package:pweb/models/chart_point.dart';
class StatusChart extends StatelessWidget {
final List<OperationItem> operations;
@@ -21,9 +23,9 @@ class StatusChart extends StatelessWidget {
counts[op.status] = (counts[op.status] ?? 0) + 1;
}
final items = counts.entries
.map((e) => _ChartData(e.key, e.value.toDouble()))
.map((e) => ChartPoint<OperationStatus>(e.key, e.value.toDouble()))
.toList();
final maxCount = items.map((e) => e.count.toInt()).fold<int>(0, max);
final maxCount = items.map((e) => e.value.toInt()).fold<int>(0, max);
final theme = Theme.of(context);
final barColor = theme.colorScheme.secondary;
@@ -66,11 +68,11 @@ class StatusChart extends StatelessWidget {
),
// ─── Bar series with tooltip enabled ───────────────
series: <ColumnSeries<_ChartData, String>>[
ColumnSeries<_ChartData, String>(
series: <ColumnSeries<ChartPoint<OperationStatus>, String>>[
ColumnSeries<ChartPoint<OperationStatus>, String>(
dataSource: items,
xValueMapper: (d, _) => d.status.localized(context),
yValueMapper: (d, _) => d.count,
xValueMapper: (d, _) => d.key.localized(context),
yValueMapper: (d, _) => d.value,
color: barColor,
width: 0.6,
borderRadius: const BorderRadius.all(Radius.circular(4)),
@@ -83,9 +85,3 @@ class StatusChart extends StatelessWidget {
);
}
}
class _ChartData {
final OperationStatus status;
final double count;
_ChartData(this.status, this.count);
}

View File

@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/payment.dart';
import 'package:pweb/pages/report/details/header.dart';
import 'package:pweb/pages/report/details/sections.dart';
import 'package:pweb/pages/report/details/summary_card/widget.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentDetailsContent extends StatelessWidget {
final Payment payment;
final VoidCallback onBack;
final VoidCallback? onDownloadAct;
const PaymentDetailsContent({
super.key,
required this.payment,
required this.onBack,
this.onDownloadAct,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
PaymentDetailsHeader(
title: loc.paymentInfo,
onBack: onBack,
),
const SizedBox(height: 16),
PaymentSummaryCard(
payment: payment,
onDownloadAct: onDownloadAct,
),
const SizedBox(height: 16),
PaymentDetailsSections(payment: payment),
],
),
);
}
}

View File

@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentDetailsHeader extends StatelessWidget {
final String title;
final VoidCallback onBack;
const PaymentDetailsHeader({
super.key,
required this.title,
required this.onBack,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: [
IconButton(
onPressed: onBack,
icon: const Icon(Icons.arrow_back),
tooltip: AppLocalizations.of(context)!.back,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
),
],
);
}
}

View File

@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/payment/payment.dart';
import 'package:pshared/models/payment/status.dart';
import 'package:pshared/provider/payment/payments.dart';
import 'package:pweb/app/router/payout_routes.dart';
import 'package:pweb/pages/report/details/content.dart';
import 'package:pweb/pages/report/details/states/error.dart';
import 'package:pweb/pages/report/details/states/not_found.dart';
import 'package:pweb/utils/report/download_act.dart';
import 'package:pweb/utils/report/payment_mapper.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentDetailsPage extends StatelessWidget {
final String paymentId;
const PaymentDetailsPage({
super.key,
required this.paymentId,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Consumer<PaymentsProvider>(
builder: (context, provider, child) {
final loc = AppLocalizations.of(context)!;
if (provider.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (provider.error != null) {
return PaymentDetailsError(
message: provider.error?.toString() ?? loc.noErrorInformation,
onRetry: () => provider.refresh(),
);
}
final payment = _findPayment(provider.payments, paymentId);
if (payment == null) {
return PaymentDetailsNotFound(onBack: () => _handleBack(context));
}
final status = statusFromPayment(payment);
final paymentRef = payment.paymentRef ?? '';
final canDownload = status == OperationStatus.success &&
paymentRef.trim().isNotEmpty;
return PaymentDetailsContent(
payment: payment,
onBack: () => _handleBack(context),
onDownloadAct: canDownload
? () => downloadPaymentAct(context, paymentRef)
: null,
);
},
),
);
}
Payment? _findPayment(List<Payment> payments, String paymentId) {
final trimmed = paymentId.trim();
if (trimmed.isEmpty) return null;
for (final payment in payments) {
if (payment.paymentRef == trimmed) return payment;
if (payment.idempotencyKey == trimmed) return payment;
}
return null;
}
void _handleBack(BuildContext context) {
final router = GoRouter.of(context);
if (router.canPop()) {
context.pop();
return;
}
context.go(PayoutRoutes.reportsPath);
}
}

View File

@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
class DetailRow extends StatelessWidget {
final String label;
final String value;
final bool multiline;
final bool monospaced;
const DetailRow({
super.key,
required this.label,
required this.value,
this.multiline = false,
this.monospaced = false,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final valueStyle = monospaced
? theme.textTheme.bodyMedium?.copyWith(fontFamily: 'monospace')
: theme.textTheme.bodyMedium;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: LayoutBuilder(
builder: (context, constraints) {
final isNarrow = constraints.maxWidth < 250;
if (isNarrow) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
SelectableText(value, style: valueStyle),
],
);
}
return Row(
crossAxisAlignment: multiline
? CrossAxisAlignment.start
: CrossAxisAlignment.center,
children: [
Expanded(
flex: 2,
child: Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: 8),
Expanded(
flex: 3,
child: SelectableText(value, style: valueStyle),
),
],
);
},
),
);
}
}

View File

@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
class DetailsSection extends StatelessWidget {
final String title;
final List<Widget> children;
const DetailsSection({
super.key,
required this.title,
required this.children,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
side: BorderSide(color: theme.dividerColor.withAlpha(25)),
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 12),
...children,
],
),
),
);
}
}

View File

@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/payment.dart';
import 'package:pweb/pages/report/details/sections/fx.dart';
import 'package:pweb/pages/report/details/sections/metadata.dart';
class PaymentDetailsSections extends StatelessWidget {
final Payment payment;
const PaymentDetailsSections({
super.key,
required this.payment,
});
@override
Widget build(BuildContext context) {
final hasFx = _hasFxQuote(payment);
if (!hasFx) {
return PaymentMetadataSection(payment: payment);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: PaymentFxSection(payment: payment)),
const SizedBox(width: 16),
Expanded(child: PaymentMetadataSection(payment: payment)),
],
);
}
bool _hasFxQuote(Payment payment) => payment.lastQuote?.fxQuote != null;
}

View File

@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/payment.dart';
import 'package:pshared/models/payment/fx/quote.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pweb/pages/report/details/section.dart';
import 'package:pweb/pages/report/details/sections/rows.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentFxSection extends StatelessWidget {
final Payment payment;
const PaymentFxSection({
super.key,
required this.payment,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final fx = payment.lastQuote?.fxQuote;
final rows = buildDetailRows([
DetailValue(
label: loc.fxRateLabel,
value: _formatRate(fx),
),
]);
return DetailsSection(
title: loc.paymentDetailsFx,
children: rows,
);
}
String? _formatRate(FxQuote? fx) {
if (fx == null) return null;
final price = fx.price?.trim();
if (price == null || price.isEmpty) return null;
final base = _firstNonEmpty([
currencySymbolFromCode(fx.baseCurrency),
currencySymbolFromCode(fx.baseAmount?.currency),
fx.baseCurrency,
fx.baseAmount?.currency,
]);
final quote = _firstNonEmpty([
currencySymbolFromCode(fx.quoteCurrency),
currencySymbolFromCode(fx.quoteAmount?.currency),
fx.quoteCurrency,
fx.quoteAmount?.currency,
]);
if (base == null || quote == null) {
return price;
}
return '1 $base = $price $quote';
}
String? _firstNonEmpty(List<String?> values) {
for (final value in values) {
final trimmed = value?.trim();
if (trimmed != null && trimmed.isNotEmpty) return trimmed;
}
return null;
}
}

View File

@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/payment.dart';
import 'package:pweb/pages/report/details/row.dart';
import 'package:pweb/pages/report/details/section.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentMetadataSection extends StatelessWidget {
final Payment payment;
const PaymentMetadataSection({
super.key,
required this.payment,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final metadata = payment.metadata ?? const {};
const allowedKeys = {'upload_filename', 'upload_rows'};
final filtered = Map<String, String>.fromEntries(
metadata.entries.where((entry) => allowedKeys.contains(entry.key)),
);
if (filtered.isEmpty) {
return DetailsSection(
title: loc.paymentDetailsMetadata,
children: [
Text(
loc.metadataEmpty,
style: Theme.of(context).textTheme.bodyMedium,
),
],
);
}
final entries = filtered.entries.toList()
..sort((a, b) => a.key.compareTo(b.key));
return DetailsSection(
title: loc.paymentDetailsMetadata,
children: entries
.map(
(entry) => DetailRow(
label: _metadataLabel(loc, entry.key),
value: entry.value,
monospaced: true,
),
)
.toList(),
);
}
}
String _metadataLabel(AppLocalizations loc, String key) {
switch (key) {
case 'upload_filename':
return loc.metadataUploadFileName;
case 'upload_rows':
return loc.metadataTotalRecipients;
default:
return key;
}
}

View File

@@ -0,0 +1,31 @@
import 'package:pweb/pages/report/details/row.dart';
class DetailValue {
final String label;
final String? value;
final bool multiline;
final bool monospaced;
const DetailValue({
required this.label,
required this.value,
this.multiline = false,
this.monospaced = false,
});
}
List<DetailRow> buildDetailRows(List<DetailValue> values) {
return values
.where((item) {
final value = item.value?.trim();
return value != null && value.isNotEmpty && value != '-';
})
.map((item) => DetailRow(
label: item.label,
value: item.value!.trim(),
multiline: item.multiline,
monospaced: item.monospaced,
))
.toList();
}

View File

@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentDetailsError extends StatelessWidget {
final String message;
final VoidCallback onRetry;
const PaymentDetailsError({
super.key,
required this.message,
required this.onRetry,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(loc.notificationError(message)),
const SizedBox(height: 12),
ElevatedButton(
onPressed: onRetry,
child: Text(loc.retry),
),
],
),
);
}
}

View File

@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentDetailsNotFound extends StatelessWidget {
final VoidCallback onBack;
const PaymentDetailsNotFound({
super.key,
required this.onBack,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
loc.paymentDetailsNotFound,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: onBack,
icon: const Icon(Icons.arrow_back),
label: Text(loc.back),
),
],
),
);
}
}

View File

@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
class AmountHeadline extends StatelessWidget {
final String amount;
final String currency;
const AmountHeadline({
required this.amount,
required this.currency,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final amountStyle = theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
);
final currencyStyle = theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
);
if (currency.isEmpty) {
return Text(amount, style: amountStyle);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(amount, style: amountStyle),
const SizedBox(width: 4),
Text(currency, style: currencyStyle),
],
);
}
}

View File

@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
class CopyableId extends StatelessWidget {
final String label;
final String value;
final VoidCallback onCopy;
const CopyableId({
required this.label,
required this.value,
required this.onCopy,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final labelStyle = theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
final valueStyle = theme.textTheme.labelLarge?.copyWith(
fontFamily: 'monospace',
);
return InkWell(
onTap: onCopy,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withAlpha(120),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.dividerColor.withAlpha(40)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: labelStyle),
const SizedBox(width: 8),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320),
child: Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: valueStyle,
),
),
const SizedBox(width: 8),
Icon(
Icons.copy_outlined,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
class InfoLine extends StatelessWidget {
final IconData icon;
final String text;
final bool muted;
const InfoLine({
required this.icon,
required this.text,
this.muted = false,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = muted
? theme.colorScheme.onSurfaceVariant
: theme.colorScheme.onSurface;
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
children: [
Icon(icon, size: 18, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: 6),
Expanded(
child: Text(
text,
style: theme.textTheme.bodyMedium?.copyWith(color: color),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/payment.dart';
import 'package:pweb/pages/report/details/summary_card/amount_headline.dart';
import 'package:pweb/pages/report/details/summary_card/copy_id.dart';
import 'package:pweb/pages/report/details/summary_card/info_line.dart';
import 'package:pweb/pages/report/table/badge.dart';
import 'package:pweb/utils/report/amount_parts.dart';
import 'package:pweb/utils/report/format.dart';
import 'package:pweb/utils/report/payment_mapper.dart';
import 'package:pweb/utils/clipboard.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentSummaryCard extends StatelessWidget {
final Payment payment;
final VoidCallback? onDownloadAct;
const PaymentSummaryCard({
super.key,
required this.payment,
this.onDownloadAct,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final status = statusFromPayment(payment);
final dateLabel = formatDateLabel(context, resolvePaymentDate(payment));
final primaryAmount = payment.lastQuote?.debitAmount ??
payment.lastQuote?.expectedSettlementAmount;
final toAmount = payment.lastQuote?.expectedSettlementAmount;
final fee = payment.lastQuote?.expectedFeeTotal ??
payment.lastQuote?.networkFee?.networkFee;
final amountLabel = formatMoney(primaryAmount);
final toAmountLabel = formatMoney(toAmount);
final feeLabel = formatMoney(fee);
final paymentRef = (payment.paymentRef ?? '').trim();
final showToAmount = toAmountLabel != '-' && toAmountLabel != amountLabel;
final showPaymentId = paymentRef.isNotEmpty;
final amountParts = splitAmount(amountLabel);
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
side: BorderSide(color: theme.dividerColor.withAlpha(25)),
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
OperationStatusBadge(status: status),
const Spacer(),
Text(
dateLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 12),
AmountHeadline(
amount: amountParts.amount,
currency: amountParts.currency,
),
const SizedBox(height: 6),
if (amountLabel != '-')
InfoLine(
icon: Icons.send_outlined,
text: loc.sentAmount(amountLabel),
),
if (showToAmount && toAmountLabel != '-')
InfoLine(
icon: Icons.south_east,
text: loc.recipientWillReceive(toAmountLabel),
),
if (feeLabel != '-')
InfoLine(
icon: Icons.receipt_long_outlined,
text: loc.fee(feeLabel),
muted: true,
),
if (onDownloadAct != null) ...[
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: onDownloadAct,
icon: const Icon(Icons.download),
label: Text(loc.downloadAct),
),
],
if (showPaymentId) ...[
const SizedBox(height: 16),
Divider(color: theme.dividerColor.withAlpha(35), height: 1),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: CopyableId(
label: loc.paymentIdLabel,
value: paymentRef,
onCopy: () => copyToClipboard(
context,
paymentRef,
loc.paymentIdCopied,
),
),
),
],
],
),
),
);
}
}

View File

@@ -3,45 +3,41 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/payment/operation.dart';
import 'package:pshared/models/payment/payment.dart';
import 'package:pshared/models/payment/status.dart';
import 'package:pshared/provider/payment/payments.dart';
import 'package:pweb/pages/report/charts/distribution.dart';
import 'package:pweb/pages/report/cards/list.dart';
import 'package:pweb/pages/report/charts/payout_volumes/chart.dart';
import 'package:pweb/pages/report/charts/status.dart';
import 'package:pweb/controllers/report_operations.dart';
import 'package:pweb/pages/report/table/filters.dart';
import 'package:pweb/pages/report/table/widget.dart';
import 'package:pweb/utils/report/payment_mapper.dart';
import 'package:pweb/app/router/payout_routes.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class OperationHistoryPage extends StatefulWidget {
class OperationHistoryPage extends StatelessWidget {
const OperationHistoryPage({super.key});
@override
State<OperationHistoryPage> createState() => _OperationHistoryPageState();
Widget build(BuildContext context) {
return ChangeNotifierProxyProvider<PaymentsProvider, ReportOperationsController>(
create: (_) => ReportOperationsController(),
update: (_, payments, controller) => controller!..update(payments),
child: const _OperationHistoryView(),
);
}
}
class _OperationHistoryPageState extends State<OperationHistoryPage> {
DateTimeRange? _pendingRange;
DateTimeRange? _appliedRange;
final Set<OperationStatus> _pendingStatuses = {};
Set<OperationStatus> _appliedStatuses = {};
class _OperationHistoryView extends StatelessWidget {
const _OperationHistoryView();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final provider = context.read<PaymentsProvider>();
if (!provider.isReady && !provider.isLoading) {
provider.refresh();
}
});
}
Future<void> _pickRange() async {
Future<void> _pickRange(
BuildContext context,
ReportOperationsController controller,
) async {
final now = DateTime.now();
final initial = _pendingRange ??
final initial = controller.selectedRange ??
DateTimeRange(
start: now.subtract(const Duration(days: 30)),
end: now,
@@ -55,147 +51,36 @@ class _OperationHistoryPageState extends State<OperationHistoryPage> {
);
if (picked != null) {
setState(() {
_pendingRange = picked;
});
controller.setRange(picked);
}
}
void _toggleStatus(OperationStatus status) {
setState(() {
if (_pendingStatuses.contains(status)) {
_pendingStatuses.remove(status);
} else {
_pendingStatuses.add(status);
}
});
void _openPaymentDetails(BuildContext context, OperationItem operation) {
final paymentId = paymentIdFromOperation(operation);
if (paymentId == null) return;
context.pushToReportPayment(paymentId);
}
void _applyFilters() {
setState(() {
_appliedRange = _pendingRange;
_appliedStatuses = {..._pendingStatuses};
});
}
List<OperationItem> _mapPayments(List<Payment> payments) {
return payments.map(_mapPayment).toList();
}
OperationItem _mapPayment(Payment payment) {
final debit = payment.lastQuote?.debitAmount;
final settlement = payment.lastQuote?.expectedSettlementAmount;
final amountMoney = debit ?? settlement;
final amount = _parseAmount(amountMoney?.amount);
final currency = amountMoney?.currency ?? '';
final toAmount = settlement == null ? amount : _parseAmount(settlement.amount);
final toCurrency = settlement?.currency ?? currency;
final payId = _firstNonEmpty([payment.paymentRef, payment.idempotencyKey]) ?? '-';
final name = _firstNonEmpty([payment.lastQuote?.quoteRef, payment.paymentRef, payment.idempotencyKey]) ?? '-';
final comment = _firstNonEmpty([payment.failureReason, payment.failureCode, payment.state]) ?? '';
return OperationItem(
status: _statusFromPaymentState(payment.state),
fileName: null,
amount: amount,
currency: currency,
toAmount: toAmount,
toCurrency: toCurrency,
payId: payId,
paymentRef: payment.paymentRef,
cardNumber: null,
name: name,
date: _resolvePaymentDate(payment),
comment: comment,
);
}
List<OperationItem> _filterOperations(List<OperationItem> operations) {
if (_appliedRange == null && _appliedStatuses.isEmpty) {
return operations;
}
return operations.where((op) {
final statusMatch =
_appliedStatuses.isEmpty || _appliedStatuses.contains(op.status);
final dateMatch = _appliedRange == null ||
_isUnknownDate(op.date) ||
(op.date.isAfter(_appliedRange!.start.subtract(const Duration(seconds: 1))) &&
op.date.isBefore(_appliedRange!.end.add(const Duration(seconds: 1))));
return statusMatch && dateMatch;
}).toList();
}
OperationStatus _statusFromPaymentState(String? raw) {
final state = raw?.trim().toLowerCase();
switch (state) {
case 'accepted':
case 'funds_reserved':
case 'submitted':
case 'unspecified':
case null:
return OperationStatus.processing;
case 'settled':
case 'success':
return OperationStatus.success;
case 'failed':
case 'cancelled':
return OperationStatus.error;
default:
// Future-proof: any new backend state is treated as processing
return OperationStatus.processing;
}
}
DateTime _resolvePaymentDate(Payment payment) {
final expiresAt = payment.lastQuote?.fxQuote?.expiresAtUnixMs;
if (expiresAt != null && expiresAt > 0) {
return DateTime.fromMillisecondsSinceEpoch(expiresAt, isUtc: true);
}
return DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
}
double _parseAmount(String? amount) {
if (amount == null || amount.trim().isEmpty) return 0;
return double.tryParse(amount) ?? 0;
}
String? _firstNonEmpty(List<String?> values) {
for (final value in values) {
final trimmed = value?.trim();
if (trimmed != null && trimmed.isNotEmpty) return trimmed;
}
return null;
}
bool _isUnknownDate(DateTime date) => date.millisecondsSinceEpoch == 0;
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
return Consumer<PaymentsProvider>(
builder: (context, provider, child) {
if (provider.isLoading) {
return Consumer<ReportOperationsController>(
builder: (context, controller, child) {
if (controller.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (provider.error != null) {
final message = provider.error?.toString() ?? loc.noErrorInformation;
if (controller.error != null) {
final message =
controller.error?.toString() ?? loc.noErrorInformation;
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(loc.notificationError(message)),
ElevatedButton(
onPressed: () => provider.refresh(),
onPressed: () => controller.refresh(),
child: Text(loc.retry),
),
],
@@ -203,11 +88,8 @@ class _OperationHistoryPageState extends State<OperationHistoryPage> {
);
}
final operations = _mapPayments(provider.payments);
final filteredOperations = _filterOperations(operations);
final hasFileName = operations.any(
(operation) => (operation.fileName ?? '').trim().isNotEmpty,
);
final operations = controller.operations;
final filteredOperations = controller.filteredOperations;
return Padding(
padding: const EdgeInsets.all(16.0),
@@ -215,6 +97,7 @@ class _OperationHistoryPageState extends State<OperationHistoryPage> {
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 16,
children: [
//TODO Make charts more useful and re-enable
SizedBox(
height: 200,
child: Row(
@@ -224,7 +107,7 @@ class _OperationHistoryPageState extends State<OperationHistoryPage> {
child: StatusChart(operations: operations),
),
Expanded(
child: PayoutDistributionChart(
child: PayoutVolumesChart(
operations: operations,
),
),
@@ -232,15 +115,15 @@ class _OperationHistoryPageState extends State<OperationHistoryPage> {
),
),
OperationFilters(
selectedRange: _pendingRange,
selectedStatuses: _pendingStatuses,
onPickRange: _pickRange,
onToggleStatus: _toggleStatus,
onApply: _applyFilters,
selectedRange: controller.selectedRange,
selectedStatuses: controller.selectedStatuses,
onPickRange: () => _pickRange(context, controller),
onToggleStatus: controller.toggleStatus,
onClear: controller.clearFilters,
),
OperationsTable(
OperationsCardsList(
operations: filteredOperations,
showFileNameColumn: hasFileName,
onTap: (operation) => _openPaymentDetails(context, operation),
),
],
),

View File

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

View File

@@ -1,70 +1,80 @@
import 'package:flutter/material.dart';
import 'package:badges/badges.dart' as badges; // Make sure to add badges package in pubspec.yaml
import 'package:pshared/models/payment/status.dart';
import 'package:pshared/utils/localization.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class OperationFilters extends StatelessWidget {
final DateTimeRange? selectedRange;
final Set<OperationStatus> selectedStatuses;
final VoidCallback onPickRange;
final VoidCallback onApply;
final ValueChanged<OperationStatus> onToggleStatus;
final VoidCallback onClear;
const OperationFilters({
super.key,
required this.selectedRange,
required this.selectedStatuses,
required this.onPickRange,
required this.onApply,
required this.onToggleStatus,
required this.onClear,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final hasActive = selectedRange != null || selectedStatuses.isNotEmpty;
final periodLabel = selectedRange == null
? l10n.selectPeriod
: '${dateToLocalFormat(context, selectedRange!.start)} ${dateToLocalFormat(context, selectedRange!.end)}';
return Card(
margin: const EdgeInsets.all(16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 2,
elevation: 0,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.filters,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 12),
GestureDetector(
onTap: onPickRange,
child: Row(
children: [
Icon(Icons.date_range_outlined, color: Theme.of(context).primaryColor),
const SizedBox(width: 8),
Expanded(
child: Text(
selectedRange == null
? l10n.selectPeriod
: '${dateToLocalFormat(context, selectedRange!.start)} ${dateToLocalFormat(context, selectedRange!.end)}',
style: TextStyle(
color: selectedRange == null
? Colors.grey
: Colors.black87,
),
),
Row(
children: [
Text(
l10n.filters,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
Icon(Icons.keyboard_arrow_down, color: Colors.grey),
],
),
const Spacer(),
if (hasActive)
TextButton.icon(
onPressed: onClear,
icon: const Icon(Icons.close, size: 16),
label: Text(l10n.reset),
),
],
),
const SizedBox(height: 10),
OutlinedButton.icon(
onPressed: onPickRange,
icon: const Icon(Icons.date_range_outlined, size: 18),
label: Text(
periodLabel,
overflow: TextOverflow.ellipsis,
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
),
const SizedBox(height: 16),
const SizedBox(height: 12),
Wrap(
spacing: 12,
spacing: 10,
runSpacing: 8,
children: const [
OperationStatus.success,
@@ -73,51 +83,28 @@ class OperationFilters extends StatelessWidget {
].map((status) {
final label = status.localized(context);
final isSelected = selectedStatuses.contains(status);
return GestureDetector(
onTap: () => onToggleStatus(status),
child: badges.Badge(
badgeAnimation: badges.BadgeAnimation.fade(),
badgeStyle: badges.BadgeStyle(
shape: badges.BadgeShape.square,
badgeColor: isSelected
? Theme.of(context).primaryColor
: Colors.grey.shade300,
borderRadius: BorderRadius.circular(8),
),
badgeContent: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: Text(
l10n.status(label),
style: TextStyle(
color: isSelected ? Colors.white : Colors.black87,
fontSize: 14,
),
),
),
return FilterChip(
label: Text(l10n.status(label)),
selected: isSelected,
onSelected: (_) => onToggleStatus(status),
selectedColor: theme.colorScheme.primaryContainer,
checkmarkColor: theme.colorScheme.onPrimaryContainer,
labelStyle: theme.textTheme.bodySmall?.copyWith(
color: isSelected
? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onSurface,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
side: BorderSide(
color: isSelected
? theme.colorScheme.primaryContainer
: theme.dividerColor.withAlpha(60),
),
);
}).toList(),
),
const SizedBox(height: 24),
Align(
alignment: Alignment.centerRight,
child: ElevatedButton(
onPressed: onApply,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Text(l10n.apply),
),
),
],
),
),

View File

@@ -1,17 +1,13 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/payment/operation.dart';
import 'package:pshared/models/payment/status.dart';
import 'package:pshared/provider/organizations.dart';
import 'package:pshared/service/payment/documents.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
import 'package:pweb/pages/report/table/badge.dart';
import 'package:pweb/utils/download.dart';
import 'package:pweb/utils/error/snackbar.dart';
import 'package:pweb/utils/report/download_act.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class OperationRow {
@@ -29,7 +25,7 @@ class OperationRow {
final documentCell = canDownload
? TextButton.icon(
onPressed: () => _downloadAct(context, op),
onPressed: () => downloadPaymentAct(context, op.paymentRef ?? ''),
icon: const Icon(Icons.download),
label: Text(loc.downloadAct),
)
@@ -47,28 +43,4 @@ class OperationRow {
DataCell(Text(op.comment)),
]);
}
static Future<void> _downloadAct(BuildContext context, OperationItem op) async {
final organizations = context.read<OrganizationsProvider>();
if (!organizations.isOrganizationSet) {
return;
}
final paymentRef = (op.paymentRef ?? '').trim();
if (paymentRef.isEmpty) {
return;
}
final loc = AppLocalizations.of(context)!;
await executeActionWithNotification(
context: context,
action: () async {
final file = await PaymentDocumentsService.getAct(
organizations.current.id,
paymentRef,
);
await downloadFile(file);
},
errorMessage: loc.downloadActError,
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,117 @@
import 'package:pshared/models/payment/operation.dart';
import 'package:pshared/models/payment/payment.dart';
import 'package:pshared/models/payment/status.dart';
OperationItem mapPaymentToOperation(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: 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 normalized = _normalizePaymentState(payment.state);
switch (normalized) {
case 'SUCCESS':
return OperationStatus.success;
case 'FAILED':
case 'CANCELLED':
return OperationStatus.error;
default:
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();
if (trimmed != null && trimmed.isNotEmpty) return trimmed;
}
return null;
}
String _normalizePaymentState(String? raw) {
Review

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

по идее это должен быть просто enum с mapper'ом.
final trimmed = (raw ?? '').trim().toUpperCase();
if (trimmed.startsWith('PAYMENT_STATE_')) {
return trimmed.substring('PAYMENT_STATE_'.length);
}
return trimmed;
}
double _parseAmount(String? amount) {
Outdated
Review

должен быть где-то стандартный разбиральщик Amount, типа DTO. Похоже на дублирование кода

должен быть где-то стандартный разбиральщик Amount, типа DTO. Похоже на дублирование кода
if (amount == null || amount.trim().isEmpty) return 0;
return double.tryParse(amount) ?? 0;
}

View File

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