diff --git a/docs/epics/BrokerPortfolio.md b/docs/epics/BrokerPortfolio.md index d97ca85..811ae29 100644 --- a/docs/epics/BrokerPortfolio.md +++ b/docs/epics/BrokerPortfolio.md @@ -26,7 +26,7 @@ - [T-Bank broker portfolios](../features/tbank-broker-portfolios/spec.md) - [Отображение брокерского портфеля](../features/broker-portfolio-display/spec.md) -- [Разделы брокерского счёта](../features/broker-account-sections/spec.md) — ожидает проверки SDD +- [Разделы брокерского счёта](../features/broker-account-sections/spec.md) — планирование - [Улучшение UI операций](../features/broker-operations-ui-improvements/spec.md) - [Пагинация и загрузка позиций](../features/broker-positions-pagination-and-loading/spec.md) - [Исправление deadline и очереди T-Bank](../features/tbank-deadline-queue-fix/spec.md) diff --git a/docs/features/broker-account-sections/plan.md b/docs/features/broker-account-sections/plan.md new file mode 100644 index 0000000..a9a101b --- /dev/null +++ b/docs/features/broker-account-sections/plan.md @@ -0,0 +1,1305 @@ +# 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 | diff --git a/docs/features/broker-account-sections/spec.md b/docs/features/broker-account-sections/spec.md index 9496824..4f1933b 100644 --- a/docs/features/broker-account-sections/spec.md +++ b/docs/features/broker-account-sections/spec.md @@ -1,7 +1,7 @@ # Разделы брокерского счёта Дата: 2026-06-18 -Статус: ожидает проверки +Статус: утверждено к планированию Эпик: [Портфель брокера](../../epics/BrokerPortfolio.md) ## Цель diff --git a/docs/features/broker-account-sections/tasks.md b/docs/features/broker-account-sections/tasks.md new file mode 100644 index 0000000..ef1ba52 --- /dev/null +++ b/docs/features/broker-account-sections/tasks.md @@ -0,0 +1,51 @@ +# Разделы брокерского счёта — задачи + +Дата: 2026-06-18 +Статус: ожидает проверки плана + +Связанные документы: + +- [Epic](../../epics/BrokerPortfolio.md) +- [Spec](spec.md) +- [Plan](plan.md) +- [Research](../../research/2026-06-18-broker-account-sections.md) + +## Backend и API + +- [ ] Получить отдельное утверждение `plan.md` и `tasks.md` и обновить SDD-статусы до начала кода. +- [ ] Добавить в broker portfolio contract счётчики `shares`, `bonds`, `etf`, `other`. +- [ ] Покрыть классификацию позиций mapper- и service-тестами. +- [ ] Обновить Swagger DTO и сгенерировать актуальные frontend OpenAPI types. +- [ ] Синхронизировать handwritten `BrokerPortfolio` response type. + +## Чистые frontend-модели + +- [ ] Добавить и протестировать расчёт секторов полного брокерского портфеля. +- [ ] Обработать нулевые, отрицательные и отсутствующие значения диаграммы. +- [ ] Экспортировать и протестировать список точных типов операций для select. + +## Навигация и overview + +- [ ] Создать nested-route shell брокерского счёта. +- [ ] Добавить desktop sidebar и mobile horizontal tabs с активным состоянием. +- [ ] Создать overview со стоимостью, деньгами и существующей доходностью. +- [ ] Добавить доступную диаграмму, легенду и предупреждения об отрицательных значениях. +- [ ] Добавить кликабельные карточки акций и облигаций с точными счётчиками. +- [ ] Показывать на overview последние пять операций и ссылку на историю. + +## Отдельные страницы + +- [ ] Создать страницу акций с cursor-пагинацией по 10 строк. +- [ ] Создать страницу облигаций с cursor-пагинацией по 10 строк. +- [ ] Создать страницу операций с cursor-пагинацией по 10 строк. +- [ ] Добавить single-select точного operation type, синхронизацию URL и сброс cursor. +- [ ] Удалить старую общую detail page и grouped positions section после переноса поведения. + +## Состояния и качество + +- [ ] Покрыть shell, overview, asset pages и operations page компонентными тестами. +- [ ] Проверить skeleton, loading overlay, локальные ошибки и отдельные empty states. +- [ ] Выполнить backend/frontend tests, builds и lint. +- [ ] Проверить четыре маршрута в локальном браузере на desktop и mobile ширине. +- [ ] Провести code review и устранить блокирующие замечания. +- [ ] Обновить статусы spec, epic, roadmap и этого списка только после полной проверки. diff --git a/docs/roadmap.md b/docs/roadmap.md index 2365f8f..1e26b2b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -10,10 +10,10 @@ Roadmap отражает порядок продуктовой работы, н Цель: сделать реальные брокерские счета понятными на уровне обзора, позиций и операций. -- [Разделы брокерского счёта](features/broker-account-sections/spec.md) — SDD, ожидает проверки. +- [Разделы брокерского счёта](features/broker-account-sections/spec.md) — SDD, планирование. ## Следующие этапы для активной фичи -1. Проверить и утвердить `spec.md`. -2. Подготовить и проверить `plan.md` и `tasks.md`. -3. Начать реализацию только после отдельного подтверждения пользователя. +1. [x] Проверить и утвердить `spec.md` для планирования. +2. [ ] Проверить и утвердить подготовленные `plan.md` и `tasks.md`. +3. [ ] Получить отдельное подтверждение пользователя перед началом реализации.