Импорт (/imports, /instruments/pending) и фаза 4 (/goals, /income, /rebalance, /tax, аналитика-хаб с benchmarks_card) — по docs/ai/import-contract.md и docs/ai/phase4-contract.md. file_picker для загрузки отчёта.
309 lines
12 KiB
Dart
309 lines
12 KiB
Dart
import 'package:decimal/decimal.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../core/utils/json.dart';
|
|
import '../../core/widgets/async_value_view.dart';
|
|
import '../../core/widgets/section_card.dart';
|
|
import '../portfolio/labels.dart' show assetClassLabels;
|
|
import 'data/rebalance_api.dart';
|
|
import 'labels.dart';
|
|
import 'providers.dart';
|
|
import 'weights.dart';
|
|
|
|
/// Целевые веса: the editable target set for one dimension.
|
|
///
|
|
/// The sum of the weights is shown permanently and Save is blocked whenever it differs from
|
|
/// 100 % by more than 0,01 pp. The server answers 422 in that case — but a user should see
|
|
/// the problem while typing, not after a round trip.
|
|
class TargetsTab extends ConsumerStatefulWidget {
|
|
const TargetsTab({super.key});
|
|
|
|
@override
|
|
ConsumerState<TargetsTab> createState() => _TargetsTabState();
|
|
}
|
|
|
|
class _TargetsTabState extends ConsumerState<TargetsTab> {
|
|
/// Local draft. Seeded from the server set and reseeded whenever the portfolio or the
|
|
/// dimension changes — editing one dimension must never leak into another.
|
|
List<TargetWeight>? _draft;
|
|
String? _seededFor;
|
|
bool _saving = false;
|
|
bool _dirty = false;
|
|
|
|
void _seed(TargetSet set, String key) {
|
|
if (_seededFor == key) return;
|
|
_seededFor = key;
|
|
_draft = [...set.targets];
|
|
_dirty = false;
|
|
}
|
|
|
|
Decimal get _sum => sumDecimals((_draft ?? const []).map((t) => t.targetWeight));
|
|
|
|
bool get _sumIsValid => (_sum - Decimal.one).abs() <= Decimal.parse('0.0001');
|
|
|
|
Future<void> _save() async {
|
|
final portfolioId = ref.read(selectedPortfolioProvider);
|
|
final dimension = ref.read(targetDimensionProvider);
|
|
final draft = _draft;
|
|
if (portfolioId == null || draft == null) return;
|
|
|
|
setState(() => _saving = true);
|
|
try {
|
|
await ref
|
|
.read(rebalanceApiProvider)
|
|
.putTargets(portfolioId, TargetSet(dimension: dimension, targets: draft));
|
|
if (!mounted) return;
|
|
_dirty = false;
|
|
_seededFor = null; // refetch reseeds the draft from the saved set
|
|
invalidateRebalanceProviders(ref);
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(const SnackBar(content: Text('Целевые веса сохранены')));
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(SnackBar(content: Text(apiErrorMessage(e))));
|
|
} finally {
|
|
if (mounted) setState(() => _saving = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final portfolioId = ref.watch(selectedPortfolioProvider);
|
|
final dimension = ref.watch(targetDimensionProvider);
|
|
final targets = ref.watch(targetsProvider);
|
|
|
|
return AsyncValueView(
|
|
value: targets,
|
|
onRetry: () => ref.invalidate(targetsProvider),
|
|
data: (set) {
|
|
_seed(set, '$portfolioId/$dimension');
|
|
final draft = _draft ?? const <TargetWeight>[];
|
|
return ListView(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 88),
|
|
children: [
|
|
_SumBanner(sum: _sum, valid: _sumIsValid, serverSum: set.weightsSum),
|
|
const SizedBox(height: 12),
|
|
SectionCard(
|
|
title: 'Целевые веса',
|
|
subtitle: 'Вес — доля портфеля; допуск (band) — ширина коридора, '
|
|
'внутри которого сделки не предлагаются.',
|
|
child: Column(
|
|
children: [
|
|
for (var i = 0; i < draft.length; i++)
|
|
_TargetRow(
|
|
key: ValueKey('${_seededFor}_$i'),
|
|
dimension: dimension,
|
|
target: draft[i],
|
|
onChanged: (t) => setState(() {
|
|
_draft![i] = t;
|
|
_dirty = true;
|
|
}),
|
|
onRemove: () => setState(() {
|
|
_draft!.removeAt(i);
|
|
_dirty = true;
|
|
}),
|
|
),
|
|
if (draft.isEmpty)
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 16),
|
|
child: Text('Целевые веса ещё не заданы.'),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: TextButton.icon(
|
|
onPressed: () => setState(() {
|
|
_draft = [
|
|
...draft,
|
|
const TargetWeight(bucket: '', targetWeight: '0', band: '0.05'),
|
|
];
|
|
_dirty = true;
|
|
}),
|
|
icon: const Icon(Icons.add),
|
|
label: const Text('Добавить группу'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
children: [
|
|
FilledButton.icon(
|
|
onPressed: _saving || !_sumIsValid || draft.any((t) => t.bucket.isEmpty)
|
|
? null
|
|
: _save,
|
|
icon: _saving
|
|
? const SizedBox(
|
|
width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
|
: const Icon(Icons.save_outlined),
|
|
label: const Text('Сохранить'),
|
|
),
|
|
const SizedBox(width: 12),
|
|
if (_dirty)
|
|
TextButton(
|
|
onPressed: () => setState(() {
|
|
_seededFor = null;
|
|
_seed(set, '$portfolioId/$dimension');
|
|
}),
|
|
child: const Text('Отменить изменения'),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
if (!_sumIsValid)
|
|
Text(
|
|
'Сохранение заблокировано: сумма весов должна быть ровно 100 %. '
|
|
'Сервер не нормализует веса — сумма 90 % означает, что 10 % портфеля '
|
|
'не отнесены ни к одной группе, а не что доли надо растянуть.',
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
)
|
|
else if (draft.any((t) => t.bucket.isEmpty))
|
|
Text(
|
|
'У одной из групп пустое имя — сохранить нельзя.',
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SumBanner extends StatelessWidget {
|
|
const _SumBanner({required this.sum, required this.valid, this.serverSum});
|
|
|
|
final Decimal sum;
|
|
final bool valid;
|
|
final String? serverSum;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
return Card(
|
|
color: valid ? scheme.secondaryContainer : scheme.errorContainer,
|
|
child: ListTile(
|
|
leading: Icon(valid ? Icons.check_circle_outline : Icons.error_outline),
|
|
title: Text('Сумма весов: ${formatShareAsPercent(sum.toString())}'),
|
|
subtitle: Text(
|
|
valid
|
|
? 'Набор можно сохранить.'
|
|
: 'Нужно ровно 100 %. Разница: '
|
|
'${formatShareAsPercent((sum - Decimal.one).toString(), signed: true)}',
|
|
),
|
|
trailing: serverSum == null
|
|
? null
|
|
: Tooltip(
|
|
message: 'Последняя сумма, сохранённая на сервере',
|
|
child: Text(formatShareAsPercent(serverSum)),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TargetRow extends StatefulWidget {
|
|
const _TargetRow({
|
|
required this.dimension,
|
|
required this.target,
|
|
required this.onChanged,
|
|
required this.onRemove,
|
|
super.key,
|
|
});
|
|
|
|
final String dimension;
|
|
final TargetWeight target;
|
|
final ValueChanged<TargetWeight> onChanged;
|
|
final VoidCallback onRemove;
|
|
|
|
@override
|
|
State<_TargetRow> createState() => _TargetRowState();
|
|
}
|
|
|
|
class _TargetRowState extends State<_TargetRow> {
|
|
late final TextEditingController _weight =
|
|
TextEditingController(text: shareToPercentText(widget.target.targetWeight));
|
|
late final TextEditingController _band =
|
|
TextEditingController(text: shareToPercentText(widget.target.band));
|
|
late final TextEditingController _bucket =
|
|
TextEditingController(text: widget.target.bucket);
|
|
|
|
@override
|
|
void dispose() {
|
|
_weight.dispose();
|
|
_band.dispose();
|
|
_bucket.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// The known asset classes are offered as a list; every other dimension (sector, country,
|
|
// currency) has an open set of buckets that only the data knows, so those stay free text.
|
|
final knownBuckets = widget.dimension == 'asset_class'
|
|
? [...assetClassLabels.keys, 'cash']
|
|
: const <String>[];
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
flex: 3,
|
|
child: knownBuckets.isEmpty
|
|
? TextField(
|
|
controller: _bucket,
|
|
decoration: const InputDecoration(labelText: 'Группа', isDense: true),
|
|
onChanged: (v) => widget.onChanged(widget.target.copyWith(bucket: v)),
|
|
)
|
|
: DropdownButtonFormField<String>(
|
|
initialValue:
|
|
knownBuckets.contains(widget.target.bucket) ? widget.target.bucket : null,
|
|
isExpanded: true,
|
|
decoration: const InputDecoration(labelText: 'Группа', isDense: true),
|
|
items: [
|
|
for (final b in knownBuckets)
|
|
DropdownMenuItem(value: b, child: Text(bucketLabelForKey('asset_class', b))),
|
|
],
|
|
onChanged: (v) =>
|
|
widget.onChanged(widget.target.copyWith(bucket: v ?? '')),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
flex: 2,
|
|
child: TextField(
|
|
controller: _weight,
|
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
|
decoration: const InputDecoration(labelText: 'Вес, %', isDense: true),
|
|
onChanged: (v) {
|
|
final share = percentTextToShare(v);
|
|
widget.onChanged(widget.target.copyWith(targetWeight: share ?? '0'));
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
flex: 2,
|
|
child: TextField(
|
|
controller: _band,
|
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
|
decoration: const InputDecoration(labelText: 'Допуск, %', isDense: true),
|
|
onChanged: (v) =>
|
|
widget.onChanged(widget.target.copyWith(band: percentTextToShare(v))),
|
|
),
|
|
),
|
|
IconButton(
|
|
tooltip: 'Удалить группу',
|
|
onPressed: widget.onRemove,
|
|
icon: const Icon(Icons.delete_outline),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|