refactor(frontend): move broker operations and layout to fsd

This commit is contained in:
Sergey Krylov 2026-06-20 13:00:49 +03:00
parent 09213624c3
commit 00f01fae4f
19 changed files with 683 additions and 568 deletions

View File

@ -0,0 +1,49 @@
import { NavLink, Outlet, useOutletContext, useParams } from 'react-router-dom';
import { useBrokerPortfolio } from '../model/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>
<div className="broker-account__content">
<Outlet context={context} />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1 @@
export { getBrokerOperations, type BrokerOperationQuery } from '../../../api/broker';

View File

@ -0,0 +1,9 @@
export { getBrokerOperations, type BrokerOperationQuery } from './api/brokerOperationApi';
export {
BROKER_OPERATION_TYPE_OPTIONS,
getBrokerOperationImpact,
getBrokerOperationTypeLabel,
isBrokerOperationType,
type BrokerOperationImpact,
} from './model/operationFilters';
export { useBrokerOperations } from './model/useBrokerOperations';

View File

@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { BROKER_OPERATION_TYPE_OPTIONS, isBrokerOperationType } from '../model/operationFilters';
describe('operationFilters', () => {
it('accepts only declared broker operation types', () => {
expect(isBrokerOperationType('OPERATION_TYPE_BUY')).toBe(true);
expect(isBrokerOperationType('unexpected')).toBe(false);
});
it('keeps operation type option values unique and labels sorted for the filter', () => {
const values = BROKER_OPERATION_TYPE_OPTIONS.map(({ value }) => value);
const labels = BROKER_OPERATION_TYPE_OPTIONS.map(({ label }) => label);
expect(new Set(values).size).toBe(values.length);
expect(labels).toEqual([...labels].sort((left, right) => left.localeCompare(right, 'ru')));
});
});

View File

