320 lines
9.6 KiB
TypeScript
320 lines
9.6 KiB
TypeScript
import { useState } from 'react';
|
||
import { Link } from 'react-router-dom';
|
||
import type {
|
||
BrokerMoney,
|
||
BrokerPosition,
|
||
BrokerPositionsPage as BrokerPositionsPageData,
|
||
} from '../../../api/responses';
|
||
import { TableSkeleton } from '../../../components/TableSkeleton';
|
||
import { getBrokerInstrumentPath, useBrokerPositions } from '../../../entities/broker-position';
|
||
import { useBrokerAccountContext } from '../../broker/BrokerAccountLayout';
|
||
|
||
const tableStyle = {
|
||
width: '100%',
|
||
borderCollapse: 'collapse',
|
||
fontSize: 14,
|
||
} satisfies React.CSSProperties;
|
||
|
||
const thStyle = {
|
||
borderBottom: '1px solid #e0e0e0',
|
||
color: 'var(--color-text-secondary)',
|
||
fontWeight: 600,
|
||
padding: '10px 8px',
|
||
} satisfies React.CSSProperties;
|
||
|
||
const tdStyle = {
|
||
borderBottom: '1px solid #eeeeee',
|
||
padding: '10px 8px',
|
||
verticalAlign: 'top',
|
||
} satisfies React.CSSProperties;
|
||
|
||
const pagButtonStyle = {
|
||
padding: '6px 14px',
|
||
borderRadius: 6,
|
||
border: '1px solid #e0e0e0',
|
||
background: 'var(--color-surface)',
|
||
color: 'var(--color-text)',
|
||
fontSize: 14,
|
||
fontWeight: 600,
|
||
cursor: 'pointer',
|
||
lineHeight: 1.4,
|
||
} satisfies React.CSSProperties;
|
||
|
||
const pagButtonDisabledStyle = {
|
||
...pagButtonStyle,
|
||
opacity: 0.35,
|
||
cursor: 'not-allowed',
|
||
} satisfies React.CSSProperties;
|
||
|
||
function formatMoney(value: BrokerMoney | null | undefined) {
|
||
if (!value) return '-';
|
||
return new Intl.NumberFormat('ru-RU', {
|
||
style: 'currency',
|
||
currency: value.currency || 'RUB',
|
||
maximumFractionDigits: 2,
|
||
}).format(value.value);
|
||
}
|
||
|
||
function formatQuantity(value: number | null | undefined) {
|
||
return value == null ? '-' : value.toLocaleString('ru-RU');
|
||
}
|
||
|
||
function PositionTicker({ position }: { position: BrokerPosition }) {
|
||
const label = position.ticker || position.figi || '-';
|
||
const path = getBrokerInstrumentPath({
|
||
ticker: position.ticker,
|
||
instrumentType: position.instrumentType,
|
||
classCode: position.classCode,
|
||
});
|
||
|
||
if (!path || label === '-') {
|
||
return <strong>{label}</strong>;
|
||
}
|
||
|
||
return (
|
||
<Link to={path} style={{ fontWeight: 700 }}>
|
||
{label}
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
function BrokerPositionTable({
|
||
title,
|
||
page,
|
||
isLoading,
|
||
isFetching,
|
||
emptyMessage,
|
||
pageNumber,
|
||
onNext,
|
||
onPrevious,
|
||
}: {
|
||
title: string;
|
||
page: BrokerPositionsPageData | undefined;
|
||
isLoading: boolean;
|
||
isFetching: boolean;
|
||
emptyMessage: string;
|
||
pageNumber: number;
|
||
onNext: () => void;
|
||
onPrevious: () => void;
|
||
}) {
|
||
const positions = page?.items ?? [];
|
||
const canGoBack = pageNumber > 1;
|
||
const canGoForward = Boolean(page?.hasNext && page.nextCursor);
|
||
|
||
return (
|
||
<section aria-labelledby={`broker-${title.toLowerCase()}-heading`}>
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 12,
|
||
justifyContent: 'space-between',
|
||
marginBottom: 10,
|
||
}}
|
||
>
|
||
<h2 id={`broker-${title.toLowerCase()}-heading`} style={{ fontSize: 20, margin: 0 }}>
|
||
{title}
|
||
</h2>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<button
|
||
type="button"
|
||
aria-label="Предыдущая страница"
|
||
onClick={onPrevious}
|
||
disabled={!canGoBack || isFetching}
|
||
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||
>
|
||
{isFetching ? (
|
||
<span
|
||
className="loading-spinner"
|
||
style={{ width: 14, height: 14, display: 'block' }}
|
||
/>
|
||
) : (
|
||
'←'
|
||
)}
|
||
</button>
|
||
<span
|
||
style={{
|
||
minWidth: 20,
|
||
textAlign: 'center',
|
||
color: 'var(--color-text-secondary)',
|
||
fontSize: 14,
|
||
fontWeight: 600,
|
||
}}
|
||
>
|
||
{pageNumber}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
aria-label="Следующая страница"
|
||
onClick={onNext}
|
||
disabled={!canGoForward || isFetching}
|
||
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||
>
|
||
{isFetching ? (
|
||
<span
|
||
className="loading-spinner"
|
||
style={{ width: 14, height: 14, display: 'block' }}
|
||
/>
|
||
) : (
|
||
'→'
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{isLoading ? (
|
||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||
<table style={tableStyle}>
|
||
<thead>
|
||
<tr>
|
||
<th align="left" style={thStyle}>
|
||
Тикер
|
||
</th>
|
||
<th align="left" style={thStyle}>
|
||
Название
|
||
</th>
|
||
<th align="right" style={thStyle}>
|
||
Количество
|
||
</th>
|
||
<th align="right" style={thStyle}>
|
||
Цена
|
||
</th>
|
||
<th align="right" style={thStyle}>
|
||
Стоимость
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<TableSkeleton
|
||
rows={4}
|
||
columns={[
|
||
{ width: '30%' },
|
||
{ width: '50%' },
|
||
{ width: '20%' },
|
||
{ width: '25%' },
|
||
{ width: '25%' },
|
||
]}
|
||
/>
|
||
</table>
|
||
</div>
|
||
) : positions.length === 0 && !isFetching ? (
|
||
<p style={{ color: 'var(--color-text-secondary)' }}>{emptyMessage}</p>
|
||
) : (
|
||
<div className="table-container">
|
||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||
<table aria-label={`Брокерские позиции: ${title}`} style={tableStyle}>
|
||
<thead>
|
||
<tr>
|
||
<th align="left" style={thStyle}>
|
||
Тикер
|
||
</th>
|
||
<th align="left" style={thStyle}>
|
||
Название
|
||
</th>
|
||
<th align="right" style={thStyle}>
|
||
Количество
|
||
</th>
|
||
<th align="right" style={thStyle}>
|
||
Цена
|
||
</th>
|
||
<th align="right" style={thStyle}>
|
||
Стоимость
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{positions.map((position) => (
|
||
<tr
|
||
key={
|
||
position.positionUid ||
|
||
position.instrumentUid ||
|
||
position.ticker ||
|
||
position.figi
|
||
}
|
||
>
|
||
<td style={tdStyle}>
|
||
<PositionTicker position={position} />
|
||
</td>
|
||
<td style={tdStyle}>
|
||
<span style={{ color: 'var(--color-text-secondary)' }}>
|
||
{position.name || '-'}
|
||
</span>
|
||
</td>
|
||
<td align="right" style={tdStyle}>
|
||
{formatQuantity(position.quantity)}
|
||
</td>
|
||
<td align="right" style={tdStyle}>
|
||
{formatMoney(position.currentPrice)}
|
||
</td>
|
||
<td align="right" style={tdStyle}>
|
||
{formatMoney(position.currentValue)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{isFetching && (
|
||
<div className="table-loading-overlay">
|
||
<div className="loading-spinner" />
|
||
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
||
Загрузка страницы {pageNumber}…
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
type BrokerPositionsPageProps = {
|
||
type: 'share' | 'bond';
|
||
title: 'Акции' | 'Облигации';
|
||
};
|
||
|
||
export function BrokerPositionsPage({ type, title }: BrokerPositionsPageProps) {
|
||
const { accountId } = useBrokerAccountContext();
|
||
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
||
const [cursorStack, setCursorStack] = useState<Array<string | undefined>>([]);
|
||
const positions = useBrokerPositions(accountId, { type, limit: 10, cursor });
|
||
|
||
function handleNext() {
|
||
const nextCursor = positions.data?.nextCursor;
|
||
if (!nextCursor || !positions.data?.hasNext) return;
|
||
setCursorStack((previous) => [...previous, cursor]);
|
||
setCursor(nextCursor);
|
||
}
|
||
|
||
function handlePrevious() {
|
||
if (cursorStack.length === 0) return;
|
||
setCursor(cursorStack[cursorStack.length - 1]);
|
||
setCursorStack((previous) => previous.slice(0, -1));
|
||
}
|
||
|
||
if (positions.error) {
|
||
return (
|
||
<section aria-labelledby={`broker-${type}-heading`}>
|
||
<h2 id={`broker-${type}-heading`} style={{ fontSize: 20, margin: 0 }}>
|
||
{title}
|
||
</h2>
|
||
<p role="alert">
|
||
{type === 'share' ? 'Не удалось загрузить акции' : 'Не удалось загрузить облигации'}
|
||
</p>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<BrokerPositionTable
|
||
title={title}
|
||
page={positions.data}
|
||
isLoading={positions.isLoading}
|
||
isFetching={positions.isFetching}
|
||
emptyMessage={type === 'share' ? 'На счёте нет акций' : 'На счёте нет облигаций'}
|
||
pageNumber={cursorStack.length + 1}
|
||
onNext={handleNext}
|
||
onPrevious={handlePrevious}
|
||
/>
|
||
);
|
||
}
|