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
+177
View File
@@ -0,0 +1,177 @@
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 '../../core/widgets/money_text.dart';
import '../../core/widgets/section_card.dart';
import '../portfolio/labels.dart' show formatQty, signColor;
import 'data/tax_api.dart';
import 'providers.dart';
/// Лоты и ЛДВ: the practical screen of the phase — what a sale costs **today** versus what
/// it costs after the three-year mark.
///
/// Lots close to the ЛДВ date are called out, because selling a lot 20 days early is the
/// one mistake this screen exists to prevent.
class TaxLotsTab extends ConsumerStatefulWidget {
const TaxLotsTab({super.key});
@override
ConsumerState<TaxLotsTab> createState() => _TaxLotsTabState();
}
class _TaxLotsTabState extends ConsumerState<TaxLotsTab> {
bool _onlyNearLdv = false;
@override
Widget build(BuildContext context) {
final lots = ref.watch(taxLotsProvider);
return RefreshIndicator(
onRefresh: () async => ref.invalidate(taxLotsProvider),
child: AsyncValueView(
value: lots,
onRetry: () => ref.invalidate(taxLotsProvider),
data: (all) {
final near = all.where((l) => l.nearLdv).toList();
// a copy: the provider's list must not be reordered under other watchers
final rows = [...(_onlyNearLdv ? near : all)];
// soonest ЛДВ first among the lots that do not have it yet, eligible ones last
rows.sort((a, b) {
if (a.ldvEligible != b.ldvEligible) return a.ldvEligible ? 1 : -1;
return (a.daysToLdv ?? 1 << 30).compareTo(b.daysToLdv ?? 1 << 30);
});
if (all.isEmpty) {
return ListView(
padding: const EdgeInsets.all(16),
children: const [
SizedBox(height: 48),
EmptyState(
icon: Icons.inventory_2_outlined,
message: 'Открытых лотов нет.',
),
],
);
}
return ListView(
padding: const EdgeInsets.all(16),
children: [
if (near.isNotEmpty)
Card(
color: Theme.of(context).colorScheme.tertiaryContainer,
child: ListTile(
leading: const Icon(Icons.schedule),
title: Text('До ЛДВ меньше полугода: ${near.length} лот(ов)'),
subtitle: const Text(
'Продажа до этой даты облагается налогом на весь прирост.'),
trailing: FilterChip(
label: const Text('только они'),
selected: _onlyNearLdv,
onSelected: (v) => setState(() => _onlyNearLdv = v),
),
),
),
const SizedBox(height: 12),
SectionCard(
title: 'Открытые лоты',
subtitle: 'налог при продаже сегодня — оценка по ставке из сводки',
child: _LotsTable(rows: rows),
),
],
);
},
),
);
}
}
class _LotsTable extends StatelessWidget {
const _LotsTable({required this.rows});
final List<TaxLot> rows;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final headerStyle = theme.textTheme.labelMedium;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
columns: [
DataColumn(label: Text('Бумага', style: headerStyle)),
DataColumn(label: Text('Куплен', style: headerStyle)),
DataColumn(label: Text('Кол-во', style: headerStyle), numeric: true),
DataColumn(label: Text('Стоимость', style: headerStyle), numeric: true),
DataColumn(label: Text('Рынок', style: headerStyle), numeric: true),
DataColumn(label: Text('Нереализ.', style: headerStyle), numeric: true),
DataColumn(label: Text('Дата ЛДВ', style: headerStyle)),
DataColumn(label: Text('Дней до ЛДВ', style: headerStyle), numeric: true),
DataColumn(label: Text('Налог при продаже', style: headerStyle), numeric: true),
],
rows: [
for (final l in rows)
DataRow(
color: l.nearLdv
? WidgetStatePropertyAll(
theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5))
: null,
onSelectChanged: l.instrumentId == null
? null
: (_) => context.push('/portfolio/instrument/${l.instrumentId}'),
cells: [
DataCell(Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(l.title),
if (l.nearLdv) ...[
const SizedBox(width: 6),
Tooltip(
message: 'До ЛДВ осталось ${l.daysToLdv} дн. — '
'продажа сейчас облагается налогом полностью',
child: Icon(Icons.schedule, size: 16, color: theme.colorScheme.error),
),
],
],
)),
DataCell(Text(l.openDate == null ? '' : ruDate(l.openDate!))),
DataCell(Text(l.qtyRemaining == null ? '' : formatQty(l.qtyRemaining!))),
DataCell(l.costRub == null
? const Text('')
: MoneyText(l.costRub!, currency: 'RUB')),
DataCell(l.marketValueRub == null
? const Text('')
: MoneyText(l.marketValueRub!, currency: 'RUB')),
DataCell(l.unrealizedGainRub == null
? const Text('')
: MoneyText(
l.unrealizedGainRub!,
currency: 'RUB',
style: TextStyle(color: signColor(context, l.unrealizedGainRub)),
)),
DataCell(l.ldvEligible
? const Text('уже действует')
: Text(l.ldvDate == null ? '' : ruDate(l.ldvDate!))),
DataCell(l.ldvEligible
? const Text('')
: Text(
l.daysToLdv == null ? '' : '${l.daysToLdv}',
style: l.nearLdv
? TextStyle(
color: theme.colorScheme.error, fontWeight: FontWeight.w600)
: null,
)),
DataCell(l.taxIfSoldNowRub == null
? const Text('')
: MoneyText(l.taxIfSoldNowRub!, currency: 'RUB')),
],
),
],
),
);
}
}