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