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:
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user