44 KiB
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-запрос не нужен.
Правила классификации:
share→shares;bond→bonds;etfиfund→etf;- пустой, неизвестный и любой другой тип →
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— handwrittenBrokerPortfolio.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.mdandroadmap.mdtoреализовано. -
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 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 |