diff --git a/apps/frontend/src/pages/broker/BrokerPages.test.tsx b/apps/frontend/src/pages/broker/BrokerPages.test.tsx index 4d7dba3..b2e5cfb 100644 --- a/apps/frontend/src/pages/broker/BrokerPages.test.tsx +++ b/apps/frontend/src/pages/broker/BrokerPages.test.tsx @@ -13,6 +13,7 @@ import { BrokerAccountLayout, useBrokerAccountContext } from './BrokerAccountLay import { BrokerAccountDetailPage } from './BrokerAccountDetailPage'; import { BrokerAccountOverviewPage } from './BrokerAccountOverviewPage'; import { BrokerAccountsPage } from './BrokerAccountsPage'; +import { BrokerPositionsPage } from './BrokerPositionsPage'; function renderWithClient(ui: ReactElement, initialEntries = ['/broker']) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -1093,4 +1094,229 @@ describe('Broker pages', () => { expect(screen.getByRole('status')).toHaveTextContent('Обновление операций…'); expect(screen.getByText('SU26238RMFS5')).toBeInTheDocument(); }); + + describe('Broker positions page', () => { + function renderPositionsPage(initialEntry = '/broker/acc-1/shares') { + return renderWithClient( + + }> + } /> + } /> + + , + [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(); + }); + }); }); diff --git a/apps/frontend/src/pages/broker/BrokerPositionsPage.tsx b/apps/frontend/src/pages/broker/BrokerPositionsPage.tsx new file mode 100644 index 0000000..7cccb20 --- /dev/null +++ b/apps/frontend/src/pages/broker/BrokerPositionsPage.tsx @@ -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 {label}; + } + + return ( + + {label} + + ); +} + +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 ( +
+
+

+ {title} +

+
+ + + {pageNumber} + + +
+
+ + {isLoading ? ( +
+ + + + + + + + + + + +
+ Тикер + + Название + + Количество + + Цена + + Стоимость +
+
+ ) : positions.length === 0 && !isFetching ? ( +

{emptyMessage}

+ ) : ( +
+
+ + + + + + + + + + + + {positions.map((position) => ( + + + + + + + + ))} + +
+ Тикер + + Название + + Количество + + Цена + + Стоимость +
+ + + + {position.name || '-'} + + + {formatQuantity(position.quantity)} + + {formatMoney(position.currentPrice)} + + {formatMoney(position.currentValue)} +
+
+ {isFetching && ( +
+
+ + Загрузка страницы {pageNumber}… + +
+ )} +
+ )} +
+ ); +} + +type BrokerPositionsPageProps = { + type: 'share' | 'bond'; + title: 'Акции' | 'Облигации'; +}; + +export function BrokerPositionsPage({ type, title }: BrokerPositionsPageProps) { + const { accountId } = useBrokerAccountContext(); + const [cursor, setCursor] = useState(undefined); + const [cursorStack, setCursorStack] = useState>([]); + 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 ( +
+

+ {title} +

+

+ {type === 'share' ? 'Не удалось загрузить акции' : 'Не удалось загрузить облигации'} +

+
+ ); + } + + return ( + + ); +}