style(app): остальные экраны под новый визуальный язык и форматирование
Доходы, ребалансировка, налоги, импорт, цели, здоровье данных, правила, транзакции, категории, cashflow, pending: новые виджеты и токены темы, dart format. Тесты подстроены под новые модели.
This commit is contained in:
@@ -17,14 +17,20 @@ import '../../pending/data/pending_api.dart';
|
||||
|
||||
/// A candidate account for an import whose `account_id` the server could not resolve.
|
||||
class AccountSuggestion {
|
||||
const AccountSuggestion({required this.id, required this.name, this.broker, this.sourceId});
|
||||
const AccountSuggestion({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.broker,
|
||||
this.sourceId,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
final String? broker;
|
||||
final String? sourceId;
|
||||
|
||||
static AccountSuggestion fromJson(Map<String, dynamic> json) => AccountSuggestion(
|
||||
static AccountSuggestion fromJson(Map<String, dynamic> json) =>
|
||||
AccountSuggestion(
|
||||
id: asInt(json['id'])!,
|
||||
name: asString(json['name']) ?? '#${json['id']}',
|
||||
broker: asString(json['broker']),
|
||||
@@ -97,15 +103,15 @@ class ReconPosition {
|
||||
String get title => instrumentName ?? ticker ?? isin ?? '—';
|
||||
|
||||
static ReconPosition fromJson(Map<String, dynamic> json) => ReconPosition(
|
||||
matches: json['matches'] == true,
|
||||
instrumentId: asInt(json['instrument_id']),
|
||||
instrumentName: asString(json['instrument_name']),
|
||||
ticker: asString(json['ticker']),
|
||||
isin: asString(json['isin']),
|
||||
qtyReport: asString(json['qty_report']),
|
||||
qtyDerived: asString(json['qty_derived']),
|
||||
qtyDelta: asString(json['qty_delta']),
|
||||
);
|
||||
matches: json['matches'] == true,
|
||||
instrumentId: asInt(json['instrument_id']),
|
||||
instrumentName: asString(json['instrument_name']),
|
||||
ticker: asString(json['ticker']),
|
||||
isin: asString(json['isin']),
|
||||
qtyReport: asString(json['qty_report']),
|
||||
qtyDerived: asString(json['qty_derived']),
|
||||
qtyDelta: asString(json['qty_delta']),
|
||||
);
|
||||
}
|
||||
|
||||
class ReconCash {
|
||||
@@ -124,12 +130,12 @@ class ReconCash {
|
||||
final String? delta;
|
||||
|
||||
static ReconCash fromJson(Map<String, dynamic> json) => ReconCash(
|
||||
currency: asString(json['currency']) ?? 'RUB',
|
||||
matches: json['matches'] == true,
|
||||
balanceReport: asString(json['balance_report']),
|
||||
balanceDerived: asString(json['balance_derived']),
|
||||
delta: asString(json['delta']),
|
||||
);
|
||||
currency: asString(json['currency']) ?? 'RUB',
|
||||
matches: json['matches'] == true,
|
||||
balanceReport: asString(json['balance_report']),
|
||||
balanceDerived: asString(json['balance_derived']),
|
||||
delta: asString(json['delta']),
|
||||
);
|
||||
}
|
||||
|
||||
class Reconciliation {
|
||||
@@ -197,23 +203,23 @@ class SampleEvent {
|
||||
final String? description;
|
||||
|
||||
static SampleEvent fromJson(Map<String, dynamic> json) => SampleEvent(
|
||||
lineNo: asInt(json['line_no']) ?? 0,
|
||||
kind: asString(json['kind']) ?? 'other',
|
||||
isDuplicate: json['is_duplicate'] == true,
|
||||
tradeDate: asDate(json['trade_date']),
|
||||
settleDate: asDate(json['settle_date']),
|
||||
instrumentKey: asString(json['instrument_key']),
|
||||
instrumentName: asString(json['instrument_name']),
|
||||
instrumentId: asInt(json['instrument_id']),
|
||||
quantity: asString(json['quantity']),
|
||||
price: asString(json['price']),
|
||||
amount: asString(json['amount']),
|
||||
currency: asString(json['currency']),
|
||||
fee: asString(json['fee']),
|
||||
tradeNo: asString(json['trade_no']),
|
||||
dedupeKey: asString(json['dedupe_key']),
|
||||
description: asString(json['description']),
|
||||
);
|
||||
lineNo: asInt(json['line_no']) ?? 0,
|
||||
kind: asString(json['kind']) ?? 'other',
|
||||
isDuplicate: json['is_duplicate'] == true,
|
||||
tradeDate: asDate(json['trade_date']),
|
||||
settleDate: asDate(json['settle_date']),
|
||||
instrumentKey: asString(json['instrument_key']),
|
||||
instrumentName: asString(json['instrument_name']),
|
||||
instrumentId: asInt(json['instrument_id']),
|
||||
quantity: asString(json['quantity']),
|
||||
price: asString(json['price']),
|
||||
amount: asString(json['amount']),
|
||||
currency: asString(json['currency']),
|
||||
fee: asString(json['fee']),
|
||||
tradeNo: asString(json['trade_no']),
|
||||
dedupeKey: asString(json['dedupe_key']),
|
||||
description: asString(json['description']),
|
||||
);
|
||||
}
|
||||
|
||||
/// `ImportPreview` and `ImportSummary` in one class: the summary is the same object without
|
||||
@@ -281,34 +287,38 @@ class ImportPreview {
|
||||
bool get canDelete => !isCommitted;
|
||||
|
||||
static ImportPreview fromJson(Map<String, dynamic> json) => ImportPreview(
|
||||
id: asInt(json['id'])!,
|
||||
filename: asString(json['filename']) ?? 'без имени',
|
||||
parseStatus: asString(json['parse_status']) ?? 'uploaded',
|
||||
broker: asString(json['broker']),
|
||||
sha256: asString(json['sha256']),
|
||||
sizeBytes: asInt(json['size_bytes']),
|
||||
parserName: asString(json['parser_name']),
|
||||
parserVersion: asString(json['parser_version']),
|
||||
error: asString(json['error']),
|
||||
duplicateOfId: asInt(json['duplicate_of_id']),
|
||||
accountExternalId: asString(json['account_external_id']),
|
||||
accountId: asInt(json['account_id']),
|
||||
accountName: asString(json['account_name']),
|
||||
accountSuggestions:
|
||||
asList(json['account_suggestions']).map(AccountSuggestion.fromJson).toList(),
|
||||
periodFrom: asDate(json['period_from']),
|
||||
periodTo: asDate(json['period_to']),
|
||||
uploadedAt: asDate(json['uploaded_at']),
|
||||
committedAt: asDate(json['committed_at']),
|
||||
counts: ImportCounts.fromJson(asMap(json['counts'])),
|
||||
pendingInstruments:
|
||||
asList(json['pending_instruments']).map(PendingInstrument.fromJson).toList(),
|
||||
reconciliation: Reconciliation.fromJson(asMap(json['reconciliation'])),
|
||||
warnings: json['warnings'] is List
|
||||
? (json['warnings'] as List).map((e) => e.toString()).toList()
|
||||
: const [],
|
||||
sampleEvents: asList(json['sample_events']).map(SampleEvent.fromJson).toList(),
|
||||
);
|
||||
id: asInt(json['id'])!,
|
||||
filename: asString(json['filename']) ?? 'без имени',
|
||||
parseStatus: asString(json['parse_status']) ?? 'uploaded',
|
||||
broker: asString(json['broker']),
|
||||
sha256: asString(json['sha256']),
|
||||
sizeBytes: asInt(json['size_bytes']),
|
||||
parserName: asString(json['parser_name']),
|
||||
parserVersion: asString(json['parser_version']),
|
||||
error: asString(json['error']),
|
||||
duplicateOfId: asInt(json['duplicate_of_id']),
|
||||
accountExternalId: asString(json['account_external_id']),
|
||||
accountId: asInt(json['account_id']),
|
||||
accountName: asString(json['account_name']),
|
||||
accountSuggestions: asList(json['account_suggestions'])
|
||||
.map(AccountSuggestion.fromJson)
|
||||
.toList(),
|
||||
periodFrom: asDate(json['period_from']),
|
||||
periodTo: asDate(json['period_to']),
|
||||
uploadedAt: asDate(json['uploaded_at']),
|
||||
committedAt: asDate(json['committed_at']),
|
||||
counts: ImportCounts.fromJson(asMap(json['counts'])),
|
||||
pendingInstruments: asList(json['pending_instruments'])
|
||||
.map(PendingInstrument.fromJson)
|
||||
.toList(),
|
||||
reconciliation: Reconciliation.fromJson(asMap(json['reconciliation'])),
|
||||
warnings: json['warnings'] is List
|
||||
? (json['warnings'] as List).map((e) => e.toString()).toList()
|
||||
: const [],
|
||||
sampleEvents: asList(json['sample_events'])
|
||||
.map(SampleEvent.fromJson)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// What `POST /imports/{id}/commit` reports back.
|
||||
@@ -336,16 +346,16 @@ class ImportResult {
|
||||
final bool metricsRefreshed;
|
||||
|
||||
static ImportResult fromJson(Map<String, dynamic> json) => ImportResult(
|
||||
importId: asInt(json['import_id']) ?? 0,
|
||||
committed: json['committed'] == true,
|
||||
eventsCreated: asInt(json['events_created']) ?? 0,
|
||||
eventsUpdated: asInt(json['events_updated']) ?? 0,
|
||||
eventsSkipped: asInt(json['events_skipped']) ?? 0,
|
||||
eventsShadow: asInt(json['events_shadow']) ?? 0,
|
||||
pendingInstruments: asInt(json['pending_instruments']) ?? 0,
|
||||
reconciliation: Reconciliation.fromJson(asMap(json['reconciliation'])),
|
||||
metricsRefreshed: json['metrics_refreshed'] == true,
|
||||
);
|
||||
importId: asInt(json['import_id']) ?? 0,
|
||||
committed: json['committed'] == true,
|
||||
eventsCreated: asInt(json['events_created']) ?? 0,
|
||||
eventsUpdated: asInt(json['events_updated']) ?? 0,
|
||||
eventsSkipped: asInt(json['events_skipped']) ?? 0,
|
||||
eventsShadow: asInt(json['events_shadow']) ?? 0,
|
||||
pendingInstruments: asInt(json['pending_instruments']) ?? 0,
|
||||
reconciliation: Reconciliation.fromJson(asMap(json['reconciliation'])),
|
||||
metricsRefreshed: json['metrics_refreshed'] == true,
|
||||
);
|
||||
}
|
||||
|
||||
/// A report chosen by the user. On web `file_picker` can only hand over [bytes]; on desktop
|
||||
@@ -366,14 +376,14 @@ class ImportsApi {
|
||||
|
||||
static const _base = '/api/v1/imports';
|
||||
|
||||
Future<List<ImportPreview>> list({int limit = 50, int offset = 0, String? status}) async {
|
||||
Future<List<ImportPreview>> list({
|
||||
int limit = 50,
|
||||
int offset = 0,
|
||||
String? status,
|
||||
}) async {
|
||||
final r = await _dio.get<List<dynamic>>(
|
||||
_base,
|
||||
queryParameters: {
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
'status': ?status,
|
||||
},
|
||||
queryParameters: {'limit': limit, 'offset': offset, 'status': ?status},
|
||||
);
|
||||
return (r.data ?? const [])
|
||||
.map((e) => ImportPreview.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||
@@ -385,7 +395,11 @@ class ImportsApi {
|
||||
return ImportPreview.fromJson(r.data!);
|
||||
}
|
||||
|
||||
Future<ImportPreview> upload(PickedReport report, {int? accountId, String? parser}) async {
|
||||
Future<ImportPreview> upload(
|
||||
PickedReport report, {
|
||||
int? accountId,
|
||||
String? parser,
|
||||
}) async {
|
||||
final bytes = report.bytes;
|
||||
final form = FormData.fromMap({
|
||||
'file': bytes != null
|
||||
@@ -404,11 +418,14 @@ class ImportsApi {
|
||||
bool confirmDuplicates = false,
|
||||
bool dryRun = false,
|
||||
}) async {
|
||||
final r = await _dio.post<Map<String, dynamic>>('$_base/$id/commit', data: {
|
||||
'account_id': ?accountId,
|
||||
'confirm_duplicates': confirmDuplicates,
|
||||
'dry_run': dryRun,
|
||||
});
|
||||
final r = await _dio.post<Map<String, dynamic>>(
|
||||
'$_base/$id/commit',
|
||||
data: {
|
||||
'account_id': ?accountId,
|
||||
'confirm_duplicates': confirmDuplicates,
|
||||
'dry_run': dryRun,
|
||||
},
|
||||
);
|
||||
return ImportResult.fromJson(r.data ?? const {});
|
||||
}
|
||||
|
||||
@@ -438,4 +455,5 @@ List<Map<String, dynamic>> asList(Object? v) => v is List
|
||||
? v.whereType<Map>().map((e) => Map<String, dynamic>.from(e)).toList()
|
||||
: const [];
|
||||
|
||||
Map<String, dynamic>? asMap(Object? v) => v is Map ? Map<String, dynamic>.from(v) : null;
|
||||
Map<String, dynamic>? asMap(Object? v) =>
|
||||
v is Map ? Map<String, dynamic>.from(v) : null;
|
||||
|
||||
@@ -34,4 +34,6 @@ class FilePickerReportPicker implements ReportPicker {
|
||||
}
|
||||
}
|
||||
|
||||
final reportPickerProvider = Provider<ReportPicker>((ref) => const FilePickerReportPicker());
|
||||
final reportPickerProvider = Provider<ReportPicker>(
|
||||
(ref) => const FilePickerReportPicker(),
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/utils/ru_date.dart';
|
||||
import '../../core/widgets/async_value_view.dart';
|
||||
import '../accounts/account_create_dialog.dart';
|
||||
import '../portfolio/labels.dart' show eventKindLabels, formatQty;
|
||||
import 'data/imports_api.dart';
|
||||
import 'labels.dart';
|
||||
@@ -31,14 +32,17 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
|
||||
ImportResult? _result;
|
||||
|
||||
void _snack(String message) =>
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(message)));
|
||||
|
||||
Future<void> _commit(ImportPreview preview) async {
|
||||
final accountId = preview.accountId ?? _accountChoice;
|
||||
if (accountId == null) return;
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
final result = await ref.read(importsApiProvider).commit(
|
||||
final result = await ref
|
||||
.read(importsApiProvider)
|
||||
.commit(
|
||||
preview.id,
|
||||
accountId: preview.accountId == null ? accountId : null,
|
||||
confirmDuplicates: _confirmDuplicates,
|
||||
@@ -49,8 +53,10 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
|
||||
invalidateLedgerDependents(ref);
|
||||
ref.invalidate(importsListProvider);
|
||||
ref.invalidate(importPreviewProvider(preview.id));
|
||||
_snack('Импортировано: создано ${result.eventsCreated}, '
|
||||
'обновлено ${result.eventsUpdated}, пропущено ${result.eventsSkipped}');
|
||||
_snack(
|
||||
'Импортировано: создано ${result.eventsCreated}, '
|
||||
'обновлено ${result.eventsUpdated}, пропущено ${result.eventsSkipped}',
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
_snack(importErrorMessage(e));
|
||||
@@ -64,13 +70,19 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Удалить импорт?'),
|
||||
content: Text('Файл «${preview.filename}» и разобранные строки будут удалены. '
|
||||
'События в леджере не создавались, так что удалять нечего.'),
|
||||
content: Text(
|
||||
'Файл «${preview.filename}» и разобранные строки будут удалены. '
|
||||
'События в леджере не создавались, так что удалять нечего.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false), child: const Text('Отмена')),
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true), child: const Text('Удалить')),
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Удалить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -104,7 +116,8 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
|
||||
IconButton(
|
||||
tooltip: 'Обновить',
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () => ref.invalidate(importPreviewProvider(widget.importId)),
|
||||
onPressed: () =>
|
||||
ref.invalidate(importPreviewProvider(widget.importId)),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -131,7 +144,8 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
|
||||
icon: Icons.copy_all_outlined,
|
||||
color: theme.colorScheme.secondary,
|
||||
title: 'Этот файл уже загружали',
|
||||
body: 'Показан ранее созданный импорт №${p.duplicateOfId}. '
|
||||
body:
|
||||
'Показан ранее созданный импорт №${p.duplicateOfId}. '
|
||||
'Повторная загрузка не создаёт новых событий.',
|
||||
),
|
||||
],
|
||||
@@ -172,8 +186,10 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
|
||||
children: [
|
||||
Text('Строки отчёта', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text('Первые ${p.sampleEvents.length} из ${p.counts.lines}',
|
||||
style: theme.textTheme.bodySmall),
|
||||
Text(
|
||||
'Первые ${p.sampleEvents.length} из ${p.counts.lines}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SampleEventsTable(events: p.sampleEvents),
|
||||
],
|
||||
@@ -217,13 +233,17 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
|
||||
_kv(
|
||||
'Счёт',
|
||||
p.accountName ??
|
||||
(p.accountId != null ? '#${p.accountId}' : 'не определён по отчёту'),
|
||||
(p.accountId != null
|
||||
? '#${p.accountId}'
|
||||
: 'не определён по отчёту'),
|
||||
),
|
||||
if (p.accountExternalId != null) _kv('Счёт в отчёте', p.accountExternalId!),
|
||||
if (p.accountExternalId != null)
|
||||
_kv('Счёт в отчёте', p.accountExternalId!),
|
||||
if (p.parserName != null)
|
||||
_kv('Парсер', '${p.parserName} v${p.parserVersion ?? '1'}'),
|
||||
if (p.sizeBytes != null) _kv('Размер', formatBytes(p.sizeBytes)),
|
||||
if (p.uploadedAt != null) _kv('Загружен', ruDate(p.uploadedAt!.toLocal())),
|
||||
if (p.uploadedAt != null)
|
||||
_kv('Загружен', ruDate(p.uploadedAt!.toLocal())),
|
||||
if (p.committedAt != null)
|
||||
_kv('Импортирован', ruDate(p.committedAt!.toLocal())),
|
||||
],
|
||||
@@ -232,6 +252,22 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _createAccountFromReport(ImportPreview p) async {
|
||||
final created = await showAccountCreateDialog(
|
||||
context,
|
||||
broker: brokerFromImportKey(p.broker),
|
||||
sourceId: p.accountExternalId,
|
||||
name: p.accountExternalId == null
|
||||
? null
|
||||
: '${brokerLabel(p.broker)} ${p.accountExternalId}',
|
||||
);
|
||||
if (created == null || !mounted) return;
|
||||
// refetch first: the dropdown only accepts a value that is among the server's suggestions
|
||||
ref.invalidate(importPreviewProvider(widget.importId));
|
||||
await ref.read(importPreviewProvider(widget.importId).future);
|
||||
if (mounted) setState(() => _accountChoice = created.id);
|
||||
}
|
||||
|
||||
Widget _accountPicker(ImportPreview p) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
@@ -243,12 +279,18 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.account_balance_outlined, color: theme.colorScheme.onErrorContainer),
|
||||
Icon(
|
||||
Icons.account_balance_outlined,
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text('Счёт не определён',
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(color: theme.colorScheme.onErrorContainer)),
|
||||
child: Text(
|
||||
'Счёт не определён',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -256,10 +298,17 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
|
||||
Text(
|
||||
p.accountSuggestions.isEmpty
|
||||
? 'В отчёте номер счёта ${p.accountExternalId ?? '—'}, но подходящего '
|
||||
'счёта в базе нет. Заведите счёт, затем вернитесь сюда.'
|
||||
: 'Выберите счёт, в который писать события. Без него импорт недоступен.',
|
||||
'счёта в базе нет. Создайте счёт по данным из отчёта.'
|
||||
: 'Выберите счёт, в который писать события, или создайте новый. '
|
||||
'Без счёта импорт недоступен.',
|
||||
style: TextStyle(color: theme.colorScheme.onErrorContainer),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _createAccountFromReport(p),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Создать счёт из отчёта'),
|
||||
),
|
||||
if (p.accountSuggestions.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<int>(
|
||||
@@ -274,11 +323,13 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
|
||||
for (final s in p.accountSuggestions)
|
||||
DropdownMenuItem(
|
||||
value: s.id,
|
||||
child: Text([
|
||||
s.name,
|
||||
if (s.broker != null) brokerLabel(s.broker),
|
||||
if (s.sourceId != null) s.sourceId!,
|
||||
].join(' · ')),
|
||||
child: Text(
|
||||
[
|
||||
s.name,
|
||||
if (s.broker != null) brokerLabel(s.broker),
|
||||
if (s.sourceId != null) s.sourceId!,
|
||||
].join(' · '),
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: (v) => setState(() => _accountChoice = v),
|
||||
@@ -308,14 +359,25 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
|
||||
_stat('Строк', c.lines),
|
||||
_stat('Событий', c.eventsTotal),
|
||||
_stat('Новых', c.eventsNew, color: Colors.green),
|
||||
_stat('Дубликатов', c.eventsDuplicate,
|
||||
color: c.eventsDuplicate > 0 ? theme.colorScheme.secondary : null,
|
||||
hint: 'Уже есть в леджере: будут обновлены, а не продублированы'),
|
||||
_stat('Shadow', c.eventsShadow,
|
||||
hint: 'Не первичный источник — в аналитику не идут'),
|
||||
_stat('Ждут инструмента', c.eventsPending,
|
||||
color: c.eventsPending > 0 ? theme.colorScheme.error : null,
|
||||
hint: 'Инструмент не распознан, события останутся в статусе pending'),
|
||||
_stat(
|
||||
'Дубликатов',
|
||||
c.eventsDuplicate,
|
||||
color: c.eventsDuplicate > 0
|
||||
? theme.colorScheme.secondary
|
||||
: null,
|
||||
hint: 'Уже есть в леджере: будут обновлены, а не продублированы',
|
||||
),
|
||||
_stat(
|
||||
'Shadow',
|
||||
c.eventsShadow,
|
||||
hint: 'Не первичный источник — в аналитику не идут',
|
||||
),
|
||||
_stat(
|
||||
'Ждут инструмента',
|
||||
c.eventsPending,
|
||||
color: c.eventsPending > 0 ? theme.colorScheme.error : null,
|
||||
hint: 'Инструмент не распознан, события останутся в статусе pending',
|
||||
),
|
||||
],
|
||||
),
|
||||
if (c.byKind.isNotEmpty) ...[
|
||||
@@ -329,7 +391,9 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
|
||||
for (final e in c.byKind.entries)
|
||||
Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: Text('${eventKindLabels[e.key] ?? e.key}: ${e.value}'),
|
||||
label: Text(
|
||||
'${eventKindLabels[e.key] ?? e.key}: ${e.value}',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -348,8 +412,10 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Нераспознанные инструменты (${p.pendingInstruments.length})',
|
||||
style: theme.textTheme.titleMedium),
|
||||
Text(
|
||||
'Нераспознанные инструменты (${p.pendingInstruments.length})',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Сервер не угадывает инструмент. Пока вы не привяжете эти строки вручную, '
|
||||
@@ -362,11 +428,14 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
title: Text(pi.title),
|
||||
subtitle: Text([
|
||||
if (pi.isin != null) 'ISIN ${pi.isin}',
|
||||
'встречается ${pi.occurrences}',
|
||||
if (pi.sampleQuantity != null) 'кол-во ${formatQty(pi.sampleQuantity!)}',
|
||||
].join(' · ')),
|
||||
subtitle: Text(
|
||||
[
|
||||
if (pi.isin != null) 'ISIN ${pi.isin}',
|
||||
'встречается ${pi.occurrences}',
|
||||
if (pi.sampleQuantity != null)
|
||||
'кол-во ${formatQty(pi.sampleQuantity!)}',
|
||||
].join(' · '),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
@@ -392,7 +461,10 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_outlined, color: theme.colorScheme.tertiary),
|
||||
Icon(
|
||||
Icons.warning_amber_outlined,
|
||||
color: theme.colorScheme.tertiary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text('Предупреждения', style: theme.textTheme.titleMedium),
|
||||
],
|
||||
@@ -434,7 +506,8 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
|
||||
}
|
||||
|
||||
Widget _actions(ImportPreview p, int? accountId) {
|
||||
final canCommit = !p.isCommitted && !p.isFailed && accountId != null && !_busy;
|
||||
final canCommit =
|
||||
!p.isCommitted && !p.isFailed && accountId != null && !_busy;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -444,8 +517,10 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
|
||||
value: _confirmDuplicates,
|
||||
onChanged: (v) => setState(() => _confirmDuplicates = v ?? false),
|
||||
title: const Text('Обновлять дубликаты'),
|
||||
subtitle: Text('Перезаписать ${p.counts.eventsDuplicate} событий, '
|
||||
'которые уже есть в леджере'),
|
||||
subtitle: Text(
|
||||
'Перезаписать ${p.counts.eventsDuplicate} событий, '
|
||||
'которые уже есть в леджере',
|
||||
),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
@@ -455,7 +530,10 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
|
||||
onPressed: canCommit ? () => _commit(p) : null,
|
||||
icon: _busy
|
||||
? const SizedBox(
|
||||
width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.playlist_add_check),
|
||||
label: Text(p.isCommitted ? 'Уже импортирован' : 'Импортировать'),
|
||||
),
|
||||
@@ -497,11 +575,11 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleSmall
|
||||
?.copyWith(color: color)),
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleSmall
|
||||
?.copyWith(color: color),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(body),
|
||||
],
|
||||
@@ -525,8 +603,10 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: theme.textTheme.bodySmall),
|
||||
Text('$value',
|
||||
style: theme.textTheme.titleLarge?.copyWith(color: color)),
|
||||
Text(
|
||||
'$value',
|
||||
style: theme.textTheme.titleLarge?.copyWith(color: color),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -35,7 +35,9 @@ class _ImportsPageState extends ConsumerState<ImportsPage> {
|
||||
if (!mounted) return;
|
||||
ref.invalidate(importsListProvider);
|
||||
if (preview.duplicateOfId != null) {
|
||||
_snack('Этот файл уже загружали — открыт существующий импорт №${preview.id}');
|
||||
_snack(
|
||||
'Этот файл уже загружали — открыт существующий импорт №${preview.id}',
|
||||
);
|
||||
}
|
||||
context.go('/imports/${preview.id}');
|
||||
} catch (e) {
|
||||
@@ -47,7 +49,8 @@ class _ImportsPageState extends ConsumerState<ImportsPage> {
|
||||
}
|
||||
|
||||
void _snack(String message) =>
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(message)));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -72,7 +75,10 @@ class _ImportsPageState extends ConsumerState<ImportsPage> {
|
||||
onPressed: _uploading ? null : _upload,
|
||||
icon: _uploading
|
||||
? const SizedBox(
|
||||
width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.upload_file),
|
||||
label: Text(_uploading ? 'Загрузка…' : 'Загрузить отчёт'),
|
||||
),
|
||||
@@ -91,7 +97,8 @@ class _ImportsPageState extends ConsumerState<ImportsPage> {
|
||||
leading: const Icon(Icons.help_outline),
|
||||
title: Text('Нераспознанных инструментов: $pendingCount'),
|
||||
subtitle: const Text(
|
||||
'События по ним ждут в статусе pending и не попадают в аналитику.'),
|
||||
'События по ним ждут в статусе pending и не попадают в аналитику.',
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => context.go('/instruments/pending'),
|
||||
),
|
||||
@@ -106,11 +113,16 @@ class _ImportsPageState extends ConsumerState<ImportsPage> {
|
||||
padding: EdgeInsets.only(top: 48),
|
||||
child: EmptyState(
|
||||
icon: Icons.upload_file_outlined,
|
||||
message: 'Отчёты ещё не загружались.\n'
|
||||
message:
|
||||
'Отчёты ещё не загружались.\n'
|
||||
'Поддерживаются выгрузки Сбера и ВТБ (HTML, XLSX) и CSV.',
|
||||
),
|
||||
)
|
||||
: Column(children: [for (final row in rows) _ImportCard(item: row)]),
|
||||
: Column(
|
||||
children: [
|
||||
for (final row in rows) _ImportCard(item: row),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -164,8 +176,12 @@ class _ImportCard extends StatelessWidget {
|
||||
: 'период не определён';
|
||||
final subtitle = [
|
||||
period,
|
||||
item.accountName ?? (item.accountId != null ? 'счёт #${item.accountId}' : 'счёт не найден'),
|
||||
if (item.uploadedAt != null) 'загружен ${ruDate(item.uploadedAt!.toLocal())}',
|
||||
item.accountName ??
|
||||
(item.accountId != null
|
||||
? 'счёт #${item.accountId}'
|
||||
: 'счёт не найден'),
|
||||
if (item.uploadedAt != null)
|
||||
'загружен ${ruDate(item.uploadedAt!.toLocal())}',
|
||||
if (item.sizeBytes != null) formatBytes(item.sizeBytes),
|
||||
].join(' · ');
|
||||
|
||||
@@ -174,7 +190,9 @@ class _ImportCard extends StatelessWidget {
|
||||
onTap: () => context.go('/imports/${item.id}'),
|
||||
title: Row(
|
||||
children: [
|
||||
Flexible(child: Text(item.filename, overflow: TextOverflow.ellipsis)),
|
||||
Flexible(
|
||||
child: Text(item.filename, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
parseStatusChip(context, item.parseStatus),
|
||||
],
|
||||
@@ -198,9 +216,12 @@ class _ImportCard extends StatelessWidget {
|
||||
if (item.isFailed && item.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(item.error!,
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(color: theme.colorScheme.error)),
|
||||
child: Text(
|
||||
item.error!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -6,13 +6,17 @@ import '../home/providers.dart';
|
||||
import '../portfolio/providers.dart';
|
||||
import 'data/imports_api.dart';
|
||||
|
||||
final importsApiProvider = Provider<ImportsApi>((ref) => ImportsApi(ref.watch(apiProvider).dio));
|
||||
final importsApiProvider = Provider<ImportsApi>(
|
||||
(ref) => ImportsApi(ref.watch(apiProvider).dio),
|
||||
);
|
||||
|
||||
/// The list on `/imports`. Not auto-disposed by status: the filter is a separate provider so
|
||||
/// changing it refetches without rebuilding the page state.
|
||||
final importsStatusFilterProvider = StateProvider<String?>((ref) => null);
|
||||
|
||||
final importsListProvider = FutureProvider.autoDispose<List<ImportPreview>>((ref) async {
|
||||
final importsListProvider = FutureProvider.autoDispose<List<ImportPreview>>((
|
||||
ref,
|
||||
) async {
|
||||
final status = ref.watch(importsStatusFilterProvider);
|
||||
return ref.watch(importsApiProvider).list(status: status);
|
||||
});
|
||||
@@ -20,10 +24,10 @@ final importsListProvider = FutureProvider.autoDispose<List<ImportPreview>>((ref
|
||||
/// A single import's preview. For an uncommitted import the server recomputes counts and
|
||||
/// reconciliation on every read, so this is deliberately re-fetched rather than cached from
|
||||
/// the list response.
|
||||
final importPreviewProvider =
|
||||
FutureProvider.autoDispose.family<ImportPreview, int>((ref, id) async {
|
||||
return ref.watch(importsApiProvider).get(id);
|
||||
});
|
||||
final importPreviewProvider = FutureProvider.autoDispose
|
||||
.family<ImportPreview, int>((ref, id) async {
|
||||
return ref.watch(importsApiProvider).get(id);
|
||||
});
|
||||
|
||||
/// Everything whose numbers change once events land in (or move inside) the ledger:
|
||||
/// portfolio, holdings, allocation, the value series, the dashboard and the event list.
|
||||
|
||||
@@ -23,7 +23,10 @@ class ReconciliationCard extends StatelessWidget {
|
||||
if (reconciliation.isEmpty) {
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.remove_circle_outline, color: theme.colorScheme.outline),
|
||||
leading: Icon(
|
||||
Icons.remove_circle_outline,
|
||||
color: theme.colorScheme.outline,
|
||||
),
|
||||
title: Text(title),
|
||||
subtitle: const Text('В отчёте нет остатков для сверки'),
|
||||
),
|
||||
@@ -67,7 +70,12 @@ class ReconciliationCard extends StatelessWidget {
|
||||
Text('Позиции', style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 4),
|
||||
_ScrollableTable(
|
||||
columns: const ['Позиция', 'Из отчёта', 'Из леджера', 'Расхождение'],
|
||||
columns: const [
|
||||
'Позиция',
|
||||
'Из отчёта',
|
||||
'Из леджера',
|
||||
'Расхождение',
|
||||
],
|
||||
rows: [
|
||||
for (final p in reconciliation.positions)
|
||||
_Row(
|
||||
@@ -87,7 +95,12 @@ class ReconciliationCard extends StatelessWidget {
|
||||
Text('Денежные остатки', style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 4),
|
||||
_ScrollableTable(
|
||||
columns: const ['Валюта', 'Из отчёта', 'Из леджера', 'Расхождение'],
|
||||
columns: const [
|
||||
'Валюта',
|
||||
'Из отчёта',
|
||||
'Из леджера',
|
||||
'Расхождение',
|
||||
],
|
||||
rows: [
|
||||
for (final c in reconciliation.cash)
|
||||
_Row(
|
||||
@@ -145,16 +158,20 @@ class _ScrollableTable extends StatelessWidget {
|
||||
for (final r in rows)
|
||||
DataRow(
|
||||
color: r.highlight
|
||||
? WidgetStatePropertyAll(theme.colorScheme.errorContainer.withValues(alpha: 0.4))
|
||||
? WidgetStatePropertyAll(
|
||||
theme.colorScheme.errorContainer.withValues(alpha: 0.4),
|
||||
)
|
||||
: null,
|
||||
cells: [
|
||||
for (final cell in r.cells)
|
||||
DataCell(Text(
|
||||
cell,
|
||||
style: r.highlight
|
||||
? TextStyle(color: theme.colorScheme.error)
|
||||
: null,
|
||||
)),
|
||||
DataCell(
|
||||
Text(
|
||||
cell,
|
||||
style: r.highlight
|
||||
? TextStyle(color: theme.colorScheme.error)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -37,11 +37,16 @@ class SampleEventsTable extends StatelessWidget {
|
||||
DataRow(
|
||||
color: e.isDuplicate
|
||||
? WidgetStatePropertyAll(
|
||||
theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.6))
|
||||
theme.colorScheme.surfaceContainerHighest.withValues(
|
||||
alpha: 0.6,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
cells: [
|
||||
DataCell(Text('${e.lineNo}')),
|
||||
DataCell(Text(e.tradeDate == null ? '—' : ruDate(e.tradeDate!))),
|
||||
DataCell(
|
||||
Text(e.tradeDate == null ? '—' : ruDate(e.tradeDate!)),
|
||||
),
|
||||
DataCell(Text(eventKindLabels[e.kind] ?? e.kind)),
|
||||
DataCell(
|
||||
Tooltip(
|
||||
@@ -49,20 +54,26 @@ class SampleEventsTable extends StatelessWidget {
|
||||
child: Text(e.instrumentName ?? e.instrumentKey ?? '—'),
|
||||
),
|
||||
),
|
||||
DataCell(Text(e.quantity == null ? '—' : formatQty(e.quantity!))),
|
||||
DataCell(
|
||||
Text(e.quantity == null ? '—' : formatQty(e.quantity!)),
|
||||
),
|
||||
DataCell(Text(_money(e.price, e.currency))),
|
||||
DataCell(Text(_money(e.amount, e.currency))),
|
||||
DataCell(e.isDuplicate
|
||||
? Tooltip(
|
||||
message: 'Такое событие уже есть в леджере',
|
||||
child: Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: const Text('дубль'),
|
||||
backgroundColor:
|
||||
theme.colorScheme.secondaryContainer.withValues(alpha: 0.8),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink()),
|
||||
DataCell(
|
||||
e.isDuplicate
|
||||
? Tooltip(
|
||||
message: 'Такое событие уже есть в леджере',
|
||||
child: Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: const Text('дубль'),
|
||||
backgroundColor: theme
|
||||
.colorScheme
|
||||
.secondaryContainer
|
||||
.withValues(alpha: 0.8),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -71,5 +82,7 @@ class SampleEventsTable extends StatelessWidget {
|
||||
}
|
||||
|
||||
static String _money(String? value, String? currency) =>
|
||||
value == null || value.isEmpty ? '—' : MoneyText.format(value, currency ?? 'RUB');
|
||||
value == null || value.isEmpty
|
||||
? '—'
|
||||
: MoneyText.format(value, currency ?? 'RUB');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user