small fixes for single payout and big chunck for multiple payouts

This commit is contained in:
Arseni
2026-02-05 21:58:37 +03:00
parent 8034847e46
commit b9748b8ab2
37 changed files with 1708 additions and 224 deletions

View File

@@ -1,5 +1,13 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pweb/controllers/multiple_payouts.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/source_quote_panel.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/upload_panel.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -8,11 +16,11 @@ class UploadCSVSection extends StatelessWidget {
static const double _verticalSpacing = 10;
static const double _iconTextSpacing = 5;
static const double _buttonVerticalPadding = 12;
static const double _buttonHorizontalPadding = 24;
@override
Widget build(BuildContext context) {
final controller = context.watch<MultiplePayoutsController>();
final walletsController = context.watch<WalletsController>();
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context)!;
@@ -34,34 +42,57 @@ class UploadCSVSection extends StatelessWidget {
),
const SizedBox(height: _verticalSpacing),
Container(
height: 140,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.outline),
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.upload_file, size: 36, color: theme.colorScheme.primary),
const SizedBox(height: 8),
ElevatedButton(
onPressed: () {},
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: _buttonHorizontalPadding,
vertical: _buttonVerticalPadding,
child: LayoutBuilder(
builder: (context, constraints) {
final useHorizontal = constraints.maxWidth >= 760;
if (!useHorizontal) {
return Column(
children: [
UploadPanel(
controller: controller,
theme: theme,
l10n: l10n,
),
const SizedBox(height: 12),
SourceQuotePanel(
controller: controller,
walletsController: walletsController,
theme: theme,
l10n: l10n,
),
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 6,
child: UploadPanel(
controller: controller,
theme: theme,
l10n: l10n,
),
),
child: Text(l10n.upload),
),
const SizedBox(height: 8),
Text(
l10n.hintUpload,
style: const TextStyle(fontSize: 12),
),
],
),
const SizedBox(width: 12),
Expanded(
flex: 5,
child: SourceQuotePanel(
controller: controller,
walletsController: walletsController,
theme: theme,
l10n: l10n,
),
),
],
);
},
),
),
],

View File

@@ -1,13 +1,17 @@
class MultiplePayoutRow {
final String token;
final String pan;
final String firstName;
final String lastName;
final int expMonth;
final int expYear;
final String amount;
final String currency;
final String comment;
const MultiplePayoutRow({
required this.token,
required this.pan,
required this.firstName,
required this.lastName,
required this.expMonth,
required this.expYear,
required this.amount,
required this.currency,
required this.comment,
});
}

View File

@@ -1,9 +1,12 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
import 'package:intl/intl.dart';
import 'package:pweb/providers/upload_history.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/payment/payments.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class UploadHistorySection extends StatelessWidget {
@@ -14,18 +17,28 @@ class UploadHistorySection extends StatelessWidget {
@override
Widget build(BuildContext context) {
final provider = context.watch<UploadHistoryProvider>();
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));
return Text(
l10.notificationError(provider.error ?? l10.noErrorInformation),
);
}
final items = provider.data ?? [];
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: [
@@ -36,33 +49,72 @@ class UploadHistorySection extends StatelessWidget {
Text(l10.uploadHistory, style: theme.textTheme.bodyLarge),
],
),
DataTable(
columns: [
DataColumn(label: Text(l10.fileNameColumn)),
DataColumn(label: Text(l10.colStatus)),
DataColumn(label: Text(l10.dateColumn)),
DataColumn(label: Text(l10.details)),
],
rows: items.map((file) {
final isError = file.status == "Error";
final statusColor = isError ? Colors.red : Colors.green;
return DataRow(
cells: [
DataCell(Text(file.name)),
DataCell(Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: statusColor.withAlpha(20),
borderRadius: BorderRadius.circular(_radius),
const SizedBox(height: 8),
if (items.isEmpty)
Align(
alignment: Alignment.centerLeft,
child: Text(
l10.walletHistoryEmpty,
style: theme.textTheme.bodyMedium,
),
)
else
DataTable(
columns: [
DataColumn(label: Text(l10.fileNameColumn)),
DataColumn(label: Text(l10.rowsColumn)),
DataColumn(label: Text(l10.dateColumn)),
DataColumn(label: Text(l10.amountColumn)),
DataColumn(label: Text(l10.statusColumn)),
],
rows: items.map((payment) {
final metadata = payment.metadata;
final state = payment.state ?? '-';
final statusColor =
payment.isFailure ? Colors.red : Colors.green;
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 amountValue = metadata?['upload_amount'];
final amountCurrency = metadata?['upload_currency'];
final fallbackAmount = payment.lastQuote?.debitAmount;
final amountText = (amountValue == null || amountValue.isEmpty)
? (fallbackAmount == null
? '-'
: '${fallbackAmount.amount} ${fallbackAmount.currency}')
: (amountCurrency == null || amountCurrency.isEmpty
? amountValue
: '$amountValue $amountCurrency');
return DataRow(
cells: [
DataCell(Text(fileNameText)),
DataCell(Text(rowsText)),
DataCell(Text(dateText)),
DataCell(Text(amountText)),
DataCell(
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: statusColor.withAlpha(20),
borderRadius: BorderRadius.circular(_radius),
),
child: Text(state, style: TextStyle(color: statusColor)),
),
),
child: Text(file.status, style: TextStyle(color: statusColor)),
)),
DataCell(Text(file.time)),
DataCell(TextButton(onPressed: () {}, child: Text(l10.showDetails))),
],
);
}).toList(),
),
],
);
}).toList(),
),
],
);
}

