From 2223463dd3ac5606b65ea3c6b23d626a6cd636df Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Fri, 19 Jun 2026 07:06:49 +0300 Subject: [PATCH] feat: filter broker operations by exact type --- apps/frontend/src/api/broker.test.ts | 25 + .../pages/broker/BrokerAccountDetailPage.tsx | 139 ----- .../src/pages/broker/BrokerOperationsPage.tsx | 86 +++ .../src/pages/broker/BrokerPages.test.tsx | 583 ++++++------------ .../pages/broker/BrokerPositionsSection.tsx | 311 ---------- apps/frontend/src/routes.tsx | 14 +- apps/frontend/src/styles.css | 71 ++- 7 files changed, 380 insertions(+), 849 deletions(-) delete mode 100644 apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx create mode 100644 apps/frontend/src/pages/broker/BrokerOperationsPage.tsx delete mode 100644 apps/frontend/src/pages/broker/BrokerPositionsSection.tsx diff --git a/apps/frontend/src/api/broker.test.ts b/apps/frontend/src/api/broker.test.ts index c0b9119..71bf7b4 100644 --- a/apps/frontend/src/api/broker.test.ts +++ b/apps/frontend/src/api/broker.test.ts @@ -25,6 +25,31 @@ describe('broker api', () => { ); }); + it('serializes operations query parameters including operationTypes', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ + data: { + data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: 'now' }, + meta: { fromCache: false, cachedAt: null }, + }, + }), + } as Response); + + await getBrokerOperations('acc-1', { + cursor: 'c1', + limit: 10, + operationTypes: 'OPERATION_TYPE_COUPON', + }); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining( + '/api/v1/broker/accounts/acc-1/operations?cursor=c1&limit=10&operationTypes=OPERATION_TYPE_COUPON', + ), + expect.any(Object), + ); + }); + it('serializes positions query parameters', async () => { vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, diff --git a/apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx b/apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx deleted file mode 100644 index 39167e8..0000000 --- a/apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { useState } from 'react'; -import { useParams } from 'react-router-dom'; -import type { BrokerMoney } from '../../api/responses'; -import { useBrokerOperations } from '../../hooks/useBrokerOperations'; -import { useBrokerPortfolio } from '../../hooks/useBrokerPortfolio'; -import { BrokerOperationsTable } from './BrokerOperationsTable'; -import { BrokerPositionsSection } from './BrokerPositionsSection'; -import { SkeletonBlock } from '../../components/SkeletonBlock'; - -function formatMoney(value: BrokerMoney | null | undefined) { - if (!value) return '-'; - return new Intl.NumberFormat('ru-RU', { - style: 'currency', - currency: value.currency || 'RUB', - maximumFractionDigits: 2, - }).format(value.value); -} - -export function BrokerAccountDetailPage() { - const { accountId } = useParams(); - const [operationCursor, setOperationCursor] = useState(undefined); - const [operationCursorStack, setOperationCursorStack] = useState>([]); - const portfolio = useBrokerPortfolio(accountId); - const operations = useBrokerOperations(accountId, { limit: 10, cursor: operationCursor }); - - if (portfolio.isLoading) { - return ( -
-
- - -
-
- {[1, 2, 3].map((i) => ( -
- -
- -
- ))} -
-
- ); - } - - if (portfolio.error || !portfolio.data) { - return

Не удалось загрузить портфель

; - } - - function handleNextOperationsPage() { - const nextCursor = operations.data?.nextCursor; - if (!nextCursor || !operations.data?.hasNext) return; - setOperationCursorStack((previous) => [...previous, operationCursor]); - setOperationCursor(nextCursor); - } - - function handlePreviousOperationsPage() { - if (operationCursorStack.length === 0) return; - const nextStack = operationCursorStack.slice(0, -1); - const previousCursor = operationCursorStack[operationCursorStack.length - 1]; - setOperationCursorStack(nextStack); - setOperationCursor(previousCursor); - } - - return ( -
-
-

- {portfolio.data.account.name} -

-
- {formatMoney(portfolio.data.totals.portfolio)} - - День: {formatMoney(portfolio.data.yields.daily)} - - - Ожидаемая: {portfolio.data.yields.expectedPercent ?? '-'}% - -
-
- -
- {portfolio.data.cash.map((money) => ( -
-
- {money.currency} -
- {formatMoney(money)} -
- ))} -
- - - - 0, - canGoForward: Boolean(operations.data?.hasNext && operations.data.nextCursor), - onPrevious: handlePreviousOperationsPage, - onNext: handleNextOperationsPage, - }} - /> -
- ); -} diff --git a/apps/frontend/src/pages/broker/BrokerOperationsPage.tsx b/apps/frontend/src/pages/broker/BrokerOperationsPage.tsx new file mode 100644 index 0000000..82c534d --- /dev/null +++ b/apps/frontend/src/pages/broker/BrokerOperationsPage.tsx @@ -0,0 +1,86 @@ +import { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { useBrokerAccountContext } from './BrokerAccountLayout'; +import { useBrokerOperations } from '../../hooks/useBrokerOperations'; +import { BrokerOperationsTable } from './BrokerOperationsTable'; +import { BROKER_OPERATION_TYPE_OPTIONS, isBrokerOperationType } from './brokerDisplay'; + +export function BrokerOperationsPage() { + const { accountId } = useBrokerAccountContext(); + const [searchParams, setSearchParams] = useSearchParams(); + const urlType = searchParams.get('type'); + const selectedType = isBrokerOperationType(urlType) ? urlType : ''; + const [cursor, setCursor] = useState(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/BrokerPages.test.tsx b/apps/frontend/src/pages/broker/BrokerPages.test.tsx index b2e5cfb..fb9e536 100644 --- a/apps/frontend/src/pages/broker/BrokerPages.test.tsx +++ b/apps/frontend/src/pages/broker/BrokerPages.test.tsx @@ -10,10 +10,11 @@ import * as portfolioHook from '../../hooks/useBrokerPortfolio'; import * as positionsHook from '../../hooks/useBrokerPositions'; import type { BrokerPortfolio, BrokerPosition } from '../../api/responses'; import { BrokerAccountLayout, useBrokerAccountContext } from './BrokerAccountLayout'; -import { BrokerAccountDetailPage } from './BrokerAccountDetailPage'; + import { BrokerAccountOverviewPage } from './BrokerAccountOverviewPage'; import { BrokerAccountsPage } from './BrokerAccountsPage'; import { BrokerPositionsPage } from './BrokerPositionsPage'; +import { BrokerOperationsPage } from './BrokerOperationsPage'; function renderWithClient(ui: ReactElement, initialEntries = ['/broker']) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -306,389 +307,6 @@ describe('Broker pages', () => { expect(screen.getByText('IIS')).toBeInTheDocument(); }); - it('renders positions and operations for account detail', () => { - vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ - data: { - account: { - id: 'acc-1', - type: 'brokerage', - name: 'Broker', - status: 'ACCOUNT_STATUS_OPEN', - openedAt: null, - accessLevel: null, - }, - totals: { portfolio: { currency: 'RUB', units: '1000', nano: 0, value: 1000 } }, - yields: { expectedPercent: 5, daily: null, dailyPercent: null }, - cash: [{ currency: 'RUB', units: '100', nano: 0, value: 100 }], - blockedCash: [], - asOf: '2026-06-16T00:00:00.000Z', - }, - isLoading: false, - isFetching: false, - error: null, - } as any); - vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({ - data: { - accountId: 'acc-1', - items: [ - { - cursor: 'cursor-1', - accountId: 'acc-1', - id: 'op-1', - parentOperationId: null, - date: '2026-06-16T00:00:00.000Z', - category: 'trade', - type: 'OPERATION_TYPE_BUY', - description: 'Buy', - state: 'OPERATION_STATE_EXECUTED', - instrumentUid: 'uid-1', - figi: null, - ticker: 'SBER', - classCode: 'TQBR', - instrumentType: 'share', - payment: { currency: 'RUB', units: '-1000', nano: 0, value: -1000 }, - price: null, - commission: null, - yield: null, - accruedInt: null, - quantity: 10, - quantityDone: 10, - }, - ], - nextCursor: null, - hasNext: false, - asOf: '2026-06-16T00:00:00.000Z', - }, - isLoading: false, - isFetching: false, - error: null, - } as any); - mockUseBrokerPositions( - createPosition({ - instrumentUid: 'uid-1', - ticker: 'SBER', - classCode: 'TQBR', - instrumentType: 'share', - name: 'Sberbank', - quantity: 10, - currentValue: { currency: 'RUB', units: '1000', nano: 0, value: 1000 }, - }), - ); - - renderWithClient( - - } /> - , - ['/broker/acc-1'], - ); - - expect(screen.getAllByText('SBER').length).toBeGreaterThan(0); - expect(screen.getByText('Покупка')).toBeInTheDocument(); - }); - - it('renders broker positions as separate linked stock and bond tables with current price', () => { - vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ - data: { - account: { - id: 'acc-1', - type: 'brokerage', - name: 'Broker', - status: 'ACCOUNT_STATUS_OPEN', - openedAt: null, - accessLevel: null, - }, - totals: { portfolio: { currency: 'RUB', units: '10000', nano: 0, value: 10000 } }, - yields: { expectedPercent: 5, daily: null, dailyPercent: null }, - cash: [], - blockedCash: [], - asOf: '2026-06-17T00:00:00.000Z', - }, - isLoading: false, - isFetching: false, - error: null, - } as any); - vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({ - data: { - accountId: 'acc-1', - items: [], - nextCursor: null, - hasNext: false, - asOf: '2026-06-17T00:00:00.000Z', - }, - isLoading: false, - isFetching: false, - error: null, - } as any); - mockUseBrokerPositions( - createPosition({ - instrumentUid: 'share-uid', - ticker: 'SBER', - classCode: 'TQBR', - instrumentType: 'share', - name: 'Sberbank', - quantity: 10, - currentPrice: { currency: 'RUB', units: '250', nano: 0, value: 250 }, - currentValue: { currency: 'RUB', units: '2500', nano: 0, value: 2500 }, - expectedYieldPercent: 20, - }), - createPosition({ - instrumentUid: 'bond-uid', - ticker: 'SU26238RMFS5', - classCode: 'TQOB', - instrumentType: 'bond', - name: 'ОФЗ 26238', - quantity: 2, - currentPrice: { currency: 'RUB', units: '900', nano: 0, value: 900 }, - currentValue: { currency: 'RUB', units: '1800', nano: 0, value: 1800 }, - expectedYieldPercent: 10, - }), - ); - - renderWithClient( - - } /> - , - ['/broker/acc-1'], - ); - - expect(screen.getByRole('heading', { name: 'Акции' })).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: 'Облигации' })).toBeInTheDocument(); - expect(screen.queryByRole('columnheader', { name: 'Доходность' })).not.toBeInTheDocument(); - expect(screen.getAllByRole('columnheader', { name: 'Цена' })).toHaveLength(2); - expect(screen.getByRole('link', { name: 'SBER' })).toHaveAttribute('href', '/stocks/SBER'); - expect(screen.getByRole('link', { name: 'SU26238RMFS5' })).toHaveAttribute( - 'href', - '/bonds/SU26238RMFS5', - ); - expect(screen.getByText(/250,00/)).toBeInTheDocument(); - expect(screen.getByText(/900,00/)).toBeInTheDocument(); - }); - - it('renders broker operations with Russian labels, linked instruments and colored amounts', () => { - vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ - data: { - account: { - id: 'acc-1', - type: 'brokerage', - name: 'Broker', - status: 'ACCOUNT_STATUS_OPEN', - openedAt: null, - accessLevel: null, - }, - totals: { portfolio: { currency: 'RUB', units: '10000', nano: 0, value: 10000 } }, - yields: { expectedPercent: null, daily: null, dailyPercent: null }, - cash: [], - blockedCash: [], - asOf: '2026-06-17T00:00:00.000Z', - }, - isLoading: false, - isFetching: false, - error: null, - } as any); - vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({ - data: { - accountId: 'acc-1', - items: [ - { - cursor: 'op-1', - accountId: 'acc-1', - id: 'op-1', - parentOperationId: null, - date: '2026-06-17T10:00:00.000Z', - category: 'income', - type: 'OPERATION_TYPE_COUPON', - description: 'Coupon', - state: 'OPERATION_STATE_EXECUTED', - instrumentUid: 'bond-uid', - figi: null, - ticker: 'SU26238RMFS5', - classCode: 'TQOB', - instrumentType: 'bond', - payment: { currency: 'RUB', units: '120', nano: 0, value: 120 }, - price: null, - commission: null, - yield: null, - accruedInt: null, - quantity: 2, - quantityDone: 2, - }, - { - cursor: 'op-2', - accountId: 'acc-1', - id: 'op-2', - parentOperationId: null, - date: '2026-06-17T11:00:00.000Z', - category: 'tax', - type: 'OPERATION_TYPE_TAX', - description: 'Tax', - state: 'OPERATION_STATE_EXECUTED', - instrumentUid: null, - figi: null, - ticker: null, - classCode: null, - instrumentType: null, - payment: { currency: 'RUB', units: '-13', nano: 0, value: -13 }, - price: null, - commission: null, - yield: null, - accruedInt: null, - quantity: null, - quantityDone: null, - }, - ], - nextCursor: null, - hasNext: false, - asOf: '2026-06-17T00:00:00.000Z', - }, - isLoading: false, - isFetching: false, - error: null, - } as any); - mockUseBrokerPositions(); - - renderWithClient( - - } /> - , - ['/broker/acc-1'], - ); - - expect(screen.getByText('Выплата купона')).toBeInTheDocument(); - expect(screen.getByText('Налог')).toBeInTheDocument(); - expect(screen.getByText(/\+120,00\s*₽/)).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'SU26238RMFS5' })).toHaveAttribute( - 'href', - '/bonds/SU26238RMFS5', - ); - }); - - it('requests broker operations by cursor with a page size of 10', async () => { - const user = userEvent.setup(); - vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ - data: { - account: { - id: 'acc-1', - type: 'brokerage', - name: 'Broker', - status: 'ACCOUNT_STATUS_OPEN', - openedAt: null, - accessLevel: null, - }, - totals: { portfolio: { currency: 'RUB', units: '10000', nano: 0, value: 10000 } }, - yields: { expectedPercent: null, daily: null, dailyPercent: null }, - cash: [], - blockedCash: [], - asOf: '2026-06-17T00:00:00.000Z', - }, - isLoading: false, - isFetching: false, - error: null, - } as any); - const operationsSpy = vi.spyOn(operationsHook, 'useBrokerOperations').mockImplementation( - (_accountId, query) => - ({ - data: - query.cursor === 'cursor-page-2' - ? { - accountId: 'acc-1', - items: [ - { - cursor: 'op-page-2', - accountId: 'acc-1', - id: 'op-page-2', - parentOperationId: null, - date: '2026-06-17T12:00:00.000Z', - category: 'trade', - type: 'OPERATION_TYPE_SELL', - description: 'Sell', - state: 'OPERATION_STATE_EXECUTED', - instrumentUid: 'share-uid', - figi: null, - ticker: 'SBER', - classCode: 'TQBR', - instrumentType: 'share', - payment: { currency: 'RUB', units: '1000', nano: 0, value: 1000 }, - price: null, - commission: null, - yield: null, - accruedInt: null, - quantity: 1, - quantityDone: 1, - }, - ], - nextCursor: null, - hasNext: false, - asOf: '2026-06-17T00:00:00.000Z', - } - : { - accountId: 'acc-1', - items: [ - { - cursor: 'op-page-1', - accountId: 'acc-1', - id: 'op-page-1', - parentOperationId: null, - date: '2026-06-17T10:00:00.000Z', - category: 'trade', - type: 'OPERATION_TYPE_BUY', - description: 'Buy', - state: 'OPERATION_STATE_EXECUTED', - instrumentUid: 'share-uid', - figi: null, - ticker: 'SBER', - classCode: 'TQBR', - instrumentType: 'share', - payment: { currency: 'RUB', units: '-1000', nano: 0, value: -1000 }, - price: null, - commission: null, - yield: null, - accruedInt: null, - quantity: 1, - quantityDone: 1, - }, - ], - nextCursor: 'cursor-page-2', - hasNext: true, - asOf: '2026-06-17T00:00:00.000Z', - }, - isLoading: false, - isFetching: false, - error: null, - }) as any, - ); - mockUseBrokerPositions(); - - renderWithClient( - - } /> - , - ['/broker/acc-1'], - ); - - expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined }); - - const operationsSection = screen.getByRole('heading', { name: 'Операции' }).closest('section')!; - const withinOperations = within(operationsSection); - const nextButton = withinOperations.getByRole('button', { name: 'Следующая страница' }); - const prevButton = withinOperations.getByRole('button', { name: 'Предыдущая страница' }); - expect(prevButton).toBeDisabled(); - expect(nextButton).not.toBeDisabled(); - - await user.click(nextButton); - - expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { - limit: 10, - cursor: 'cursor-page-2', - }); - - expect(withinOperations.getByText('2')).toBeInTheDocument(); - - await user.click(prevButton); - - expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined }); - expect(withinOperations.getByText('1')).toBeInTheDocument(); - }); - it('renders the broker account overview with allocation, asset links and recent operations', () => { vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ data: createOverviewPortfolio(), @@ -1319,4 +937,201 @@ describe('Broker pages', () => { ).toBeInTheDocument(); }); }); + + describe('Broker operations page', () => { + function renderOperationsPage(initialEntry = '/broker/acc-1/operations') { + return renderWithClient( + + }> + } /> + + , + [initialEntry], + ); + } + + function mockOperationsPortfolio() { + return vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ + data: { + account: { + id: 'acc-1', + type: 'brokerage', + name: 'Broker', + status: 'ACCOUNT_STATUS_OPEN', + openedAt: null, + accessLevel: null, + }, + totals: { portfolio: { currency: 'RUB', units: '10000', nano: 0, value: 10000 } }, + yields: { expectedPercent: 5, daily: null, dailyPercent: null }, + cash: [], + blockedCash: [], + asOf: '2026-06-19T00:00:00.000Z', + }, + isLoading: false, + isFetching: false, + error: null, + } as any); + } + + it('reads the operation type filter from the URL and requests the correct API query', () => { + mockOperationsPortfolio(); + const operationsSpy = vi.spyOn(operationsHook, 'useBrokerOperations').mockImplementation( + (_accountId, _query) => + ({ + data: { + accountId: 'acc-1', + items: [], + nextCursor: null, + hasNext: false, + asOf: '2026-06-19T00:00:00.000Z', + }, + isLoading: false, + isFetching: false, + error: null, + }) as any, + ); + + renderOperationsPage('/broker/acc-1/operations?type=OPERATION_TYPE_COUPON'); + + expect(screen.getByRole('combobox', { name: 'Тип операции' })).toHaveValue( + 'OPERATION_TYPE_COUPON', + ); + expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { + limit: 10, + cursor: undefined, + operationTypes: 'OPERATION_TYPE_COUPON', + }); + }); + + it('resets cursor when the filter changes and updates the URL', async () => { + const user = userEvent.setup(); + mockOperationsPortfolio(); + const operationsSpy = vi.spyOn(operationsHook, 'useBrokerOperations').mockImplementation( + (_accountId, query) => + ({ + data: { + accountId: 'acc-1', + items: query.cursor + ? [] + : [ + { + cursor: 'op-1', + accountId: 'acc-1', + id: 'op-1', + parentOperationId: null, + date: '2026-06-19T10:00:00.000Z', + category: 'income', + type: 'OPERATION_TYPE_COUPON', + description: 'Coupon', + state: 'OPERATION_STATE_EXECUTED', + instrumentUid: null, + figi: null, + ticker: null, + classCode: null, + instrumentType: null, + payment: { currency: 'RUB', units: '120', nano: 0, value: 120 }, + price: null, + commission: null, + yield: null, + accruedInt: null, + quantity: null, + quantityDone: null, + }, + ], + nextCursor: query.cursor ? null : 'cursor-page-2', + hasNext: !query.cursor, + asOf: '2026-06-19T00:00:00.000Z', + }, + isLoading: false, + isFetching: false, + error: null, + }) as any, + ); + + renderOperationsPage('/broker/acc-1/operations?type=OPERATION_TYPE_COUPON'); + + const section = screen.getByRole('heading', { name: 'Операции' }).closest('section')!; + const withinSection = within(section); + const nextButton = withinSection.getByRole('button', { name: 'Следующая страница' }); + await user.click(nextButton); + + expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { + limit: 10, + cursor: 'cursor-page-2', + operationTypes: 'OPERATION_TYPE_COUPON', + }); + + const select = screen.getByRole('combobox', { name: 'Тип операции' }); + await user.selectOptions(select, 'OPERATION_TYPE_TAX'); + + expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { + limit: 10, + cursor: undefined, + operationTypes: 'OPERATION_TYPE_TAX', + }); + expect(withinSection.getByText('1')).toBeInTheDocument(); + }); + + it('removes the type parameter and query when selecting Все операции', async () => { + const user = userEvent.setup(); + mockOperationsPortfolio(); + const operationsSpy = vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({ + data: { + accountId: 'acc-1', + items: [], + nextCursor: null, + hasNext: false, + asOf: '2026-06-19T00:00:00.000Z', + }, + isLoading: false, + isFetching: false, + error: null, + } as any); + + renderOperationsPage('/broker/acc-1/operations?type=OPERATION_TYPE_COUPON'); + + const select = screen.getByRole('combobox', { name: 'Тип операции' }); + await user.selectOptions(select, ''); + + expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined }); + }); + + it('treats an invalid URL type as Все операции', () => { + mockOperationsPortfolio(); + vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({ + data: { + accountId: 'acc-1', + items: [], + nextCursor: null, + hasNext: false, + asOf: '2026-06-19T00:00:00.000Z', + }, + isLoading: false, + isFetching: false, + error: null, + } as any); + + renderOperationsPage('/broker/acc-1/operations?type=INVALID_TYPE'); + + expect(screen.getByRole('combobox', { name: 'Тип операции' })).toHaveValue(''); + }); + + it('keeps account navigation and filter visible when operations fail to load', () => { + mockOperationsPortfolio(); + vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({ + data: undefined, + isLoading: false, + isFetching: false, + error: new Error('operations failed'), + } as any); + + renderOperationsPage('/broker/acc-1/operations'); + + expect(screen.getByRole('alert')).toHaveTextContent('Не удалось загрузить историю операций'); + expect( + screen.getByRole('navigation', { name: 'Разделы брокерского счёта' }), + ).toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: 'Тип операции' })).toBeInTheDocument(); + }); + }); }); diff --git a/apps/frontend/src/pages/broker/BrokerPositionsSection.tsx b/apps/frontend/src/pages/broker/BrokerPositionsSection.tsx deleted file mode 100644 index 5c8cb6c..0000000 --- a/apps/frontend/src/pages/broker/BrokerPositionsSection.tsx +++ /dev/null @@ -1,311 +0,0 @@ -import { useState } from 'react'; -import { Link } from 'react-router-dom'; -import type { BrokerMoney, BrokerPosition } from '../../api/responses'; -import { getBrokerInstrumentPath } from './brokerDisplay'; -import { TableSkeleton } from '../../components/TableSkeleton'; -import { useBrokerPositions } from '../../hooks/useBrokerPositions'; - -type BrokerPositionGroupConfig = { - key: string; - type?: string; - title: string; -}; - -const GROUPS: BrokerPositionGroupConfig[] = [ - { key: 'shares', type: 'share', title: 'Акции' }, - { key: 'bonds', type: 'bond', title: 'Облигации' }, - { key: 'etf', type: 'etf', title: 'ETF' }, - { key: 'fund', type: 'fund', title: 'Фонды' }, -]; - -const KNOWN_TYPES = new Set(GROUPS.map((g) => g.type).filter(Boolean)); - -const tableStyle = { - width: '100%', - borderCollapse: 'collapse', - fontSize: 14, -} satisfies React.CSSProperties; - -const thStyle = { - borderBottom: '1px solid #e0e0e0', - color: 'var(--color-text-secondary)', - fontWeight: 600, - padding: '10px 8px', -} satisfies React.CSSProperties; - -const tdStyle = { - borderBottom: '1px solid #eeeeee', - padding: '10px 8px', - verticalAlign: 'top', -} satisfies React.CSSProperties; - -const pagButtonStyle = { - padding: '6px 14px', - borderRadius: 6, - border: '1px solid #e0e0e0', - background: 'var(--color-surface)', - color: 'var(--color-text)', - fontSize: 14, - fontWeight: 600, - cursor: 'pointer', - lineHeight: 1.4, -} satisfies React.CSSProperties; - -const pagButtonDisabledStyle = { - ...pagButtonStyle, - opacity: 0.35, - cursor: 'not-allowed', -} satisfies React.CSSProperties; - -function formatMoney(value: BrokerMoney | null | undefined) { - if (!value) return '-'; - return new Intl.NumberFormat('ru-RU', { - style: 'currency', - currency: value.currency || 'RUB', - maximumFractionDigits: 2, - }).format(value.value); -} - -function formatQuantity(value: number | null | undefined) { - return value == null ? '-' : value.toLocaleString('ru-RU'); -} - -function PositionTicker({ position }: { position: BrokerPosition }) { - const label = position.ticker || position.figi || '-'; - const path = getBrokerInstrumentPath({ - ticker: position.ticker, - instrumentType: position.instrumentType, - classCode: position.classCode, - }); - - if (!path || label === '-') { - return {label}; - } - - return ( - - {label} - - ); -} - -function PositionGroupTable({ - accountId, - group, -}: { - accountId: string; - group: BrokerPositionGroupConfig; -}) { - const [cursorStack, setCursorStack] = useState>([]); - const [cursor, setCursor] = useState(undefined); - - const query = group.type ? { type: group.type, limit: 10, cursor } : { limit: 100, cursor }; - const { data: page, isLoading, isFetching } = useBrokerPositions(accountId, query); - - const rawPositions = page?.items ?? []; - const positions = group.type - ? rawPositions - : rawPositions.filter( - (p) => p.instrumentType && !KNOWN_TYPES.has(p.instrumentType.toLowerCase()), - ); - - const pageNumber = cursorStack.length + 1; - const canGoBack = cursorStack.length > 0; - const canGoForward = Boolean(page?.hasNext && page.nextCursor && !!group.type); - - function handleNext() { - const nextCursor = page?.nextCursor; - if (!nextCursor || !page?.hasNext || !group.type) return; - setCursorStack((prev) => [...prev, cursor]); - setCursor(nextCursor); - } - - function handlePrevious() { - if (cursorStack.length === 0) return; - const prev = cursorStack[cursorStack.length - 1]; - setCursorStack((prevStack) => prevStack.slice(0, -1)); - setCursor(prev); - } - - if (!isLoading && positions.length === 0) { - return null; - } - - return ( -
-
-

{group.title}

- {group.type && ( -
- - - {pageNumber} - - -
- )} -
- - {isLoading && ( -
- - - - - - - - - - - -
- Тикер - - Название - - Количество - - Цена - - Стоимость -
-
- )} - - {!isLoading && positions.length > 0 && ( -
-
- - - - - - - - - - - - {positions.map((position) => ( - - - - - - - - ))} - -
- Тикер - - Название - - Количество - - Цена - - Стоимость -
- - - - {position.name || '-'} - - - {formatQuantity(position.quantity)} - - {formatMoney(position.currentPrice)} - - {formatMoney(position.currentValue)} -
-
- {isFetching && ( -
-
- - Загрузка страницы {pageNumber}… - -
- )} -
- )} -
- ); -} - -type BrokerPositionsSectionProps = { - accountId: string; -}; - -export function BrokerPositionsSection({ accountId }: BrokerPositionsSectionProps) { - return ( -
-

Позиции

- {GROUPS.map((group) => ( - - ))} -
- ); -} diff --git a/apps/frontend/src/routes.tsx b/apps/frontend/src/routes.tsx index fd5ad03..f239f18 100644 --- a/apps/frontend/src/routes.tsx +++ b/apps/frontend/src/routes.tsx @@ -11,7 +11,10 @@ import { PortfoliosListPage } from './pages/portfolios/PortfoliosListPage'; import { PortfolioDetailPage } from './pages/portfolios/PortfolioDetailPage'; import { ScreenerPage } from './pages/screener/ScreenerPage'; import { BrokerAccountsPage } from './pages/broker/BrokerAccountsPage'; -import { BrokerAccountDetailPage } from './pages/broker/BrokerAccountDetailPage'; +import { BrokerAccountLayout } from './pages/broker/BrokerAccountLayout'; +import { BrokerAccountOverviewPage } from './pages/broker/BrokerAccountOverviewPage'; +import { BrokerPositionsPage } from './pages/broker/BrokerPositionsPage'; +import { BrokerOperationsPage } from './pages/broker/BrokerOperationsPage'; export function AppRoutes() { return ( @@ -59,10 +62,15 @@ export function AppRoutes() { path="/broker/:accountId" element={ - + } - /> + > + } /> + } /> + } /> + } /> + ); diff --git a/apps/frontend/src/styles.css b/apps/frontend/src/styles.css index 7f04025..471b828 100644 --- a/apps/frontend/src/styles.css +++ b/apps/frontend/src/styles.css @@ -18,9 +18,15 @@ --shadow: 0 1px 3px rgba(0, 0, 0, 0.12); } -.pnl-cell { text-align: right; } -.positive { color: var(--color-positive); } -.negative { color: var(--color-negative); } +.pnl-cell { + text-align: right; +} +.positive { + color: var(--color-positive); +} +.negative { + color: var(--color-negative); +} body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; @@ -35,24 +41,25 @@ a { } @keyframes shimmer { - 0% { background-position: 200% 0; } - 100% { background-position: -200% 0; } + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } } .skeleton { - background: linear-gradient( - 90deg, - var(--color-bg) 25%, - #f0f0f0 50%, - var(--color-bg) 75% - ); + background: linear-gradient(90deg, var(--color-bg) 25%, #f0f0f0 50%, var(--color-bg) 75%); background-size: 200% 100%; animation: shimmer 1.5s ease-in-out infinite; border-radius: 4px; } @keyframes loading-spin { - to { transform: rotate(360deg); } + to { + transform: rotate(360deg); + } } .loading-spinner { @@ -245,3 +252,43 @@ a { align-self: center; } } + +.broker-operations__toolbar { + display: flex; + align-items: end; + justify-content: space-between; + gap: 16px; + margin-bottom: 20px; +} + +.broker-operations__toolbar label { + display: grid; + gap: 6px; + color: var(--color-text-secondary); + font-size: 13px; +} + +.broker-operations__toolbar select { + min-width: 240px; + padding: 8px 10px; + border: 1px solid #d8d8d8; + border-radius: var(--border-radius); + background: var(--color-surface); + color: var(--color-text); +} + +.broker-operations__toolbar select:focus-visible { + outline: 3px solid color-mix(in srgb, var(--color-primary) 35%, transparent); + outline-offset: 2px; +} + +@media (max-width: 720px) { + .broker-operations__toolbar { + align-items: stretch; + flex-direction: column; + } + .broker-operations__toolbar select { + width: 100%; + min-width: 0; + } +}