41 KiB
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.
- Presentational component for
- 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:
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>): 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>): 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:
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:
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<string, string> = {
OPERATION_TYPE_BUY: 'Покупка',
OPERATION_TYPE_BUY_CARD: 'Покупка',
OPERATION_TYPE_SELL: 'Продажа',
OPERATION_TYPE_SELL_CARD: 'Продажа',
OPERATION_TYPE_BUY_MARGIN: 'Покупка с маржой',
OPERATION_TYPE_SELL_MARGIN: 'Продажа с маржой',
OPERATION_TYPE_DELIVERY_BUY: 'Поставка покупки',
OPERATION_TYPE_DELIVERY_SELL: 'Поставка продажи',
OPERATION_TYPE_COUPON: 'Выплата купона',
OPERATION_TYPE_DIVIDEND: 'Дивиденды',
OPERATION_TYPE_BOND_REPAYMENT: 'Погашение облигации',
OPERATION_TYPE_BOND_REPAYMENT_FULL: 'Полное погашение облигации',
OPERATION_TYPE_TAX: 'Налог',
OPERATION_TYPE_BOND_TAX: 'Налог по облигациям',
OPERATION_TYPE_DIVIDEND_TAX: 'Налог на дивиденды',
OPERATION_TYPE_TAX_CORRECTION: 'Корректировка налога',
OPERATION_TYPE_TAX_CORRECTION_COUPON: 'Корректировка налога по купону',
OPERATION_TYPE_BROKER_FEE: 'Комиссия брокера',
OPERATION_TYPE_SERVICE_FEE: 'Комиссия за обслуживание',
OPERATION_TYPE_MARGIN_FEE: 'Комиссия за маржу',
OPERATION_TYPE_SUCCESS_FEE: 'Комиссия за результат',
OPERATION_TYPE_INPUT: 'Пополнение',
OPERATION_TYPE_OUTPUT: 'Вывод средств',
OPERATION_TYPE_INPUT_SECURITIES: 'Зачисление бумаг',
OPERATION_TYPE_OUTPUT_SECURITIES: 'Списание бумаг',
};
export function getBrokerPositionGroup(
position: Pick<BrokerPosition, 'instrumentType'>,
): 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<BrokerOperation, 'type' | 'description'>,
): string {
const knownLabel = OPERATION_TYPE_LABELS[operation.type];
if (knownLabel) return knownLabel;
if (operation.description) return operation.description;
return operation.type
.replace(/^OPERATION_TYPE_/, '')
.replaceAll('_', ' ')
.toLowerCase();
}
export function getBrokerOperationImpact(
operation: Pick<BrokerOperation, 'type' | 'category' | 'payment'>,
): BrokerOperationImpact {
if (
TRADE_TYPES.has(operation.type) ||
BOND_REPAYMENT_TYPES.has(operation.type) ||
SECURITY_TRANSFER_TYPES.has(operation.type)
) {
return 'neutral';
}
if (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:
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:
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', () => { ... }):
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(
<Routes>
<Route path="/broker/:accountId" element={<BrokerAccountDetailPage />} />
</Routes>,
['/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:
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:
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 <strong>{label}</strong>;
}
return (
<Link to={path} style={{ fontWeight: 700 }}>
{label}
</Link>
);
}
function PositionTable({ title, positions }: { title: string; positions: BrokerPosition[] }) {
return (
<section>
<h3 style={{ fontSize: 18, marginBottom: 10 }}>{title}</h3>
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table aria-label={`Брокерские позиции: ${title}`} style={tableStyle}>
<thead>
<tr>
<th align="left" style={thStyle}>
Тикер
</th>
<th align="left" style={thStyle}>
Название
</th>
<th align="right" style={thStyle}>
Количество
</th>
<th align="right" style={thStyle}>
Цена
</th>
<th align="right" style={thStyle}>
Стоимость
</th>
</tr>
</thead>
<tbody>
{positions.map((position) => (
<tr
key={
position.positionUid || position.instrumentUid || position.ticker || position.figi
}
>
<td style={tdStyle}>
<PositionTicker position={position} />
</td>
<td style={tdStyle}>
<span style={{ color: 'var(--color-text-secondary)' }}>
{position.name || '-'}
</span>
</td>
<td align="right" style={tdStyle}>
{formatQuantity(position.quantity)}
</td>
<td align="right" style={tdStyle}>
{formatMoney(position.currentPrice)}
</td>
<td align="right" style={tdStyle}>
{formatMoney(position.currentValue)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}
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 (
<section>
<h2 style={{ fontSize: 20, marginBottom: 12 }}>Позиции</h2>
<p style={{ color: 'var(--color-text-secondary)' }}>В портфеле нет позиций</p>
</section>
);
}
return (
<section>
<h2 style={{ fontSize: 20, marginBottom: 12 }}>Позиции</h2>
<div style={{ display: 'grid', gap: 20 }}>
{grouped.map((group) => (
<PositionTable key={group.key} title={group.title} positions={group.positions} />
))}
</div>
</section>
);
}
- Step 4: Replace inline positions table in the page
Modify apps/frontend/src/pages/broker/BrokerAccountDetailPage.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 <section> with heading Позиции and its inline table with:
<BrokerPositionsSection positions={portfolio.data.positions} />
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:
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:
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:
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(
<Routes>
<Route path="/broker/:accountId" element={<BrokerAccountDetailPage />} />
</Routes>,
['/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:
import userEvent from '@testing-library/user-event';
Append this test to apps/frontend/src/pages/broker/BrokerPages.test.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(
<Routes>
<Route path="/broker/:accountId" element={<BrokerAccountDetailPage />} />
</Routes>,
['/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:
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:
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<BrokerOperationImpact, React.CSSProperties> = {
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 <span>{label}</span>;
}
return <Link to={path}>{label}</Link>;
}
function OperationType({ operation }: { operation: BrokerOperation }) {
const impact = getBrokerOperationImpact(operation);
return (
<div style={{ display: 'grid', gap: 4 }}>
<span>{getBrokerOperationTypeLabel(operation)}</span>
<span
style={{
justifySelf: 'start',
borderRadius: 999,
fontSize: 12,
fontWeight: 700,
lineHeight: 1,
padding: '5px 8px',
...impactStyles[impact],
}}
>
{getBrokerOperationImpactLabel(impact)}
</span>
</div>
);
}
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 (
<section>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
justifyContent: 'space-between',
marginBottom: 12,
}}
>
<h2 style={{ fontSize: 20, margin: 0 }}>Операции</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button type="button" onClick={onPrevious} disabled={!canGoBack}>
Назад
</button>
<span style={{ color: 'var(--color-text-secondary)', fontSize: 13 }}>
Страница {pageNumber}
</span>
<button type="button" onClick={onNext} disabled={!canGoForward}>
Вперед
</button>
</div>
</div>
{isLoading ? (
<p>Загрузка операций...</p>
) : operations.length === 0 ? (
<p style={{ color: 'var(--color-text-secondary)' }}>Операций за выбранный период нет</p>
) : (
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table style={tableStyle}>
<thead>
<tr>
<th align="left" style={thStyle}>
Дата
</th>
<th align="left" style={thStyle}>
Тип
</th>
<th align="left" style={thStyle}>
Инструмент
</th>
<th align="right" style={thStyle}>
Сумма
</th>
</tr>
</thead>
<tbody>
{operations.map((operation) => {
const impact = getBrokerOperationImpact(operation);
return (
<tr key={operation.cursor || operation.id}>
<td style={tdStyle}>{formatDate(operation.date)}</td>
<td style={tdStyle}>
<OperationType operation={operation} />
</td>
<td style={tdStyle}>
<OperationInstrument operation={operation} />
</td>
<td
align="right"
style={{ ...tdStyle, color: moneyColor(impact), fontWeight: 700 }}
>
{formatMoney(operation.payment)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
);
}
- Step 5: Add cursor pagination state to the page
Modify apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx.
Update imports:
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:
const [operationCursor, setOperationCursor] = useState<string | undefined>(undefined);
const [operationCursorStack, setOperationCursorStack] = useState<Array<string | undefined>>([]);
Replace the existing operations hook call:
const operations = useBrokerOperations(accountId, {
limit: 10,
cursor: operationCursor,
});
Add handlers after error/loading guards or before 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);
}
Remove formatDate from BrokerAccountDetailPage.tsx after the inline operations table is deleted.
Date formatting now belongs to BrokerOperationsTable.tsx.
Replace the current inline operations <section> with:
<BrokerOperationsTable
isLoading={operations.isLoading}
page={operations.data}
pageNumber={operationCursorStack.length + 1}
canGoBack={operationCursorStack.length > 0}
canGoForward={Boolean(operations.data?.hasNext && operations.data.nextCursor)}
onPrevious={handlePreviousOperationsPage}
onNext={handleNextOperationsPage}
/>
- Step 6: Run page tests and verify success
Run:
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:
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:
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:
npm run test:frontend
Expected: PASS for the frontend Vitest suite.
- Step 3: Run frontend build
Run:
npm run build:frontend
Expected: TypeScript build and Vite build complete successfully.
- Step 4: Run lint
Run:
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:
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:
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.