feat: filter broker operations by exact type

This commit is contained in:
Sergey Krylov 2026-06-19 07:06:49 +03:00
parent 910494a4e5
commit 2223463dd3
7 changed files with 380 additions and 849 deletions

View File

@ -25,6 +25,31 @@ describe('broker api', () => {
); );
}); });
it('serializes operations query parameters including operationTypes', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({
data: {
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: 'now' },
meta: { fromCache: false, cachedAt: null },
},
}),
} as Response);
await getBrokerOperations('acc-1', {
cursor: 'c1',
limit: 10,
operationTypes: 'OPERATION_TYPE_COUPON',
});
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining(
'/api/v1/broker/accounts/acc-1/operations?cursor=c1&limit=10&operationTypes=OPERATION_TYPE_COUPON',
),
expect.any(Object),
);
});
it('serializes positions query parameters', async () => { it('serializes positions query parameters', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue({ vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true, ok: true,

View File

@ -1,139 +0,0 @@
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import type { BrokerMoney } from '../../api/responses';
import { useBrokerOperations } from '../../hooks/useBrokerOperations';
import { useBrokerPortfolio } from '../../hooks/useBrokerPortfolio';
import { BrokerOperationsTable } from './BrokerOperationsTable';
import { BrokerPositionsSection } from './BrokerPositionsSection';
import { SkeletonBlock } from '../../components/SkeletonBlock';
function formatMoney(value: BrokerMoney | null | undefined) {
if (!value) return '-';
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: value.currency || 'RUB',
maximumFractionDigits: 2,
}).format(value.value);
}
export function BrokerAccountDetailPage() {
const { accountId } = useParams();
const [operationCursor, setOperationCursor] = useState<string | undefined>(undefined);
const [operationCursorStack, setOperationCursorStack] = useState<Array<string | undefined>>([]);
const portfolio = useBrokerPortfolio(accountId);
const operations = useBrokerOperations(accountId, { limit: 10, cursor: operationCursor });
if (portfolio.isLoading) {
return (
<div style={{ display: 'grid', gap: 24 }}>
<div style={{ display: 'grid', gap: 12 }}>
<SkeletonBlock height={32} width="60%" />
<SkeletonBlock height={24} width="40%" />
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
gap: 12,
}}
>
{[1, 2, 3].map((i) => (
<div
key={i}
style={{
padding: 16,
background: 'var(--color-surface)',
border: '1px solid #e0e0e0',
borderRadius: 8,
}}
>
<SkeletonBlock height={14} width="40%" />
<div style={{ height: 8 }} />
<SkeletonBlock height={20} width="60%" />
</div>
))}
</div>
</div>
);
}
if (portfolio.error || !portfolio.data) {
return <p style={{ color: 'var(--color-negative)' }}>Не удалось загрузить портфель</p>;
}
function handleNextOperationsPage() {
const nextCursor = operations.data?.nextCursor;
if (!nextCursor || !operations.data?.hasNext) return;
setOperationCursorStack((previous) => [...previous, operationCursor]);
setOperationCursor(nextCursor);
}
function handlePreviousOperationsPage() {
if (operationCursorStack.length === 0) return;
const nextStack = operationCursorStack.slice(0, -1);
const previousCursor = operationCursorStack[operationCursorStack.length - 1];
setOperationCursorStack(nextStack);
setOperationCursor(previousCursor);
}
return (
<div style={{ display: 'grid', gap: 24 }}>
<header>
<h1 style={{ fontSize: 28, lineHeight: 1.2, marginBottom: 12 }}>
{portfolio.data.account.name}
</h1>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'baseline' }}>
<strong style={{ fontSize: 24 }}>{formatMoney(portfolio.data.totals.portfolio)}</strong>
<span style={{ color: 'var(--color-text-secondary)' }}>
День: {formatMoney(portfolio.data.yields.daily)}
</span>
<span style={{ color: 'var(--color-text-secondary)' }}>
Ожидаемая: {portfolio.data.yields.expectedPercent ?? '-'}%
</span>
</div>
</header>
<section
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
gap: 12,
}}
>
{portfolio.data.cash.map((money) => (
<div
key={money.currency}
style={{
background: 'var(--color-surface)',
border: '1px solid #e0e0e0',
borderRadius: 8,
padding: 16,
}}
>
<div style={{ color: 'var(--color-text-secondary)', fontSize: 13 }}>
{money.currency}
</div>
<strong>{formatMoney(money)}</strong>
</div>
))}
</section>
<BrokerPositionsSection accountId={accountId!} />
<BrokerOperationsTable
title="Операции"
emptyMessage="Операций за выбранный период нет"
isLoading={operations.isLoading}
isFetching={operations.isFetching}
page={operations.data}
pagination={{
pageNumber: operationCursorStack.length + 1,
canGoBack: operationCursorStack.length > 0,
canGoForward: Boolean(operations.data?.hasNext && operations.data.nextCursor),
onPrevious: handlePreviousOperationsPage,
onNext: handleNextOperationsPage,
}}
/>
</div>
);
}

