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,277 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/widgets/async_value_view.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../core/widgets/money_text.dart';
|
||||
import '../imports/data/imports_api.dart' show importErrorMessage;
|
||||
import '../imports/providers.dart' show invalidateLedgerDependents;
|
||||
import '../portfolio/labels.dart' show assetClassLabel, formatQty;
|
||||
import 'data/pending_api.dart';
|
||||
import 'providers.dart';
|
||||
import 'widgets/create_instrument_dialog.dart';
|
||||
import 'widgets/link_instrument_dialog.dart';
|
||||
|
||||
/// Нераспознанные инструменты: the queue of report lines whose instrument the server
|
||||
/// refused to guess. Every row waits for an explicit decision — link, create or ignore.
|
||||
class PendingInstrumentsPage extends ConsumerStatefulWidget {
|
||||
const PendingInstrumentsPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<PendingInstrumentsPage> createState() => _PendingInstrumentsPageState();
|
||||
}
|
||||
|
||||
class _PendingInstrumentsPageState extends ConsumerState<PendingInstrumentsPage> {
|
||||
int? _busyId;
|
||||
|
||||
void _snack(String message) =>
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
||||
|
||||
Future<void> _run(int id, Future<PendingResolveResult> Function() action) async {
|
||||
setState(() => _busyId = id);
|
||||
try {
|
||||
final result = await action();
|
||||
if (!mounted) return;
|
||||
ref.invalidate(pendingInstrumentsProvider);
|
||||
ref.invalidate(pendingCountProvider);
|
||||
// Events moved out of `pending`, so holdings, allocation and the event list changed.
|
||||
invalidateLedgerDependents(ref);
|
||||
_snack(result.status == 'ignored'
|
||||
? 'Строка помечена как «не инструмент»'
|
||||
: 'Привязано событий: ${result.eventsBound}'
|
||||
'${result.aliasCreated ? ', добавлен алиас' : ''}');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
_snack(importErrorMessage(e));
|
||||
} finally {
|
||||
if (mounted) setState(() => _busyId = null);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _link(PendingInstrument p) async {
|
||||
final instrumentId = await showDialog<int>(
|
||||
context: context,
|
||||
builder: (_) => LinkInstrumentDialog(
|
||||
initialQuery: p.isin ?? p.ticker ?? p.name ?? '',
|
||||
),
|
||||
);
|
||||
if (instrumentId == null || !mounted) return;
|
||||
await _run(p.id, () => ref.read(pendingApiProvider).link(p.id, instrumentId));
|
||||
}
|
||||
|
||||
Future<void> _create(PendingInstrument p) async {
|
||||
final instrument = await showDialog<NewInstrument>(
|
||||
context: context,
|
||||
builder: (_) => CreateInstrumentDialog(pending: p),
|
||||
);
|
||||
if (instrument == null || !mounted) return;
|
||||
await _run(p.id, () => ref.read(pendingApiProvider).create(p.id, instrument));
|
||||
}
|
||||
|
||||
Future<void> _ignore(PendingInstrument p) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Игнорировать строку?'),
|
||||
content: Text('«${p.title}» больше не будет предлагаться к резолву. '
|
||||
'Её события останутся без инструмента.'),
|
||||
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;
|
||||
await _run(p.id, () => ref.read(pendingApiProvider).ignore(p.id));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final rows = ref.watch(pendingInstrumentsProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Нераспознанные инструменты'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Обновить',
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () {
|
||||
ref.invalidate(pendingInstrumentsProvider);
|
||||
ref.invalidate(pendingCountProvider);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async => ref.invalidate(pendingInstrumentsProvider),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
const _StatusFilter(),
|
||||
const SizedBox(height: 8),
|
||||
AsyncValueView(
|
||||
value: rows,
|
||||
onRetry: () => ref.invalidate(pendingInstrumentsProvider),
|
||||
data: (items) => items.isEmpty
|
||||
? const Padding(
|
||||
padding: EdgeInsets.only(top: 48),
|
||||
child: EmptyState(
|
||||
icon: Icons.check_circle_outline,
|
||||
message: 'Нераспознанных инструментов нет.',
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
for (final p in items)
|
||||
_PendingCard(
|
||||
pending: p,
|
||||
busy: _busyId == p.id,
|
||||
onLink: () => _link(p),
|
||||
onCreate: () => _create(p),
|
||||
onIgnore: () => _ignore(p),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusFilter extends ConsumerWidget {
|
||||
const _StatusFilter();
|
||||
|
||||
static const _options = {
|
||||
'pending': 'Ждут решения',
|
||||
'resolved': 'Привязаны',
|
||||
'ignored': 'Игнорируются',
|
||||
'all': 'Все',
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final current = ref.watch(pendingStatusFilterProvider);
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (final e in _options.entries)
|
||||
ChoiceChip(
|
||||
label: Text(e.value),
|
||||
selected: current == e.key,
|
||||
onSelected: (_) => ref.read(pendingStatusFilterProvider.notifier).state = e.key,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PendingCard extends StatelessWidget {
|
||||
const _PendingCard({
|
||||
required this.pending,
|
||||
required this.busy,
|
||||
required this.onLink,
|
||||
required this.onCreate,
|
||||
required this.onIgnore,
|
||||
});
|
||||
|
||||
final PendingInstrument pending;
|
||||
final bool busy;
|
||||
final VoidCallback onLink;
|
||||
final VoidCallback onCreate;
|
||||
final VoidCallback onIgnore;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final p = pending;
|
||||
final open = p.status == 'pending';
|
||||
|
||||
final facts = <String>[
|
||||
if (p.isin != null) 'ISIN ${p.isin}',
|
||||
if (p.ticker != null) 'тикер ${p.ticker}',
|
||||
if (p.board != null) 'доска ${p.board}',
|
||||
if (p.currency != null) p.currency!,
|
||||
if (p.assetClassHint != null) 'в отчёте: ${assetClassLabel(p.assetClassHint)}',
|
||||
];
|
||||
final sample = <String>[
|
||||
'встречается ${p.occurrences} раз',
|
||||
if (p.sampleQuantity != null) 'кол-во ${formatQty(p.sampleQuantity!)}',
|
||||
if (p.samplePrice != null)
|
||||
'цена ${MoneyText.format(p.samplePrice!, p.currency ?? 'RUB')}',
|
||||
];
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Text(p.title, style: theme.textTheme.titleMedium)),
|
||||
if (!open)
|
||||
Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: Text(p.status == 'ignored' ? 'игнорируется' : 'привязан'),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (facts.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(facts.join(' · '), style: theme.textTheme.bodySmall),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(sample.join(' · '), style: theme.textTheme.bodySmall),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
'источник ${p.source} · ключ ${p.sourceKey}'
|
||||
'${p.firstSeenFileId != null ? ' · файл №${p.firstSeenFileId}' : ''}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.outline),
|
||||
),
|
||||
),
|
||||
if (open) ...[
|
||||
const SizedBox(height: 12),
|
||||
if (busy)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: LinearProgressIndicator(),
|
||||
)
|
||||
else
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onLink,
|
||||
icon: const Icon(Icons.link, size: 18),
|
||||
label: const Text('Привязать к существующему'),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onCreate,
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: const Text('Создать новый'),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: onIgnore,
|
||||
icon: const Icon(Icons.block, size: 18),
|
||||
label: const Text('Игнорировать'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user