44 KiB
Raw Blame History

Broker Account Sections 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: Превратить страницу брокерского счёта в компактный overview с распределением активов и последними операциями, а акции, облигации и всю доступную историю вынести в отдельные разделы счёта.

Architecture: Существующий portfolio endpoint расширяется агрегированными счётчиками позиций, чтобы overview не загружал все cursor-страницы. Frontend получает общий nested-route shell счёта с постоянной навигацией и локальными страницами; чистые правила распределения и operation-type options остаются вне React и покрываются unit-тестами.

Tech Stack: NestJS 10, Swagger/OpenAPI, React 18, React Router 6, TanStack Query 5, TypeScript, Vitest, Testing Library, CSS custom properties.


Связанные SDD-артефакты

  • Epic: docs/epics/BrokerPortfolio.md
  • Spec: docs/features/broker-account-sections/spec.md
  • Research: docs/research/2026-06-18-broker-account-sections.md
  • Tasks: docs/features/broker-account-sections/tasks.md

Gate перед реализацией

До Task 1 пользователь должен отдельно утвердить plan.md и tasks.md. После утверждения, но до изменения кода, статус spec меняется с утверждено к планированию на утверждено к реализации, а статусы feature в epic и roadmap — на готово к реализации. Само утверждение плана не является разрешением начинать код, если пользователь явно оставил отдельный implementation gate.

Архитектурные решения

Backend contract

GET /api/v1/broker/accounts/:accountId/portfolio получает новое обязательное поле:

positionCounts: {
  shares: number;
  bonds: number;
  etf: number;
  other: number;
};

Счётчики строятся из полного TBankPortfolioResponse.positions, уже получаемого внутри BrokerPortfolioService.getPortfolio. Дополнительный T-Bank или внутренний HTTP-запрос не нужен.

Правила классификации:

  • shareshares;
  • bondbonds;
  • etf и fundetf;
  • пустой, неизвестный и любой другой тип → other.

Одна запись T-Bank portfolio position считается одной различимой позицией. Cursor-пагинация frontend на счётчики не влияет.

Frontend routes

/broker/:accountId             overview
/broker/:accountId/shares      таблица акций
/broker/:accountId/bonds       таблица облигаций
/broker/:accountId/operations  история операций

Общий BrokerAccountLayout всегда показывает название счёта и навигацию. Ошибка дочернего запроса не удаляет shell. На desktop используется sidebar, на viewport до 720 px — горизонтальная строка вкладок с overflow-x: auto.

Data flow

portfolio endpoint ──> BrokerAccountLayout ──> Outlet context
                                      ├──────> Overview + allocation
positions?type=share ─────────────────┴──────> Shares page
positions?type=bond ─────────────────────────> Bonds page
operations?limit=5 ──────────────────────────> Overview recent operations
operations?limit=10&operationTypes=X ────────> Operations page

Allocation calculation

Диаграмма использует RUB-normalized totals текущего portfolio ответа:

  • акции: totals.shares;
  • облигации: totals.bonds;
  • ETF/фонды: totals.etf;
  • деньги: totals.currencies;
  • прочие: остаток portfolio - shares - bonds - etf - currencies, который включает поддерживаемые прочие и неизвестные классы.

Положительные значения создают секторы. Нулевые скрываются. Отрицательные значения попадают в текстовый список предупреждений, но не в SVG. Процент всегда делится на положительную полную стоимость totals.portfolio; если она отсутствует или неположительна, диаграмма показывает пустое состояние и не вычисляет ложные проценты.

Operation filter

URL использует один пользовательский query parameter type, например:

/broker/acc-1/operations?type=OPERATION_TYPE_COUPON

Frontend валидирует его по списку известных options. Валидное значение передаётся существующему API как operationTypes. Неизвестное значение трактуется как Все операции. При смене select frontend одновременно очищает cursor stack и возвращается на первую страницу.

File Structure

Backend

  • Modify apps/backend/src/modules/tbank/types/broker.types.ts — добавить positionCounts.
  • Modify apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts — классифицировать полный набор позиций и вернуть счётчики.
  • Modify apps/backend/src/modules/tbank/mappers/portfolio.mapper.spec.ts — unit-тест классификации.
  • Modify apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts — подтвердить, что summary возвращает counts без отдельной загрузки cursor-страниц.
  • Modify apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts — Swagger DTO.

Frontend contract and pure rules

  • Modify apps/frontend/src/api/types.ts — regenerate from live Swagger.
  • Modify apps/frontend/src/api/responses.ts — handwritten BrokerPortfolio.positionCounts.
  • Create apps/frontend/src/pages/broker/brokerAllocation.ts — pure allocation sectors.
  • Create apps/frontend/src/pages/broker/brokerAllocation.test.ts — sector/count edge cases.
  • Modify apps/frontend/src/pages/broker/brokerDisplay.ts — public exact operation type options.
  • Modify apps/frontend/src/pages/broker/brokerDisplay.test.ts — option uniqueness and labels.

