1306 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# Broker 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<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`:
```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<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:
```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 };
});
<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:
```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 <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:
```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<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**
```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<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:
```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
<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**
```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 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 |