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:
Dmitry
2026-09-19 10:44:38 +03:00
parent 15f5812ea4
commit b69bb4a0c9
52 changed files with 7404 additions and 13 deletions
@@ -0,0 +1,551 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/utils/ru_date.dart';
import '../../core/widgets/async_value_view.dart';
import '../portfolio/labels.dart' show eventKindLabels, formatQty;
import 'data/imports_api.dart';
import 'labels.dart';
import 'providers.dart';
import 'widgets/reconciliation_card.dart';
import 'widgets/sample_events_table.dart';
/// The preview of one uploaded report: what the parser found, how it lines up with the
/// ledger, and the one button that actually writes events. Nothing on this screen has
/// touched `event` yet — upload only parses.
class ImportPreviewPage extends ConsumerStatefulWidget {
const ImportPreviewPage({required this.importId, super.key});
final int importId;
@override
ConsumerState<ImportPreviewPage> createState() => _ImportPreviewPageState();
}
class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
/// Chosen by the user when the server could not resolve `account_id` itself.
int? _accountChoice;
bool _confirmDuplicates = false;
bool _busy = false;
ImportResult? _result;
void _snack(String 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(
preview.id,
accountId: preview.accountId == null ? accountId : null,
confirmDuplicates: _confirmDuplicates,
);
if (!mounted) return;
setState(() => _result = result);
// The ledger changed: every screen that counts events or values positions is stale.
invalidateLedgerDependents(ref);
ref.invalidate(importsListProvider);
ref.invalidate(importPreviewProvider(preview.id));
_snack('Импортировано: создано ${result.eventsCreated}, '
'обновлено ${result.eventsUpdated}, пропущено ${result.eventsSkipped}');
} catch (e) {
if (!mounted) return;
_snack(importErrorMessage(e));
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _delete(ImportPreview preview) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Удалить импорт?'),
content: Text('Файл «${preview.filename}» и разобранные строки будут удалены. '
'События в леджере не создавались, так что удалять нечего.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false), child: const Text('Отмена')),
FilledButton(
onPressed: () => Navigator.of(context).pop(true), child: const Text('Удалить')),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _busy = true);
try {
await ref.read(importsApiProvider).delete(preview.id);
if (!mounted) return;
ref.invalidate(importsListProvider);
context.go('/imports');
} catch (e) {
if (!mounted) return;
_snack(importErrorMessage(e));
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final preview = ref.watch(importPreviewProvider(widget.importId));
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/imports'),
),
title: const Text('Импорт отчёта'),
actions: [
IconButton(
tooltip: 'Обновить',
icon: const Icon(Icons.refresh),
onPressed: () => ref.invalidate(importPreviewProvider(widget.importId)),
),
],
),
body: AsyncValueView(
value: preview,
onRetry: () => ref.invalidate(importPreviewProvider(widget.importId)),
data: _buildBody,
),
);
}
Widget _buildBody(ImportPreview p) {
final theme = Theme.of(context);
final accountId = p.accountId ?? _accountChoice;
final recon = p.reconciliation;
return ListView(
padding: const EdgeInsets.all(16),
children: [
_header(p),
if (p.duplicateOfId != null) ...[
const SizedBox(height: 12),
_banner(
icon: Icons.copy_all_outlined,
color: theme.colorScheme.secondary,
title: 'Этот файл уже загружали',
body: 'Показан ранее созданный импорт №${p.duplicateOfId}. '
'Повторная загрузка не создаёт новых событий.',
),
],
if (p.isFailed) ...[
const SizedBox(height: 12),
_banner(
icon: Icons.error_outline,
color: theme.colorScheme.error,
title: 'Файл не разобрался',
body: p.error ?? 'Парсер не смог прочитать отчёт.',
),
],
if (p.accountId == null && !p.isFailed) ...[
const SizedBox(height: 12),
_accountPicker(p),
],
const SizedBox(height: 12),
_countsCard(p),
if (recon != null) ...[
const SizedBox(height: 12),
ReconciliationCard(reconciliation: recon),
],
if (p.pendingInstruments.isNotEmpty) ...[
const SizedBox(height: 12),
_pendingCard(p),
],
if (p.warnings.isNotEmpty) ...[
const SizedBox(height: 12),
_warningsCard(p),
],
if (p.sampleEvents.isNotEmpty) ...[
const SizedBox(height: 12),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Строки отчёта', style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
Text('Первые ${p.sampleEvents.length} из ${p.counts.lines}',
style: theme.textTheme.bodySmall),
const SizedBox(height: 8),
SampleEventsTable(events: p.sampleEvents),
],
),
),
),
],
if (_result != null) ...[
const SizedBox(height: 12),
_resultCard(_result!),
],
const SizedBox(height: 16),
_actions(p, accountId),
const SizedBox(height: 32),
],
);
}
Widget _header(ImportPreview p) {
final theme = Theme.of(context);
final period = p.periodFrom != null && p.periodTo != null
? '${ruDate(p.periodFrom!)} ${ruDate(p.periodTo!)}'
: 'период не определён';
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(p.filename, style: theme.textTheme.titleMedium),
),
parseStatusChip(context, p.parseStatus),
],
),
const SizedBox(height: 8),
_kv('Брокер', brokerLabel(p.broker)),
_kv('Период', period),
_kv(
'Счёт',
p.accountName ??
(p.accountId != null ? '#${p.accountId}' : 'не определён по отчёту'),
),
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.committedAt != null)
_kv('Импортирован', ruDate(p.committedAt!.toLocal())),
],
),
),
);
}
Widget _accountPicker(ImportPreview p) {
final theme = Theme.of(context);
return Card(
color: theme.colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
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)),
),
],
),
const SizedBox(height: 4),
Text(
p.accountSuggestions.isEmpty
? 'В отчёте номер счёта ${p.accountExternalId ?? ''}, но подходящего '
'счёта в базе нет. Заведите счёт, затем вернитесь сюда.'
: 'Выберите счёт, в который писать события. Без него импорт недоступен.',
style: TextStyle(color: theme.colorScheme.onErrorContainer),
),
if (p.accountSuggestions.isNotEmpty) ...[
const SizedBox(height: 12),
DropdownButtonFormField<int>(
initialValue: _accountChoice,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Счёт',
filled: true,
border: OutlineInputBorder(),
),
items: [
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(' · ')),
),
],
onChanged: (v) => setState(() => _accountChoice = v),
),
],
],
),
),
);
}
Widget _countsCard(ImportPreview p) {
final theme = Theme.of(context);
final c = p.counts;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Что нашлось', style: theme.textTheme.titleMedium),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
_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'),
],
),
if (c.byKind.isNotEmpty) ...[
const SizedBox(height: 16),
Text('По типам', style: theme.textTheme.titleSmall),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final e in c.byKind.entries)
Chip(
visualDensity: VisualDensity.compact,
label: Text('${eventKindLabels[e.key] ?? e.key}: ${e.value}'),
),
],
),
],
],
),
),
);
}
Widget _pendingCard(ImportPreview p) {
final theme = Theme.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Нераспознанные инструменты (${p.pendingInstruments.length})',
style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
Text(
'Сервер не угадывает инструмент. Пока вы не привяжете эти строки вручную, '
'их события останутся в статусе pending и не попадут в аналитику.',
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 8),
for (final pi in p.pendingInstruments)
ListTile(
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(' · ')),
),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: () => context.go('/instruments/pending'),
icon: const Icon(Icons.open_in_new, size: 18),
label: const Text('К резолву инструментов'),
),
),
],
),
),
);
}
Widget _warningsCard(ImportPreview p) {
final theme = Theme.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.warning_amber_outlined, color: theme.colorScheme.tertiary),
const SizedBox(width: 8),
Text('Предупреждения', style: theme.textTheme.titleMedium),
],
),
const SizedBox(height: 8),
for (final w in p.warnings)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text('$w'),
),
],
),
),
);
}
Widget _resultCard(ImportResult r) {
final theme = Theme.of(context);
return Card(
color: theme.colorScheme.secondaryContainer,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Результат импорта', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
_kv('Создано событий', '${r.eventsCreated}'),
_kv('Обновлено', '${r.eventsUpdated}'),
_kv('Пропущено', '${r.eventsSkipped}'),
if (r.eventsShadow > 0) _kv('Shadow', '${r.eventsShadow}'),
if (r.pendingInstruments > 0)
_kv('Ждут инструмента', '${r.pendingInstruments}'),
_kv('Метрики пересчитаны', r.metricsRefreshed ? 'да' : 'нет'),
],
),
),
);
}
Widget _actions(ImportPreview p, int? accountId) {
final canCommit = !p.isCommitted && !p.isFailed && accountId != null && !_busy;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (p.counts.eventsDuplicate > 0 && !p.isCommitted)
CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: _confirmDuplicates,
onChanged: (v) => setState(() => _confirmDuplicates = v ?? false),
title: const Text('Обновлять дубликаты'),
subtitle: Text('Перезаписать ${p.counts.eventsDuplicate} событий, '
'которые уже есть в леджере'),
),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
FilledButton.icon(
onPressed: canCommit ? () => _commit(p) : null,
icon: _busy
? const SizedBox(
width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.playlist_add_check),
label: Text(p.isCommitted ? 'Уже импортирован' : 'Импортировать'),
),
if (p.canDelete)
OutlinedButton.icon(
onPressed: _busy ? null : () => _delete(p),
icon: const Icon(Icons.delete_outline),
label: const Text('Удалить'),
),
],
),
if (accountId == null && !p.isFailed)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'Импорт недоступен, пока не выбран счёт.',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
],
);
}
Widget _banner({
required IconData icon,
required Color color,
required String title,
required String body,
}) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: color),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: Theme.of(context)
.textTheme
.titleSmall
?.copyWith(color: color)),
const SizedBox(height: 4),
Text(body),
],
),
),
],
),
),
);
}
Widget _stat(String label, int value, {Color? color, String? hint}) {
final theme = Theme.of(context);
final tile = Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: theme.textTheme.bodySmall),
Text('$value',
style: theme.textTheme.titleLarge?.copyWith(color: color)),
],
),
);
return hint == null ? tile : Tooltip(message: hint, child: tile);
}
Widget _kv(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 140,
child: Text(label, style: Theme.of(context).textTheme.bodySmall),
),
Expanded(child: Text(value)),
],
),
);
}
}