refactor(frontend): move broker operations and layout to fsd
This commit is contained in:
parent
09213624c3
commit
00f01fae4f
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export { getBrokerOperations, type BrokerOperationQuery } from '../../../api/broker';
|
||||||
9
apps/frontend/src/entities/broker-operation/index.ts
Normal file
9
apps/frontend/src/entities/broker-operation/index.ts
Normal 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';
|
||||||
@ -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')));
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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';
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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';
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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 '—';
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -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%',
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
export { BrokerOperationsTable } from './ui/BrokerOperationsTable';
|
||||||
@ -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;
|
||||||
|
};
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user