refactor(frontend): remove flat components/portfolios
This commit is contained in:
parent
1ef1a3c902
commit
16f5e1752c
@ -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