From dc4b6ddf1b5b44e1bc8931e087142966dcffc530 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Fri, 19 Jun 2026 06:23:35 +0300 Subject: [PATCH] feat: add broker account section navigation --- .../src/pages/broker/BrokerAccountLayout.tsx | 49 ++++++++++++ .../src/pages/broker/BrokerPages.test.tsx | 80 +++++++++++++++++++ apps/frontend/src/styles.css | 66 +++++++++++++++ 3 files changed, 195 insertions(+) create mode 100644 apps/frontend/src/pages/broker/BrokerAccountLayout.tsx diff --git a/apps/frontend/src/pages/broker/BrokerAccountLayout.tsx b/apps/frontend/src/pages/broker/BrokerAccountLayout.tsx new file mode 100644 index 0000000..f5aaea0 --- /dev/null +++ b/apps/frontend/src/pages/broker/BrokerAccountLayout.tsx @@ -0,0 +1,49 @@ +import { NavLink, Outlet, useOutletContext, useParams } from 'react-router-dom'; +import { useBrokerPortfolio } from '../../hooks/useBrokerPortfolio'; + +export type BrokerAccountContext = { + accountId: string; + portfolio: ReturnType; +}; + +export function useBrokerAccountContext() { + return useOutletContext(); +} + +export function BrokerAccountLayout() { + const { accountId = '' } = useParams(); + const portfolio = useBrokerPortfolio(accountId); + const basePath = `/broker/${encodeURIComponent(accountId)}`; + const context: BrokerAccountContext = { accountId, portfolio }; + const linkClassName = ({ isActive }: { isActive: boolean }) => + `broker-account__link${isActive ? ' is-active' : ''}`; + + return ( +
+
+

{portfolio.data?.account.name || 'Брокерский счёт'}

+
+ +
+ + +
+ +
+
+
+ ); +} diff --git a/apps/frontend/src/pages/broker/BrokerPages.test.tsx b/apps/frontend/src/pages/broker/BrokerPages.test.tsx index 233ee9c..ab13560 100644 --- a/apps/frontend/src/pages/broker/BrokerPages.test.tsx +++ b/apps/frontend/src/pages/broker/BrokerPages.test.tsx @@ -9,6 +9,7 @@ import * as operationsHook from '../../hooks/useBrokerOperations'; import * as portfolioHook from '../../hooks/useBrokerPortfolio'; import * as positionsHook from '../../hooks/useBrokerPositions'; import type { BrokerPosition } from '../../api/responses'; +import { BrokerAccountLayout } from './BrokerAccountLayout'; import { BrokerAccountDetailPage } from './BrokerAccountDetailPage'; import { BrokerAccountsPage } from './BrokerAccountsPage'; @@ -63,6 +64,85 @@ function mockUseBrokerPositions(...positions: BrokerPosition[]) { } describe('Broker pages', () => { + it('renders account section navigation with the current nested route', () => { + 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: '1000', nano: 0, value: 1000 } }, + yields: { expectedPercent: 5, daily: null, dailyPercent: null }, + cash: [], + blockedCash: [], + asOf: '2026-06-17T00:00:00.000Z', + }, + isLoading: false, + isFetching: false, + error: null, + } as any); + + renderWithClient( + + }> + Содержимое облигаций

} /> +
+
, + ['/broker/acc-1/bonds'], + ); + + expect(screen.getByRole('heading', { level: 1, name: 'Broker' })).toBeInTheDocument(); + const navigation = screen.getByRole('navigation', { name: 'Разделы брокерского счёта' }); + expect(within(navigation).getByRole('link', { name: 'Обзор' })).toHaveAttribute( + 'href', + '/broker/acc-1', + ); + expect(within(navigation).getByRole('link', { name: 'Акции' })).toHaveAttribute( + 'href', + '/broker/acc-1/shares', + ); + expect(within(navigation).getByRole('link', { name: 'Облигации' })).toHaveAttribute( + 'href', + '/broker/acc-1/bonds', + ); + expect(within(navigation).getByRole('link', { name: 'Операции' })).toHaveAttribute( + 'href', + '/broker/acc-1/operations', + ); + const activeLink = within(navigation).getByRole('link', { name: 'Облигации' }); + expect(activeLink).toHaveAttribute('aria-current', 'page'); + expect(activeLink).toHaveClass('is-active'); + expect(screen.getByText('Содержимое облигаций')).toBeInTheDocument(); + }); + + it('keeps account navigation and nested content visible when the portfolio is unavailable', () => { + vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ + data: undefined, + isLoading: false, + isFetching: false, + error: new Error('Portfolio unavailable'), + } as any); + + renderWithClient( + + }> + Содержимое облигаций

} /> +
+
, + ['/broker/acc-1/bonds'], + ); + + expect(screen.getByRole('heading', { level: 1, name: 'Брокерский счёт' })).toBeInTheDocument(); + expect( + screen.getByRole('navigation', { name: 'Разделы брокерского счёта' }), + ).toBeInTheDocument(); + expect(screen.getByText('Содержимое облигаций')).toBeInTheDocument(); + }); + it('renders broker and IIS accounts', () => { vi.spyOn(accountHook, 'useBrokerAccounts').mockReturnValue({ data: [ diff --git a/apps/frontend/src/styles.css b/apps/frontend/src/styles.css index 18678e9..69b0d5d 100644 --- a/apps/frontend/src/styles.css +++ b/apps/frontend/src/styles.css @@ -80,3 +80,69 @@ a { transition: opacity 0.2s ease; z-index: 1; } + +.broker-account { + display: grid; + gap: 24px; +} + +.broker-account__header h1 { + font-size: 28px; + line-height: 1.2; +} + +.broker-account__workspace { + display: grid; + grid-template-columns: minmax(150px, 190px) minmax(0, 1fr); + gap: 24px; +} + +.broker-account__navigation { + display: flex; + flex-direction: column; + gap: 4px; +} + +.broker-account__link { + padding: 10px 12px; + border-radius: var(--border-radius); + color: var(--color-text-secondary); +} + +.broker-account__link.is-active, +.broker-account__link[aria-current='page'] { + background: color-mix(in srgb, var(--color-primary) 10%, transparent); + color: var(--color-primary); + font-weight: 700; +} + +.broker-account__link:focus-visible { + outline: 3px solid var(--color-primary); + outline-offset: 2px; +} + +.broker-account__content { + min-width: 0; +} + +@media (max-width: 720px) { + .broker-account, + .broker-account__workspace { + gap: 16px; + } + + .broker-account__workspace { + grid-template-columns: minmax(0, 1fr); + } + + .broker-account__navigation { + flex-direction: row; + overflow-x: auto; + scrollbar-width: thin; + } + + .broker-account__link { + flex: none; + white-space: nowrap; + } +}