262 lines
7.2 KiB
Markdown
262 lines
7.2 KiB
Markdown
# Диаграмма распределения портфеля — План реализации
|
||
|
||
> **Для агентов:** Требуется навык `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: без изменений
|