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 '../../core/widgets/empty_state.dart'; import '../pending/providers.dart'; import 'data/imports_api.dart'; import 'data/report_picker.dart'; import 'labels.dart'; import 'providers.dart'; /// Импорт: the list of uploaded broker reports plus the upload button. /// /// Uploading never writes to the ledger — it parses the file and opens the preview, where /// the numbers are checked against the ledger before anything is committed. class ImportsPage extends ConsumerStatefulWidget { const ImportsPage({super.key}); @override ConsumerState createState() => _ImportsPageState(); } class _ImportsPageState extends ConsumerState { bool _uploading = false; Future _upload() async { final picked = await ref.read(reportPickerProvider).pick(); if (picked == null || !mounted) return; setState(() => _uploading = true); try { final preview = await ref.read(importsApiProvider).upload(picked); if (!mounted) return; ref.invalidate(importsListProvider); if (preview.duplicateOfId != null) { _snack( 'Этот файл уже загружали — открыт существующий импорт №${preview.id}', ); } context.go('/imports/${preview.id}'); } catch (e) { if (!mounted) return; _snack(importErrorMessage(e)); } finally { if (mounted) setState(() => _uploading = false); } } void _snack(String message) => ScaffoldMessenger.of(context) .showSnackBar(SnackBar(content: Text(message))); @override Widget build(BuildContext context) { final imports = ref.watch(importsListProvider); final pendingCount = ref.watch(pendingCountProvider).valueOrNull ?? 0; return Scaffold( appBar: AppBar( title: const Text('Импорт отчётов'), actions: [ IconButton( tooltip: 'Обновить', icon: const Icon(Icons.refresh), onPressed: () { ref.invalidate(importsListProvider); ref.invalidate(pendingCountProvider); }, ), ], ), floatingActionButton: FloatingActionButton.extended( onPressed: _uploading ? null : _upload, icon: _uploading ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.upload_file), label: Text(_uploading ? 'Загрузка…' : 'Загрузить отчёт'), ), body: RefreshIndicator( onRefresh: () async { ref.invalidate(importsListProvider); ref.invalidate(pendingCountProvider); }, child: ListView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 88), children: [ if (pendingCount > 0) Card( color: Theme.of(context).colorScheme.tertiaryContainer, child: ListTile( leading: const Icon(Icons.help_outline), title: Text('Нераспознанных инструментов: $pendingCount'), subtitle: const Text( 'События по ним ждут в статусе pending и не попадают в аналитику.', ), trailing: const Icon(Icons.chevron_right), onTap: () => context.go('/instruments/pending'), ), ), const _StatusFilter(), const SizedBox(height: 8), AsyncValueView( value: imports, onRetry: () => ref.invalidate(importsListProvider), data: (rows) => rows.isEmpty ? const Padding( padding: EdgeInsets.only(top: 48), child: EmptyState( icon: Icons.upload_file_outlined, message: 'Отчёты ещё не загружались.\n' 'Поддерживаются выгрузки Сбера и ВТБ (HTML, XLSX) и CSV.', ), ) : Column( children: [ for (final row in rows) _ImportCard(item: row), ], ), ), ], ), ), ); } } class _StatusFilter extends ConsumerWidget { const _StatusFilter(); static const _options = { null: 'Все', 'uploaded': 'Загружены', 'parsed': 'Разобраны', 'committed': 'Импортированы', 'failed': 'С ошибкой', }; @override Widget build(BuildContext context, WidgetRef ref) { final current = ref.watch(importsStatusFilterProvider); return Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Wrap( spacing: 8, children: [ for (final e in _options.entries) ChoiceChip( label: Text(e.value), selected: current == e.key, onSelected: (_) => ref.read(importsStatusFilterProvider.notifier).state = e.key, ), ], ), ); } } class _ImportCard extends StatelessWidget { const _ImportCard({required this.item}); final ImportPreview item; @override Widget build(BuildContext context) { final theme = Theme.of(context); final period = item.periodFrom != null && item.periodTo != null ? '${ruDate(item.periodFrom!)} – ${ruDate(item.periodTo!)}' : 'период не определён'; final subtitle = [ period, item.accountName ?? (item.accountId != null ? 'счёт #${item.accountId}' : 'счёт не найден'), if (item.uploadedAt != null) 'загружен ${ruDate(item.uploadedAt!.toLocal())}', if (item.sizeBytes != null) formatBytes(item.sizeBytes), ].join(' · '); return Card( child: ListTile( onTap: () => context.go('/imports/${item.id}'), title: Row( children: [ Flexible( child: Text(item.filename, overflow: TextOverflow.ellipsis), ), const SizedBox(width: 8), parseStatusChip(context, item.parseStatus), ], ), subtitle: Padding( padding: const EdgeInsets.only(top: 4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('${brokerLabel(item.broker)} · $subtitle'), if (item.counts.eventsTotal > 0) Padding( padding: const EdgeInsets.only(top: 4), child: Text( 'событий ${item.counts.eventsTotal}' ' · новых ${item.counts.eventsNew}' ' · дубликатов ${item.counts.eventsDuplicate}', style: theme.textTheme.bodySmall, ), ), if (item.isFailed && item.error != null) Padding( padding: const EdgeInsets.only(top: 4), child: Text( item.error!, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.error, ), ), ), ], ), ), trailing: const Icon(Icons.chevron_right), ), ); } }