Импорт (/imports, /instruments/pending) и фаза 4 (/goals, /income, /rebalance, /tax, аналитика-хаб с benchmarks_card) — по docs/ai/import-contract.md и docs/ai/phase4-contract.md. file_picker для загрузки отчёта.
213 lines
7.4 KiB
Dart
213 lines
7.4 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 '../../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<ImportsPage> createState() => _ImportsPageState();
|
||
}
|
||
|
||
class _ImportsPageState extends ConsumerState<ImportsPage> {
|
||
bool _uploading = false;
|
||
|
||
Future<void> _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 = <String?, String>{
|
||
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),
|
||
),
|
||
);
|
||
}
|
||
}
|