@ -0,0 +1,133 @@
import type { BrokerOperation } from '../../../api/responses';
export type BrokerOperationImpact = 'adds' | 'reduces' | 'neutral' | 'unknown';
const TRADE_TYPES = new Set([
'OPERATION_TYPE_BUY',
'OPERATION_TYPE_BUY_CARD',
'OPERATION_TYPE_SELL',
'OPERATION_TYPE_SELL_CARD',
'OPERATION_TYPE_BUY_MARGIN',
'OPERATION_TYPE_SELL_MARGIN',
'OPERATION_TYPE_DELIVERY_BUY',
'OPERATION_TYPE_DELIVERY_SELL',
]);
const BOND_REPAYMENT_TYPES = new Set([
'OPERATION_TYPE_BOND_REPAYMENT',
'OPERATION_TYPE_BOND_REPAYMENT_FULL',
]);
const INCOME_TYPES = new Set(['OPERATION_TYPE_COUPON', 'OPERATION_TYPE_DIVIDEND']);
const TAX_TYPES = new Set([
'OPERATION_TYPE_TAX',
'OPERATION_TYPE_BOND_TAX',
'OPERATION_TYPE_DIVIDEND_TAX',
'OPERATION_TYPE_TAX_CORRECTION',
'OPERATION_TYPE_TAX_CORRECTION_COUPON',
]);
const FEE_TYPES = new Set([
'OPERATION_TYPE_BROKER_FEE',
'OPERATION_TYPE_SERVICE_FEE',
'OPERATION_TYPE_MARGIN_FEE',
'OPERATION_TYPE_SUCCESS_FEE',
]);
const TRANSFER_INPUT_TYPES = new Set([
'OPERATION_TYPE_INPUT',
'OPERATION_TYPE_INPUT_SWIFT',
'OPERATION_TYPE_INPUT_ACQUIRING',
'OPERATION_TYPE_INP_MULTI',
]);
const TRANSFER_OUTPUT_TYPES = new Set([
'OPERATION_TYPE_OUTPUT',
'OPERATION_TYPE_OUTPUT_SWIFT',
'OPERATION_TYPE_OUTPUT_ACQUIRING',
'OPERATION_TYPE_OUT_MULTI',
]);
const SECURITY_TRANSFER_TYPES = new Set([
'OPERATION_TYPE_INPUT_SECURITIES',
'OPERATION_TYPE_OUTPUT_SECURITIES',
'OPERATION_TYPE_TRANS_IIS_BS',
'OPERATION_TYPE_TRANS_BS_BS',
]);
const OPERATION_TYPE_LABELS: Record<string, string> = {
OPERATION_TYPE_BUY: 'Покупка',
OPERATION_TYPE_BUY_CARD: 'Покупка',
OPERATION_TYPE_SELL: 'Продажа',
OPERATION_TYPE_SELL_CARD: 'Продажа',
OPERATION_TYPE_BUY_MARGIN: 'Покупка с маржой',
OPERATION_TYPE_SELL_MARGIN: 'Продажа с маржой',
OPERATION_TYPE_DELIVERY_BUY: 'Поставка покупки',
OPERATION_TYPE_DELIVERY_SELL: 'Поставка продажи',
OPERATION_TYPE_COUPON: 'Выплата купона',
OPERATION_TYPE_DIVIDEND: 'Дивиденды',
OPERATION_TYPE_BOND_REPAYMENT: 'Погашение облигации',
OPERATION_TYPE_BOND_REPAYMENT_FULL: 'Полное погашение облигации',
OPERATION_TYPE_TAX: 'Налог',
OPERATION_TYPE_BOND_TAX: 'Налог по облигациям',
OPERATION_TYPE_DIVIDEND_TAX: 'Налог на дивиденды',
OPERATION_TYPE_TAX_CORRECTION: 'Корректировка налога',
OPERATION_TYPE_TAX_CORRECTION_COUPON: 'Корректировка налога по купону',
OPERATION_TYPE_BROKER_FEE: 'Комиссия брокера',
OPERATION_TYPE_SERVICE_FEE: 'Комиссия за обслуживание',
OPERATION_TYPE_MARGIN_FEE: 'Комиссия за маржу',
OPERATION_TYPE_SUCCESS_FEE: 'Комиссия за результат',
OPERATION_TYPE_INPUT: 'Пополнение',
OPERATION_TYPE_OUTPUT: 'Вывод средств',
OPERATION_TYPE_INPUT_SECURITIES: 'Зачисление бумаг',
OPERATION_TYPE_OUTPUT_SECURITIES: 'Списание бумаг',
};
export const BROKER_OPERATION_TYPE_OPTIONS: ReadonlyArray<
Readonly<{ value: string; label: string }>
> = Object.freeze(
Object.entries(OPERATION_TYPE_LABELS)
.map(([value, label]) => Object.freeze({ value, label }))
.sort((left, right) => left.label.localeCompare(right.label, 'ru')),
);
const BROKER_OPERATION_TYPES = new Set(BROKER_OPERATION_TYPE_OPTIONS.map(({ value }) => value));
export function isBrokerOperationType(value: string | null): value is string {
return value !== null && BROKER_OPERATION_TYPES.has(value);
}
export function getBrokerOperationTypeLabel(
operation: Pick<BrokerOperation, 'type' | 'description'>,
): string {
const knownLabel = OPERATION_TYPE_LABELS[operation.type];
if (knownLabel) return knownLabel;
if (operation.description) return operation.description;
return operation.type
.replace(/^OPERATION_TYPE_/, '')
.replace(/_/g, ' ')
.toLowerCase();
}
export function getBrokerOperationImpact(
operation: Pick<BrokerOperation, 'type' | 'category' | 'payment'>,
): BrokerOperationImpact {
if (
TRADE_TYPES.has(operation.type) ||
BOND_REPAYMENT_TYPES.has(operation.type) ||
SECURITY_TRANSFER_TYPES.has(operation.type)
) {
return 'neutral';
}
if (INCOME_TYPES.has(operation.type)) return 'adds';
if (TAX_TYPES.has(operation.type) || FEE_TYPES.has(operation.type)) return 'reduces';
if (TRANSFER_INPUT_TYPES.has(operation.type)) return 'adds';
if (TRANSFER_OUTPUT_TYPES.has(operation.type)) return 'reduces';
if (operation.category === 'tax' || operation.category === 'fee') return 'reduces';
if (operation.category === 'income' && (operation.payment?.value ?? 0) > 0) return 'adds';
return 'unknown';
}

View File