View File

@ -0,0 +1,86 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useBrokerAccountContext } from './BrokerAccountLayout';
import { useBrokerOperations } from '../../hooks/useBrokerOperations';
import { BrokerOperationsTable } from './BrokerOperationsTable';
import { BROKER_OPERATION_TYPE_OPTIONS, isBrokerOperationType } from './brokerDisplay';
export function BrokerOperationsPage() {
const { accountId } = useBrokerAccountContext();
const [searchParams, setSearchParams] = useSearchParams();
const urlType = searchParams.get('type');
const selectedType = isBrokerOperationType(urlType) ? urlType : '';
const [cursor, setCursor] = useState<string | undefined>(undefined);
const [cursorStack, setCursorStack] = useState<Array<string | undefined>>([]);
const operations = useBrokerOperations(accountId, {
limit: 10,
cursor,
operationTypes: selectedType || undefined,
});
useEffect(() => {
setCursor(undefined);
setCursorStack([]);
}, [selectedType]);
function handleTypeChange(event: React.ChangeEvent<HTMLSelectElement>) {
const nextType = event.target.value;
setSearchParams(nextType ? { type: nextType } : {}, { replace: true });
}
function handleNext() {
const nextCursor = operations.data?.nextCursor;
if (!nextCursor || !operations.data?.hasNext) return;
setCursorStack((previous) => [...previous, cursor]);
setCursor(nextCursor);
}
function handlePrevious() {
if (cursorStack.length === 0) return;
setCursor(cursorStack[cursorStack.length - 1]);
setCursorStack((previous) => previous.slice(0, -1));
}
const history = operations.error ? (
<p role="alert">Не удалось загрузить историю операций</p>
) : (
<BrokerOperationsTable
title="История операций"
emptyMessage={
selectedType ? 'Операций выбранного типа нет' : 'Операций с начала текущего года нет'
}
isLoading={operations.isLoading}
isFetching={operations.isFetching}
page={operations.data}
pagination={{
pageNumber: cursorStack.length + 1,
canGoBack: cursorStack.length > 0,
canGoForward: Boolean(operations.data?.hasNext && operations.data.nextCursor),
onPrevious: handlePrevious,
onNext: handleNext,
}}
/>
);
return (
<section aria-labelledby="broker-operations-heading">
<div className="broker-operations__toolbar">
<h2 id="broker-operations-heading" style={{ fontSize: 20, margin: 0 }}>
Операции
</h2>
<label>
<span>Тип операции</span>
<select value={selectedType} onChange={handleTypeChange}>
<option value="">Все операции</option>
{BROKER_OPERATION_TYPE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div>
{history}
</section>
);
}

View File

@ -10,10 +10,11 @@ import * as portfolioHook from '../../hooks/useBrokerPortfolio';
import * as positionsHook from '../../hooks/useBrokerPositions'; import * as positionsHook from '../../hooks/useBrokerPositions';
import type { BrokerPortfolio, BrokerPosition } from '../../api/responses'; import type { BrokerPortfolio, BrokerPosition } from '../../api/responses';
import { BrokerAccountLayout, useBrokerAccountContext } from './BrokerAccountLayout'; import { BrokerAccountLayout, useBrokerAccountContext } from './BrokerAccountLayout';
import { BrokerAccountDetailPage } from './BrokerAccountDetailPage';
import { BrokerAccountOverviewPage } from './BrokerAccountOverviewPage'; import { BrokerAccountOverviewPage } from './BrokerAccountOverviewPage';
import { BrokerAccountsPage } from './BrokerAccountsPage'; import { BrokerAccountsPage } from './BrokerAccountsPage';
import { BrokerPositionsPage } from './BrokerPositionsPage'; import { BrokerPositionsPage } from './BrokerPositionsPage';
import { BrokerOperationsPage } from './BrokerOperationsPage';
function renderWithClient(ui: ReactElement, initialEntries = ['/broker']) { function renderWithClient(ui: ReactElement, initialEntries = ['/broker']) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@ -306,389 +307,6 @@ describe('Broker pages', () => {
expect(screen.getByText('IIS')).toBeInTheDocument(); expect(screen.getByText('IIS')).toBeInTheDocument();
}); });
it('renders positions and operations for account detail', () => {
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: [{ currency: 'RUB', units: '100', nano: 0, value: 100 }],
blockedCash: [],
asOf: '2026-06-16T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
} as any);
vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({
data: {
accountId: 'acc-1',
items: [
{
cursor: 'cursor-1',
accountId: 'acc-1',
id: 'op-1',
parentOperationId: null,
date: '2026-06-16T00:00:00.000Z',
category: 'trade',
type: 'OPERATION_TYPE_BUY',
description: 'Buy',
state: 'OPERATION_STATE_EXECUTED',
instrumentUid: 'uid-1',
figi: null,
ticker: 'SBER',
classCode: 'TQBR',
instrumentType: 'share',
payment: { currency: 'RUB', units: '-1000', nano: 0, value: -1000 },
price: null,
commission: null,
yield: null,
accruedInt: null,
quantity: 10,
quantityDone: 10,
},
],
nextCursor: null,
hasNext: false,
asOf: '2026-06-16T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
} as any);
mockUseBrokerPositions(
createPosition({
instrumentUid: 'uid-1',
ticker: 'SBER',
classCode: 'TQBR',
instrumentType: 'share',
name: 'Sberbank',
quantity: 10,
currentValue: { currency: 'RUB', units: '1000', nano: 0, value: 1000 },
}),
);
renderWithClient(
<Routes>
<Route path="/broker/:accountId" element={<BrokerAccountDetailPage />} />
</Routes>,
['/broker/acc-1'],
);
expect(screen.getAllByText('SBER').length).toBeGreaterThan(0);
expect(screen.getByText('Покупка')).toBeInTheDocument();
});
it('renders broker positions as separate linked stock and bond tables with current price', () => {
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: '10000', nano: 0, value: 10000 } },
yields: { expectedPercent: 5, daily: null, dailyPercent: null },
cash: [],
blockedCash: [],
asOf: '2026-06-17T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
} as any);
vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({
data: {
accountId: 'acc-1',
items: [],
nextCursor: null,
hasNext: false,
asOf: '2026-06-17T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
} as any);
mockUseBrokerPositions(
createPosition({
instrumentUid: 'share-uid',
ticker: 'SBER',
classCode: 'TQBR',
instrumentType: 'share',
name: 'Sberbank',
quantity: 10,
currentPrice: { currency: 'RUB', units: '250', nano: 0, value: 250 },
currentValue: { currency: 'RUB', units: '2500', nano: 0, value: 2500 },
expectedYieldPercent: 20,
}),
createPosition({
instrumentUid: 'bond-uid',
ticker: 'SU26238RMFS5',
classCode: 'TQOB',
instrumentType: 'bond',
name: 'ОФЗ 26238',
quantity: 2,
currentPrice: { currency: 'RUB', units: '900', nano: 0, value: 900 },
currentValue: { currency: 'RUB', units: '1800', nano: 0, value: 1800 },
expectedYieldPercent: 10,
}),
);
renderWithClient(
<Routes>
<Route path="/broker/:accountId" element={<BrokerAccountDetailPage />} />
</Routes>,
['/broker/acc-1'],
);
expect(screen.getByRole('heading', { name: 'Акции' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Облигации' })).toBeInTheDocument();
expect(screen.queryByRole('columnheader', { name: 'Доходность' })).not.toBeInTheDocument();
expect(screen.getAllByRole('columnheader', { name: 'Цена' })).toHaveLength(2);
expect(screen.getByRole('link', { name: 'SBER' })).toHaveAttribute('href', '/stocks/SBER');
expect(screen.getByRole('link', { name: 'SU26238RMFS5' })).toHaveAttribute(
'href',
'/bonds/SU26238RMFS5',
);
expect(screen.getByText(/250,00/)).toBeInTheDocument();
expect(screen.getByText(/900,00/)).toBeInTheDocument();
});
it('renders broker operations with Russian labels, linked instruments and colored amounts', () => {
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: '10000', nano: 0, value: 10000 } },
yields: { expectedPercent: null, daily: null, dailyPercent: null },
cash: [],
blockedCash: [],
asOf: '2026-06-17T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
} as any);
vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({
data: {
accountId: 'acc-1',
items: [
{
cursor: 'op-1',
accountId: 'acc-1',
id: 'op-1',
parentOperationId: null,
date: '2026-06-17T10:00:00.000Z',
category: 'income',
type: 'OPERATION_TYPE_COUPON',
description: 'Coupon',
state: 'OPERATION_STATE_EXECUTED',
instrumentUid: 'bond-uid',
figi: null,
ticker: 'SU26238RMFS5',
classCode: 'TQOB',
instrumentType: 'bond',
payment: { currency: 'RUB', units: '120', nano: 0, value: 120 },
price: null,
commission: null,
yield: null,
accruedInt: null,
quantity: 2,
quantityDone: 2,
},
{
cursor: 'op-2',
accountId: 'acc-1',
id: 'op-2',
parentOperationId: null,
date: '2026-06-17T11:00:00.000Z',
category: 'tax',
type: 'OPERATION_TYPE_TAX',
description: 'Tax',
state: 'OPERATION_STATE_EXECUTED',
instrumentUid: null,
figi: null,
ticker: null,
classCode: null,
instrumentType: null,
payment: { currency: 'RUB', units: '-13', nano: 0, value: -13 },
price: null,
commission: null,
yield: null,
accruedInt: null,
quantity: null,
quantityDone: null,
},
],
nextCursor: null,
hasNext: false,
asOf: '2026-06-17T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
} as any);
mockUseBrokerPositions();
renderWithClient(
<Routes>
<Route path="/broker/:accountId" element={<BrokerAccountDetailPage />} />
</Routes>,
['/broker/acc-1'],
);
expect(screen.getByText('Выплата купона')).toBeInTheDocument();
expect(screen.getByText('Налог')).toBeInTheDocument();
expect(screen.getByText(/\+120,00\s*₽/)).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'SU26238RMFS5' })).toHaveAttribute(
'href',
'/bonds/SU26238RMFS5',
);
});
it('requests broker operations by cursor with a page size of 10', async () => {
const user = userEvent.setup();
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: '10000', nano: 0, value: 10000 } },
yields: { expectedPercent: null, daily: null, dailyPercent: null },
cash: [],
blockedCash: [],
asOf: '2026-06-17T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
} as any);
const operationsSpy = vi.spyOn(operationsHook, 'useBrokerOperations').mockImplementation(
(_accountId, query) =>
({
data:
query.cursor === 'cursor-page-2'
? {
accountId: 'acc-1',
items: [
{
cursor: 'op-page-2',
accountId: 'acc-1',
id: 'op-page-2',
parentOperationId: null,
date: '2026-06-17T12:00:00.000Z',
category: 'trade',
type: 'OPERATION_TYPE_SELL',
description: 'Sell',
state: 'OPERATION_STATE_EXECUTED',
instrumentUid: 'share-uid',
figi: null,
ticker: 'SBER',
classCode: 'TQBR',
instrumentType: 'share',
payment: { currency: 'RUB', units: '1000', nano: 0, value: 1000 },
price: null,
commission: null,
yield: null,
accruedInt: null,
quantity: 1,
quantityDone: 1,
},
],
nextCursor: null,
hasNext: false,
asOf: '2026-06-17T00:00:00.000Z',
}
: {
accountId: 'acc-1',
items: [
{
cursor: 'op-page-1',
accountId: 'acc-1',
id: 'op-page-1',
parentOperationId: null,
date: '2026-06-17T10:00:00.000Z',
category: 'trade',
type: 'OPERATION_TYPE_BUY',
description: 'Buy',
state: 'OPERATION_STATE_EXECUTED',
instrumentUid: 'share-uid',
figi: null,
ticker: 'SBER',
classCode: 'TQBR',
instrumentType: 'share',
payment: { currency: 'RUB', units: '-1000', nano: 0, value: -1000 },
price: null,
commission: null,
yield: null,
accruedInt: null,
quantity: 1,
quantityDone: 1,
},
],
nextCursor: 'cursor-page-2',
hasNext: true,
asOf: '2026-06-17T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
}) as any,
);
mockUseBrokerPositions();
renderWithClient(
<Routes>
<Route path="/broker/:accountId" element={<BrokerAccountDetailPage />} />
</Routes>,
['/broker/acc-1'],
);
expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined });
const operationsSection = screen.getByRole('heading', { name: 'Операции' }).closest('section')!;
const withinOperations = within(operationsSection);
const nextButton = withinOperations.getByRole('button', { name: 'Следующая страница' });
const prevButton = withinOperations.getByRole('button', { name: 'Предыдущая страница' });
expect(prevButton).toBeDisabled();
expect(nextButton).not.toBeDisabled();
await user.click(nextButton);
expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', {
limit: 10,
cursor: 'cursor-page-2',
});
expect(withinOperations.getByText('2')).toBeInTheDocument();
await user.click(prevButton);
expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined });
expect(withinOperations.getByText('1')).toBeInTheDocument();
});
it('renders the broker account overview with allocation, asset links and recent operations', () => { it('renders the broker account overview with allocation, asset links and recent operations', () => {
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: createOverviewPortfolio(), data: createOverviewPortfolio(),
@ -1319,4 +937,201 @@ describe('Broker pages', () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
}); });
describe('Broker operations page', () => {
function renderOperationsPage(initialEntry = '/broker/acc-1/operations') {
return renderWithClient(
<Routes>
<Route path="/broker/:accountId" element={<BrokerAccountLayout />}>
<Route path="operations" element={<BrokerOperationsPage />} />
</Route>
</Routes>,
[initialEntry],
);
}
function mockOperationsPortfolio() {
return 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: '10000', nano: 0, value: 10000 } },
yields: { expectedPercent: 5, daily: null, dailyPercent: null },
cash: [],
blockedCash: [],
asOf: '2026-06-19T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
} as any);
}
it('reads the operation type filter from the URL and requests the correct API query', () => {
mockOperationsPortfolio();
const operationsSpy = vi.spyOn(operationsHook, 'useBrokerOperations').mockImplementation(
(_accountId, _query) =>
({
data: {
accountId: 'acc-1',
items: [],
nextCursor: null,
hasNext: false,
asOf: '2026-06-19T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
}) as any,
);
renderOperationsPage('/broker/acc-1/operations?type=OPERATION_TYPE_COUPON');
expect(screen.getByRole('combobox', { name: 'Тип операции' })).toHaveValue(
'OPERATION_TYPE_COUPON',
);
expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', {
limit: 10,
cursor: undefined,
operationTypes: 'OPERATION_TYPE_COUPON',
});
});
it('resets cursor when the filter changes and updates the URL', async () => {
const user = userEvent.setup();
mockOperationsPortfolio();
const operationsSpy = vi.spyOn(operationsHook, 'useBrokerOperations').mockImplementation(
(_accountId, query) =>
({
data: {
accountId: 'acc-1',
items: query.cursor
? []
: [
{
cursor: 'op-1',
accountId: 'acc-1',
id: 'op-1',
parentOperationId: null,
date: '2026-06-19T10:00:00.000Z',
category: 'income',
type: 'OPERATION_TYPE_COUPON',
description: 'Coupon',
state: 'OPERATION_STATE_EXECUTED',
instrumentUid: null,
figi: null,
ticker: null,
classCode: null,
instrumentType: null,
payment: { currency: 'RUB', units: '120', nano: 0, value: 120 },
price: null,
commission: null,
yield: null,
accruedInt: null,
quantity: null,
quantityDone: null,
},
],
nextCursor: query.cursor ? null : 'cursor-page-2',
hasNext: !query.cursor,
asOf: '2026-06-19T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
}) as any,
);
renderOperationsPage('/broker/acc-1/operations?type=OPERATION_TYPE_COUPON');
const section = screen.getByRole('heading', { name: 'Операции' }).closest('section')!;
const withinSection = within(section);
const nextButton = withinSection.getByRole('button', { name: 'Следующая страница' });
await user.click(nextButton);
expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', {
limit: 10,
cursor: 'cursor-page-2',
operationTypes: 'OPERATION_TYPE_COUPON',
});
const select = screen.getByRole('combobox', { name: 'Тип операции' });
await user.selectOptions(select, 'OPERATION_TYPE_TAX');
expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', {
limit: 10,
cursor: undefined,
operationTypes: 'OPERATION_TYPE_TAX',
});
expect(withinSection.getByText('1')).toBeInTheDocument();
});
it('removes the type parameter and query when selecting Все операции', async () => {
const user = userEvent.setup();
mockOperationsPortfolio();
const operationsSpy = vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({
data: {
accountId: 'acc-1',
items: [],
nextCursor: null,
hasNext: false,
asOf: '2026-06-19T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
} as any);
renderOperationsPage('/broker/acc-1/operations?type=OPERATION_TYPE_COUPON');
const select = screen.getByRole('combobox', { name: 'Тип операции' });
await user.selectOptions(select, '');
expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined });
});
it('treats an invalid URL type as Все операции', () => {
mockOperationsPortfolio();
vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({
data: {
accountId: 'acc-1',
items: [],
nextCursor: null,
hasNext: false,
asOf: '2026-06-19T00:00:00.000Z',
},
isLoading: false,
isFetching: false,
error: null,
} as any);
renderOperationsPage('/broker/acc-1/operations?type=INVALID_TYPE');
expect(screen.getByRole('combobox', { name: 'Тип операции' })).toHaveValue('');
});
it('keeps account navigation and filter visible when operations fail to load', () => {
mockOperationsPortfolio();
vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({
data: undefined,
isLoading: false,
isFetching: false,
error: new Error('operations failed'),
} as any);
renderOperationsPage('/broker/acc-1/operations');
expect(screen.getByRole('alert')).toHaveTextContent('Не удалось загрузить историю операций');
expect(
screen.getByRole('navigation', { name: 'Разделы брокерского счёта' }),
).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: 'Тип операции' })).toBeInTheDocument();
});
});
}); });