Frontend pages and components

  • Create apps/frontend/src/pages/broker/BrokerAccountLayout.tsx — shell, outlet context and nav.
  • Create apps/frontend/src/pages/broker/BrokerAccountOverviewPage.tsx — summary and recent operations.
  • Create apps/frontend/src/pages/broker/BrokerAllocationChart.tsx — accessible SVG and legend.
  • Create apps/frontend/src/pages/broker/BrokerPositionsPage.tsx — one typed paginated table page.
  • Create apps/frontend/src/pages/broker/BrokerOperationsPage.tsx — exact-type filter and pagination.
  • Modify apps/frontend/src/pages/broker/BrokerOperationsTable.tsx — reusable header/action and optional pagination.
  • Delete apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx — responsibilities move to layout and overview.
  • Delete apps/frontend/src/pages/broker/BrokerPositionsSection.tsx — replaced by one typed page.
  • Modify apps/frontend/src/pages/broker/BrokerPages.test.tsx — nested routes and page behavior.
  • Modify apps/frontend/src/routes.tsx — nested broker account routes после готовности всех страниц.
  • Modify apps/frontend/src/styles.css — responsive shell, tabs, cards, chart and focus states.

Task 1: Backend portfolio position counts

Files:

  • Modify: apps/backend/src/modules/tbank/mappers/portfolio.mapper.spec.ts

  • Modify: apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts

  • Modify: apps/backend/src/modules/tbank/types/broker.types.ts

  • Modify: apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts

  • Modify: apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts

  • Step 1: Write failing mapper and service assertions

Add portfolio positions that cover every group:

positions: [
  { positionUid: 'share-1', instrumentType: 'share' },
  { positionUid: 'share-2', instrumentType: 'SHARE' },
  { positionUid: 'bond-1', instrumentType: 'bond' },
  { positionUid: 'etf-1', instrumentType: 'etf' },
  { positionUid: 'fund-1', instrumentType: 'fund' },
  { positionUid: 'future-1', instrumentType: 'future' },
  { positionUid: 'unknown-1' },
],

Assert the public result:

expect(result.positionCounts).toEqual({
  shares: 2,
  bonds: 1,
  etf: 2,
  other: 2,
});

In broker-portfolio.service.spec.ts, make the cached GetPortfolio response contain one share and one bond, then assert:

expect(result.data.positionCounts).toEqual({ shares: 1, bonds: 1, etf: 0, other: 0 });
expect(client.callUnary).toHaveBeenCalledTimes(2);

The two calls remain the existing concurrent GetPortfolio and GetPositions calls; there must be no third request for pagination totals.

  • Step 2: Run the focused backend tests and verify failure

Run:

npx vitest run src/modules/tbank/mappers/portfolio.mapper.spec.ts src/modules/tbank/services/broker-portfolio.service.spec.ts -w apps/backend

Expected: FAIL because positionCounts does not exist.

  • Step 3: Add the backend type, classifier and mapper result

Add to BrokerPortfolio:

positionCounts: {
  shares: number;
  bonds: number;
  etf: number;
  other: number;
};

Add to portfolio.mapper.ts:

function countPortfolioPositions(positions: TBankPortfolioResponse['positions'] = []) {
  return positions.reduce(
    (counts, position) => {
      const type = position.instrumentType?.toLowerCase();
      if (type === 'share') counts.shares += 1;
      else if (type === 'bond') counts.bonds += 1;
      else if (type === 'etf' || type === 'fund') counts.etf += 1;
      else counts.other += 1;
      return counts;
    },
    { shares: 0, bonds: 0, etf: 0, other: 0 },
  );
}

Return it from mapBrokerPortfolio:

positionCounts: countPortfolioPositions(input.portfolio.positions),
  • Step 4: Document the field in Swagger DTO

Add:

export class BrokerPortfolioPositionCountsDto {
  @ApiProperty({ minimum: 0 })
  shares!: number;

  @ApiProperty({ minimum: 0 })
  bonds!: number;

  @ApiProperty({ minimum: 0 })
  etf!: number;

  @ApiProperty({ minimum: 0 })
  other!: number;
}

and in BrokerPortfolioResponseDto:

@ApiProperty({ type: BrokerPortfolioPositionCountsDto })
positionCounts!: BrokerPortfolioPositionCountsDto;
  • Step 5: Run focused tests and backend build

Run:

npx vitest run src/modules/tbank/mappers/portfolio.mapper.spec.ts src/modules/tbank/services/broker-portfolio.service.spec.ts -w apps/backend
npm run build:backend

Expected: all focused tests PASS and Nest build exits 0.

  • Step 6: Commit backend contract
git add apps/backend/src/modules/tbank/types/broker.types.ts apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts apps/backend/src/modules/tbank/mappers/portfolio.mapper.spec.ts apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts
git commit -m "feat: expose broker position counts"

Task 2: OpenAPI and frontend contract synchronization

Files:

  • Modify: apps/frontend/src/api/types.ts

  • Modify: apps/frontend/src/api/responses.ts

  • Step 1: Start backend and verify live Swagger

Run npm run dev:backend and wait for /api/docs-json on port 3000. Verify the schema contains:

BrokerPortfolioResponseDto.positionCounts
BrokerPortfolioPositionCountsDto.shares
BrokerPortfolioPositionCountsDto.bonds
BrokerPortfolioPositionCountsDto.etf
BrokerPortfolioPositionCountsDto.other
  • Step 2: Regenerate OpenAPI TypeScript

