Импорт (/imports, /instruments/pending) и фаза 4 (/goals, /income, /rebalance, /tax, аналитика-хаб с benchmarks_card) — по docs/ai/import-contract.md и docs/ai/phase4-contract.md. file_picker для загрузки отчёта.
278 lines
9.7 KiB
Dart
278 lines
9.7 KiB
Dart
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('Игнорировать'),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|