1340 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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>): 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:
```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<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:
```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(
<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:
```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 <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`:
```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:
```tsx
<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:
```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(
<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`:
```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(
<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:
```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<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:
```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<string | undefined>(undefined);
const [operationCursorStack, setOperationCursorStack] = useState<Array<string | undefined>>([]);
```
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 `<section>` with:
```tsx
<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:
```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.