# 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` получает новое обязательное поле: ```ts positionCounts: { shares: number; bonds: number; etf: number; other: number; }; ``` Счётчики строятся из полного `TBankPortfolioResponse.positions`, уже получаемого внутри `BrokerPortfolioService.getPortfolio`. Дополнительный T-Bank или внутренний HTTP-запрос не нужен. Правила классификации: - `share` → `shares`; - `bond` → `bonds`; - `etf` и `fund` → `etf`; - пустой, неизвестный и любой другой тип → `other`. Одна запись T-Bank portfolio position считается одной различимой позицией. Cursor-пагинация frontend на счётчики не влияет. ### Frontend routes ```text /broker/:accountId overview /broker/:accountId/shares таблица акций /broker/:accountId/bonds таблица облигаций /broker/:accountId/operations история операций ``` Общий `BrokerAccountLayout` всегда показывает название счёта и навигацию. Ошибка дочернего запроса не удаляет shell. На desktop используется sidebar, на viewport до 720 px — горизонтальная строка вкладок с `overflow-x: auto`. ### Data flow ```text 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`, например: ```text /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: ```ts 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: ```ts 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: ```ts 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: ```bash 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`: ```ts positionCounts: { shares: number; bonds: number; etf: number; other: number; }; ``` Add to `portfolio.mapper.ts`: ```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`: ```ts positionCounts: countPortfolioPositions(input.portfolio.positions), ``` - [ ] **Step 4: Document the field in Swagger DTO** Add: ```ts 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`: ```ts @ApiProperty({ type: BrokerPortfolioPositionCountsDto }) positionCounts!: BrokerPortfolioPositionCountsDto; ``` - [ ] **Step 5: Run focused tests and backend build** Run: ```bash 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** ```bash 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: ```text BrokerPortfolioResponseDto.positionCounts BrokerPortfolioPositionCountsDto.shares BrokerPortfolioPositionCountsDto.bonds BrokerPortfolioPositionCountsDto.etf BrokerPortfolioPositionCountsDto.other ``` - [ ] **Step 2: Regenerate OpenAPI TypeScript** Run: ```bash 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`: ```ts positionCounts: { shares: number; bonds: number; etf: number; other: number; }; ``` - [ ] **Step 4: Build frontend to verify type consistency** Run: ```bash npm run build:frontend ``` Expected: TypeScript and Vite builds exit 0. - [ ] **Step 5: Commit generated contract** ```bash 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: ```ts 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** ```bash 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: ```ts 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[]; } { 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`: ```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: ```ts 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** ```bash 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** ```bash 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: ```ts 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** ```bash 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: ```tsx import { NavLink, Outlet, useOutletContext, useParams } from 'react-router-dom'; import { useBrokerPortfolio } from '../../hooks/useBrokerPortfolio'; type BrokerAccountContext = { accountId: string; portfolio: ReturnType; }; export function useBrokerAccountContext() { return useOutletContext(); } 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 (

{portfolio.data?.account.name ?? 'Брокерский счёт'}

); } ``` - [ ] **Step 4: Add desktop and mobile navigation CSS** Add the exact responsive rules: ```css .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** ```bash 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: ```ts 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** ```bash 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: ```tsx 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 }; });
Структура брокерского портфеля {arcs.map((sector) => ( ))}
    {sectors.map((sector) => (
  • ))}
{negative.length > 0 && (
    {negative.map((item) => (
  • {item.label}: отрицательное значение {formatMoneyValue(item.value)}
  • ))}
)}
``` Before JSX, calculate dash offsets without hidden state: ```ts 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: ```ts 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: ```tsx export function BrokerAccountOverviewPage() { const { accountId, portfolio } = useBrokerAccountContext(); const operations = useBrokerOperations(accountId, { limit: 5 }); if (portfolio.isLoading) return ; if (portfolio.error || !portfolio.data) { return

Не удалось загрузить сводку счёта

; } const recentOperations = operations.error ? (

Не удалось загрузить последние операции

) : ( Вся история} emptyMessage="Операций с начала текущего года нет" isLoading={operations.isLoading} isFetching={operations.isFetching} page={operations.data} /> ); return (
{recentOperations}
); } ``` `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: ```css .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** ```bash 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** ```bash 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: ```ts 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** ```bash 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: ```tsx type BrokerPositionsPageProps = { type: 'share' | 'bond'; title: 'Акции' | 'Облигации'; }; export function BrokerPositionsPage({ type, title }: BrokerPositionsPageProps) { const { accountId } = useBrokerAccountContext(); const [cursor, setCursor] = useState(); const [cursorStack, setCursorStack] = useState>([]); 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 (

{title}

{type === 'share' ? 'Не удалось загрузить акции' : 'Не удалось загрузить облигации'}

); } return (

{title}

); } ``` 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** ```bash 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** ```bash 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: ```ts 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: ```ts 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** ```bash 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: ```tsx export function BrokerOperationsPage() { const { accountId } = useBrokerAccountContext(); const [searchParams, setSearchParams] = useSearchParams(); const urlType = searchParams.get('type'); const selectedType = isBrokerOperationType(urlType) ? urlType : ''; const [cursor, setCursor] = useState(); const [cursorStack, setCursorStack] = useState>([]); const operations = useBrokerOperations(accountId, { limit: 10, cursor, operationTypes: selectedType || undefined, }); useEffect(() => { setCursor(undefined); setCursorStack([]); }, [selectedType]); function handleTypeChange(event: React.ChangeEvent) { 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 ? (

Не удалось загрузить историю операций

) : ( 0, canGoForward: Boolean(operations.data?.hasNext && operations.data.nextCursor), onPrevious: handlePrevious, onNext: handleNext, }} /> ); return (

Операции

{history}
); } ``` Preserve the existing table overlay during `isFetching`. Add toolbar and accessible focus styles: ```css .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: ```tsx } > } /> } /> } /> } /> ``` Delete `BrokerAccountDetailPage.tsx` and `BrokerPositionsSection.tsx` only after imports and tests use the new pages. - [ ] **Step 6: Run focused frontend tests** ```bash 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** ```bash 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** ```bash 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** ```bash 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: ```text /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** ```bash 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 1–2 | 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 4–7 | component tests + browser | | No implementation before approval | Planning gate | user approval of plan/tasks |