refactor(frontend): remove flat components/portfolios

This commit is contained in:
Sergey Krylov 2026-06-20 21:03:35 +03:00
parent 1ef1a3c902
commit 16f5e1752c
9 changed files with 0 additions and 1343 deletions

View File

@ -1,162 +0,0 @@
import type { PositionWithPrice } from '@/shared/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>
);
}

View File

@ -1,68 +0,0 @@
import type { PortfolioSummary } from '@/shared/api/responses';
export function AnalyticsSummary({ summary }: { summary: PortfolioSummary }) {
const formatRub = (val: number | null) =>
val != null
? val.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
: '—';
const formatPct = (val: number | null) => (val != null ? `${val.toFixed(2)}%` : '—');
const pnlColor =
summary.totalPnl > 0
? 'var(--color-positive)'
: summary.totalPnl < 0
? 'var(--color-negative)'
: 'inherit';
return (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
gap: 24,
padding: 20,
background: 'var(--color-surface)',
border: '1px solid #e0e0e0',
borderRadius: 'var(--border-radius)',
marginTop: 16,
}}
>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
Инвестировано
</div>
<div style={{ fontSize: 18, fontWeight: 700 }}>{formatRub(summary.totalInvested)}</div>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
Текущая стоимость
</div>
<div style={{ fontSize: 18, fontWeight: 700 }}>{formatRub(summary.totalValue)}</div>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
Прибыль/Убыток
</div>
<div style={{ fontSize: 18, fontWeight: 700, color: pnlColor }}>
{summary.totalPnl > 0 ? '+' : ''}
{formatRub(summary.totalPnl)}
<span style={{ fontSize: 13, fontWeight: 500, marginLeft: 6 }}>
({formatPct(summary.totalPnlPercent)})
</span>
</div>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
Доходность (weighted)
</div>
<div style={{ fontSize: 18, fontWeight: 700, color: pnlColor }}>
{formatPct(summary.weightedYield)}
</div>
</div>
</div>
);
}

View File

