feat: add broker asset pages
This commit is contained in:
parent
b82771107d
commit
910494a4e5
@ -13,6 +13,7 @@ import { BrokerAccountLayout, useBrokerAccountContext } from './BrokerAccountLay
|
|||||||
import { BrokerAccountDetailPage } from './BrokerAccountDetailPage';
|
import { BrokerAccountDetailPage } from './BrokerAccountDetailPage';
|
||||||
import { BrokerAccountOverviewPage } from './BrokerAccountOverviewPage';
|
import { BrokerAccountOverviewPage } from './BrokerAccountOverviewPage';
|
||||||
import { BrokerAccountsPage } from './BrokerAccountsPage';
|
import { BrokerAccountsPage } from './BrokerAccountsPage';
|
||||||
|
import { BrokerPositionsPage } from './BrokerPositionsPage';
|
||||||
|
|
||||||
function renderWithClient(ui: ReactElement, initialEntries = ['/broker']) {
|
function renderWithClient(ui: ReactElement, initialEntries = ['/broker']) {
|
||||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
@ -1093,4 +1094,229 @@ describe('Broker pages', () => {
|
|||||||
expect(screen.getByRole('status')).toHaveTextContent('Обновление операций…');
|
expect(screen.getByRole('status')).toHaveTextContent('Обновление операций…');
|
||||||
expect(screen.getByText('SU26238RMFS5')).toBeInTheDocument();
|
expect(screen.getByText('SU26238RMFS5')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Broker positions page', () => {
|
||||||
|
function renderPositionsPage(initialEntry = '/broker/acc-1/shares') {
|
||||||
|
return renderWithClient(
|
||||||
|
<Routes>
|
||||||
|
<Route path="/broker/:accountId" element={<BrokerAccountLayout />}>
|
||||||
|
<Route path="shares" element={<BrokerPositionsPage type="share" title="Акции" />} />
|
||||||
|
<Route path="bonds" element={<BrokerPositionsPage type="bond" title="Облигации" />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>,
|
||||||
|
[initialEntry],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockPositionsPortfolio() {
|
||||||
|
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
|
||||||
|
data: {
|
||||||
|
account: {
|
||||||
|
id: 'acc-1',
|
||||||
|
type: 'brokerage',
|
||||||
|
name: 'Broker',
|
||||||
|
status: 'ACCOUNT_STATUS_OPEN',
|
||||||
|
openedAt: null,
|
||||||
|
accessLevel: null,
|
||||||
|
},
|
||||||
|
totals: { portfolio: { currency: 'RUB', units: '10000', nano: 0, value: 10000 } },
|
||||||
|
yields: { expectedPercent: 5, daily: null, dailyPercent: null },
|
||||||
|
cash: [],
|
||||||
|
blockedCash: [],
|
||||||
|
asOf: '2026-06-19T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
isFetching: false,
|
||||||
|
error: null,
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders share positions with correct query and instrument links', () => {
|
||||||
|
mockPositionsPortfolio();
|
||||||
|
const positionsSpy = mockUseBrokerPositions(
|
||||||
|
createPosition({
|
||||||
|
instrumentUid: 'share-uid',
|
||||||
|
ticker: 'SBER',
|
||||||
|
classCode: 'TQBR',
|
||||||
|
instrumentType: 'share',
|
||||||
|
name: 'Sberbank',
|
||||||
|
quantity: 10,
|
||||||
|
currentPrice: { currency: 'RUB', units: '250', nano: 0, value: 250 },
|
||||||
|
currentValue: { currency: 'RUB', units: '2500', nano: 0, value: 2500 },
|
||||||
|
}),
|
||||||
|
createPosition({
|
||||||
|
instrumentUid: 'bond-uid',
|
||||||
|
ticker: 'SU26238RMFS5',
|
||||||
|
classCode: 'TQOB',
|
||||||
|
instrumentType: 'bond',
|
||||||
|
name: 'ОФЗ 26238',
|
||||||
|
quantity: 2,
|
||||||
|
currentPrice: { currency: 'RUB', units: '900', nano: 0, value: 900 },
|
||||||
|
currentValue: { currency: 'RUB', units: '1800', nano: 0, value: 1800 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderPositionsPage('/broker/acc-1/shares');
|
||||||
|
|
||||||
|
expect(positionsSpy).toHaveBeenLastCalledWith('acc-1', {
|
||||||
|
type: 'share',
|
||||||
|
limit: 10,
|
||||||
|
cursor: undefined,
|
||||||
|
});
|
||||||
|
expect(screen.getByRole('heading', { name: 'Акции' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('link', { name: 'SBER' })).toHaveAttribute('href', '/stocks/SBER');
|
||||||
|
expect(screen.queryByText('SU26238RMFS5')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders bond positions with correct query and instrument links', () => {
|
||||||
|
mockPositionsPortfolio();
|
||||||
|
const positionsSpy = mockUseBrokerPositions(
|
||||||
|
createPosition({
|
||||||
|
instrumentUid: 'share-uid',
|
||||||
|
ticker: 'SBER',
|
||||||
|
instrumentType: 'share',
|
||||||
|
name: 'Sberbank',
|
||||||
|
}),
|
||||||
|
createPosition({
|
||||||
|
instrumentUid: 'bond-uid',
|
||||||
|
ticker: 'SU26238RMFS5',
|
||||||
|
classCode: 'TQOB',
|
||||||
|
instrumentType: 'bond',
|
||||||
|
name: 'ОФЗ 26238',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderPositionsPage('/broker/acc-1/bonds');
|
||||||
|
|
||||||
|
expect(positionsSpy).toHaveBeenLastCalledWith('acc-1', {
|
||||||
|
type: 'bond',
|
||||||
|
limit: 10,
|
||||||
|
cursor: undefined,
|
||||||
|
});
|
||||||
|
expect(screen.getByRole('heading', { name: 'Облигации' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('link', { name: 'SU26238RMFS5' })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'/bonds/SU26238RMFS5',
|
||||||
|
);
|
||||||
|
expect(screen.queryByText('SBER')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates positions forward and backward by cursor', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
mockPositionsPortfolio();
|
||||||
|
const positionsSpy = vi
|
||||||
|
.spyOn(positionsHook, 'useBrokerPositions')
|
||||||
|
.mockImplementation((_accountId, query) => {
|
||||||
|
const items =
|
||||||
|
query.cursor === 'cursor-page-2'
|
||||||
|
? [
|
||||||
|
createPosition({
|
||||||
|
instrumentUid: 'share-2',
|
||||||
|
ticker: 'GAZP',
|
||||||
|
instrumentType: 'share',
|
||||||
|
name: 'Gazprom',
|
||||||
|
quantity: 5,
|
||||||
|
currentValue: { currency: 'RUB', units: '5000', nano: 0, value: 5000 },
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
createPosition({
|
||||||
|
instrumentUid: 'share-1',
|
||||||
|
ticker: 'SBER',
|
||||||
|
instrumentType: 'share',
|
||||||
|
name: 'Sberbank',
|
||||||
|
quantity: 10,
|
||||||
|
currentValue: { currency: 'RUB', units: '2500', nano: 0, value: 2500 },
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
accountId: 'acc-1',
|
||||||
|
items,
|
||||||
|
nextCursor: query.cursor ? null : 'cursor-page-2',
|
||||||
|
hasNext: !query.cursor,
|
||||||
|
asOf: '2026-06-19T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
isFetching: false,
|
||||||
|
error: null,
|
||||||
|
} as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
renderPositionsPage('/broker/acc-1/shares');
|
||||||
|
|
||||||
|
const section = screen.getByRole('heading', { name: 'Акции' }).closest('section')!;
|
||||||
|
const withinSection = within(section);
|
||||||
|
expect(positionsSpy).toHaveBeenLastCalledWith('acc-1', {
|
||||||
|
type: 'share',
|
||||||
|
limit: 10,
|
||||||
|
cursor: undefined,
|
||||||
|
});
|
||||||
|
expect(withinSection.getByText('SBER')).toBeInTheDocument();
|
||||||
|
|
||||||
|
const nextButton = withinSection.getByRole('button', { name: 'Следующая страница' });
|
||||||
|
await user.click(nextButton);
|
||||||
|
|
||||||
|
expect(positionsSpy).toHaveBeenLastCalledWith('acc-1', {
|
||||||
|
type: 'share',
|
||||||
|
limit: 10,
|
||||||
|
cursor: 'cursor-page-2',
|
||||||
|
});
|
||||||
|
expect(withinSection.getByText('GAZP')).toBeInTheDocument();
|
||||||
|
expect(withinSection.queryByText('SBER')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
const prevButton = withinSection.getByRole('button', { name: 'Предыдущая страница' });
|
||||||
|
await user.click(prevButton);
|
||||||
|
|
||||||
|
expect(positionsSpy).toHaveBeenLastCalledWith('acc-1', {
|
||||||
|
type: 'share',
|
||||||
|
limit: 10,
|
||||||
|
cursor: undefined,
|
||||||
|
});
|
||||||
|
expect(withinSection.getByText('SBER')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an empty message when there are no positions of the given type', () => {
|
||||||
|
mockPositionsPortfolio();
|
||||||
|
mockUseBrokerPositions();
|
||||||
|
|
||||||
|
renderPositionsPage('/broker/acc-1/shares');
|
||||||
|
|
||||||
|
expect(screen.getByText('На счёте нет акций')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a skeleton while positions are loading', () => {
|
||||||
|
mockPositionsPortfolio();
|
||||||
|
vi.spyOn(positionsHook, 'useBrokerPositions').mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: true,
|
||||||
|
isFetching: true,
|
||||||
|
error: null,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const { container } = renderPositionsPage('/broker/acc-1/shares');
|
||||||
|
|
||||||
|
expect(container.querySelectorAll('.skeleton').length).toBeGreaterThan(0);
|
||||||
|
expect(
|
||||||
|
screen.getByRole('navigation', { name: 'Разделы брокерского счёта' }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps account navigation visible when positions fail to load', () => {
|
||||||
|
mockPositionsPortfolio();
|
||||||
|
vi.spyOn(positionsHook, 'useBrokerPositions').mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: false,
|
||||||
|
isFetching: false,
|
||||||
|
error: new Error('positions failed'),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
renderPositionsPage('/broker/acc-1/shares');
|
||||||
|
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent('Не удалось загрузить акции');
|
||||||
|
expect(
|
||||||
|
screen.getByRole('navigation', { name: 'Разделы брокерского счёта' }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
316
apps/frontend/src/pages/broker/BrokerPositionsPage.tsx
Normal file
316
apps/frontend/src/pages/broker/BrokerPositionsPage.tsx
Normal file
@ -0,0 +1,316 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import type { BrokerMoney, BrokerPosition, BrokerPositionsPage } from '../../api/responses';
|
||||||
|
import { getBrokerInstrumentPath } from './brokerDisplay';
|
||||||
|
import { TableSkeleton } from '../../components/TableSkeleton';
|
||||||
|
import { useBrokerPositions } from '../../hooks/useBrokerPositions';
|
||||||
|
import { useBrokerAccountContext } from './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: BrokerPositionsPage | 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user