feat(app): экраны импорта отчётов, целей, доходов, ребалансировки, налогов и бенчмарков
Импорт (/imports, /instruments/pending) и фаза 4 (/goals, /income, /rebalance, /tax, аналитика-хаб с benchmarks_card) — по docs/ai/import-contract.md и docs/ai/phase4-contract.md. file_picker для загрузки отчёта.
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/utils/ru_date.dart';
|
||||
import '../../../core/widgets/money_text.dart';
|
||||
import '../../portfolio/labels.dart' show formatQty;
|
||||
import '../data/imports_api.dart';
|
||||
|
||||
/// Сверка: the report's own positions and cash balances against what the ledger derives.
|
||||
///
|
||||
/// When everything matches this collapses to a single green line — a full table of zeroes
|
||||
/// is noise. A mismatch is the whole point of the screen, so it stays expanded and red.
|
||||
class ReconciliationCard extends StatelessWidget {
|
||||
const ReconciliationCard({required this.reconciliation, super.key});
|
||||
|
||||
final Reconciliation reconciliation;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final asOf = reconciliation.asOf;
|
||||
final title = asOf == null ? 'Сверка' : 'Сверка на ${ruDate(asOf)}';
|
||||
|
||||
if (reconciliation.isEmpty) {
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.remove_circle_outline, color: theme.colorScheme.outline),
|
||||
title: Text(title),
|
||||
subtitle: const Text('В отчёте нет остатков для сверки'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (reconciliation.matches) {
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.check_circle_outline, color: Colors.green),
|
||||
title: Text(title),
|
||||
subtitle: Text(
|
||||
'Всё сошлось: позиций ${reconciliation.positions.length}, '
|
||||
'остатков ${reconciliation.cash.length}',
|
||||
style: const TextStyle(color: Colors.green),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: theme.colorScheme.error),
|
||||
const SizedBox(width: 8),
|
||||
Text(title, style: theme.textTheme.titleMedium),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Отчёт и леджер разошлись. Импортировать можно, но расхождение стоит объяснить.',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
if (reconciliation.positions.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text('Позиции', style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 4),
|
||||
_ScrollableTable(
|
||||
columns: const ['Позиция', 'Из отчёта', 'Из леджера', 'Расхождение'],
|
||||
rows: [
|
||||
for (final p in reconciliation.positions)
|
||||
_Row(
|
||||
cells: [
|
||||
p.title,
|
||||
_qty(p.qtyReport),
|
||||
_qty(p.qtyDerived),
|
||||
_qty(p.qtyDelta),
|
||||
],
|
||||
highlight: !p.matches,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (reconciliation.cash.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text('Денежные остатки', style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 4),
|
||||
_ScrollableTable(
|
||||
columns: const ['Валюта', 'Из отчёта', 'Из леджера', 'Расхождение'],
|
||||
rows: [
|
||||
for (final c in reconciliation.cash)
|
||||
_Row(
|
||||
cells: [
|
||||
c.currency,
|
||||
_money(c.balanceReport, c.currency),
|
||||
_money(c.balanceDerived, c.currency),
|
||||
_money(c.delta, c.currency),
|
||||
],
|
||||
highlight: !c.matches,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A quantity the ledger could not produce is an em dash, not a zero: an unknown position
|
||||
/// and an empty one are different findings.
|
||||
static String _qty(String? value) =>
|
||||
value == null || value.isEmpty ? '—' : formatQty(value);
|
||||
|
||||
static String _money(String? value, String currency) =>
|
||||
value == null || value.isEmpty ? '—' : MoneyText.format(value, currency);
|
||||
}
|
||||
|
||||
class _Row {
|
||||
const _Row({required this.cells, required this.highlight});
|
||||
final List<String> cells;
|
||||
final bool highlight;
|
||||
}
|
||||
|
||||
/// A narrow table that scrolls sideways rather than overflowing on a phone.
|
||||
class _ScrollableTable extends StatelessWidget {
|
||||
const _ScrollableTable({required this.columns, required this.rows});
|
||||
|
||||
final List<String> columns;
|
||||
final List<_Row> rows;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: DataTable(
|
||||
columnSpacing: 24,
|
||||
headingRowHeight: 36,
|
||||
dataRowMinHeight: 36,
|
||||
dataRowMaxHeight: 48,
|
||||
columns: [for (final c in columns) DataColumn(label: Text(c))],
|
||||
rows: [
|
||||
for (final r in rows)
|
||||
DataRow(
|
||||
color: r.highlight
|
||||
? 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,
|
||||
)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/utils/ru_date.dart';
|
||||
import '../../../core/widgets/money_text.dart';
|
||||
import '../../portfolio/labels.dart' show eventKindLabels, formatQty;
|
||||
import '../data/imports_api.dart';
|
||||
|
||||
/// The first lines of the report as the parser read them. A row already present in the
|
||||
/// ledger (same `dedupe_key`) is marked: committing will update it, not add a second one.
|
||||
class SampleEventsTable extends StatelessWidget {
|
||||
const SampleEventsTable({required this.events, super.key});
|
||||
|
||||
final List<SampleEvent> events;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: DataTable(
|
||||
columnSpacing: 20,
|
||||
headingRowHeight: 36,
|
||||
dataRowMinHeight: 36,
|
||||
dataRowMaxHeight: 52,
|
||||
columns: const [
|
||||
DataColumn(label: Text('№')),
|
||||
DataColumn(label: Text('Дата')),
|
||||
DataColumn(label: Text('Тип')),
|
||||
DataColumn(label: Text('Инструмент')),
|
||||
DataColumn(label: Text('Кол-во')),
|
||||
DataColumn(label: Text('Цена')),
|
||||
DataColumn(label: Text('Сумма')),
|
||||
DataColumn(label: Text('')),
|
||||
],
|
||||
rows: [
|
||||
for (final e in events)
|
||||
DataRow(
|
||||
color: e.isDuplicate
|
||||
? WidgetStatePropertyAll(
|
||||
theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.6))
|
||||
: null,
|
||||
cells: [
|
||||
DataCell(Text('${e.lineNo}')),
|
||||
DataCell(Text(e.tradeDate == null ? '—' : ruDate(e.tradeDate!))),
|
||||
DataCell(Text(eventKindLabels[e.kind] ?? e.kind)),
|
||||
DataCell(
|
||||
Tooltip(
|
||||
message: e.instrumentKey ?? '',
|
||||
child: Text(e.instrumentName ?? e.instrumentKey ?? '—'),
|
||||
),
|
||||
),
|
||||
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()),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _money(String? value, String? currency) =>
|
||||
value == null || value.isEmpty ? '—' : MoneyText.format(value, currency ?? 'RUB');
|
||||
}
|
||||
Reference in New Issue
Block a user