View File

@@ -1,18 +1,47 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
import 'package:pshared/models/file/downloaded_file.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/form.dart';
import 'package:pweb/utils/download.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class FileFormatSampleSection extends StatelessWidget {
const FileFormatSampleSection({super.key});
static final List<MultiplePayoutRow> sampleRows = [
MultiplePayoutRow(token: "d921...161", amount: "500", currency: "RUB", comment: "cashback001"),
MultiplePayoutRow(token: "d921...162", amount: "100", currency: "USD", comment: "cashback002"),
MultiplePayoutRow(token: "d921...163", amount: "120", currency: "EUR", comment: "cashback003"),
MultiplePayoutRow(
pan: "9022****11",
firstName: "Alex",
lastName: "Ivanov",
expMonth: 12,
expYear: 27,
amount: "500",
),
MultiplePayoutRow(
pan: "9022****12",
firstName: "Maria",
lastName: "Sokolova",
expMonth: 7,
expYear: 26,
amount: "100",
),
MultiplePayoutRow(
pan: "9022****13",
firstName: "Dmitry",
lastName: "Smirnov",
expMonth: 3,
expYear: 28,
amount: "120",
),
];
static const String _sampleFileName = 'sample.csv';
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -41,7 +70,7 @@ class FileFormatSampleSection extends StatelessWidget {
_buildDataTable(l10n),
const SizedBox(height: 10),
TextButton(
onPressed: () {},
onPressed: _downloadSampleCsv,
style: TextButton.styleFrom(padding: EdgeInsets.zero),
child: Text(l10n.downloadSampleCSV, style: linkStyle),
),
@@ -53,19 +82,44 @@ class FileFormatSampleSection extends StatelessWidget {
return DataTable(
columnSpacing: 20,
columns: [
DataColumn(label: Text(l10n.tokenColumn)),
DataColumn(label: Text(l10n.cardNumberColumn)),
DataColumn(label: Text(l10n.firstName)),
DataColumn(label: Text(l10n.lastName)),
DataColumn(label: Text(l10n.expiryDate)),
DataColumn(label: Text(l10n.amount)),
DataColumn(label: Text(l10n.currency)),
DataColumn(label: Text(l10n.comment)),
],
rows: sampleRows.map((row) {
return DataRow(cells: [
DataCell(Text(row.token)),
DataCell(Text(row.amount)),
DataCell(Text(row.currency)),
DataCell(Text(row.comment)),
]);
return DataRow(
cells: [
DataCell(Text(row.pan)),
DataCell(Text(row.firstName)),
DataCell(Text(row.lastName)),
DataCell(
Text('${row.expMonth.toString().padLeft(2, '0')}/${row.expYear}'),
),
DataCell(Text(row.amount)),
],
);
}).toList(),
);
}
}
Future<void> _downloadSampleCsv() async {
final rows = <String>[
'pan,first_name,last_name,exp_month,exp_year,amount',
...sampleRows.map(
(row) =>
'${row.pan},${row.firstName},${row.lastName},${row.expMonth},${row.expYear},${row.amount}',
),
];
final content = rows.join('\n');
await downloadFile(
DownloadedFile(
bytes: utf8.encode(content),
filename: _sampleFileName,
mimeType: 'text/csv;charset=utf-8',
),
);
}
}

View File

