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:
Dmitry
2026-09-19 10:44:38 +03:00
parent 15f5812ea4
commit b69bb4a0c9
52 changed files with 7404 additions and 13 deletions
+212
View File
@@ -0,0 +1,212 @@
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),
),
);
}
}