feat: add broker asset pages

This commit is contained in:
Sergey Krylov 2026-06-19 07:03:09 +03:00
parent b82771107d
commit 910494a4e5
2 changed files with 542 additions and 0 deletions

View File

@ -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();
});
});
}); });

View 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}
/>
);
}