# Broker Portfolio Display Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Улучшить страницу брокерского счета: разделить позиции акций и облигаций, добавить текущую цену, сделать инструменты кликабельными и оформить операции с русскими типами, бейджами эффекта и cursor-пагинацией по 10 строк. **Architecture:** Изолировать правила отображения в `brokerDisplay.ts`, чтобы маршруты инструментов, русские labels и impact-классификация тестировались отдельно от React. Вынести таблицы позиций и операций в небольшие компоненты рядом со страницей брокера, а `BrokerAccountDetailPage.tsx` оставить контейнером данных и состояния пагинации. **Tech Stack:** React 18, react-router-dom v6 `Link`, TanStack Query через существующие hooks, Vitest, Testing Library, TypeScript. --- ## File Structure - Create: `apps/frontend/src/pages/broker/brokerDisplay.ts` - Pure helper functions for grouping positions, building instrument links, mapping operation labels and classifying operation impact. - Create: `apps/frontend/src/pages/broker/brokerDisplay.test.ts` - Unit tests for all display helpers. - Create: `apps/frontend/src/pages/broker/BrokerPositionsSection.tsx` - Presentational component for `Акции`, `Облигации`, and `Другие инструменты` tables. - Create: `apps/frontend/src/pages/broker/BrokerOperationsTable.tsx` - Presentational component for operations table, impact badges, linked instruments and pagination controls. - Modify: `apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx` - Replace inline tables with the two components and add cursor stack state for operations. - Modify: `apps/frontend/src/pages/broker/BrokerPages.test.tsx` - Page-level tests for positions, operations links, Russian labels and pagination behavior. ## Task 1: Display Helpers **Files:** - Create: `apps/frontend/src/pages/broker/brokerDisplay.ts` - Create: `apps/frontend/src/pages/broker/brokerDisplay.test.ts` - [ ] **Step 1: Write the failing helper tests** Create `apps/frontend/src/pages/broker/brokerDisplay.test.ts`: ```ts import { describe, expect, it } from 'vitest'; import type { BrokerOperation, BrokerPosition } from '../../api/responses'; import { getBrokerInstrumentPath, getBrokerOperationImpact, getBrokerOperationImpactLabel, getBrokerOperationTypeLabel, getBrokerPositionGroup, } from './brokerDisplay'; function position(input: Partial): BrokerPosition { return { figi: null, instrumentUid: null, positionUid: null, ticker: null, classCode: null, instrumentType: null, name: null, quantity: null, blockedLots: null, currentPrice: null, currentValue: null, averagePositionPrice: null, expectedYieldPercent: null, dailyYield: null, ...input, }; } function operation(input: Partial): BrokerOperation { return { cursor: null, accountId: 'acc-1', id: null, parentOperationId: null, date: null, type: 'OPERATION_TYPE_UNSPECIFIED', category: 'other', description: null, state: null, instrumentUid: null, figi: null, ticker: null, classCode: null, instrumentType: null, payment: null, price: null, commission: null, yield: null, accruedInt: null, quantity: null, quantityDone: null, ...input, }; } describe('broker display helpers', () => { it('groups positions by instrument type', () => { expect(getBrokerPositionGroup(position({ instrumentType: 'share' }))).toBe('shares'); expect(getBrokerPositionGroup(position({ instrumentType: 'bond' }))).toBe('bonds'); expect(getBrokerPositionGroup(position({ instrumentType: 'etf' }))).toBe('other'); expect(getBrokerPositionGroup(position({ instrumentType: null }))).toBe('other'); }); it('builds stock and bond routes from instrument metadata', () => { expect( getBrokerInstrumentPath({ ticker: 'sber', instrumentType: 'share', classCode: 'TQBR' }), ).toBe('/stocks/SBER'); expect( getBrokerInstrumentPath({ ticker: 'SU26238RMFS5', instrumentType: 'bond', classCode: 'TQOB', }), ).toBe('/bonds/SU26238RMFS5'); expect( getBrokerInstrumentPath({ ticker: null, instrumentType: 'share', classCode: 'TQBR' }), ).toBeNull(); expect( getBrokerInstrumentPath({ ticker: 'TMOS', instrumentType: 'etf', classCode: 'TQTF' }), ).toBeNull(); }); it('uses class code fallback when instrument type is missing', () => { expect( getBrokerInstrumentPath({ ticker: 'SBER', instrumentType: null, classCode: 'TQBR' }), ).toBe('/stocks/SBER'); expect( getBrokerInstrumentPath({ ticker: 'RU000A0JX0J2', instrumentType: null, classCode: 'TQOB' }), ).toBe('/bonds/RU000A0JX0J2'); }); it('maps operation enum values to Russian labels', () => { expect(getBrokerOperationTypeLabel(operation({ type: 'OPERATION_TYPE_COUPON' }))).toBe( 'Выплата купона', ); expect(getBrokerOperationTypeLabel(operation({ type: 'OPERATION_TYPE_TAX' }))).toBe('Налог'); expect(getBrokerOperationTypeLabel(operation({ type: 'OPERATION_TYPE_BUY' }))).toBe('Покупка'); expect( getBrokerOperationTypeLabel( operation({ type: 'OPERATION_TYPE_UNKNOWN_VALUE', description: 'Custom' }), ), ).toBe('Custom'); }); it('classifies operations by portfolio impact', () => { expect( getBrokerOperationImpact( operation({ type: 'OPERATION_TYPE_COUPON', category: 'income', payment: { currency: 'RUB', units: '120', nano: 0, value: 120 }, }), ), ).toBe('adds'); expect( getBrokerOperationImpact( operation({ type: 'OPERATION_TYPE_TAX', category: 'tax', payment: { currency: 'RUB', units: '-13', nano: 0, value: -13 }, }), ), ).toBe('reduces'); expect( getBrokerOperationImpact( operation({ type: 'OPERATION_TYPE_SELL', category: 'trade', payment: { currency: 'RUB', units: '1000', nano: 0, value: 1000 }, }), ), ).toBe('neutral'); expect(getBrokerOperationImpact(operation({ type: 'OPERATION_TYPE_UNSPECIFIED' }))).toBe( 'unknown', ); }); it('provides Russian impact labels', () => { expect(getBrokerOperationImpactLabel('adds')).toBe('Пополняет'); expect(getBrokerOperationImpactLabel('reduces')).toBe('Списывает'); expect(getBrokerOperationImpactLabel('neutral')).toBe('Перекладка'); expect(getBrokerOperationImpactLabel('unknown')).toBe('Неясно'); }); }); ``` - [ ] **Step 2: Run helper tests and verify failure** Run: ```bash npx vitest run src/pages/broker/brokerDisplay.test.ts -w apps/frontend ``` Expected: FAIL because `./brokerDisplay` does not exist. - [ ] **Step 3: Implement the display helpers** Create `apps/frontend/src/pages/broker/brokerDisplay.ts`: ```ts import type { BrokerOperation, BrokerPosition } from '../../api/responses'; export type BrokerPositionGroup = 'shares' | 'bonds' | 'other'; export type BrokerOperationImpact = 'adds' | 'reduces' | 'neutral' | 'unknown'; type BrokerInstrumentLinkInput = { ticker: string | null; instrumentType: string | null; classCode: string | null; }; 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 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 function getBrokerPositionGroup( position: Pick, ): BrokerPositionGroup { const instrumentType = position.instrumentType?.toLowerCase(); if (instrumentType === 'share') return 'shares'; if (instrumentType === 'bond') return 'bonds'; return 'other'; } export function getBrokerInstrumentPath(input: BrokerInstrumentLinkInput): string | null { const ticker = input.ticker?.trim().toUpperCase(); if (!ticker) return null; const instrumentType = input.instrumentType?.toLowerCase(); const classCode = input.classCode?.toUpperCase() ?? null; if (instrumentType === 'share' || (classCode && STOCK_CLASS_CODES.has(classCode))) { return `/stocks/${encodeURIComponent(ticker)}`; } if (instrumentType === 'bond' || (classCode && BOND_CLASS_CODES.has(classCode))) { return `/bonds/${encodeURIComponent(ticker)}`; } 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_/, '') .replaceAll('_', ' ') .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 (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'; const paymentValue = operation.payment?.value ?? 0; if (paymentValue > 0) return 'adds'; if (paymentValue < 0) return 'reduces'; return 'unknown'; } export function getBrokerOperationImpactLabel(impact: BrokerOperationImpact): string { switch (impact) { case 'adds': return 'Пополняет'; case 'reduces': return 'Списывает'; case 'neutral': return 'Перекладка'; case 'unknown': return 'Неясно'; } } ``` - [ ] **Step 4: Run helper tests and verify success** Run: ```bash npx vitest run src/pages/broker/brokerDisplay.test.ts -w apps/frontend ``` Expected: PASS for all `broker display helpers` tests. - [ ] **Step 5: Commit helper changes** Run: ```bash git add apps/frontend/src/pages/broker/brokerDisplay.ts apps/frontend/src/pages/broker/brokerDisplay.test.ts git commit -m "feat: add broker display helpers" ``` Expected: commit succeeds. ## Task 2: Broker Position Tables **Files:** - Create: `apps/frontend/src/pages/broker/BrokerPositionsSection.tsx` - Modify: `apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx` - Modify: `apps/frontend/src/pages/broker/BrokerPages.test.tsx` - [ ] **Step 1: Add failing page test for split position tables** Append this test to `apps/frontend/src/pages/broker/BrokerPages.test.tsx` inside `describe('Broker pages', () => { ... })`: ```tsx 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: [], positions: [ { figi: null, instrumentUid: 'share-uid', positionUid: null, ticker: 'SBER', classCode: 'TQBR', instrumentType: 'share', name: 'Sberbank', quantity: 10, blockedLots: null, currentPrice: { currency: 'RUB', units: '250', nano: 0, value: 250 }, currentValue: { currency: 'RUB', units: '2500', nano: 0, value: 2500 }, averagePositionPrice: null, expectedYieldPercent: 20, dailyYield: null, }, { figi: null, instrumentUid: 'bond-uid', positionUid: null, ticker: 'SU26238RMFS5', classCode: 'TQOB', instrumentType: 'bond', name: 'ОФЗ 26238', quantity: 2, blockedLots: null, currentPrice: { currency: 'RUB', units: '900', nano: 0, value: 900 }, currentValue: { currency: 'RUB', units: '1800', nano: 0, value: 1800 }, averagePositionPrice: null, expectedYieldPercent: 10, dailyYield: null, }, ], asOf: '2026-06-17T00:00:00.000Z', }, isLoading: 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, error: null, } as any); 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(); }); ``` - [ ] **Step 2: Run page test and verify failure** Run: ```bash npx vitest run src/pages/broker/BrokerPages.test.tsx -w apps/frontend ``` Expected: FAIL because the current page renders one `Позиции` table, has `Доходность`, and does not link tickers. - [ ] **Step 3: Create the positions component** Create `apps/frontend/src/pages/broker/BrokerPositionsSection.tsx`: ```tsx import { Link } from 'react-router-dom'; import type { BrokerMoney, BrokerPosition } from '../../api/responses'; import { getBrokerInstrumentPath, getBrokerPositionGroup } from './brokerDisplay'; type BrokerPositionGroupConfig = { key: 'shares' | 'bonds' | 'other'; title: string; }; const GROUPS: BrokerPositionGroupConfig[] = [ { key: 'shares', title: 'Акции' }, { key: 'bonds', title: 'Облигации' }, { key: 'other', title: 'Другие инструменты' }, ]; 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 '-'; 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 PositionTable({ title, positions }: { title: string; positions: BrokerPosition[] }) { return (

