refactor(frontend): remove flat components/portfolios
This commit is contained in:
parent
0b3c9a9287
commit
4d6e6620d7
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user