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 BrokerOperationImpact = 'adds' | 'reduces' | 'neutral' | 'unknown';
|
||||
|
||||
type BrokerInstrumentLinkInput = {
|
||||
ticker: string | null;
|
||||
@ -12,102 +18,6 @@ type BrokerInstrumentLinkInput = {
|
||||
const STOCK_CLASS_CODES = new Set(['TQBR']);
|
||||
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(
|
||||
position: Pick<BrokerPosition, 'instrumentType'>,
|
||||
): BrokerPositionGroup {
|
||||
@ -141,37 +51,3 @@ export function getBrokerInstrumentPath(input: BrokerInstrumentLinkInput): strin
|
||||
|
||||
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';
|
||||
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,
|
||||
});
|
||||
}
|
||||
export { useBrokerOperations } from '../entities/broker-operation';
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { BrokerMoney, BrokerPortfolio } from '../../../api/responses';
|
||||
import { SkeletonBlock } from '../../../components/SkeletonBlock';
|
||||
import { useBrokerOperations } from '../../../hooks/useBrokerOperations';
|
||||
import { useBrokerAccountContext } from '../../broker/BrokerAccountLayout';
|
||||
import { useBrokerOperations } from '../../../entities/broker-operation';
|
||||
import { useBrokerAccountContext } from '../../../entities/broker-account/ui/BrokerAccountLayout';
|
||||
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) {
|
||||
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';
|
||||
import { TableSkeleton } from '../../../components/TableSkeleton';
|
||||
import { getBrokerInstrumentPath, useBrokerPositions } from '../../../entities/broker-position';
|
||||
import { useBrokerAccountContext } from '../../broker/BrokerAccountLayout';
|
||||
import { useBrokerAccountContext } from '../../../entities/broker-account/ui/BrokerAccountLayout';
|
||||
|
||||
const tableStyle = {
|
||||
width: '100%',
|
||||
|
||||
@ -1,49 +1,5 @@
|
||||
import { NavLink, Outlet, useOutletContext, useParams } from 'react-router-dom';
|
||||
import { useBrokerPortfolio } from '../../hooks/useBrokerPortfolio';
|
||||
|
||||
export type BrokerAccountContext = {
|
||||
accountId: string;
|
||||
portfolio: ReturnType<typeof useBrokerPortfolio>;
|
||||
};
|
||||
|
||||
export function useBrokerAccountContext() {
|
||||
return useOutletContext<BrokerAccountContext>();
|
||||
}
|
||||
|
||||
export function BrokerAccountLayout() {
|
||||
const { accountId = '' } = useParams();
|
||||
const portfolio = useBrokerPortfolio(accountId);
|
||||
const basePath = `/broker/${encodeURIComponent(accountId)}`;
|
||||
const context: BrokerAccountContext = { accountId, portfolio };
|
||||
const linkClassName = ({ isActive }: { isActive: boolean }) =>
|
||||
`broker-account__link${isActive ? ' is-active' : ''}`;
|
||||
|
||||
return (
|
||||
<div className="broker-account">
|
||||
<header className="broker-account__header">
|
||||
<h1>{portfolio.data?.account.name || 'Брокерский счёт'}</h1>
|
||||
</header>
|
||||
|
||||
<div className="broker-account__workspace">
|
||||
<nav className="broker-account__navigation" aria-label="Разделы брокерского счёта">
|
||||
<NavLink className={linkClassName} end to={basePath}>
|
||||
Обзор
|
||||
</NavLink>
|
||||
<NavLink className={linkClassName} to={`${basePath}/shares`}>
|
||||
Акции
|
||||
</NavLink>
|
||||
<NavLink className={linkClassName} to={`${basePath}/bonds`}>
|
||||
Облигации
|
||||
</NavLink>
|
||||
<NavLink className={linkClassName} to={`${basePath}/operations`}>
|
||||
Операции
|
||||
</NavLink>
|
||||
</nav>
|
||||
|
||||
<div className="broker-account__content">
|
||||
<Outlet context={context} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export {
|
||||
BrokerAccountLayout,
|
||||
useBrokerAccountContext,
|
||||
type BrokerAccountContext,
|
||||
} from '../../entities/broker-account/ui/BrokerAccountLayout';
|
||||
|
||||
@ -1,86 +1 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useBrokerAccountContext } from './BrokerAccountLayout';
|
||||
import { useBrokerOperations } from '../../hooks/useBrokerOperations';
|
||||
import { BrokerOperationsTable } from './BrokerOperationsTable';
|
||||
import { BROKER_OPERATION_TYPE_OPTIONS, isBrokerOperationType } from './brokerDisplay';
|
||||
|
||||
export function BrokerOperationsPage() {
|
||||
const { accountId } = useBrokerAccountContext();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const urlType = searchParams.get('type');
|
||||
const selectedType = isBrokerOperationType(urlType) ? urlType : '';
|
||||
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
||||
const [cursorStack, setCursorStack] = useState<Array<string | undefined>>([]);
|
||||
const operations = useBrokerOperations(accountId, {
|
||||
limit: 10,
|
||||
cursor,
|
||||
operationTypes: selectedType || undefined,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setCursor(undefined);
|
||||
setCursorStack([]);
|
||||
}, [selectedType]);
|
||||
|
||||
function handleTypeChange(event: React.ChangeEvent<HTMLSelectElement>) {
|
||||
const nextType = event.target.value;
|
||||
setSearchParams(nextType ? { type: nextType } : {}, { replace: true });
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
const nextCursor = operations.data?.nextCursor;
|
||||
if (!nextCursor || !operations.data?.hasNext) return;
|
||||
setCursorStack((previous) => [...previous, cursor]);
|
||||
setCursor(nextCursor);
|
||||
}
|
||||
|
||||
function handlePrevious() {
|
||||
if (cursorStack.length === 0) return;
|
||||
setCursor(cursorStack[cursorStack.length - 1]);
|
||||
setCursorStack((previous) => previous.slice(0, -1));
|
||||
}
|
||||
|
||||
const history = operations.error ? (
|
||||
<p role="alert">Не удалось загрузить историю операций</p>
|
||||
) : (
|
||||
<BrokerOperationsTable
|
||||
title="История операций"
|
||||
emptyMessage={
|
||||
selectedType ? 'Операций выбранного типа нет' : 'Операций с начала текущего года нет'
|
||||
}
|
||||
isLoading={operations.isLoading}
|
||||
isFetching={operations.isFetching}
|
||||
page={operations.data}
|
||||
pagination={{
|
||||
pageNumber: cursorStack.length + 1,
|
||||
canGoBack: cursorStack.length > 0,
|
||||
canGoForward: Boolean(operations.data?.hasNext && operations.data.nextCursor),
|
||||
onPrevious: handlePrevious,
|
||||
onNext: handleNext,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<section aria-labelledby="broker-operations-heading">
|
||||
<div className="broker-operations__toolbar">
|
||||
<h2 id="broker-operations-heading" style={{ fontSize: 20, margin: 0 }}>
|
||||
Операции
|
||||
</h2>
|
||||
<label>
|
||||
<span>Тип операции</span>
|
||||
<select value={selectedType} onChange={handleTypeChange}>
|
||||
<option value="">Все операции</option>
|
||||
{BROKER_OPERATION_TYPE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{history}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
export { BrokerOperationsPage } from '../broker-operations';
|
||||
|
||||
@ -1,274 +1 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
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;
|
||||
};
|
||||
};
|
||||
export { BrokerOperationsTable } from '../../widgets/broker-operations-table';
|
||||
|
||||
@ -4,15 +4,18 @@ import userEvent from '@testing-library/user-event';
|
||||
import { type ReactElement } from 'react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
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 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 * as positionsHook from '../../entities/broker-position';
|
||||
import { AppRoutes } from '../../routes';
|
||||
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 { BrokerPositionsPage } from '../broker-positions';
|
||||
|
||||
@ -11,7 +11,7 @@ import { PortfoliosListPage } from './pages/portfolios/PortfoliosListPage';
|
||||
import { PortfolioDetailPage } from './pages/portfolios/PortfolioDetailPage';
|
||||
import { ScreenerPage } from './pages/screener/ScreenerPage';
|
||||
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 { BrokerPositionsPage } from './pages/broker-positions';
|
||||
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