1340 lines
41 KiB
Markdown
1340 lines
41 KiB
Markdown
# 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.
|