Run:

npm run codegen -w apps/frontend

Expected: apps/frontend/src/api/types.ts contains positionCounts and no stale positions field in BrokerPortfolioResponseDto.

  • Step 3: Update the handwritten response type

Add to BrokerPortfolio in responses.ts:

positionCounts: {
  shares: number;
  bonds: number;
  etf: number;
  other: number;
};
  • Step 4: Build frontend to verify type consistency

Run:

npm run build:frontend

Expected: TypeScript and Vite builds exit 0.

  • Step 5: Commit generated contract
git add apps/frontend/src/api/types.ts apps/frontend/src/api/responses.ts
git commit -m "chore: sync broker portfolio contract"

Task 3: Pure allocation model and exact operation options

Files:

  • Create: apps/frontend/src/pages/broker/brokerAllocation.ts

  • Create: apps/frontend/src/pages/broker/brokerAllocation.test.ts

  • Modify: apps/frontend/src/pages/broker/brokerDisplay.ts

  • Modify: apps/frontend/src/pages/broker/brokerDisplay.test.ts

  • Step 1: Write failing allocation tests

Cover positive, zero, negative and missing-total behavior:

function money(value: number) {
  return { currency: 'RUB', units: String(Math.trunc(value)), nano: 0, value };
}

function portfolioWith(values: {
  portfolio: number | null;
  shares?: number;
  bonds?: number;
  etf?: number;
  currencies?: number;
}): BrokerPortfolio {
  return {
    account: {
      id: 'acc-1',
      type: 'brokerage',
      name: 'Broker',
      status: 'ACCOUNT_STATUS_OPEN',
      openedAt: null,
      accessLevel: null,
    },
    totals: {
      shares: money(values.shares ?? 0),
      bonds: money(values.bonds ?? 0),
      etf: money(values.etf ?? 0),
      currencies: money(values.currencies ?? 0),
      futures: null,
      options: null,
      structuredProducts: null,
      dfa: null,
      portfolio: values.portfolio === null ? null : money(values.portfolio),
    },
    positionCounts: { shares: 0, bonds: 0, etf: 0, other: 0 },
    yields: { expectedPercent: null, daily: null, dailyPercent: null },
    cash: [],
    blockedCash: [],
    asOf: '2026-06-18T00:00:00.000Z',
  };
}

expect(buildBrokerAllocation(portfolioWith({
  portfolio: 1000,
  shares: 400,
  bonds: 300,
  etf: 100,
  currencies: 150,
}))).toMatchObject({
  sectors: [
    { key: 'shares', value: 400, percent: 40 },
    { key: 'bonds', value: 300, percent: 30 },
    { key: 'etf', value: 100, percent: 10 },
    { key: 'cash', value: 150, percent: 15 },
    { key: 'other', value: 50, percent: 5 },
  ],
  negative: [],
});

Also assert that a zero sector is absent, a negative residual is returned in negative, and a null or non-positive portfolio total produces sectors: [].

  • Step 2: Run allocation tests and verify failure
npx vitest run src/pages/broker/brokerAllocation.test.ts -w apps/frontend

Expected: FAIL because the module does not exist.

  • Step 3: Implement the pure allocation builder

Create these public types and function:

import type { BrokerPortfolio } from '../../api/responses';

export type BrokerAllocationKey = 'shares' | 'bonds' | 'etf' | 'cash' | 'other';

export type BrokerAllocationItem = {
  key: BrokerAllocationKey;
  label: string;
  value: number;
  percent: number;
  color: string;
};

const CONFIG = [
  { key: 'shares', label: 'Акции', color: '#4969f5' },
  { key: 'bonds', label: 'Облигации', color: '#e5a33c' },
  { key: 'etf', label: 'ETF/фонды', color: '#62b889' },
  { key: 'cash', label: 'Деньги', color: '#7b63cf' },
  { key: 'other', label: 'Прочие', color: '#aeb6c5' },
] as const;

export function buildBrokerAllocation(portfolio: BrokerPortfolio): {
  total: number;
  sectors: BrokerAllocationItem[];
  negative: Omit<BrokerAllocationItem, 'percent'>[];
} {
  const total = portfolio.totals.portfolio?.value ?? 0;
  if (total <= 0) return { total, sectors: [], negative: [] };

  const shares = portfolio.totals.shares?.value ?? 0;
  const bonds = portfolio.totals.bonds?.value ?? 0;
  const etf = portfolio.totals.etf?.value ?? 0;
  const cash = portfolio.totals.currencies?.value ?? 0;
  const values = { shares, bonds, etf, cash, other: total - shares - bonds - etf - cash };

  const items = CONFIG.map((item) => ({ ...item, value: values[item.key] }));
  return {
    total,
    sectors: items
      .filter((item) => item.value > 0)
      .map((item) => ({ ...item, percent: (item.value / total) * 100 })),
    negative: items.filter((item) => item.value < 0),
  };
}
  • Step 4: Expose exact operation type options and tests

Export a stable select list from brokerDisplay.ts:

export const BROKER_OPERATION_TYPE_OPTIONS = Object.entries(OPERATION_TYPE_LABELS)
  .map(([value, label]) => ({ value, label }))
  .sort((left, right) => left.label.localeCompare(right.label, 'ru'));