@ -0,0 +1,65 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import { type ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getBrokerOperations } from '../api/brokerOperationApi';
import { useBrokerOperations } from '../model/useBrokerOperations';
vi.mock('../api/brokerOperationApi', () => ({
getBrokerOperations: vi.fn(),
}));
function createWrapper(queryClient?: QueryClient) {
const client = queryClient ?? new QueryClient({ defaultOptions: { queries: { retry: false } } });
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
}
describe('useBrokerOperations', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns operations page data from API', async () => {
vi.mocked(getBrokerOperations).mockResolvedValue({
data: {
accountId: 'acc-1',
items: [],
nextCursor: null,
hasNext: false,
asOf: '2026-06-19T00:00:00.000Z',
},
meta: { fromCache: false, cachedAt: null },
});
const { result } = renderHook(() => useBrokerOperations('acc-1', { limit: 5 }), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.accountId).toBe('acc-1');
expect(getBrokerOperations).toHaveBeenCalledWith('acc-1', { limit: 5 });
});
it('reuses the broker operations cache key across the account overview and full history pages', async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const cachedPage = {
accountId: 'acc-1',
items: [],
nextCursor: null,
hasNext: false,
asOf: '2026-06-19T00:00:00.000Z',
};
queryClient.setQueryData(['broker', 'operations', 'acc-1', { limit: 5 }], cachedPage);
const { result } = renderHook(() => useBrokerOperations('acc-1', { limit: 5 }), {
wrapper: createWrapper(queryClient),
});
await waitFor(() => expect(result.current.data).toBe(cachedPage));
expect(getBrokerOperations).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,18 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import type { BrokerOperationsPage } from '../../../api/responses';
import { getBrokerOperations, type BrokerOperationQuery } from '../api/brokerOperationApi';
export function useBrokerOperations(
accountId: string | undefined,
query: BrokerOperationQuery = {},
) {
return useQuery<BrokerOperationsPage>({
queryKey: ['broker', 'operations', accountId, query],
enabled: Boolean(accountId),
queryFn: async () => (await getBrokerOperations(accountId!, query)).data,
staleTime: 300_000,
retry: 2,
placeholderData: keepPreviousData,
refetchOnWindowFocus: false,
});
}

View File

@ -1,7 +1,13 @@
import type { BrokerOperation, BrokerPosition } from '../../../api/responses'; import type { BrokerPosition } from '../../../api/responses';
export {
BROKER_OPERATION_TYPE_OPTIONS,
getBrokerOperationImpact,
getBrokerOperationTypeLabel,
isBrokerOperationType,
} from '../../broker-operation/model/operationFilters';
export type { BrokerOperationImpact } from '../../broker-operation/model/operationFilters';
export type BrokerPositionGroup = 'shares' | 'bonds' | 'other'; export type BrokerPositionGroup = 'shares' | 'bonds' | 'other';
export type BrokerOperationImpact = 'adds' | 'reduces' | 'neutral' | 'unknown';
type BrokerInstrumentLinkInput = { type BrokerInstrumentLinkInput = {
ticker: string | null; ticker: string | null;
@ -12,102 +18,6 @@ type BrokerInstrumentLinkInput = {
const STOCK_CLASS_CODES = new Set(['TQBR']); const STOCK_CLASS_CODES = new Set(['TQBR']);
const BOND_CLASS_CODES = new Set(['TQOB', 'TQCB', 'TQIR']); const BOND_CLASS_CODES = new Set(['TQOB', 'TQCB', 'TQIR']);
const TRADE_TYPES = new Set([
'OPERATION_TYPE_BUY',
'OPERATION_TYPE_BUY_CARD',
'OPERATION_TYPE_SELL',
'OPERATION_TYPE_SELL_CARD',
'OPERATION_TYPE_BUY_MARGIN',
'OPERATION_TYPE_SELL_MARGIN',
'OPERATION_TYPE_DELIVERY_BUY',
'OPERATION_TYPE_DELIVERY_SELL',
]);
const BOND_REPAYMENT_TYPES = new Set([
'OPERATION_TYPE_BOND_REPAYMENT',
'OPERATION_TYPE_BOND_REPAYMENT_FULL',
]);
const INCOME_TYPES = new Set(['OPERATION_TYPE_COUPON', 'OPERATION_TYPE_DIVIDEND']);
const TAX_TYPES = new Set([
'OPERATION_TYPE_TAX',
'OPERATION_TYPE_BOND_TAX',
'OPERATION_TYPE_DIVIDEND_TAX',
'OPERATION_TYPE_TAX_CORRECTION',
'OPERATION_TYPE_TAX_CORRECTION_COUPON',
]);
const FEE_TYPES = new Set([
'OPERATION_TYPE_BROKER_FEE',
'OPERATION_TYPE_SERVICE_FEE',
'OPERATION_TYPE_MARGIN_FEE',
'OPERATION_TYPE_SUCCESS_FEE',
]);
const TRANSFER_INPUT_TYPES = new Set([
'OPERATION_TYPE_INPUT',
'OPERATION_TYPE_INPUT_SWIFT',
'OPERATION_TYPE_INPUT_ACQUIRING',
'OPERATION_TYPE_INP_MULTI',
]);
const TRANSFER_OUTPUT_TYPES = new Set([
'OPERATION_TYPE_OUTPUT',
'OPERATION_TYPE_OUTPUT_SWIFT',
'OPERATION_TYPE_OUTPUT_ACQUIRING',
'OPERATION_TYPE_OUT_MULTI',
]);
const SECURITY_TRANSFER_TYPES = new Set([
'OPERATION_TYPE_INPUT_SECURITIES',
'OPERATION_TYPE_OUTPUT_SECURITIES',
'OPERATION_TYPE_TRANS_IIS_BS',
'OPERATION_TYPE_TRANS_BS_BS',
]);
const OPERATION_TYPE_LABELS: Record<string, string> = {
OPERATION_TYPE_BUY: 'Покупка',
OPERATION_TYPE_BUY_CARD: 'Покупка',
OPERATION_TYPE_SELL: 'Продажа',
OPERATION_TYPE_SELL_CARD: 'Продажа',
OPERATION_TYPE_BUY_MARGIN: 'Покупка с маржой',
OPERATION_TYPE_SELL_MARGIN: 'Продажа с маржой',
OPERATION_TYPE_DELIVERY_BUY: 'Поставка покупки',
OPERATION_TYPE_DELIVERY_SELL: 'Поставка продажи',
OPERATION_TYPE_COUPON: 'Выплата купона',
OPERATION_TYPE_DIVIDEND: 'Дивиденды',
OPERATION_TYPE_BOND_REPAYMENT: 'Погашение облигации',
OPERATION_TYPE_BOND_REPAYMENT_FULL: 'Полное погашение облигации',
OPERATION_TYPE_TAX: 'Налог',
OPERATION_TYPE_BOND_TAX: 'Налог по облигациям',
OPERATION_TYPE_DIVIDEND_TAX: 'Налог на дивиденды',
OPERATION_TYPE_TAX_CORRECTION: 'Корректировка налога',
OPERATION_TYPE_TAX_CORRECTION_COUPON: 'Корректировка налога по купону',
OPERATION_TYPE_BROKER_FEE: 'Комиссия брокера',
OPERATION_TYPE_SERVICE_FEE: 'Комиссия за обслуживание',
OPERATION_TYPE_MARGIN_FEE: 'Комиссия за маржу',
OPERATION_TYPE_SUCCESS_FEE: 'Комиссия за результат',
OPERATION_TYPE_INPUT: 'Пополнение',
OPERATION_TYPE_OUTPUT: 'Вывод средств',
OPERATION_TYPE_INPUT_SECURITIES: 'Зачисление бумаг',
OPERATION_TYPE_OUTPUT_SECURITIES: 'Списание бумаг',
};
export const BROKER_OPERATION_TYPE_OPTIONS: ReadonlyArray<
Readonly<{ value: string; label: string }>
> = Object.freeze(
Object.entries(OPERATION_TYPE_LABELS)
.map(([value, label]) => Object.freeze({ value, label }))
.sort((left, right) => left.label.localeCompare(right.label, 'ru')),
);
const BROKER_OPERATION_TYPES = new Set(BROKER_OPERATION_TYPE_OPTIONS.map(({ value }) => value));
export function isBrokerOperationType(value: string | null): value is string {
return value !== null && BROKER_OPERATION_TYPES.has(value);
}
export function getBrokerPositionGroup( export function getBrokerPositionGroup(
position: Pick<BrokerPosition, 'instrumentType'>, position: Pick<BrokerPosition, 'instrumentType'>,
): BrokerPositionGroup { ): BrokerPositionGroup {
@ -141,37 +51,3 @@ export function getBrokerInstrumentPath(input: BrokerInstrumentLinkInput): strin
return null; return null;
} }
export function getBrokerOperationTypeLabel(
operation: Pick<BrokerOperation, 'type' | 'description'>,
): string {
const knownLabel = OPERATION_TYPE_LABELS[operation.type];
if (knownLabel) return knownLabel;
if (operation.description) return operation.description;
return operation.type
.replace(/^OPERATION_TYPE_/, '')
.replace(/_/g, ' ')
.toLowerCase();
}
export function getBrokerOperationImpact(
operation: Pick<BrokerOperation, 'type' | 'category' | 'payment'>,
): BrokerOperationImpact {
if (
TRADE_TYPES.has(operation.type) ||
BOND_REPAYMENT_TYPES.has(operation.type) ||
SECURITY_TRANSFER_TYPES.has(operation.type)
) {
return 'neutral';
}
if (INCOME_TYPES.has(operation.type)) return 'adds';
if (TAX_TYPES.has(operation.type) || FEE_TYPES.has(operation.type)) return 'reduces';
if (TRANSFER_INPUT_TYPES.has(operation.type)) return 'adds';
if (TRANSFER_OUTPUT_TYPES.has(operation.type)) return 'reduces';
if (operation.category === 'tax' || operation.category === 'fee') return 'reduces';
if (operation.category === 'income' && (operation.payment?.value ?? 0) > 0) return 'adds';
return 'unknown';
}

View File

@ -1,18 +1 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query'; export { useBrokerOperations } from '../entities/broker-operation';
import { getBrokerOperations, type BrokerOperationQuery } from '../api/broker';
import type { BrokerOperationsPage } from '../api/responses';
export function useBrokerOperations(
accountId: string | undefined,
query: BrokerOperationQuery = {},
) {
return useQuery<BrokerOperationsPage>({
queryKey: ['broker', 'operations', accountId, query],
enabled: Boolean(accountId),
queryFn: async () => (await getBrokerOperations(accountId!, query)).data,
staleTime: 300_000,
retry: 2,
placeholderData: keepPreviousData,
refetchOnWindowFocus: false,
});
}

View File

@ -1,10 +1,10 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import type { BrokerMoney, BrokerPortfolio } from '../../../api/responses'; import type { BrokerMoney, BrokerPortfolio } from '../../../api/responses';
import { SkeletonBlock } from '../../../components/SkeletonBlock'; import { SkeletonBlock } from '../../../components/SkeletonBlock';
import { useBrokerOperations } from '../../../hooks/useBrokerOperations'; import { useBrokerOperations } from '../../../entities/broker-operation';
import { useBrokerAccountContext } from '../../broker/BrokerAccountLayout'; import { useBrokerAccountContext } from '../../../entities/broker-account/ui/BrokerAccountLayout';
import { BrokerAllocationChart } from '../../../widgets/broker-allocation-chart'; import { BrokerAllocationChart } from '../../../widgets/broker-allocation-chart';
import { BrokerOperationsTable } from '../../broker/BrokerOperationsTable'; import { BrokerOperationsTable } from '../../../widgets/broker-operations-table';
function formatMoney(value: BrokerMoney | null | undefined) { function formatMoney(value: BrokerMoney | null | undefined) {
if (!value) return '—'; if (!value) return '—';

View File

@ -1 +1,89 @@
export { BrokerOperationsPage } from '../../broker/BrokerOperationsPage'; import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
BROKER_OPERATION_TYPE_OPTIONS,
isBrokerOperationType,
useBrokerOperations,
} from '../../../entities/broker-operation';
import { useBrokerAccountContext } from '../../../entities/broker-account/ui/BrokerAccountLayout';
import { BrokerOperationsTable } from '../../../widgets/broker-operations-table';
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

@ -7,7 +7,7 @@ import type {
} from '../../../api/responses'; } from '../../../api/responses';
import { TableSkeleton } from '../../../components/TableSkeleton'; import { TableSkeleton } from '../../../components/TableSkeleton';
import { getBrokerInstrumentPath, useBrokerPositions } from '../../../entities/broker-position'; import { getBrokerInstrumentPath, useBrokerPositions } from '../../../entities/broker-position';
import { useBrokerAccountContext } from '../../broker/BrokerAccountLayout'; import { useBrokerAccountContext } from '../../../entities/broker-account/ui/BrokerAccountLayout';
const tableStyle = { const tableStyle = {
width: '100%', width: '100%',

View File

@ -1,49 +1,5 @@
import { NavLink, Outlet, useOutletContext, useParams } from 'react-router-dom'; export {
import { useBrokerPortfolio } from '../../hooks/useBrokerPortfolio'; BrokerAccountLayout,
useBrokerAccountContext,
export type BrokerAccountContext = { type BrokerAccountContext,
accountId: string; } from '../../entities/broker-account/ui/BrokerAccountLayout';
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>
<div className="broker-account__content">
<Outlet context={context} />
</div>
</div>
</div>
);
}

View File

@ -1,86 +1 @@
import { useEffect, useState } from 'react'; export { BrokerOperationsPage } from '../broker-operations';
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

@ -1,274 +1 @@
import { Link } from 'react-router-dom'; export { BrokerOperationsTable } from '../../widgets/broker-operations-table';
import type { ReactNode } from 'react';
import type { BrokerMoney, BrokerOperation, BrokerOperationsPage } from '../../api/responses';
import {
getBrokerInstrumentPath,
getBrokerOperationImpact,
getBrokerOperationTypeLabel,
type BrokerOperationImpact,
} from './brokerDisplay';
import { TableSkeleton } from '../../components/TableSkeleton';
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;
function formatMoney(value: BrokerMoney | null | undefined) {
if (!value) return '-';
const formatted = new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: value.currency || 'RUB',
maximumFractionDigits: 2,
}).format(value.value);
return value.value > 0 ? `+${formatted}` : formatted;
}
function formatDate(value: string | null) {
if (!value) return '-';
return new Date(value).toLocaleString('ru-RU');
}
function moneyColor(impact: BrokerOperationImpact): string {
if (impact === 'adds') return 'var(--color-positive)';
if (impact === 'reduces') return 'var(--color-negative)';
return 'var(--color-text)';
}
function OperationInstrument({ operation }: { operation: BrokerOperation }) {
const ticker = operation.ticker || operation.description || '-';
const path = getBrokerInstrumentPath({
ticker: operation.ticker,
instrumentType: operation.instrumentType,
classCode: operation.classCode,
});
const name = operation.name || operation.description;
if (!path && !name) return <span>-</span>;
if (!path) return <span>{name}</span>;
if (!ticker || ticker === '-') return <Link to={path}>{name}</Link>;
return (
<div style={{ display: 'grid', gap: 2 }}>
<Link to={path} style={{ fontWeight: 700 }}>
{ticker}
</Link>
{name && name !== ticker && (
<span style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}>{name}</span>
)}
</div>
);
}
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;
export function BrokerOperationsTable({
title,
headerAction,
emptyMessage,
isLoading,
isFetching,
page,
pagination,
}: BrokerOperationsTableProps) {
const pageNumber = pagination?.pageNumber;
const canGoBack = pagination?.canGoBack ?? false;
const canGoForward = pagination?.canGoForward ?? false;
const onPrevious = pagination?.onPrevious;
const onNext = pagination?.onNext;
const operations = page?.items ?? [];
return (
<section aria-busy={isFetching}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
justifyContent: 'space-between',
marginBottom: 12,
}}
>
<h2 style={{ fontSize: 20, margin: 0 }}>{title}</h2>
{headerAction}
{pagination && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
type="button"
aria-label="Предыдущая страница"
onClick={onPrevious}
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"
aria-label="Следующая страница"
onClick={onNext}
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="left" style={thStyle}>
Инструмент
</th>
<th align="right" style={thStyle}>
Сумма
</th>
</tr>
</thead>
<TableSkeleton
rows={5}
columns={[{ width: '35%' }, { width: '30%' }, { width: '40%' }, { width: '25%' }]}
/>
</table>
</div>
) : operations.length === 0 && !isFetching ? (
<p style={{ color: 'var(--color-text-secondary)' }}>{emptyMessage}</p>
) : (
<div className="table-container">
<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="left" style={thStyle}>
Инструмент
</th>
<th align="right" style={thStyle}>
Сумма
</th>
</tr>
</thead>
<tbody>
{operations.map((operation) => {
const impact = getBrokerOperationImpact(operation);
return (
<tr key={operation.cursor || operation.id}>
<td style={tdStyle}>{formatDate(operation.date)}</td>
<td style={tdStyle}>
<span>{getBrokerOperationTypeLabel(operation)}</span>
</td>
<td style={tdStyle}>
<OperationInstrument operation={operation} />
</td>
<td
align="right"
style={{ ...tdStyle, color: moneyColor(impact), fontWeight: 700 }}
>
{formatMoney(operation.payment)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{isFetching && (
<div className="table-loading-overlay" role="status">
<div className="loading-spinner" aria-hidden="true" />
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
{pagination ? `Загрузка страницы ${pageNumber}` : 'Обновление операций…'}
</span>
</div>
)}
</div>
)}
</section>
);
}
type BrokerOperationsTableProps = {
title: string;
headerAction?: ReactNode;
emptyMessage: string;
isLoading: boolean;
isFetching: boolean;
page: BrokerOperationsPage | undefined;
pagination?: {
pageNumber: number;
canGoBack: boolean;
canGoForward: boolean;
onPrevious: () => void;
onNext: () => void;
};
};

View File

@ -4,15 +4,18 @@ import userEvent from '@testing-library/user-event';
import { type ReactElement } from 'react'; import { type ReactElement } from 'react';
import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import * as operationsHook from '../../hooks/useBrokerOperations'; import * as operationsHook from '../../entities/broker-operation';
import * as brokerAccountsHook from '../../hooks/useBrokerAccounts'; import * as brokerAccountsHook from '../../hooks/useBrokerAccounts';
import * as brokerAccountPortfoliosHook from '../../hooks/useBrokerAccountPortfolios'; import * as brokerAccountPortfoliosHook from '../../hooks/useBrokerAccountPortfolios';
import * as portfolioHook from '../../hooks/useBrokerPortfolio'; import * as portfolioHook from '../../entities/broker-account/model/useBrokerPortfolio';
import type { BrokerAccount, BrokerPortfolio, BrokerPosition } from '../../api/responses'; import type { BrokerAccount, BrokerPortfolio, BrokerPosition } from '../../api/responses';
import * as positionsHook from '../../entities/broker-position'; import * as positionsHook from '../../entities/broker-position';
import { AppRoutes } from '../../routes'; import { AppRoutes } from '../../routes';
import { renderWithProviders } from '../../test/test-utils'; import { renderWithProviders } from '../../test/test-utils';
import { BrokerAccountLayout, useBrokerAccountContext } from './BrokerAccountLayout'; import {
BrokerAccountLayout,
useBrokerAccountContext,
} from '../../entities/broker-account/ui/BrokerAccountLayout';
import { BrokerAccountOverviewPage } from '../broker-account'; import { BrokerAccountOverviewPage } from '../broker-account';
import { BrokerPositionsPage } from '../broker-positions'; import { BrokerPositionsPage } from '../broker-positions';

View File

@ -11,7 +11,7 @@ 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-accounts'; import { BrokerAccountsPage } from './pages/broker-accounts';
import { BrokerAccountLayout } from './pages/broker/BrokerAccountLayout'; import { BrokerAccountLayout } from './entities/broker-account/ui/BrokerAccountLayout';
import { BrokerAccountOverviewPage } from './pages/broker-account'; import { BrokerAccountOverviewPage } from './pages/broker-account';
import { BrokerPositionsPage } from './pages/broker-positions'; import { BrokerPositionsPage } from './pages/broker-positions';
import { BrokerOperationsPage } from './pages/broker-operations'; import { BrokerOperationsPage } from './pages/broker-operations';

View File

@ -0,0 +1 @@
export { BrokerOperationsTable } from './ui/BrokerOperationsTable';

View File

@ -0,0 +1,274 @@
import { Link } from 'react-router-dom';
import type { ReactNode } from 'react';
import type { BrokerMoney, BrokerOperation, BrokerOperationsPage } from '../../../api/responses';
import { TableSkeleton } from '../../../components/TableSkeleton';
import {
getBrokerOperationImpact,
getBrokerOperationTypeLabel,
type BrokerOperationImpact,
} from '../../../entities/broker-operation';
import { getBrokerInstrumentPath } from '../../../entities/broker-position';
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 '-';
const formatted = new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: value.currency || 'RUB',
maximumFractionDigits: 2,
}).format(value.value);
return value.value > 0 ? `+${formatted}` : formatted;
}
function formatDate(value: string | null) {
if (!value) return '-';
return new Date(value).toLocaleString('ru-RU');
}
function moneyColor(impact: BrokerOperationImpact): string {
if (impact === 'adds') return 'var(--color-positive)';
if (impact === 'reduces') return 'var(--color-negative)';
return 'var(--color-text)';
}
function OperationInstrument({ operation }: { operation: BrokerOperation }) {
const ticker = operation.ticker || operation.description || '-';
const path = getBrokerInstrumentPath({
ticker: operation.ticker,
instrumentType: operation.instrumentType,
classCode: operation.classCode,
});
const name = operation.name || operation.description;
if (!path && !name) return <span>-</span>;
if (!path) return <span>{name}</span>;
if (!ticker || ticker === '-') return <Link to={path}>{name}</Link>;
return (
<div style={{ display: 'grid', gap: 2 }}>
<Link to={path} style={{ fontWeight: 700 }}>
{ticker}
</Link>
{name && name !== ticker && (
<span style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}>{name}</span>
)}
</div>
);
}
export function BrokerOperationsTable({
title,
headerAction,
emptyMessage,
isLoading,
isFetching,
page,
pagination,
}: BrokerOperationsTableProps) {
const pageNumber = pagination?.pageNumber;
const canGoBack = pagination?.canGoBack ?? false;
const canGoForward = pagination?.canGoForward ?? false;
const onPrevious = pagination?.onPrevious;
const onNext = pagination?.onNext;
const operations = page?.items ?? [];
return (
<section aria-busy={isFetching}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
justifyContent: 'space-between',
marginBottom: 12,
}}
>
<h2 style={{ fontSize: 20, margin: 0 }}>{title}</h2>
{headerAction}
{pagination && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
type="button"
aria-label="Предыдущая страница"
onClick={onPrevious}
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"
aria-label="Следующая страница"
onClick={onNext}
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="left" style={thStyle}>
Инструмент
</th>
<th align="right" style={thStyle}>
Сумма
</th>
</tr>
</thead>
<TableSkeleton
rows={5}
columns={[{ width: '35%' }, { width: '30%' }, { width: '40%' }, { width: '25%' }]}
/>
</table>
</div>
) : operations.length === 0 && !isFetching ? (
<p style={{ color: 'var(--color-text-secondary)' }}>{emptyMessage}</p>
) : (
<div className="table-container">
<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="left" style={thStyle}>
Инструмент
</th>
<th align="right" style={thStyle}>
Сумма
</th>
</tr>
</thead>
<tbody>
{operations.map((operation) => {
const impact = getBrokerOperationImpact(operation);
return (
<tr key={operation.cursor || operation.id}>
<td style={tdStyle}>{formatDate(operation.date)}</td>
<td style={tdStyle}>
<span>{getBrokerOperationTypeLabel(operation)}</span>
</td>
<td style={tdStyle}>
<OperationInstrument operation={operation} />
</td>
<td
align="right"
style={{ ...tdStyle, color: moneyColor(impact), fontWeight: 700 }}
>
{formatMoney(operation.payment)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{isFetching && (
<div className="table-loading-overlay" role="status">
<div className="loading-spinner" aria-hidden="true" />
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
{pagination ? `Загрузка страницы ${pageNumber}` : 'Обновление операций…'}
</span>
</div>
)}
</div>
)}
</section>
);
}
type BrokerOperationsTableProps = {
title: string;
headerAction?: ReactNode;
emptyMessage: string;
isLoading: boolean;
isFetching: boolean;
page: BrokerOperationsPage | undefined;
pagination?: {
pageNumber: number;
canGoBack: boolean;
canGoForward: boolean;
onPrevious: () => void;
onNext: () => void;
};
};