Files
fin-tracker/app/lib/features/imports/import_preview_page.dart
T
Dmitry b69bb4a0c9 feat(app): экраны импорта отчётов, целей, доходов, ребалансировки, налогов и бенчмарков
Импорт (/imports, /instruments/pending) и фаза 4 (/goals, /income,
/rebalance, /tax, аналитика-хаб с benchmarks_card) — по
docs/ai/import-contract.md и docs/ai/phase4-contract.md. file_picker для
загрузки отчёта.
2026-09-19 10:44:38 +03:00

552 lines
20 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)),
],
),
);
}
}