export function isBrokerOperationType(value: string | null): value is string {
  return Boolean(value && BROKER_OPERATION_TYPE_OPTIONS.some((option) => option.value === value));
}

Test exact independent values and uniqueness:

expect(BROKER_OPERATION_TYPE_OPTIONS).toEqual(
  expect.arrayContaining([
    { value: 'OPERATION_TYPE_COUPON', label: 'Выплата купона' },
    { value: 'OPERATION_TYPE_TAX', label: 'Налог' },
    { value: 'OPERATION_TYPE_BOND_TAX', label: 'Налог по облигациям' },
    { value: 'OPERATION_TYPE_DIVIDEND_TAX', label: 'Налог на дивиденды' },
  ]),
);
expect(new Set(BROKER_OPERATION_TYPE_OPTIONS.map((option) => option.value)).size).toBe(
  BROKER_OPERATION_TYPE_OPTIONS.length,
);
  • Step 5: Run pure frontend tests
npx vitest run src/pages/broker/brokerAllocation.test.ts src/pages/broker/brokerDisplay.test.ts -w apps/frontend

Expected: PASS.

  • Step 6: Commit pure frontend rules
git add apps/frontend/src/pages/broker/brokerAllocation.ts apps/frontend/src/pages/broker/brokerAllocation.test.ts apps/frontend/src/pages/broker/brokerDisplay.ts apps/frontend/src/pages/broker/brokerDisplay.test.ts
git commit -m "feat: add broker account display models"

Task 4: Nested account shell and responsive navigation

Files:

  • Create: apps/frontend/src/pages/broker/BrokerAccountLayout.tsx

  • Modify: apps/frontend/src/styles.css

  • Modify: apps/frontend/src/pages/broker/BrokerPages.test.tsx

  • Step 1: Write failing route and navigation tests

In the test, render BrokerAccountLayout with a child route at /broker/acc-1/bonds and assert:

