moex-vibe/docs/superpowers/plans/2026-06-17-broker-portfolio-display.md
Sergey Krylov bd8d6c11fe
All checks were successful
CI / lint (pull_request) Successful in 2m3s
CI / test (pull_request) Successful in 2m5s
CI / build (pull_request) Successful in 2m0s
CI / lint (push) Successful in 1m55s
CI / test (push) Successful in 8m13s
CI / build (push) Successful in 2m8s
feat: improove ui
2026-06-17 13:15:01 +03:00

41 KiB
Raw Permalink Blame History

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:

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.