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,243 @@
|
||||
import 'package:decimal/decimal.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.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 'data/income_api.dart';
|
||||
import 'labels.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
/// История: what was actually paid, by month and kind. Facts only — no basis column here,
|
||||
/// because every row is `paid` by construction.
|
||||
class IncomeHistoryTab extends ConsumerWidget {
|
||||
const IncomeHistoryTab({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final history = ref.watch(incomeHistoryProvider);
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => ref.invalidate(incomeHistoryProvider),
|
||||
child: AsyncValueView(
|
||||
value: history,
|
||||
onRetry: () => ref.invalidate(incomeHistoryProvider),
|
||||
data: (data) {
|
||||
if (data.rows.isEmpty) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: const [
|
||||
_PeriodChips(),
|
||||
SizedBox(height: 48),
|
||||
EmptyState(
|
||||
icon: Icons.history,
|
||||
message: 'Выплат за период не было.',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
final months = _byMonth(data.rows);
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
const _PeriodChips(),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
StatTile(
|
||||
label: 'Получено',
|
||||
value: MoneyText(data.totalRub, currency: 'RUB'),
|
||||
note: 'за выбранный период, до налога',
|
||||
),
|
||||
StatTile(
|
||||
label: 'Удержано налога',
|
||||
value: MoneyText(data.taxWithheldRub, currency: 'RUB'),
|
||||
note: 'по данным брокера',
|
||||
),
|
||||
StatTile(
|
||||
label: 'Месяцев с выплатами',
|
||||
value: Text('${months.length}'),
|
||||
note: 'строк в таблице: ${data.rows.length}',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SectionCard(
|
||||
title: 'Выплаты по месяцам',
|
||||
child: SizedBox(
|
||||
height: 220,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
reverse: true,
|
||||
child: SizedBox(
|
||||
width: (months.length * 44).toDouble().clamp(320, double.infinity),
|
||||
child: _HistoryChart(months: months),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SectionCard(
|
||||
title: 'Помесячно',
|
||||
child: _HistoryTable(rows: data.rows),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PeriodChips extends ConsumerWidget {
|
||||
const _PeriodChips();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final months = ref.watch(historyMonthsProvider);
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (final m in const [12, 24, 36, 120])
|
||||
ChoiceChip(
|
||||
label: Text(m >= 120 ? 'Всё время' : '$m мес'),
|
||||
selected: months == m,
|
||||
onSelected: (_) => ref.read(historyMonthsProvider.notifier).state = m,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One bar per month, in rubles. Rows of several kinds inside a month are summed for the
|
||||
/// chart; the breakdown by kind stays in the table below.
|
||||
class _MonthTotal {
|
||||
_MonthTotal(this.month, this.amountRub);
|
||||
final DateTime month;
|
||||
final Decimal amountRub;
|
||||
}
|
||||
|
||||
List<_MonthTotal> _byMonth(List<IncomeHistoryRow> rows) {
|
||||
final sums = <DateTime, Decimal>{};
|
||||
for (final r in rows) {
|
||||
final m = r.month;
|
||||
if (m == null) continue;
|
||||
final key = DateTime.utc(m.year, m.month);
|
||||
sums[key] = (sums[key] ?? Decimal.zero) + (Decimal.tryParse(r.amountRub ?? '') ?? Decimal.zero);
|
||||
}
|
||||
final keys = sums.keys.toList()..sort();
|
||||
return [for (final k in keys) _MonthTotal(k, sums[k]!)];
|
||||
}
|
||||
|
||||
class _HistoryChart extends StatelessWidget {
|
||||
const _HistoryChart({required this.months});
|
||||
|
||||
final List<_MonthTotal> months;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final maxY = months.fold<double>(0, (m, r) {
|
||||
final v = r.amountRub.toDouble();
|
||||
return v > m ? v : m;
|
||||
});
|
||||
return BarChart(
|
||||
BarChartData(
|
||||
maxY: maxY == 0 ? 1 : maxY * 1.15,
|
||||
gridData: const FlGridData(show: true, drawVerticalLine: false),
|
||||
borderData: FlBorderData(show: false),
|
||||
titlesData: FlTitlesData(
|
||||
topTitles: const AxisTitles(),
|
||||
rightTitles: const AxisTitles(),
|
||||
leftTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: true, reservedSize: 56),
|
||||
),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 28,
|
||||
getTitlesWidget: (value, meta) {
|
||||
final i = value.round();
|
||||
if (i < 0 || i >= months.length) return const SizedBox.shrink();
|
||||
if (months.length > 14 && i % 3 != 0) return const SizedBox.shrink();
|
||||
return Text(ruMonthYearShort(months[i].month), style: theme.textTheme.bodySmall);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
barTouchData: BarTouchData(
|
||||
touchTooltipData: BarTouchTooltipData(
|
||||
getTooltipItem: (group, _, rod, _) => BarTooltipItem(
|
||||
'${ruMonthYearShort(months[group.x].month)}\n'
|
||||
'${MoneyText.format(months[group.x].amountRub.toString(), 'RUB')}',
|
||||
theme.textTheme.bodySmall ?? const TextStyle(),
|
||||
),
|
||||
),
|
||||
),
|
||||
barGroups: [
|
||||
for (var i = 0; i < months.length; i++)
|
||||
BarChartGroupData(
|
||||
x: i,
|
||||
barRods: [
|
||||
BarChartRodData(
|
||||
toY: months[i].amountRub.toDouble(),
|
||||
color: basisColor('paid'),
|
||||
width: 12,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(2)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HistoryTable extends StatelessWidget {
|
||||
const _HistoryTable({required this.rows});
|
||||
|
||||
final List<IncomeHistoryRow> rows;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final headerStyle = Theme.of(context).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)),
|
||||
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),
|
||||
],
|
||||
rows: [
|
||||
for (final r in rows)
|
||||
DataRow(
|
||||
cells: [
|
||||
DataCell(Text(r.month == null ? '—' : ruMonthYearShort(r.month!))),
|
||||
DataCell(Text(incomeKindLabel(r.kind))),
|
||||
DataCell(Text(r.currency)),
|
||||
DataCell(MoneyText(r.amount, currency: r.currency)),
|
||||
DataCell(r.amountRub == null
|
||||
? const Text('—')
|
||||
: MoneyText(r.amountRub!, currency: 'RUB')),
|
||||
DataCell(r.taxWithheld == null
|
||||
? const Text('—')
|
||||
: MoneyText(r.taxWithheld!, currency: r.currency)),
|
||||
DataCell(Text('${r.paymentCount}')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user