223 lines
7.5 KiB
TypeScript
223 lines
7.5 KiB
TypeScript
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>
|
||
);
|
||
}
|