expect(screen.getByRole('heading', { name: 'Broker' })).toBeInTheDocument();
expect(screen.getByRole('navigation', { name: 'Разделы брокерского счёта' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Обзор' })).toHaveAttribute('href', '/broker/acc-1');
expect(screen.getByRole('link', { name: 'Акции' })).toHaveAttribute(
  'href',
  '/broker/acc-1/shares',
);
expect(screen.getByRole('link', { name: 'Облигации' })).toHaveAttribute('aria-current', 'page');
expect(screen.getByRole('link', { name: 'Операции' })).toHaveAttribute(
  'href',
  '/broker/acc-1/operations',
);

Add a portfolio-error case and assert the navigation is still rendered with the fallback heading Брокерский счёт.

  • Step 2: Run the page test and verify failure
npx vitest run src/pages/broker/BrokerPages.test.tsx -w apps/frontend

Expected: FAIL because the account shell does not exist.

  • Step 3: Implement layout context and navigation

Create:

import { NavLink, Outlet, useOutletContext, useParams } from 'react-router-dom';
import { useBrokerPortfolio } from '../../hooks/useBrokerPortfolio';

type BrokerAccountContext = {
  accountId: string;
  portfolio: ReturnType<typeof useBrokerPortfolio>;
};

export function useBrokerAccountContext() {
  return useOutletContext<BrokerAccountContext>();
}

export function BrokerAccountLayout() {
  const { accountId = '' } = useParams();
  const portfolio = useBrokerPortfolio(accountId);
  const base = `/broker/${encodeURIComponent(accountId)}`;
  const links = [
    { to: base, label: 'Обзор', end: true },
    { to: `${base}/shares`, label: 'Акции' },
    { to: `${base}/bonds`, label: 'Облигации' },
    { to: `${base}/operations`, label: 'Операции' },
  ];

  return (
    <div className="broker-account">
      <header className="broker-account__header">
        <h1>{portfolio.data?.account.name ?? 'Брокерский счёт'}</h1>
      </header>
      <div className="broker-account__workspace">
        <nav className="broker-account__nav" aria-label="Разделы брокерского счёта">
          {links.map((link) => (
            <NavLink
              key={link.to}
              to={link.to}
              end={link.end}
              className={({ isActive }) =>
                `broker-account__nav-link${isActive ? ' is-active' : ''}`
              }
            >
              {link.label}
            </NavLink>
          ))}
        </nav>
        <main className="broker-account__content">
          <Outlet context={{ accountId, portfolio } satisfies BrokerAccountContext} />
        </main>
      </div>
    </div>
  );
}
  • Step 4: Add desktop and mobile navigation CSS

Add the exact responsive rules:

.broker-account__workspace {
  display: grid;
  grid-template-columns: minmax(150px, 190px) minmax(0, 1fr);
  gap: 24px;
}

.broker-account__nav {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.broker-account__nav-link {
  padding: 10px 12px;
  border-radius: var(--border-radius);
  color: var(--color-text-secondary);
}

.broker-account__nav-link.is-active,
.broker-account__nav-link[aria-current='page'] {
  color: var(--color-primary);
  background: color-mix(in srgb, var(--color-primary) 10%, var(--color-surface));
  font-weight: 700;
}

.broker-account__nav-link:focus-visible {
  outline: 3px solid color-mix(in srgb, var(--color-primary) 35%, transparent);
  outline-offset: 2px;
}

@media (max-width: 720px) {
  .broker-account__workspace { grid-template-columns: 1fr; gap: 16px; }
  .broker-account__nav {
    flex-direction: row;
    overflow-x: auto;
    scrollbar-width: thin;
  }
  .broker-account__nav-link { white-space: nowrap; flex: 0 0 auto; }
}
  • Step 5: Run navigation tests and commit
npx vitest run src/pages/broker/BrokerPages.test.tsx -w apps/frontend
git add apps/frontend/src/pages/broker/BrokerAccountLayout.tsx apps/frontend/src/styles.css apps/frontend/src/pages/broker/BrokerPages.test.tsx
git commit -m "feat: add broker account section navigation"

Expected: navigation tests PASS.

Task 5: Overview summary, allocation and recent operations

Files:

  • Create: apps/frontend/src/pages/broker/BrokerAllocationChart.tsx

  • Create: apps/frontend/src/pages/broker/BrokerAccountOverviewPage.tsx

  • Modify: apps/frontend/src/pages/broker/BrokerOperationsTable.tsx

  • Modify: apps/frontend/src/pages/broker/BrokerPages.test.tsx

  • Modify: apps/frontend/src/styles.css

  • Step 1: Write failing overview tests

Mock a portfolio with counts and totals, then assert:

expect(screen.getByText(/1[\s\u00a0]?250[\s\u00a0]?000/)).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Акции.*14 позиций/i })).toHaveAttribute(
  'href',
  '/broker/acc-1/shares',
);
expect(screen.getByRole('link', { name: /Облигации.*8 выпусков/i })).toHaveAttribute(
  'href',
  '/broker/acc-1/bonds',
);
expect(screen.getByRole('img', { name: 'Структура брокерского портфеля' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Вся история' })).toHaveAttribute(
  'href',
  '/broker/acc-1/operations',
);
expect(operationsSpy).toHaveBeenCalledWith('acc-1', { limit: 5 });
expect(screen.queryByRole('heading', { name: 'Позиции' })).not.toBeInTheDocument();

Add separate loading, portfolio-error, operations-error, empty-operations and negative-allocation cases. In the operations-error case the overview summary and account navigation must remain visible.

  • Step 2: Run overview tests and verify failure
npx vitest run src/pages/broker/BrokerPages.test.tsx -w apps/frontend

Expected: FAIL on missing overview cards/chart/recent-operation behavior.

  • Step 3: Implement accessible allocation chart

BrokerAllocationChart calls buildBrokerAllocation(portfolio) and renders:

const { sectors, negative } = buildBrokerAllocation(portfolio);
const circumference = 2 * Math.PI * 44;
let consumedPercent = 0;
const arcs = sectors.map((sector) => {
  const dashOffset = -((consumedPercent / 100) * circumference);
  const dashLength = (sector.percent / 100) * circumference;
  consumedPercent += sector.percent;
  return { ...sector, dashOffset, dashLength };
});

<figure className="broker-allocation">
  <svg role="img" aria-label="Структура брокерского портфеля" viewBox="0 0 120 120">
    <title>Структура брокерского портфеля</title>
    {arcs.map((sector) => (
      <circle
        key={sector.key}
        cx="60"
        cy="60"
        r="44"
        fill="none"
        stroke={sector.color}
        strokeWidth="14"
        strokeDasharray={`${sector.dashLength} ${circumference - sector.dashLength}`}
        strokeDashoffset={sector.dashOffset}
        transform="rotate(-90 60 60)"
      />
    ))}
  </svg>
  <figcaption>
    <ul>
      {sectors.map((sector) => (
        <li key={sector.key}>
          <span aria-hidden="true" style={{ background: sector.color }} />
          {sector.label}: {formatMoneyValue(sector.value)} · {sector.percent.toFixed(1)}%
        </li>
      ))}
    </ul>
    {negative.length > 0 && (
      <ul aria-label="Отрицательные значения распределения">
        {negative.map((item) => (
          <li key={item.key}>
            {item.label}: отрицательное значение {formatMoneyValue(item.value)}
          </li>
        ))}
      </ul>
    )}
  </figcaption>
</figure>

Before JSX, calculate dash offsets without hidden state:

function formatMoneyValue(value: number) {
  return new Intl.NumberFormat('ru-RU', {
    style: 'currency',
    currency: 'RUB',
    maximumFractionDigits: 2,
  }).format(value);
}

If sectors is empty, render Нет данных для распределения. Render each negative item below the legend as {label}: отрицательное значение {money}.

  • Step 4: Make operations table reusable without pagination

Change its props to:

type BrokerOperationsTableProps = {
  title: string;
  headerAction?: React.ReactNode;
  emptyMessage: string;
  isLoading: boolean;
  isFetching: boolean;
  page: BrokerOperationsPage | undefined;
  pagination?: {
    pageNumber: number;
    canGoBack: boolean;
    canGoForward: boolean;
    onPrevious: () => void;
    onNext: () => void;
  };
};

Render controls only when pagination exists. Preserve existing rows, links, amount colors, skeleton and overlay.

  • Step 5: Implement overview page

Use outlet context and the recent-operations query:

export function BrokerAccountOverviewPage() {
  const { accountId, portfolio } = useBrokerAccountContext();
  const operations = useBrokerOperations(accountId, { limit: 5 });

  if (portfolio.isLoading) return <BrokerOverviewSkeleton />;
  if (portfolio.error || !portfolio.data) {
    return <p role="alert">Не удалось загрузить сводку счёта</p>;
  }

  const recentOperations = operations.error ? (
    <p role="alert">Не удалось загрузить последние операции</p>
  ) : (
    <BrokerOperationsTable
      title="Последние операции"
      headerAction={<Link to={`/broker/${encodeURIComponent(accountId)}/operations`}>Вся история</Link>}
      emptyMessage="Операций с начала текущего года нет"
      isLoading={operations.isLoading}
      isFetching={operations.isFetching}
      page={operations.data}
    />
  );

  return (
    <div className="broker-overview">
      <BrokerSummary portfolio={portfolio.data} />
      <BrokerAllocationChart portfolio={portfolio.data} />
      <BrokerAssetCards accountId={accountId} portfolio={portfolio.data} />
      {recentOperations}
    </div>
  );
}

BrokerSummary, BrokerAssetCards and BrokerOverviewSkeleton may remain private focused functions in this file. Cards use positionCounts.shares/bonds, the corresponding total, and allocation percentage. Use позиция/позиции/позиций and выпуск/выпуска/выпусков plural helpers.

Add layout classes used by these functions:

.broker-overview { display: grid; gap: 24px; }
.broker-overview__summary,
.broker-overview__assets {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 12px;
}
.broker-overview__card,
.broker-allocation {
  padding: 16px;
  border: 1px solid #e0e0e0;
  border-radius: var(--border-radius);
  background: var(--color-surface);
}
.broker-overview__asset-link:focus-visible {
  outline: 3px solid color-mix(in srgb, var(--color-primary) 35%, transparent);
  outline-offset: 2px;
}
.broker-allocation { display: flex; align-items: center; gap: 20px; }
.broker-allocation svg { width: 160px; max-width: 40%; flex: 0 0 auto; }
.broker-allocation ul { list-style: none; display: grid; gap: 8px; }
@media (max-width: 720px) {
  .broker-overview__summary,
  .broker-overview__assets { grid-template-columns: 1fr; }
  .broker-allocation { align-items: stretch; flex-direction: column; }
  .broker-allocation svg { max-width: 180px; width: 100%; align-self: center; }
}
  • Step 6: Run overview tests
npx vitest run src/pages/broker/BrokerPages.test.tsx src/pages/broker/brokerAllocation.test.ts -w apps/frontend

Expected: overview, chart and recent-operations tests PASS.

  • Step 7: Commit overview
git add apps/frontend/src/pages/broker/BrokerAllocationChart.tsx apps/frontend/src/pages/broker/BrokerAccountOverviewPage.tsx apps/frontend/src/pages/broker/BrokerOperationsTable.tsx apps/frontend/src/pages/broker/BrokerPages.test.tsx apps/frontend/src/styles.css
git commit -m "feat: add broker account overview"

Task 6: Separate share and bond pages

Files:

  • Create: apps/frontend/src/pages/broker/BrokerPositionsPage.tsx

  • Modify: apps/frontend/src/pages/broker/BrokerPages.test.tsx

  • Step 1: Write failing page tests

For /broker/acc-1/shares, assert the hook query and content:

expect(positionsSpy).toHaveBeenLastCalledWith('acc-1', {
  type: 'share',
  limit: 10,
  cursor: undefined,
});
expect(screen.getByRole('heading', { name: 'Акции' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'SBER' })).toHaveAttribute('href', '/stocks/SBER');
expect(screen.queryByText('SU26238RMFS5')).not.toBeInTheDocument();

Repeat for bonds. Add cursor forward/back assertions, empty messages На счёте нет акций and На счёте нет облигаций, initial skeleton, update overlay and local error messages. In the error case, account navigation must remain visible.

  • Step 2: Run page tests and verify failure
npx vitest run src/pages/broker/BrokerPages.test.tsx -w apps/frontend

Expected: FAIL because the typed positions page does not exist.

  • Step 3: Implement one generic typed page

Use this public interface:

type BrokerPositionsPageProps = {
  type: 'share' | 'bond';
  title: 'Акции' | 'Облигации';
};

export function BrokerPositionsPage({ type, title }: BrokerPositionsPageProps) {
  const { accountId } = useBrokerAccountContext();
  const [cursor, setCursor] = useState<string>();
  const [cursorStack, setCursorStack] = useState<Array<string | undefined>>([]);
  const positions = useBrokerPositions(accountId, { type, limit: 10, cursor });

  function handleNext() {
    const nextCursor = positions.data?.nextCursor;
    if (!nextCursor || !positions.data?.hasNext) return;
    setCursorStack((previous) => [...previous, cursor]);
    setCursor(nextCursor);
  }

  function handlePrevious() {
    if (cursorStack.length === 0) return;
    setCursor(cursorStack[cursorStack.length - 1]);
    setCursorStack((previous) => previous.slice(0, -1));
  }

  if (positions.error) {
    return (
      <section aria-labelledby={`broker-${type}-heading`}>
        <h2 id={`broker-${type}-heading`}>{title}</h2>
        <p role="alert">
          {type === 'share' ? 'Не удалось загрузить акции' : 'Не удалось загрузить облигации'}
        </p>
      </section>
    );
  }

  return (
    <section aria-labelledby={`broker-${type}-heading`}>
      <h2 id={`broker-${type}-heading`}>{title}</h2>
      <BrokerPositionTable
        title={title}
        page={positions.data}
        isLoading={positions.isLoading}
        isFetching={positions.isFetching}
        emptyMessage={type === 'share' ? 'На счёте нет акций' : 'На счёте нет облигаций'}
        pageNumber={cursorStack.length + 1}
        onNext={handleNext}
        onPrevious={handlePrevious}
      />
    </section>
  );
}

Keep the existing five columns and PositionTicker behavior from BrokerPositionsSection. Move them into private BrokerPositionTable and PositionTicker functions in this file. Do not retain ETF/fund/other queries.

  • Step 4: Run typed position page tests
npx vitest run src/pages/broker/BrokerPages.test.tsx -w apps/frontend

Expected: share, bond, pagination, loading and empty tests PASS.

  • Step 5: Commit position pages
git add apps/frontend/src/pages/broker/BrokerPositionsPage.tsx apps/frontend/src/pages/broker/BrokerPages.test.tsx
git commit -m "feat: add broker asset pages"

Task 7: Exact operation-type filter page

Files:

  • Create: apps/frontend/src/pages/broker/BrokerOperationsPage.tsx

  • Modify: apps/frontend/src/pages/broker/BrokerPages.test.tsx

  • Modify: apps/frontend/src/api/broker.test.ts

  • Modify: apps/frontend/src/routes.tsx

  • Modify: apps/frontend/src/styles.css

  • Delete: apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx

  • Delete: apps/frontend/src/pages/broker/BrokerPositionsSection.tsx

  • Step 1: Strengthen API serialization test

Call:

await getBrokerOperations('acc-1', {
  cursor: 'c1',
  limit: 10,
  operationTypes: 'OPERATION_TYPE_COUPON',
});

Assert the URL contains all three parameters and the exact enum value.

  • Step 2: Write failing operations page tests

Start at /broker/acc-1/operations?type=OPERATION_TYPE_COUPON and assert:

expect(screen.getByRole('combobox', { name: 'Тип операции' })).toHaveValue(
  'OPERATION_TYPE_COUPON',
);
expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', {
  limit: 10,
  cursor: undefined,
  operationTypes: 'OPERATION_TYPE_COUPON',
});

Click next, then change select to OPERATION_TYPE_TAX. Assert the query returns to cursor: undefined, page number becomes 1, the URL is ?type=OPERATION_TYPE_TAX, and the request contains exactly that one value. Select Все операции and assert the query parameter and operationTypes are removed. Add an invalid URL type case that behaves as Все операции, plus a request-error case where the account navigation and filter remain visible.

  • Step 3: Run operations tests and verify failure
npx vitest run src/pages/broker/BrokerPages.test.tsx src/api/broker.test.ts -w apps/frontend

Expected: FAIL because the filter page does not exist.

  • Step 4: Implement URL-backed filter and cursor state

Create:

export function BrokerOperationsPage() {
  const { accountId } = useBrokerAccountContext();
  const [searchParams, setSearchParams] = useSearchParams();
  const urlType = searchParams.get('type');
  const selectedType = isBrokerOperationType(urlType) ? urlType : '';
  const [cursor, setCursor] = useState<string>();
  const [cursorStack, setCursorStack] = useState<Array<string | undefined>>([]);
  const operations = useBrokerOperations(accountId, {
    limit: 10,
    cursor,
    operationTypes: selectedType || undefined,
  });

  useEffect(() => {
    setCursor(undefined);
    setCursorStack([]);
  }, [selectedType]);

  function handleTypeChange(event: React.ChangeEvent<HTMLSelectElement>) {
    const nextType = event.target.value;
    setSearchParams(nextType ? { type: nextType } : {}, { replace: true });
  }

  function handleNext() {
    const nextCursor = operations.data?.nextCursor;
    if (!nextCursor || !operations.data?.hasNext) return;
    setCursorStack((previous) => [...previous, cursor]);
    setCursor(nextCursor);
  }

  function handlePrevious() {
    if (cursorStack.length === 0) return;
    setCursor(cursorStack[cursorStack.length - 1]);
    setCursorStack((previous) => previous.slice(0, -1));
  }

  const history = operations.error ? (
    <p role="alert">Не удалось загрузить историю операций</p>
  ) : (
    <BrokerOperationsTable
      title="История операций"
      emptyMessage={selectedType ? 'Операций выбранного типа нет' : 'Операций с начала текущего года нет'}
      isLoading={operations.isLoading}
      isFetching={operations.isFetching}
      page={operations.data}
      pagination={{
        pageNumber: cursorStack.length + 1,
        canGoBack: cursorStack.length > 0,
        canGoForward: Boolean(operations.data?.hasNext && operations.data.nextCursor),
        onPrevious: handlePrevious,
        onNext: handleNext,
      }}
    />
  );

  return (
    <section aria-labelledby="broker-operations-heading">
      <div className="broker-operations__toolbar">
        <h2 id="broker-operations-heading">Операции</h2>
        <label>
          <span>Тип операции</span>
          <select value={selectedType} onChange={handleTypeChange}>
            <option value="">Все операции</option>
            {BROKER_OPERATION_TYPE_OPTIONS.map((option) => (
              <option key={option.value} value={option.value}>{option.label}</option>
            ))}
          </select>
        </label>
      </div>
      {history}
    </section>
  );
}

Preserve the existing table overlay during isFetching.

Add toolbar and accessible focus styles:

.broker-operations__toolbar {
  display: flex;
  align-items: end;
  justify-content: space-between;
  gap: 16px;
  margin-bottom: 20px;
}

.broker-operations__toolbar label {
  display: grid;
  gap: 6px;
  color: var(--color-text-secondary);
  font-size: 13px;
}

.broker-operations__toolbar select {
  min-width: 240px;
  padding: 8px 10px;
  border: 1px solid #d8d8d8;
  border-radius: var(--border-radius);
  background: var(--color-surface);
  color: var(--color-text);
}

.broker-operations__toolbar select:focus-visible {
  outline: 3px solid color-mix(in srgb, var(--color-primary) 35%, transparent);
  outline-offset: 2px;
}

@media (max-width: 720px) {
  .broker-operations__toolbar { align-items: stretch; flex-direction: column; }
  .broker-operations__toolbar select { width: 100%; min-width: 0; }
}
  • Step 5: Switch application routing after every child page exists

Replace the single account route in routes.tsx with:

<Route
  path="/broker/:accountId"
  element={
    <ProtectedRoute>
      <BrokerAccountLayout />
    </ProtectedRoute>
  }
>
  <Route index element={<BrokerAccountOverviewPage />} />
  <Route path="shares" element={<BrokerPositionsPage type="share" title="Акции" />} />
  <Route path="bonds" element={<BrokerPositionsPage type="bond" title="Облигации" />} />
  <Route path="operations" element={<BrokerOperationsPage />} />
</Route>

Delete BrokerAccountDetailPage.tsx and BrokerPositionsSection.tsx only after imports and tests use the new pages.

  • Step 6: Run focused frontend tests
npx vitest run src/pages/broker/BrokerPages.test.tsx src/api/broker.test.ts src/pages/broker/brokerDisplay.test.ts -w apps/frontend

Expected: filter, URL restoration, exact API serialization and pagination tests PASS.

  • Step 7: Commit operations page and route cutover
git add apps/frontend/src/pages/broker/BrokerOperationsPage.tsx apps/frontend/src/pages/broker/BrokerPages.test.tsx apps/frontend/src/api/broker.test.ts apps/frontend/src/routes.tsx apps/frontend/src/styles.css apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx apps/frontend/src/pages/broker/BrokerPositionsSection.tsx
git commit -m "feat: filter broker operations by exact type"

Task 8: Full verification and documentation status

Files:

  • Modify: docs/features/broker-account-sections/tasks.md

  • Modify: docs/features/broker-account-sections/spec.md

  • Modify: docs/epics/BrokerPortfolio.md

  • Modify: docs/roadmap.md

  • Step 1: Run format on changed TypeScript

npm run format

Review the diff and ensure formatting did not touch unrelated files. Restore no user changes.

  • Step 2: Run all automated quality gates
npm run test:backend
npm run test:frontend
npm run build:backend
npm run build:frontend
npm run lint

Expected: every command exits 0.

  • Step 3: Verify local UI in browser

Start backend and frontend, then verify at desktop and mobile widths:

/broker/:accountId
/broker/:accountId/shares
/broker/:accountId/bonds
/broker/:accountId/operations?type=OPERATION_TYPE_COUPON

Check active navigation, horizontal mobile tabs, chart legend, card links, empty states, exact filter, cursor reset and table loading overlay. Record any discovered requirement change in spec before code changes.

  • Step 4: Request code review

Invoke superpowers:requesting-code-review and resolve blocking findings with superpowers:receiving-code-review.

  • Step 5: Update SDD statuses only after verification

  • Mark all completed checkboxes in tasks.md.

  • Change spec status from утверждено к реализации to реализовано.

  • Change the feature status in BrokerPortfolio.md and roadmap.md to реализовано.

  • Do not mark status complete while a required check or review finding remains open.

  • Step 6: Commit verification and SDD updates

git add docs/features/broker-account-sections/spec.md docs/features/broker-account-sections/tasks.md docs/epics/BrokerPortfolio.md docs/roadmap.md
git commit -m "docs: complete broker account sections"

Final acceptance mapping

Spec area Implemented by Verified by
Desktop sidebar and mobile tabs Task 4 BrokerPages route/nav tests + browser
Overview summary and last five operations Task 5 BrokerPages overview tests
Whole-account allocation Tasks 1, 3, 5 mapper + allocation + page tests
Exact full-portfolio counts Tasks 12 mapper/service contract tests
Separate shares and bonds Task 6 typed page and pagination tests
Exact single operation filter Task 7 URL, hook and API serialization tests
Loading/error/empty states Tasks 47 component tests + browser
No implementation before approval Planning gate user approval of plan/tasks