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 '../accounts/account_create_dialog.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 createState() => _ImportPreviewPageState(); } class _ImportPreviewPageState extends ConsumerState { /// 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 _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 _delete(ImportPreview preview) async { final confirmed = await showDialog( 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())), ], ), ), ); } Future _createAccountFromReport(ImportPreview p) async { final created = await showAccountCreateDialog( context, broker: brokerFromImportKey(p.broker), sourceId: p.accountExternalId, name: p.accountExternalId == null ? null : '${brokerLabel(p.broker)} ${p.accountExternalId}', ); if (created == null || !mounted) return; // refetch first: the dropdown only accepts a value that is among the server's suggestions ref.invalidate(importPreviewProvider(widget.importId)); await ref.read(importPreviewProvider(widget.importId).future); if (mounted) setState(() => _accountChoice = created.id); } 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), ), const SizedBox(height: 8), OutlinedButton.icon( onPressed: () => _createAccountFromReport(p), icon: const Icon(Icons.add), label: const Text('Создать счёт из отчёта'), ), if (p.accountSuggestions.isNotEmpty) ...[ const SizedBox(height: 12), DropdownButtonFormField( 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)), ], ), ); } }