1306 lines
44 KiB
Markdown
1306 lines
44 KiB
Markdown
# 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 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 |
|