Импорт (/imports, /instruments/pending) и фаза 4 (/goals, /income, /rebalance, /tax, аналитика-хаб с benchmarks_card) — по docs/ai/import-contract.md и docs/ai/phase4-contract.md. file_picker для загрузки отчёта.
552 lines
20 KiB
Dart
552 lines
20 KiB
Dart
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)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|