feat: filter broker operations by exact type
This commit is contained in:
parent
910494a4e5
commit
2223463dd3
@ -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,
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
86
apps/frontend/src/pages/broker/BrokerOperationsPage.tsx
Normal file
86
apps/frontend/src/pages/broker/BrokerOperationsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user