View File

@ -1,311 +0,0 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import type { BrokerMoney, BrokerPosition } from '../../api/responses';
import { getBrokerInstrumentPath } from './brokerDisplay';
import { TableSkeleton } from '../../components/TableSkeleton';
import { useBrokerPositions } from '../../hooks/useBrokerPositions';
type BrokerPositionGroupConfig = {
key: string;
type?: string;
title: string;
};
const GROUPS: BrokerPositionGroupConfig[] = [
{ key: 'shares', type: 'share', title: 'Акции' },
{ key: 'bonds', type: 'bond', title: 'Облигации' },
{ key: 'etf', type: 'etf', title: 'ETF' },
{ key: 'fund', type: 'fund', title: 'Фонды' },
];
const KNOWN_TYPES = new Set(GROUPS.map((g) => g.type).filter(Boolean));
const tableStyle = {
width: '100%',
borderCollapse: 'collapse',
fontSize: 14,
} satisfies React.CSSProperties;
const thStyle = {
borderBottom: '1px solid #e0e0e0',
color: 'var(--color-text-secondary)',
fontWeight: 600,
padding: '10px 8px',
} satisfies React.CSSProperties;
const tdStyle = {
borderBottom: '1px solid #eeeeee',
padding: '10px 8px',
verticalAlign: 'top',
} satisfies React.CSSProperties;
const pagButtonStyle = {
padding: '6px 14px',
borderRadius: 6,
border: '1px solid #e0e0e0',
background: 'var(--color-surface)',
color: 'var(--color-text)',
fontSize: 14,
fontWeight: 600,
cursor: 'pointer',
lineHeight: 1.4,
} satisfies React.CSSProperties;
const pagButtonDisabledStyle = {
...pagButtonStyle,
opacity: 0.35,
cursor: 'not-allowed',
} satisfies React.CSSProperties;
function formatMoney(value: BrokerMoney | null | undefined) {
if (!value) return '-';
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: value.currency || 'RUB',
maximumFractionDigits: 2,
}).format(value.value);
}
function formatQuantity(value: number | null | undefined) {
return value == null ? '-' : value.toLocaleString('ru-RU');
}
function PositionTicker({ position }: { position: BrokerPosition }) {
const label = position.ticker || position.figi || '-';
const path = getBrokerInstrumentPath({
ticker: position.ticker,
instrumentType: position.instrumentType,
classCode: position.classCode,
});
if (!path || label === '-') {
return <strong>{label}</strong>;
}
return (
<Link to={path} style={{ fontWeight: 700 }}>
{label}
</Link>
);
}
function PositionGroupTable({
accountId,
group,
}: {
accountId: string;
group: BrokerPositionGroupConfig;
}) {
const [cursorStack, setCursorStack] = useState<Array<string | undefined>>([]);
const [cursor, setCursor] = useState<string | undefined>(undefined);
const query = group.type ? { type: group.type, limit: 10, cursor } : { limit: 100, cursor };
const { data: page, isLoading, isFetching } = useBrokerPositions(accountId, query);
const rawPositions = page?.items ?? [];
const positions = group.type
? rawPositions
: rawPositions.filter(
(p) => p.instrumentType && !KNOWN_TYPES.has(p.instrumentType.toLowerCase()),
);
const pageNumber = cursorStack.length + 1;
const canGoBack = cursorStack.length > 0;
const canGoForward = Boolean(page?.hasNext && page.nextCursor && !!group.type);
function handleNext() {
const nextCursor = page?.nextCursor;
if (!nextCursor || !page?.hasNext || !group.type) return;
setCursorStack((prev) => [...prev, cursor]);
setCursor(nextCursor);
}
function handlePrevious() {
if (cursorStack.length === 0) return;
const prev = cursorStack[cursorStack.length - 1];
setCursorStack((prevStack) => prevStack.slice(0, -1));
setCursor(prev);
}
if (!isLoading && positions.length === 0) {
return null;
}
return (
<section>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
justifyContent: 'space-between',
marginBottom: 10,
}}
>
<h3 style={{ fontSize: 18, margin: 0 }}>{group.title}</h3>
{group.type && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
type="button"
onClick={handlePrevious}
disabled={!canGoBack || isFetching}
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
>
{isFetching ? (
<span
className="loading-spinner"
style={{ width: 14, height: 14, display: 'block' }}
/>
) : (
'←'
)}
</button>
<span
style={{
minWidth: 20,
textAlign: 'center',
color: 'var(--color-text-secondary)',
fontSize: 14,
fontWeight: 600,
}}
>
{pageNumber}
</span>
<button
type="button"
onClick={handleNext}
disabled={!canGoForward || isFetching}
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
>
{isFetching ? (
<span
className="loading-spinner"
style={{ width: 14, height: 14, display: 'block' }}
/>
) : (
'→'
)}
</button>
</div>
)}
</div>
{isLoading && (
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table style={tableStyle}>
<thead>
<tr>
<th align="left" style={thStyle}>
Тикер
</th>
<th align="left" style={thStyle}>
Название
</th>
<th align="right" style={thStyle}>
Количество
</th>
<th align="right" style={thStyle}>
Цена
</th>
<th align="right" style={thStyle}>
Стоимость
</th>
</tr>
</thead>
<TableSkeleton
rows={4}
columns={[
{ width: '30%' },
{ width: '50%' },
{ width: '20%' },
{ width: '25%' },
{ width: '25%' },
]}
/>
</table>
</div>
)}
{!isLoading && positions.length > 0 && (
<div className="table-container">
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table aria-label={`Брокерские позиции: ${group.title}`} style={tableStyle}>
<thead>
<tr>
<th align="left" style={thStyle}>
Тикер
</th>
<th align="left" style={thStyle}>
Название
</th>
<th align="right" style={thStyle}>
Количество
</th>
<th align="right" style={thStyle}>
Цена
</th>
<th align="right" style={thStyle}>
Стоимость
</th>
</tr>
</thead>
<tbody>
{positions.map((position) => (
<tr
key={
position.positionUid ||
position.instrumentUid ||
position.ticker ||
position.figi
}
>
<td style={tdStyle}>
<PositionTicker position={position} />
</td>
<td style={tdStyle}>
<span style={{ color: 'var(--color-text-secondary)' }}>
{position.name || '-'}
</span>
</td>
<td align="right" style={tdStyle}>
{formatQuantity(position.quantity)}
</td>
<td align="right" style={tdStyle}>
{formatMoney(position.currentPrice)}
</td>
<td align="right" style={tdStyle}>
{formatMoney(position.currentValue)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{isFetching && (
<div className="table-loading-overlay">
<div className="loading-spinner" />
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
Загрузка страницы {pageNumber}
</span>
</div>
)}
</div>
)}
</section>
);
}
type BrokerPositionsSectionProps = {
accountId: string;
};
export function BrokerPositionsSection({ accountId }: BrokerPositionsSectionProps) {
return (
<div style={{ display: 'grid', gap: 20 }}>
<h2 style={{ fontSize: 20, margin: 0 }}>Позиции</h2>
{GROUPS.map((group) => (
<PositionGroupTable key={group.key} accountId={accountId} group={group} />
))}
</div>
);
}

View File

@ -11,7 +11,10 @@ import { PortfoliosListPage } from './pages/portfolios/PortfoliosListPage';
import { PortfolioDetailPage } from './pages/portfolios/PortfolioDetailPage'; import { PortfolioDetailPage } from './pages/portfolios/PortfolioDetailPage';
import { ScreenerPage } from './pages/screener/ScreenerPage'; import { ScreenerPage } from './pages/screener/ScreenerPage';
import { BrokerAccountsPage } from './pages/broker/BrokerAccountsPage'; import { BrokerAccountsPage } from './pages/broker/BrokerAccountsPage';
import { BrokerAccountDetailPage } from './pages/broker/BrokerAccountDetailPage'; import { BrokerAccountLayout } from './pages/broker/BrokerAccountLayout';
import { BrokerAccountOverviewPage } from './pages/broker/BrokerAccountOverviewPage';
import { BrokerPositionsPage } from './pages/broker/BrokerPositionsPage';
import { BrokerOperationsPage } from './pages/broker/BrokerOperationsPage';
export function AppRoutes() { export function AppRoutes() {
return ( return (
@ -59,10 +62,15 @@ export function AppRoutes() {
path="/broker/:accountId" path="/broker/:accountId"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<BrokerAccountDetailPage /> <BrokerAccountLayout />
</ProtectedRoute> </ProtectedRoute>
} }
/> >
<Route index element={<BrokerAccountOverviewPage />} />
<Route path="shares" element={<BrokerPositionsPage type="share" title="Акции" />} />
<Route path="bonds" element={<BrokerPositionsPage type="bond" title="Облигации" />} />
<Route path="operations" element={<BrokerOperationsPage />} />
</Route>
</Route> </Route>
</Routes> </Routes>
); );

View File

@ -18,9 +18,15 @@
--shadow: 0 1px 3px rgba(0, 0, 0, 0.12); --shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
} }
.pnl-cell { text-align: right; } .pnl-cell {
.positive { color: var(--color-positive); } text-align: right;
.negative { color: var(--color-negative); } }
.positive {
color: var(--color-positive);
}
.negative {
color: var(--color-negative);
}
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
@ -35,24 +41,25 @@ a {
} }
@keyframes shimmer { @keyframes shimmer {
0% { background-position: 200% 0; } 0% {
100% { background-position: -200% 0; } background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
} }
.skeleton { .skeleton {
background: linear-gradient( background: linear-gradient(90deg, var(--color-bg) 25%, #f0f0f0 50%, var(--color-bg) 75%);
90deg,
var(--color-bg) 25%,
#f0f0f0 50%,
var(--color-bg) 75%
);
background-size: 200% 100%; background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite; animation: shimmer 1.5s ease-in-out infinite;
border-radius: 4px; border-radius: 4px;
} }
@keyframes loading-spin { @keyframes loading-spin {
to { transform: rotate(360deg); } to {
transform: rotate(360deg);
}
} }
.loading-spinner { .loading-spinner {
@ -245,3 +252,43 @@ a {
align-self: center; align-self: center;
} }
} }
.broker-operations__toolbar {
display: flex;
align-items: end;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.broker-operations__toolbar label {
display: grid;
gap: 6px;
color: var(--color-text-secondary);
font-size: 13px;
}
.broker-operations__toolbar select {
min-width: 240px;
padding: 8px 10px;
border: 1px solid #d8d8d8;
border-radius: var(--border-radius);
background: var(--color-surface);
color: var(--color-text);
}
.broker-operations__toolbar select:focus-visible {
outline: 3px solid color-mix(in srgb, var(--color-primary) 35%, transparent);
outline-offset: 2px;
}
@media (max-width: 720px) {
.broker-operations__toolbar {
align-items: stretch;
flex-direction: column;
}
.broker-operations__toolbar select {
width: 100%;
min-width: 0;
}
}