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;
+ }
+}