diff --git a/apps/frontend/src/entities/broker-account/ui/BrokerAccountLayout.tsx b/apps/frontend/src/entities/broker-account/ui/BrokerAccountLayout.tsx new file mode 100644 index 0000000..6d784c6 --- /dev/null +++ b/apps/frontend/src/entities/broker-account/ui/BrokerAccountLayout.tsx @@ -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; +}; + +export function useBrokerAccountContext() { + return useOutletContext(); +} + +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 ( +
+
+

{portfolio.data?.account.name || 'Брокерский счёт'}

+
+ +
+ + +
+ +
+
+
+ ); +} diff --git a/apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts b/apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts new file mode 100644 index 0000000..ec73d7b --- /dev/null +++ b/apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts @@ -0,0 +1 @@ +export { getBrokerOperations, type BrokerOperationQuery } from '../../../api/broker'; diff --git a/apps/frontend/src/entities/broker-operation/index.ts b/apps/frontend/src/entities/broker-operation/index.ts new file mode 100644 index 0000000..bb45f5f --- /dev/null +++ b/apps/frontend/src/entities/broker-operation/index.ts @@ -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'; diff --git a/apps/frontend/src/entities/broker-operation/model/operationFilters.test.ts b/apps/frontend/src/entities/broker-operation/model/operationFilters.test.ts new file mode 100644 index 0000000..e5feb7e --- /dev/null +++ b/apps/frontend/src/entities/broker-operation/model/operationFilters.test.ts @@ -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'))); + }); +}); diff --git a/apps/frontend/src/entities/broker-operation/model/operationFilters.ts b/apps/frontend/src/entities/broker-operation/model/operationFilters.ts new file mode 100644 index 0000000..a2ede0e --- /dev/null +++ b/apps/frontend/src/entities/broker-operation/model/operationFilters.ts @@ -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 = { + 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, +): 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, +): 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'; +} diff --git a/apps/frontend/src/entities/broker-operation/model/useBrokerOperations.test.tsx b/apps/frontend/src/entities/broker-operation/model/useBrokerOperations.test.tsx new file mode 100644 index 0000000..7de1636 --- /dev/null +++ b/apps/frontend/src/entities/broker-operation/model/useBrokerOperations.test.tsx @@ -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 {children}; + }; +} + +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(); + }); +}); diff --git a/apps/frontend/src/entities/broker-operation/model/useBrokerOperations.ts b/apps/frontend/src/entities/broker-operation/model/useBrokerOperations.ts new file mode 100644 index 0000000..a2df7f8 --- /dev/null +++ b/apps/frontend/src/entities/broker-operation/model/useBrokerOperations.ts @@ -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({ + queryKey: ['broker', 'operations', accountId, query], + enabled: Boolean(accountId), + queryFn: async () => (await getBrokerOperations(accountId!, query)).data, + staleTime: 300_000, + retry: 2, + placeholderData: keepPreviousData, + refetchOnWindowFocus: false, + }); +} diff --git a/apps/frontend/src/entities/broker-position/model/brokerDisplay.ts b/apps/frontend/src/entities/broker-position/model/brokerDisplay.ts index 3479e78..6e1532a 100644 --- a/apps/frontend/src/entities/broker-position/model/brokerDisplay.ts +++ b/apps/frontend/src/entities/broker-position/model/brokerDisplay.ts @@ -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 = { - 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, ): BrokerPositionGroup { @@ -141,37 +51,3 @@ export function getBrokerInstrumentPath(input: BrokerInstrumentLinkInput): strin return null; } - -export function getBrokerOperationTypeLabel( - operation: Pick, -): 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, -): 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'; -} diff --git a/apps/frontend/src/hooks/useBrokerOperations.ts b/apps/frontend/src/hooks/useBrokerOperations.ts index c157c0e..84eb6fb 100644 --- a/apps/frontend/src/hooks/useBrokerOperations.ts +++ b/apps/frontend/src/hooks/useBrokerOperations.ts @@ -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({ - 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'; diff --git a/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx b/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx index ff42380..c12a621 100644 --- a/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx +++ b/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx @@ -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 '—'; diff --git a/apps/frontend/src/pages/broker-operations/ui/BrokerOperationsPage.tsx b/apps/frontend/src/pages/broker-operations/ui/BrokerOperationsPage.tsx index 5c3281e..892eff7 100644 --- a/apps/frontend/src/pages/broker-operations/ui/BrokerOperationsPage.tsx +++ b/apps/frontend/src/pages/broker-operations/ui/BrokerOperationsPage.tsx @@ -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(undefined); + const [cursorStack, setCursorStack] = useState>([]); + const operations = useBrokerOperations(accountId, { + limit: 10, + cursor, + operationTypes: selectedType || undefined, + }); + + useEffect(() => { + setCursor(undefined); + setCursorStack([]); + }, [selectedType]); + + function handleTypeChange(event: React.ChangeEvent) { + 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 ? ( +

Не удалось загрузить историю операций

+ ) : ( + 0, + canGoForward: Boolean(operations.data?.hasNext && operations.data.nextCursor), + onPrevious: handlePrevious, + onNext: handleNext, + }} + /> + ); + + return ( +
+
+

+ Операции +

+ +
+ {history} +
+ ); +} diff --git a/apps/frontend/src/pages/broker-positions/ui/BrokerPositionsPage.tsx b/apps/frontend/src/pages/broker-positions/ui/BrokerPositionsPage.tsx index c33847d..82b6aae 100644 --- a/apps/frontend/src/pages/broker-positions/ui/BrokerPositionsPage.tsx +++ b/apps/frontend/src/pages/broker-positions/ui/BrokerPositionsPage.tsx @@ -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%', diff --git a/apps/frontend/src/pages/broker/BrokerAccountLayout.tsx b/apps/frontend/src/pages/broker/BrokerAccountLayout.tsx index 35ac6c9..f82f39b 100644 --- a/apps/frontend/src/pages/broker/BrokerAccountLayout.tsx +++ b/apps/frontend/src/pages/broker/BrokerAccountLayout.tsx @@ -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; -}; - -export function useBrokerAccountContext() { - return useOutletContext(); -} - -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 ( -
-
-