@@ -0,0 +1,134 @@
import 'package:flutter/material.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pshared/models/money.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pweb/controllers/multiple_payouts.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class SourceQuotePanel extends StatelessWidget {
const SourceQuotePanel({
super.key,
required this.controller,
required this.walletsController,
required this.theme,
required this.l10n,
});
final MultiplePayoutsController controller;
final WalletsController walletsController;
final ThemeData theme;
final AppLocalizations l10n;
@override
Widget build(BuildContext context) {
final wallets = walletsController.wallets;
final selectedWalletRef = walletsController.selectedWalletRef;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.sourceOfFunds,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
if (wallets.isEmpty)
Text(l10n.noWalletsAvailable, style: theme.textTheme.bodySmall)
else
DropdownButtonFormField<String>(
initialValue: selectedWalletRef,
isExpanded: true,
decoration: InputDecoration(
labelText: l10n.whereGetMoney,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
items: wallets
.map(
(wallet) => DropdownMenuItem<String>(
value: wallet.id,
child: Text(
'${wallet.name} · ${amountToString(wallet.balance)} ${currencyCodeToString(wallet.currency)}',
overflow: TextOverflow.ellipsis,
),
),
)
.toList(growable: false),
onChanged: controller.isBusy
? null
: (value) {
if (value == null) return;
walletsController.selectWalletByRef(value);
},
),
const SizedBox(height: 12),
const Divider(height: 1),
const SizedBox(height: 12),
Text(
controller.aggregateDebitAmount == null
? l10n.quoteUnavailable
: l10n.quoteActive,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
l10n.sentAmount(_sentAmountLabel(controller)),
style: theme.textTheme.bodyMedium,
),
Text(
l10n.recipientsWillReceive(
_moneyLabel(controller.aggregateSettlementAmount),
),
style: theme.textTheme.bodyMedium,
),
Text(
controller.aggregateFeePercent == null
? l10n.fee(_moneyLabel(controller.aggregateFeeAmount))
: '${l10n.fee(_moneyLabel(controller.aggregateFeeAmount))} (${controller.aggregateFeePercent!.toStringAsFixed(2)}%)',
style: theme.textTheme.bodyMedium,
),
],
),
);
}
String _moneyLabel(Money? money) {
if (money == null) return '-';
return '${money.amount} ${money.currency}';
}
String _sentAmountLabel(MultiplePayoutsController controller) {
final requested = controller.requestedSentAmount;
final sourceDebit = controller.aggregateDebitAmount;
if (requested == null && sourceDebit == null) return '-';
if (requested == null) return _moneyLabel(sourceDebit);
if (sourceDebit == null) return _moneyLabel(requested);
if (requested.currency.toUpperCase() ==
sourceDebit.currency.toUpperCase()) {
return _moneyLabel(sourceDebit);
}
return '${_moneyLabel(requested)} (${_moneyLabel(sourceDebit)})';
}
}

View File

@@ -0,0 +1,160 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/payment/payments.dart';
import 'package:pweb/controllers/multiple_payouts.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
//TODO this file is too long
class UploadPanel extends StatelessWidget {
const UploadPanel({
super.key,
required this.controller,
required this.theme,
required this.l10n,
});
final MultiplePayoutsController controller;
final ThemeData theme;
final AppLocalizations l10n;
static const double _buttonVerticalPadding = 12;
static const double _buttonHorizontalPadding = 24;
@override
Widget build(BuildContext context) {
return Column(
children: [
Icon(Icons.upload_file, size: 36, color: theme.colorScheme.primary),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: [
ElevatedButton(
onPressed: controller.isBusy
? null
: () => context
.read<MultiplePayoutsController>()
.pickAndQuote(),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: _buttonHorizontalPadding,
vertical: _buttonVerticalPadding,
),
),
child: Text(l10n.upload),
),
ElevatedButton(
onPressed: controller.canSend
? () => _handleSend(context)
: null,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: _buttonHorizontalPadding,
vertical: _buttonVerticalPadding,
),
),
child: Text(l10n.send),
),
],
),
const SizedBox(height: 8),
Text(
l10n.hintUpload,
style: const TextStyle(fontSize: 12),
textAlign: TextAlign.center,
),
if (controller.isQuoting || controller.isSending) ...[
const SizedBox(height: 10),
const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
if (controller.selectedFileName != null) ...[
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
'${controller.selectedFileName} · ${controller.rows.length}',
style: theme.textTheme.bodySmall,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
tooltip: l10n.close,
visualDensity: VisualDensity.compact,
onPressed: controller.isBusy
? null
: () => context
.read<MultiplePayoutsController>()
.removeUploadedFile(),
icon: const Icon(Icons.close, size: 18),
),
],
),
],
if (controller.sentCount > 0) ...[
const SizedBox(height: 8),
Text(
'${l10n.payout}: ${controller.sentCount}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary,
),
),
],
if (controller.error != null) ...[
const SizedBox(height: 8),
Text(
controller.error.toString(),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
],
);
}
Future<void> _handleSend(BuildContext context) async {
final paymentsProvider = context.read<PaymentsProvider>();
final result = await controller.send();
paymentsProvider.addPayments(result);
await paymentsProvider.refresh();
if (!context.mounted) return;
final isSuccess = controller.error == null && result.isNotEmpty;
await showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: Text(
isSuccess
? l10n.paymentStatusSuccessTitle
: l10n.paymentStatusFailureTitle,
),
content: Text(
isSuccess
? l10n.paymentStatusSuccessMessage
: l10n.paymentStatusFailureMessage,
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.close),
),
],
),
);
controller.removeUploadedFile();
}
}