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,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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user