235 lines
7.3 KiB
TypeScript
235 lines
7.3 KiB
TypeScript
import type { BrokerPositionsPage as BrokerPositionsPageData } from '@/shared/api/responses';
|
||
import { Box } from '@mui/material';
|
||
import { Button, Heading, Skeleton, Text } from '@moex-vibe/design-system';
|
||
import { formatBrokerMoney as formatMoney } from '@/shared/lib/formatters';
|
||
import { PositionTicker } from './PositionTicker';
|
||
|
||
const tableSx = {
|
||
width: '100%',
|
||
borderCollapse: 'collapse',
|
||
fontSize: 14,
|
||
} as const;
|
||
|
||
const thSx = {
|
||
borderBottom: '1px solid',
|
||
borderColor: 'divider',
|
||
color: 'text.secondary',
|
||
fontWeight: 600,
|
||
p: 1,
|
||
textAlign: 'left',
|
||
} as const;
|
||
|
||
const tdSx = {
|
||
borderBottom: '1px solid',
|
||
borderColor: 'divider',
|
||
p: 1,
|
||
verticalAlign: 'top',
|
||
textAlign: 'right',
|
||
} as const;
|
||
|
||
const tdSxLeft = {
|
||
...tdSx,
|
||
textAlign: 'left',
|
||
} as const;
|
||
|
||
function formatQuantity(value: number | null | undefined) {
|
||
return value == null ? '-' : value.toLocaleString('ru-RU');
|
||
}
|
||
|
||
export 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`}>
|
||
<Box
|
||
sx={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 1.5,
|
||
justifyContent: 'space-between',
|
||
mb: 1.5,
|
||
}}
|
||
>
|
||
<Heading level={2} id={`broker-${title.toLowerCase()}-heading`}>
|
||
{title}
|
||
</Heading>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||
<Button
|
||
variant="secondary"
|
||
size="small"
|
||
aria-label="Предыдущая страница"
|
||
onClick={onPrevious}
|
||
disabled={!canGoBack || isFetching}
|
||
>
|
||
{isFetching ? <Skeleton shape="circular" width={14} height={14} /> : '←'}
|
||
</Button>
|
||
<Box
|
||
sx={{
|
||
minWidth: 20,
|
||
textAlign: 'center',
|
||
color: 'text.secondary',
|
||
fontSize: 14,
|
||
fontWeight: 600,
|
||
}}
|
||
>
|
||
{pageNumber}
|
||
</Box>
|
||
<Button
|
||
variant="secondary"
|
||
size="small"
|
||
aria-label="Следующая страница"
|
||
onClick={onNext}
|
||
disabled={!canGoForward || isFetching}
|
||
>
|
||
{isFetching ? <Skeleton shape="circular" width={14} height={14} /> : '→'}
|
||
</Button>
|
||
</Box>
|
||
</Box>
|
||
|
||
{isLoading ? (
|
||
<Box sx={{ overflowX: 'auto', bgcolor: 'surface.default' }}>
|
||
<Box component="table" sx={tableSx}>
|
||
<Box component="thead">
|
||
<Box component="tr">
|
||
<Box component="th" sx={thSx} align="left">
|
||
Тикер
|
||
</Box>
|
||
<Box component="th" sx={thSx} align="left">
|
||
Название
|
||
</Box>
|
||
<Box component="th" sx={thSx} align="right">
|
||
Количество
|
||
</Box>
|
||
<Box component="th" sx={thSx} align="right">
|
||
Цена
|
||
</Box>
|
||
<Box component="th" sx={thSx} align="right">
|
||
Стоимость
|
||
</Box>
|
||
</Box>
|
||
</Box>
|
||
<Box component="tbody">
|
||
{[1, 2, 3, 4].map((row) => (
|
||
<Box component="tr" key={row}>
|
||
<Box component="td" sx={tdSxLeft}>
|
||
<Skeleton height={12} width="30%" shape="text" />
|
||
</Box>
|
||
<Box component="td" sx={tdSxLeft}>
|
||
<Skeleton height={12} width="50%" shape="text" />
|
||
</Box>
|
||
<Box component="td" sx={tdSx}>
|
||
<Skeleton height={12} width="20%" shape="text" />
|
||
</Box>
|
||
<Box component="td" sx={tdSx}>
|
||
<Skeleton height={12} width="25%" shape="text" />
|
||
</Box>
|
||
<Box component="td" sx={tdSx}>
|
||
<Skeleton height={12} width="25%" shape="text" />
|
||
</Box>
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
</Box>
|
||
</Box>
|
||
) : positions.length === 0 && !isFetching ? (
|
||
<Text component="p" tone="secondary">
|
||
{emptyMessage}
|
||
</Text>
|
||
) : (
|
||
<Box sx={{ position: 'relative' }}>
|
||
<Box sx={{ overflowX: 'auto', bgcolor: 'surface.default' }}>
|
||
<Box component="table" sx={tableSx} aria-label={`Брокерские позиции: ${title}`}>
|
||
<Box component="thead">
|
||
<Box component="tr">
|
||
<Box component="th" sx={thSx} align="left">
|
||
Тикер
|
||
</Box>
|
||
<Box component="th" sx={thSx} align="left">
|
||
Название
|
||
</Box>
|
||
<Box component="th" sx={thSx} align="right">
|
||
Количество
|
||
</Box>
|
||
<Box component="th" sx={thSx} align="right">
|
||
Цена
|
||
</Box>
|
||
<Box component="th" sx={thSx} align="right">
|
||
Стоимость
|
||
</Box>
|
||
</Box>
|
||
</Box>
|
||
<Box component="tbody">
|
||
{positions.map((position) => (
|
||
<Box
|
||
component="tr"
|
||
key={
|
||
position.positionUid ||
|
||
position.instrumentUid ||
|
||
position.ticker ||
|
||
position.figi
|
||
}
|
||
>
|
||
<Box component="td" sx={tdSxLeft}>
|
||
<PositionTicker position={position} />
|
||
</Box>
|
||
<Box component="td" sx={tdSxLeft}>
|
||
<Text tone="secondary">{position.name || '-'}</Text>
|
||
</Box>
|
||
<Box component="td" sx={tdSx}>
|
||
{formatQuantity(position.quantity)}
|
||
</Box>
|
||
<Box component="td" sx={tdSx}>
|
||
{formatMoney(position.currentPrice)}
|
||
</Box>
|
||
<Box component="td" sx={tdSx}>
|
||
{formatMoney(position.currentValue)}
|
||
</Box>
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
</Box>
|
||
</Box>
|
||
{isFetching && (
|
||
<Box
|
||
sx={{
|
||
position: 'absolute',
|
||
inset: 0,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: 1,
|
||
bgcolor: 'rgba(255,255,255,0.7)',
|
||
}}
|
||
>
|
||
<Box className="loading-spinner" />
|
||
<Text variant="caption" tone="secondary">
|
||
Загрузка страницы {pageNumber}…
|
||
</Text>
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|