feat: add broker account section navigation

This commit is contained in:
Sergey Krylov 2026-06-19 06:23:35 +03:00
parent 6d2df6a12b
commit dc4b6ddf1b
3 changed files with 195 additions and 0 deletions

View File

@ -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<typeof useBrokerPortfolio>;
};
export function useBrokerAccountContext() {
return useOutletContext<BrokerAccountContext>();
}
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 (
<div className="broker-account">
<header className="broker-account__header">
<h1>{portfolio.data?.account.name || 'Брокерский счёт'}</h1>
</header>
<div className="broker-account__workspace">
<nav className="broker-account__navigation" aria-label="Разделы брокерского счёта">
<NavLink className={linkClassName} end to={basePath}>
Обзор
</NavLink>
<NavLink className={linkClassName} to={`${basePath}/shares`}>
Акции
</NavLink>
<NavLink className={linkClassName} to={`${basePath}/bonds`}>
Облигации
</NavLink>
<NavLink className={linkClassName} to={`${basePath}/operations`}>
Операции
</NavLink>
</nav>
<main className="broker-account__content">
<Outlet context={context} />
</main>
</div>
</div>
);
}

View File

@ -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(
<Routes>
<Route path="/broker/:accountId" element={<BrokerAccountLayout />}>
<Route path="bonds" element={<p>Содержимое облигаций</p>} />
</Route>
</Routes>,
['/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(
<Routes>
<Route path="/broker/:accountId" element={<BrokerAccountLayout />}>
<Route path="bonds" element={<p>Содержимое облигаций</p>} />
</Route>
</Routes>,
['/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: [

View File

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