feat: add allocation donut chart to portfolio summary
Some checks failed
Some checks failed
This commit is contained in:
parent
a980520261
commit
4c15bda30e
162
apps/frontend/src/components/portfolios/AllocationChart.tsx
Normal file
162
apps/frontend/src/components/portfolios/AllocationChart.tsx
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import type { PositionWithPrice } from '../../api/responses';
|
||||||
|
|
||||||
|
interface AllocationChartProps {
|
||||||
|
positions: PositionWithPrice[];
|
||||||
|
totalValue: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SectorData {
|
||||||
|
type: 'share' | 'bond';
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
count: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECTOR_COLORS = {
|
||||||
|
share: 'var(--color-primary, #1976d2)',
|
||||||
|
bond: '#f57c00',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const SECTOR_LABELS = {
|
||||||
|
share: 'Акции',
|
||||||
|
bond: 'Облигации',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function computeSectors(positions: PositionWithPrice[]): SectorData[] {
|
||||||
|
const sectors: SectorData[] = [
|
||||||
|
{ type: 'share', label: SECTOR_LABELS.share, value: 0, count: 0, color: SECTOR_COLORS.share },
|
||||||
|
{ type: 'bond', label: SECTOR_LABELS.bond, value: 0, count: 0, color: SECTOR_COLORS.bond },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const p of positions) {
|
||||||
|
const sector = sectors.find((s) => s.type === p.type);
|
||||||
|
if (sector) {
|
||||||
|
sector.value += p.currentValue ?? 0;
|
||||||
|
sector.count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sectors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AllocationChart({ positions, totalValue }: AllocationChartProps) {
|
||||||
|
const sectors = computeSectors(positions);
|
||||||
|
const nonZero = sectors.filter((s) => s.value > 0);
|
||||||
|
const hasData = nonZero.length > 0;
|
||||||
|
|
||||||
|
const cx = 60;
|
||||||
|
const cy = 60;
|
||||||
|
const r = 44;
|
||||||
|
const strokeWidth = 10;
|
||||||
|
const circumference = 2 * Math.PI * r;
|
||||||
|
const viewBoxSize = 120;
|
||||||
|
|
||||||
|
function renderArcs() {
|
||||||
|
if (!hasData) {
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
r={r}
|
||||||
|
fill="none"
|
||||||
|
stroke="#e0e0e0"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
transform={`rotate(-90 ${cx} ${cy})`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nonZero.length === 1) {
|
||||||
|
const sector = nonZero[0];
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
r={r}
|
||||||
|
fill="none"
|
||||||
|
stroke={sector.color}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
transform={`rotate(-90 ${cx} ${cy})`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sectors.map((sector, i) => {
|
||||||
|
const ratio = totalValue > 0 ? sector.value / totalValue : 0;
|
||||||
|
const dashLen = ratio * circumference;
|
||||||
|
const gapLen = circumference - dashLen;
|
||||||
|
let rotation = -90;
|
||||||
|
|
||||||
|
for (let j = 0; j < i; j++) {
|
||||||
|
const prevRatio = totalValue > 0 ? sectors[j].value / totalValue : 0;
|
||||||
|
rotation += prevRatio * 360;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
key={sector.type}
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
r={r}
|
||||||
|
fill="none"
|
||||||
|
stroke={sector.color}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeDasharray={`${dashLen} ${gapLen}`}
|
||||||
|
transform={`rotate(${rotation} ${cx} ${cy})`}
|
||||||
|
style={{ transition: 'stroke-dasharray 0.3s ease' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||||
|
<svg
|
||||||
|
width={90}
|
||||||
|
height={90}
|
||||||
|
viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{renderArcs()}
|
||||||
|
<text
|
||||||
|
x={cx}
|
||||||
|
y={cy}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="central"
|
||||||
|
style={{
|
||||||
|
fontSize: hasData && positions.length > 0 ? 14 : 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
fill: 'var(--color-text)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{positions.length === 0
|
||||||
|
? 'Нет позиций'
|
||||||
|
: totalValue.toLocaleString('ru-RU', { maximumFractionDigits: 0 })}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
<div style={{ fontSize: 13, lineHeight: 1.6 }}>
|
||||||
|
{sectors.map((s) => {
|
||||||
|
const ratio = totalValue > 0 ? (s.value / totalValue) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div key={s.type} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 2,
|
||||||
|
background: s.color,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{s.label}: {s.count} / {ratio.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { AllocationChart } from './AllocationChart';
|
||||||
import type { PortfolioDetail } from '../../api/responses';
|
import type { PortfolioDetail } from '../../api/responses';
|
||||||
|
|
||||||
export function PortfolioSummary({ portfolio }: { portfolio: PortfolioDetail }) {
|
export function PortfolioSummary({ portfolio }: { portfolio: PortfolioDetail }) {
|
||||||
@ -12,6 +13,7 @@ export function PortfolioSummary({ portfolio }: { portfolio: PortfolioDetail })
|
|||||||
borderRadius: 'var(--border-radius)',
|
borderRadius: 'var(--border-radius)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<AllocationChart positions={portfolio.positions} totalValue={portfolio.totalValue} />
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
|
||||||
Общая стоимость
|
Общая стоимость
|
||||||
|
|||||||
261
docs/superpowers/plans/2026-06-14-portfolio-allocation-chart.md
Normal file
261
docs/superpowers/plans/2026-06-14-portfolio-allocation-chart.md
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
# Диаграмма распределения портфеля — План реализации
|
||||||
|
|
||||||
|
> **Для агентов:** Требуется навык `superpowers:subagent-driven-development` или `superpowers:executing-plans`. Шаги используют `- [ ]`.
|
||||||
|
|
||||||
|
**Цель:** Добавить SVG-диаграмму donut в PortfolioSummary, показывающую распределение стоимости между акциями и облигациями.
|
||||||
|
|
||||||
|
**Архитектура:** Всё на клиенте. Бэкенд уже возвращает `positions` с `currentValue` и `type`. Новый компонент `AllocationChart` агрегирует данные и рисует SVG. `PortfolioSummary` включает его.
|
||||||
|
|
||||||
|
**Технологии:** React 18, SVG (без библиотек).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Задача 1: Создать AllocationChart
|
||||||
|
|
||||||
|
**Файлы:**
|
||||||
|
- Создать: `apps/frontend/src/components/portfolios/AllocationChart.tsx`
|
||||||
|
|
||||||
|
- [ ] **Шаг 1: Создать AllocationChart.tsx**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { PositionWithPrice } from '../../api/responses';
|
||||||
|
|
||||||
|
interface AllocationChartProps {
|
||||||
|
positions: PositionWithPrice[];
|
||||||
|
totalValue: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SectorData {
|
||||||
|
type: 'share' | 'bond';
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
count: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECTOR_COLORS = {
|
||||||
|
share: 'var(--color-primary, #1976d2)',
|
||||||
|
bond: '#f57c00',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const SECTOR_LABELS = {
|
||||||
|
share: 'Акции',
|
||||||
|
bond: 'Облигации',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function computeSectors(positions: PositionWithPrice[]): SectorData[] {
|
||||||
|
const sectors: SectorData[] = [
|
||||||
|
{ type: 'share', label: SECTOR_LABELS.share, value: 0, count: 0, color: SECTOR_COLORS.share },
|
||||||
|
{ type: 'bond', label: SECTOR_LABELS.bond, value: 0, count: 0, color: SECTOR_COLORS.bond },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const p of positions) {
|
||||||
|
const sector = sectors.find((s) => s.type === p.type);
|
||||||
|
if (sector) {
|
||||||
|
sector.value += p.currentValue ?? 0;
|
||||||
|
sector.count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sectors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AllocationChart({ positions, totalValue }: AllocationChartProps) {
|
||||||
|
const sectors = computeSectors(positions);
|
||||||
|
const nonZero = sectors.filter((s) => s.value > 0);
|
||||||
|
const hasData = nonZero.length > 0;
|
||||||
|
|
||||||
|
const cx = 60;
|
||||||
|
const cy = 60;
|
||||||
|
const r = 44;
|
||||||
|
const strokeWidth = 10;
|
||||||
|
const circumference = 2 * Math.PI * r;
|
||||||
|
const viewBoxSize = 120;
|
||||||
|
|
||||||
|
function renderArcs() {
|
||||||
|
if (!hasData) {
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
r={r}
|
||||||
|
fill="none"
|
||||||
|
stroke="#e0e0e0"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
transform={`rotate(-90 ${cx} ${cy})`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nonZero.length === 1) {
|
||||||
|
const sector = nonZero[0];
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
r={r}
|
||||||
|
fill="none"
|
||||||
|
stroke={sector.color}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
transform={`rotate(-90 ${cx} ${cy})`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sectors.map((sector, i) => {
|
||||||
|
const ratio = totalValue > 0 ? sector.value / totalValue : 0;
|
||||||
|
const dashLen = ratio * circumference;
|
||||||
|
const gapLen = circumference - dashLen;
|
||||||
|
let rotation = -90;
|
||||||
|
|
||||||
|
for (let j = 0; j < i; j++) {
|
||||||
|
const prevRatio = totalValue > 0 ? sectors[j].value / totalValue : 0;
|
||||||
|
rotation += prevRatio * 360;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
key={sector.type}
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
r={r}
|
||||||
|
fill="none"
|
||||||
|
stroke={sector.color}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeDasharray={`${dashLen} ${gapLen}`}
|
||||||
|
transform={`rotate(${rotation} ${cx} ${cy})`}
|
||||||
|
style={{ transition: 'stroke-dasharray 0.3s ease' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||||
|
<svg
|
||||||
|
width={90}
|
||||||
|
height={90}
|
||||||
|
viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{renderArcs()}
|
||||||
|
<text
|
||||||
|
x={cx}
|
||||||
|
y={cy}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="central"
|
||||||
|
style={{
|
||||||
|
fontSize: hasData && positions.length > 0 ? 14 : 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
fill: 'var(--color-text)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{positions.length === 0
|
||||||
|
? 'Нет позиций'
|
||||||
|
: totalValue.toLocaleString('ru-RU', { maximumFractionDigits: 0 })}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
<div style={{ fontSize: 13, lineHeight: 1.6 }}>
|
||||||
|
{sectors.map((s) => {
|
||||||
|
const ratio = totalValue > 0 ? (s.value / totalValue) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div key={s.type} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 2,
|
||||||
|
background: s.color,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{s.label}: {s.count} / {ratio.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Шаг 2: Проверить сборку**
|
||||||
|
|
||||||
|
Run: `npm run build:frontend`
|
||||||
|
Expected: без ошибок
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Задача 2: Интегрировать в PortfolioSummary
|
||||||
|
|
||||||
|
**Файлы:**
|
||||||
|
- Изменить: `apps/frontend/src/components/portfolios/PortfolioSummary.tsx`
|
||||||
|
|
||||||
|
- [ ] **Шаг 1: Обновить PortfolioSummary**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { AllocationChart } from './AllocationChart';
|
||||||
|
import type { PortfolioDetail } from '../../api/responses';
|
||||||
|
|
||||||
|
export function PortfolioSummary({ portfolio }: { portfolio: PortfolioDetail }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 32,
|
||||||
|
padding: 20,
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
border: '1px solid #e0e0e0',
|
||||||
|
borderRadius: 'var(--border-radius)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AllocationChart positions={portfolio.positions} totalValue={portfolio.totalValue} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
|
||||||
|
Общая стоимость
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 24, fontWeight: 700 }}>
|
||||||
|
{portfolio.totalValue.toLocaleString('ru-RU', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 400,
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
marginLeft: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{portfolio.currency}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
|
||||||
|
Позиций
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 24, fontWeight: 700 }}>{portfolio.positions.length}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Шаг 2: Проверить сборку**
|
||||||
|
|
||||||
|
Run: `npm run build:frontend`
|
||||||
|
Expected: без ошибок
|
||||||
|
|
||||||
|
- [ ] **Шаг 3: Проверить линтер**
|
||||||
|
|
||||||
|
Run: `npm run lint`
|
||||||
|
Expected: без ошибок
|
||||||
|
|
||||||
|
- [ ] **Шаг 4: Проверить форматирование**
|
||||||
|
|
||||||
|
Run: `npm run format`
|
||||||
|
Expected: без изменений
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
# Диаграмма распределения портфеля — Спецификация
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
Добавить аналитическую диаграмму (donut) на страницу детального просмотра портфеля, показывающую распределение стоимости между акциями и облигациями, с количеством позиций в легенде.
|
||||||
|
|
||||||
|
## Источник данных
|
||||||
|
`PortfolioDetail.positions[]` — бэкенд уже возвращает:
|
||||||
|
- `type: 'share' | 'bond'`
|
||||||
|
- `currentValue: number | null`
|
||||||
|
- `quantity: number`
|
||||||
|
|
||||||
|
Агрегация на клиенте. Изменений бэкенда не требуется.
|
||||||
|
|
||||||
|
## Компонент: `AllocationChart`
|
||||||
|
- Чистый SVG (два сектора + текст в центре)
|
||||||
|
- Без внешних зависимостей
|
||||||
|
- Размещается внутри `PortfolioSummary` первым ребенком flex-ряда
|
||||||
|
- В центре — общая стоимость портфеля (или «Нет позиций»)
|
||||||
|
- Справа — легенда с разбивкой по типу: количество / процент
|
||||||
|
|
||||||
|
### Цветовая схема
|
||||||
|
| Сектор | Цвет | Легенда |
|
||||||
|
|--------|------|---------|
|
||||||
|
| Акции | `var(--color-primary)` / #1976d2 | `Акции: N / XX.X%` |
|
||||||
|
| Облигации | #f57c00 | `Облигации: N / XX.X%` |
|
||||||
|
|
||||||
|
### Состояния
|
||||||
|
- **Обычное (оба типа):** две дуги пропорционально стоимости, в центре — сумма
|
||||||
|
- **Один тип (только акции или только облигации):** полный круг цветом сектора
|
||||||
|
- **Пусто (нет позиций):** серый круг, в центре «Нет позиций»
|
||||||
|
|
||||||
|
### Расположение
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────┐
|
||||||
|
│ [⭕ donut] Общая стоимость Позиций │
|
||||||
|
│ [xxx,xxx] xxx,xxx.xx RUB 12 │
|
||||||
|
│ Акции: 8 / 70.5% │
|
||||||
|
│ Облигации: 4 / 29.5% │
|
||||||
|
└────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Изменяемые файлы
|
||||||
|
|
||||||
|
### Новый
|
||||||
|
- `apps/frontend/src/components/portfolios/AllocationChart.tsx`
|
||||||
|
|
||||||
|
### Изменяемый
|
||||||
|
- `apps/frontend/src/components/portfolios/PortfolioSummary.tsx` — импорт и рендер `AllocationChart`
|
||||||
|
|
||||||
|
## Граничные случаи
|
||||||
|
|
||||||
|
- `currentValue === null` — считается как 0
|
||||||
|
- У всех позиций `currentValue === 0` — серый круг, в центре «0»
|
||||||
|
- Один из типов отсутствует (0 позиций) — в легенде `0 / 0.0%`, дуга заполняет 100% для непустого типа
|
||||||
Loading…
x
Reference in New Issue
Block a user