feat: add broker account section navigation
This commit is contained in:
parent
6d2df6a12b
commit
dc4b6ddf1b
49
apps/frontend/src/pages/broker/BrokerAccountLayout.tsx
Normal file
49
apps/frontend/src/pages/broker/BrokerAccountLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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: [
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user