{portfolio.data?.account.name || 'Брокерский счёт'}

-
- -
- - -
- -
-
-
- ); -} +export { + BrokerAccountLayout, + useBrokerAccountContext, + type BrokerAccountContext, +} from '../../entities/broker-account/ui/BrokerAccountLayout'; diff --git a/apps/frontend/src/pages/broker/BrokerOperationsPage.tsx b/apps/frontend/src/pages/broker/BrokerOperationsPage.tsx index 82c534d..47eae58 100644 --- a/apps/frontend/src/pages/broker/BrokerOperationsPage.tsx +++ b/apps/frontend/src/pages/broker/BrokerOperationsPage.tsx @@ -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(undefined); - const [cursorStack, setCursorStack] = useState>([]); - const operations = useBrokerOperations(accountId, { - limit: 10, - cursor, - operationTypes: selectedType || undefined, - }); - - useEffect(() => { - setCursor(undefined); - setCursorStack([]); - }, [selectedType]); - - function handleTypeChange(event: React.ChangeEvent) { - 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 ? ( -

Не удалось загрузить историю операций

- ) : ( - 0, - canGoForward: Boolean(operations.data?.hasNext && operations.data.nextCursor), - onPrevious: handlePrevious, - onNext: handleNext, - }} - /> - ); - - return ( -
-
-

- Операции -

- -
- {history} -
- ); -} +export { BrokerOperationsPage } from '../broker-operations'; diff --git a/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx b/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx index 96b8661..ae5a988 100644 --- a/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx +++ b/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx @@ -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 -; - if (!path) return {name}; - if (!ticker || ticker === '-') return {name}; - - return ( -
- - {ticker} - - {name && name !== ticker && ( - {name} - )} -
- ); -} - -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 ( -
-
-

{title}

- {headerAction} - {pagination && ( -
- - - {pageNumber} - - -
- )} -
- - {isLoading ? ( -
- - - - - - - - - - -
- Дата - - Тип - - Инструмент - - Сумма -
-
- ) : operations.length === 0 && !isFetching ? ( -

{emptyMessage}

- ) : ( -
-
- - - - - - - - - - - {operations.map((operation) => { - const impact = getBrokerOperationImpact(operation); - return ( - - - - - - - ); - })} - -
- Дата - - Тип - - Инструмент - - Сумма -
{formatDate(operation.date)} - {getBrokerOperationTypeLabel(operation)} - - - - {formatMoney(operation.payment)} -
-
- {isFetching && ( -
- - )} -
- )} -
- ); -} - -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'; diff --git a/apps/frontend/src/pages/broker/BrokerPages.test.tsx b/apps/frontend/src/pages/broker/BrokerPages.test.tsx index 08ddafc..7c33eae 100644 --- a/apps/frontend/src/pages/broker/BrokerPages.test.tsx +++ b/apps/frontend/src/pages/broker/BrokerPages.test.tsx @@ -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'; diff --git a/apps/frontend/src/routes.tsx b/apps/frontend/src/routes.tsx index c477d63..d757965 100644 --- a/apps/frontend/src/routes.tsx +++ b/apps/frontend/src/routes.tsx @@ -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'; diff --git a/apps/frontend/src/widgets/broker-operations-table/index.ts b/apps/frontend/src/widgets/broker-operations-table/index.ts new file mode 100644 index 0000000..234fc6b --- /dev/null +++ b/apps/frontend/src/widgets/broker-operations-table/index.ts @@ -0,0 +1 @@ +export { BrokerOperationsTable } from './ui/BrokerOperationsTable'; diff --git a/apps/frontend/src/widgets/broker-operations-table/ui/BrokerOperationsTable.tsx b/apps/frontend/src/widgets/broker-operations-table/ui/BrokerOperationsTable.tsx new file mode 100644 index 0000000..ec531bd --- /dev/null +++ b/apps/frontend/src/widgets/broker-operations-table/ui/BrokerOperationsTable.tsx @@ -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 -; + if (!path) return {name}; + if (!ticker || ticker === '-') return {name}; + + return ( +
+ + {ticker} + + {name && name !== ticker && ( + {name} + )} +
+ ); +} + +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 ( +
+
+

{title}

+ {headerAction} + {pagination && ( +
+ + + {pageNumber} + + +
+ )} +
+ + {isLoading ? ( +
+ + + + + + + + + + +
+ Дата + + Тип + + Инструмент + + Сумма +
+
+ ) : operations.length === 0 && !isFetching ? ( +

{emptyMessage}

+ ) : ( +
+
+ + + + + + + + + + + {operations.map((operation) => { + const impact = getBrokerOperationImpact(operation); + return ( + + + + + + + ); + })} + +
+ Дата + + Тип + + Инструмент + + Сумма +
{formatDate(operation.date)} + {getBrokerOperationTypeLabel(operation)} + + + + {formatMoney(operation.payment)} +
+
+ {isFetching && ( +
+ + )} +
+ )} +
+ ); +} + +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; + }; +};