@ -1,222 +0,0 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import type { PositionWithPrice } from '@/shared/api/responses';
interface Props {
position: PositionWithPrice;
onUpdate: (data: { quantity?: number; buyPrice?: number }) => void;
onDelete: () => void;
}
export function BondPositionRow({ position, onUpdate, onDelete }: Props) {
const [editingQty, setEditingQty] = useState(false);
const [editingPrice, setEditingPrice] = useState(false);
const [qty, setQty] = useState(String(position.quantity));
const [price, setPrice] = useState(String(position.buyPrice ?? ''));
function handleSaveQty() {
const num = parseInt(qty, 10);
if (!isNaN(num) && num >= 0 && num !== position.quantity) {
onUpdate({ quantity: num });
}
setEditingQty(false);
}
function handleSavePrice() {
const num = parseFloat(price);
if (!isNaN(num) && num >= 0 && num !== position.buyPrice) {
onUpdate({ buyPrice: num });
} else if (price === '' && position.buyPrice !== null) {
onUpdate({ buyPrice: undefined });
}
setEditingPrice(false);
}
function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return '—';
return new Date(dateStr).toLocaleDateString('ru-RU');
}
function formatPct(value: number | null | undefined): string {
return value != null ? `${value.toFixed(2)}%` : '—';
}
function formatRubles(value: number | null | undefined): string {
return value != null
? value.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
: '—';
}
const totalAccrued = position.accruedInt != null ? position.accruedInt * position.quantity : null;
return (
<tr style={{ borderBottom: '1px solid #f0f0f0' }}>
<td style={{ padding: '8px 12px', fontWeight: 600, fontFamily: 'monospace' }}>
<Link to={`/bonds/${position.secid}`} style={{ color: 'inherit', textDecoration: 'none' }}>
{position.secid}
</Link>
</td>
<td style={{ padding: '8px 12px', color: 'var(--color-text-secondary)' }}>
{position.shortName ?? '—'}
</td>
<td style={{ padding: '8px 12px', fontSize: 13, color: 'var(--color-text-secondary)' }}>
{position.bondType ? <span>{position.bondType}</span> : '—'}
</td>
<td style={{ padding: '8px 12px' }}>
{editingQty ? (
<input
type="number"
min={0}
value={qty}
onChange={(e) => setQty(e.target.value)}
onBlur={handleSaveQty}
onKeyDown={(e) => e.key === 'Enter' && handleSaveQty()}
autoFocus
style={{
width: 80,
padding: '4px 8px',
border: '1px solid var(--color-primary)',
borderRadius: 'var(--border-radius)',
fontSize: 14,
}}
/>
) : (
<span
onClick={() => {
setQty(String(position.quantity));
setEditingQty(true);
}}
style={{ cursor: 'pointer', padding: '4px 0', display: 'inline-block' }}
>
{position.quantity.toLocaleString('ru-RU')}
</span>
)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{editingPrice ? (
<input
type="number"
step="0.01"
value={price}
onChange={(e) => setPrice(e.target.value)}
onBlur={handleSavePrice}
onKeyDown={(e) => e.key === 'Enter' && handleSavePrice()}
autoFocus
style={{
width: 90,
padding: '4px 8px',
border: '1px solid var(--color-primary)',
borderRadius: 'var(--border-radius)',
fontSize: 14,
textAlign: 'right',
}}
/>
) : (
<span
onClick={() => {
setPrice(String(position.buyPrice ?? ''));
setEditingPrice(true);
}}
style={{
cursor: 'pointer',
padding: '4px 0',
display: 'inline-block',
color: position.buyPrice === null ? 'var(--color-text-secondary)' : 'inherit',
}}
>
{formatPct(position.buyPrice)}
</span>
)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{formatPct(position.currentPrice)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>{formatPct(position.bid)}</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>{formatPct(position.offer)}</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{formatPct(position.yieldToMaturity)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{position.duration != null ? `${position.duration.toFixed(2)}г` : '—'}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{formatRubles(position.couponValue)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{formatPct(position.couponPercent)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{position.couponPeriod ? `${position.couponPeriod}д` : '—'}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{totalAccrued != null
? totalAccrued.toLocaleString('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
: '—'}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{position.totalCost !== null
? position.totalCost.toLocaleString('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
: '—'}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{position.pnl !== null ? (
<span
style={{ color: position.pnl >= 0 ? 'var(--color-positive)' : 'var(--color-negative)' }}
>
{position.pnl >= 0 ? '+' : ''}
{position.pnl.toLocaleString('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</span>
) : (
'—'
)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{position.pnlPercent !== null ? (
<span
style={{
color: position.pnlPercent >= 0 ? 'var(--color-positive)' : 'var(--color-negative)',
}}
>
{position.pnlPercent >= 0 ? '+' : ''}
{position.pnlPercent.toFixed(2)}%
</span>
) : (
'—'
)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{formatDate(position.nextCouponDate)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>{formatDate(position.matDate)}</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>{formatDate(position.offerDate)}</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{position.weightPercent.toFixed(1)}%
</td>
<td style={{ padding: '8px 12px' }}>
<button
onClick={onDelete}
style={{
background: 'none',
border: 'none',
color: '#e53935',
cursor: 'pointer',
fontSize: 13,
padding: 4,
}}
title="Удалить позицию"
>
</button>
</td>
</tr>
);
}

View File

@ -1,273 +0,0 @@
import { BondPositionRow } from './BondPositionRow';
import type { PositionWithPrice } from '@/shared/api/responses';
interface Props {
positions: PositionWithPrice[];
onUpdatePosition: (
positionId: number,
data: { quantity?: number; buyPrice?: number; buyDate?: string },
) => void;
onDeletePosition: (positionId: number) => void;
}
export function BondPositionTable({ positions, onUpdatePosition, onDeletePosition }: Props) {
if (positions.length === 0) return null;
return (
<div style={{ marginTop: 24 }}>
<h3 style={{ margin: '0 0 12px', fontSize: 15, fontWeight: 600, color: 'var(--color-text)' }}>
Облигации
</h3>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<thead>
<tr style={{ borderBottom: '2px solid #e0e0e0' }}>
<th
style={{
textAlign: 'left',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Тикер
</th>
<th
style={{
textAlign: 'left',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Название
</th>
<th
style={{
textAlign: 'left',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Тип
</th>
<th
style={{
textAlign: 'left',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Количество
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Цена пок.
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Цена
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Бид
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Оффер
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Доходность
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Дюрация
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Купон
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Куп. %
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Период
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
НКД
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Затраты
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
P&L
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
P&L %
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
След. купон
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Погашение
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Оферта
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Доля
</th>
<th style={{ padding: '8px 12px', width: 40 }}></th>
</tr>
</thead>
<tbody>
{positions.map((pos) => (
<BondPositionRow
key={pos.id}
position={pos}
onUpdate={(data) => onUpdatePosition(pos.id, data)}
onDelete={() => onDeletePosition(pos.id)}
/>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -1,87 +0,0 @@
import { Link } from 'react-router-dom';
import type { Portfolio } from '@/shared/api/responses';
export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) {
const chipStyle = (bg: string): React.CSSProperties => ({
background: bg,
padding: '4px 10px',
borderRadius: 6,
fontSize: 12,
color: '#fff',
fontWeight: 500,
});
return (
<Link
to={`/portfolios/${portfolio.id}`}
style={{
display: 'block',
padding: 20,
background: 'var(--color-surface)',
border: '1px solid #e0e0e0',
borderRadius: 'var(--border-radius)',
textDecoration: 'none',
color: 'inherit',
transition: 'box-shadow 0.2s',
}}
onMouseEnter={(e) => (e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.08)')}
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = 'none')}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 12,
}}
>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600, flex: 1 }}>{portfolio.name}</h3>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 20, fontWeight: 700, lineHeight: 1.2 }}>
{portfolio.totalValue?.toLocaleString('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-secondary)' }}>
{portfolio.currency}
</div>
</div>
</div>
{portfolio.description && (
<p style={{ margin: '0 0 12px', fontSize: 13, color: 'var(--color-text-secondary)' }}>
{portfolio.description}
</p>
)}
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
{portfolio.shareCount > 0 && (
<span style={chipStyle('#1b5e20')}>
{portfolio.shareCount} {pluralize(portfolio.shareCount, 'акция', 'акции', 'акций')}
</span>
)}
{portfolio.bondCount > 0 && (
<span style={chipStyle('#0d47a1')}>
{portfolio.bondCount}{' '}
{pluralize(portfolio.bondCount, 'облигация', 'облигации', 'облигаций')}
</span>
)}
<span style={chipStyle('#424242')}>
{portfolio.positionCount}{' '}
{pluralize(portfolio.positionCount, 'позиция', 'позиции', 'позиций')}
</span>
</div>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
обновлён {new Date(portfolio.updatedAt).toLocaleDateString('ru-RU')}
</span>
</Link>
);
}
function pluralize(n: number, one: string, few: string, many: string): string {
if (n % 10 === 1 && n % 100 !== 11) return one;
if (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20)) return few;
return many;
}

View File

@ -1,119 +0,0 @@
import { useState } from 'react';
import type { Portfolio } from '@/shared/api/responses';
interface Props {
initial?: Portfolio;
onSave: (data: { name: string; description?: string; currency?: string }) => void;
onCancel: () => void;
isLoading?: boolean;
}
const CURRENCIES = ['RUB', 'USD', 'EUR', 'CNY', 'KZT', 'BYN'];
export function PortfolioForm({ initial, onSave, onCancel, isLoading }: Props) {
const [name, setName] = useState(initial?.name || '');
const [description, setDescription] = useState(initial?.description || '');
const [currency, setCurrency] = useState(initial?.currency || 'RUB');
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!name.trim()) return;
onSave({ name: name.trim(), description: description.trim() || undefined, currency });
}
return (
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<label style={{ display: 'block', fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Название
</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
required
maxLength={100}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #e0e0e0',
borderRadius: 'var(--border-radius)',
fontSize: 14,
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Описание
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
maxLength={500}
rows={3}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #e0e0e0',
borderRadius: 'var(--border-radius)',
fontSize: 14,
resize: 'vertical',
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
Валюта
</label>
<select
value={currency}
onChange={(e) => setCurrency(e.target.value)}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #e0e0e0',
borderRadius: 'var(--border-radius)',
fontSize: 14,
}}
>
{CURRENCIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button
type="button"
onClick={onCancel}
style={{
padding: '8px 16px',
background: 'transparent',
border: '1px solid #e0e0e0',
borderRadius: 'var(--border-radius)',
fontSize: 14,
cursor: 'pointer',
}}
>
Отмена
</button>
<button
type="submit"
disabled={isLoading || !name.trim()}
style={{
padding: '8px 16px',
background: 'var(--color-primary)',
color: '#fff',
border: 'none',
borderRadius: 'var(--border-radius)',
fontSize: 14,
fontWeight: 600,
cursor: 'pointer',
}}
>
{initial ? 'Сохранить' : 'Создать'}
</button>
</div>
</form>
);
}

View File

@ -1,46 +0,0 @@
import { AllocationChart } from './AllocationChart';
import type { PortfolioDetail } from '@/shared/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>
);
}

View File

@ -1,203 +0,0 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import type { PositionWithPrice } from '@/shared/api/responses';
interface Props {
position: PositionWithPrice;
onUpdate: (data: { quantity?: number; buyPrice?: number }) => void;
onDelete: () => void;
}
export function SharePositionRow({ position, onUpdate, onDelete }: Props) {
const [editingQty, setEditingQty] = useState(false);
const [editingPrice, setEditingPrice] = useState(false);
const [qty, setQty] = useState(String(position.quantity));
const [price, setPrice] = useState(String(position.buyPrice ?? ''));
function handleSaveQty() {
const num = parseInt(qty, 10);
if (!isNaN(num) && num >= 0 && num !== position.quantity) {
onUpdate({ quantity: num });
}
setEditingQty(false);
}
function handleSavePrice() {
const num = parseFloat(price);
if (!isNaN(num) && num >= 0 && num !== position.buyPrice) {
onUpdate({ buyPrice: num });
} else if (price === '' && position.buyPrice !== null) {
onUpdate({ buyPrice: undefined }); // Or handle null if backend supports it
}
setEditingPrice(false);
}
return (
<tr style={{ borderBottom: '1px solid #f0f0f0' }}>
<td style={{ padding: '8px 12px', fontWeight: 600, fontFamily: 'monospace' }}>
<Link to={`/stocks/${position.secid}`} style={{ color: 'inherit', textDecoration: 'none' }}>
{position.secid}
</Link>
</td>
<td style={{ padding: '8px 12px', color: 'var(--color-text-secondary)' }}>
{position.shortName ?? '—'}
</td>
<td style={{ padding: '8px 12px' }}>
{editingQty ? (
<input
type="number"
min={0}
value={qty}
onChange={(e) => setQty(e.target.value)}
onBlur={handleSaveQty}
onKeyDown={(e) => e.key === 'Enter' && handleSaveQty()}
autoFocus
style={{
width: 80,
padding: '4px 8px',
border: '1px solid var(--color-primary)',
borderRadius: 'var(--border-radius)',
fontSize: 14,
}}
/>
) : (
<span
onClick={() => {
setQty(String(position.quantity));
setEditingQty(true);
}}
style={{ cursor: 'pointer', padding: '4px 0', display: 'inline-block' }}
>
{position.quantity}
</span>
)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{editingPrice ? (
<input
type="number"
step="0.01"
value={price}
onChange={(e) => setPrice(e.target.value)}
onBlur={handleSavePrice}
onKeyDown={(e) => e.key === 'Enter' && handleSavePrice()}
autoFocus
style={{
width: 100,
padding: '4px 8px',
border: '1px solid var(--color-primary)',
borderRadius: 'var(--border-radius)',
fontSize: 14,
textAlign: 'right',
}}
/>
) : (
<span
onClick={() => {
setPrice(String(position.buyPrice ?? ''));
setEditingPrice(true);
}}
style={{
cursor: 'pointer',
padding: '4px 0',
display: 'inline-block',
color: position.buyPrice === null ? 'var(--color-text-secondary)' : 'inherit',
}}
>
{position.buyPrice !== null
? position.buyPrice.toLocaleString('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
: '—'}
</span>
)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{position.currentPrice !== null
? position.currentPrice.toLocaleString('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
: '—'}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{position.change != null && position.change !== 0 ? (
<span style={{ color: position.change > 0 ? '#43a047' : '#e53935' }}>
{position.change > 0 ? '+' : ''}
{position.change.toLocaleString('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</span>
) : (
'—'
)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{position.currentValue !== null
? position.currentValue.toLocaleString('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
: '—'}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{position.totalCost !== null
? position.totalCost.toLocaleString('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
: '—'}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{position.pnl !== null ? (
<span
style={{ color: position.pnl >= 0 ? 'var(--color-positive)' : 'var(--color-negative)' }}
>
{position.pnl >= 0 ? '+' : ''}
{position.pnl.toLocaleString('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</span>
) : (
'—'
)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{position.pnlPercent !== null ? (
<span
style={{
color: position.pnlPercent >= 0 ? 'var(--color-positive)' : 'var(--color-negative)',
}}
>
{position.pnlPercent >= 0 ? '+' : ''}
{position.pnlPercent.toFixed(2)}%
</span>
) : (
'—'
)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{position.weightPercent.toFixed(1)}%
</td>
<td style={{ padding: '8px 12px' }}>
<button
onClick={onDelete}
style={{
background: 'none',
border: 'none',
color: '#e53935',
cursor: 'pointer',
fontSize: 13,
padding: 4,
}}
title="Удалить позицию"
>
</button>
</td>
</tr>
);
}

View File

@ -1,163 +0,0 @@
import { SharePositionRow } from './SharePositionRow';
import type { PositionWithPrice } from '@/shared/api/responses';
interface Props {
positions: PositionWithPrice[];
onUpdatePosition: (
positionId: number,
data: { quantity?: number; buyPrice?: number; buyDate?: string },
) => void;
onDeletePosition: (positionId: number) => void;
}
export function SharePositionTable({ positions, onUpdatePosition, onDeletePosition }: Props) {
if (positions.length === 0) return null;
return (
<div style={{ marginTop: 16 }}>
<h3 style={{ margin: '0 0 12px', fontSize: 15, fontWeight: 600, color: 'var(--color-text)' }}>
Акции
</h3>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<thead>
<tr style={{ borderBottom: '2px solid #e0e0e0' }}>
<th
style={{
textAlign: 'left',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Тикер
</th>
<th
style={{
textAlign: 'left',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Название
</th>
<th
style={{
textAlign: 'left',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Количество
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Цена пок.
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Цена
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Изм.
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Стоимость
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Затраты
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
P&L
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
P&L %
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Доля
</th>
<th style={{ padding: '8px 12px', width: 40 }}></th>
</tr>
</thead>
<tbody>
{positions.map((pos) => (
<SharePositionRow
key={pos.id}
position={pos}
onUpdate={(data) => onUpdatePosition(pos.id, data)}
onDelete={() => onDeletePosition(pos.id)}
/>
))}
</tbody>
</table>
</div>
</div>
);
}