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 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())), ], ), ), ); } 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( 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)), ], ), ); } }