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 portfolioHook from '../../hooks/useBrokerPortfolio';
|
||||||
import * as positionsHook from '../../hooks/useBrokerPositions';
|
import * as positionsHook from '../../hooks/useBrokerPositions';
|
||||||
import type { BrokerPosition } from '../../api/responses';
|
import type { BrokerPosition } from '../../api/responses';
|
||||||
|
import { BrokerAccountLayout } from './BrokerAccountLayout';
|
||||||
import { BrokerAccountDetailPage } from './BrokerAccountDetailPage';
|
import { BrokerAccountDetailPage } from './BrokerAccountDetailPage';
|
||||||
import { BrokerAccountsPage } from './BrokerAccountsPage';
|
import { BrokerAccountsPage } from './BrokerAccountsPage';
|
||||||
|
|
||||||
@ -63,6 +64,85 @@ function mockUseBrokerPositions(...positions: BrokerPosition[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('Broker pages', () => {
|
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', () => {
|
it('renders broker and IIS accounts', () => {
|
||||||
vi.spyOn(accountHook, 'useBrokerAccounts').mockReturnValue({
|
vi.spyOn(accountHook, 'useBrokerAccounts').mockReturnValue({
|
||||||
data: [
|
data: [
|
||||||
|
|||||||
@ -80,3 +80,69 @@ a {
|
|||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
z-index: 1;
|
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