{title}

{positions.map((position) => ( ))}
Тикер Название Количество Цена Стоимость
{position.name || '-'} {formatQuantity(position.quantity)} {formatMoney(position.currentPrice)} {formatMoney(position.currentValue)}
); } export function BrokerPositionsSection({ positions }: { positions: BrokerPosition[] }) { const grouped = GROUPS.map((group) => ({ ...group, positions: positions.filter((position) => getBrokerPositionGroup(position) === group.key), })).filter((group) => group.positions.length > 0); if (grouped.length === 0) { return (

Позиции

В портфеле нет позиций

); } return (

Позиции

{grouped.map((group) => ( ))}
); } ``` - [ ] **Step 4: Replace inline positions table in the page** Modify `apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx`: ```tsx import { useParams } from 'react-router-dom'; import type { BrokerMoney } from '../../api/responses'; import { useBrokerOperations } from '../../hooks/useBrokerOperations'; import { useBrokerPortfolio } from '../../hooks/useBrokerPortfolio'; import { BrokerPositionsSection } from './BrokerPositionsSection'; ``` Replace the entire current `
` with heading `Позиции` and its inline table with: ```tsx ``` Remove `tableStyle`, `thStyle` and `tdStyle` from `BrokerAccountDetailPage.tsx` after the inline positions table is deleted. These constants move into the table components and must not remain unused. - [ ] **Step 5: Run page test and verify success** Run: ```bash npx vitest run src/pages/broker/BrokerPages.test.tsx -w apps/frontend ``` Expected: PASS for the new split positions test and existing broker page tests. - [ ] **Step 6: Commit position table changes** Run: ```bash git add apps/frontend/src/pages/broker/BrokerPositionsSection.tsx apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx apps/frontend/src/pages/broker/BrokerPages.test.tsx git commit -m "feat: split broker position tables" ``` Expected: commit succeeds. ## Task 3: Broker Operations Table and Cursor Pagination **Files:** - Create: `apps/frontend/src/pages/broker/BrokerOperationsTable.tsx` - Modify: `apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx` - Modify: `apps/frontend/src/pages/broker/BrokerPages.test.tsx` - [ ] **Step 1: Add failing test for operation labels, links and impact badges** Append this test to `apps/frontend/src/pages/broker/BrokerPages.test.tsx`: ```tsx it('renders broker operations with Russian labels, linked instruments and impact badges', () => { 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: [], positions: [], asOf: '2026-06-17T00:00:00.000Z', }, isLoading: 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, error: null, } as any); renderWithClient( } /> , ['/broker/acc-1'], ); expect(screen.getByText('Выплата купона')).toBeInTheDocument(); expect(screen.getByText('Налог')).toBeInTheDocument(); expect(screen.getByText('Пополняет')).toBeInTheDocument(); expect(screen.getByText('Списывает')).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'SU26238RMFS5' })).toHaveAttribute( 'href', '/bonds/SU26238RMFS5', ); }); ``` - [ ] **Step 2: Add failing test for cursor pagination** Add import at the top of `BrokerPages.test.tsx`: ```tsx import userEvent from '@testing-library/user-event'; ``` Append this test to `apps/frontend/src/pages/broker/BrokerPages.test.tsx`: ```tsx 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: [], positions: [], asOf: '2026-06-17T00:00:00.000Z', }, isLoading: 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, error: null, }) as any, ); renderWithClient( } /> , ['/broker/acc-1'], ); expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined }); expect(screen.getByText('Страница 1')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Вперед' })); expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: 'cursor-page-2', }); expect(screen.getByText('Страница 2')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Назад' })); expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined }); expect(screen.getByText('Страница 1')).toBeInTheDocument(); }); ``` - [ ] **Step 3: Run page tests and verify failure** Run: ```bash npx vitest run src/pages/broker/BrokerPages.test.tsx -w apps/frontend ``` Expected: FAIL because operations still render raw enum values and `BrokerAccountDetailPage` calls `useBrokerOperations(accountId, { limit: 100 })`. - [ ] **Step 4: Create the operations table component** Create `apps/frontend/src/pages/broker/BrokerOperationsTable.tsx`: ```tsx import { Link } from 'react-router-dom'; import type { BrokerMoney, BrokerOperation, BrokerOperationsPage } from '../../api/responses'; import { getBrokerInstrumentPath, getBrokerOperationImpact, getBrokerOperationImpactLabel, getBrokerOperationTypeLabel, type BrokerOperationImpact, } from './brokerDisplay'; 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 impactStyles: Record = { adds: { background: 'rgba(46, 125, 50, 0.1)', color: 'var(--color-positive)', }, reduces: { background: 'rgba(198, 40, 40, 0.1)', color: 'var(--color-negative)', }, neutral: { background: 'rgba(25, 118, 210, 0.1)', color: 'var(--color-primary)', }, unknown: { background: 'rgba(102, 102, 102, 0.12)', color: 'var(--color-text-secondary)', }, }; 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 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 label = operation.ticker || operation.description || '-'; const path = getBrokerInstrumentPath({ ticker: operation.ticker, instrumentType: operation.instrumentType, classCode: operation.classCode, }); if (!path || label === '-') { return {label}; } return {label}; } function OperationType({ operation }: { operation: BrokerOperation }) { const impact = getBrokerOperationImpact(operation); return (
{getBrokerOperationTypeLabel(operation)} {getBrokerOperationImpactLabel(impact)}
); } export function BrokerOperationsTable({ isLoading, page, pageNumber, canGoBack, canGoForward, onPrevious, onNext, }: { isLoading: boolean; page: BrokerOperationsPage | undefined; pageNumber: number; canGoBack: boolean; canGoForward: boolean; onPrevious: () => void; onNext: () => void; }) { const operations = page?.items ?? []; return (

Операции

Страница {pageNumber}
{isLoading ? (

Загрузка операций...

) : operations.length === 0 ? (

Операций за выбранный период нет

) : (
{operations.map((operation) => { const impact = getBrokerOperationImpact(operation); return ( ); })}
Дата Тип Инструмент Сумма
{formatDate(operation.date)} {formatMoney(operation.payment)}
)}
); } ``` - [ ] **Step 5: Add cursor pagination state to the page** Modify `apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx`. Update imports: ```tsx 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'; ``` Inside `BrokerAccountDetailPage`, before calling `useBrokerOperations`, add: ```tsx const [operationCursor, setOperationCursor] = useState(undefined); const [operationCursorStack, setOperationCursorStack] = useState>([]); ``` Replace the existing operations hook call: ```tsx const operations = useBrokerOperations(accountId, { limit: 10, cursor: operationCursor, }); ``` Add handlers after error/loading guards or before `return`: ```tsx 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); } ``` Remove `formatDate` from `BrokerAccountDetailPage.tsx` after the inline operations table is deleted. Date formatting now belongs to `BrokerOperationsTable.tsx`. Replace the current inline operations `
` with: ```tsx 0} canGoForward={Boolean(operations.data?.hasNext && operations.data.nextCursor)} onPrevious={handlePreviousOperationsPage} onNext={handleNextOperationsPage} /> ``` - [ ] **Step 6: Run page tests and verify success** Run: ```bash npx vitest run src/pages/broker/BrokerPages.test.tsx -w apps/frontend ``` Expected: PASS for operation labels, links, impact badges and pagination tests. - [ ] **Step 7: Commit operations table changes** Run: ```bash git add apps/frontend/src/pages/broker/BrokerOperationsTable.tsx apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx apps/frontend/src/pages/broker/BrokerPages.test.tsx git commit -m "feat: improve broker operations table" ``` Expected: commit succeeds. ## Task 4: Regression Verification **Files:** - No source file changes in this task. - [ ] **Step 1: Run focused frontend tests** Run: ```bash npx vitest run src/pages/broker/brokerDisplay.test.ts src/pages/broker/BrokerPages.test.tsx -w apps/frontend ``` Expected: PASS for helper and broker page tests. - [ ] **Step 2: Run the full frontend test suite** Run: ```bash npm run test:frontend ``` Expected: PASS for the frontend Vitest suite. - [ ] **Step 3: Run frontend build** Run: ```bash npm run build:frontend ``` Expected: TypeScript build and Vite build complete successfully. - [ ] **Step 4: Run lint** Run: ```bash npm run lint ``` Expected: ESLint completes without errors for backend and frontend workspaces. - [ ] **Step 5: Commit verification-only adjustments if tests required small fixes** If verification revealed small issues and the fixes are already applied, run: ```bash git add apps/frontend/src/pages/broker git commit -m "test: cover broker portfolio display" ``` Expected: commit succeeds when there are verification fixes. If there are no additional changes, this step is skipped. ## Task 5: Review Handoff **Files:** - No source file changes in this task. - [ ] **Step 1: Summarize final diff** Run: ```bash git status --short git log --oneline -3 ``` Expected: working tree contains only intentional changes for this feature, and recent commits correspond to helpers, positions and operations. - [ ] **Step 2: Request code review** Use `superpowers:requesting-code-review` before merging or opening a PR. Ask the reviewer to focus on: - cursor stack behavior when navigating back and forward; - operation impact classification for trades, taxes, fees, coupons and transfers; - accessibility of impact badges when color is not visible; - whether helper tests cover unknown operation types and missing tickers. - [ ] **Step 3: Choose execution mode for this plan** Recommended execution mode: Subagent-Driven. Subagent split: - Subagent 1: Task 1 helpers and unit tests. - Subagent 2: Task 2 positions component and page tests. - Subagent 3: Task 3 operations component and pagination tests. - Main agent: Task 4 verification and Task 5 review handoff. Inline execution is also acceptable if branch state or local context makes subagent handoff less efficient.