989 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# Broker Dashboard Redesign 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:** Rebuild `/broker/:accountId` overview as a light-theme investment dashboard using current broker portfolio, events, operations, analytics, and allocation data.
**Architecture:** Keep the route and FSD boundaries unchanged. Add focused dashboard widgets under `widgets/broker-dashboard` and keep domain data access in existing `entities/*` hooks. Extend `@moex-vibe/design-system` only for generic presentation gaps; do not move broker-specific logic into DS.
**Tech Stack:** React 18, TanStack Router, TanStack Query, MUI via `@moex-vibe/design-system`, Vitest, Testing Library.
---
## File Structure
- Modify: `apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx` — replace vertical overview composition with dashboard shell.
- Create: `apps/frontend/src/widgets/broker-dashboard/index.ts` — public API for dashboard widgets.
- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx` — top-level dashboard composition.
- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx` — KPI hero using portfolio and analytics data.
- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx` — local card pattern if DS `Card`/`Surface` remains too generic.
- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx` — compact events table for overview.
- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx` — compact income table from operations.
- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx` — analytics summary card.
- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx` — allocation card wrapping existing allocation chart logic.
- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardSkeleton.tsx` — dashboard-shaped loading skeleton.
- Create: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts` — pure helpers for filtering and summarising dividend/coupon operations.
- Create: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts` — date preset, filter validation, operation type mapping, and pagination helpers.
- Create: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts` — local presentation helpers for KPI fallback and event status labels.
- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx` — composition tests.
- Create: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts` — income helper unit tests.
- Modify if needed: `packages/design-system/src/components/Surface/Surface.tsx` — allow `sx` passthrough for generic surface customization.
- Modify if needed: `packages/design-system/src/components/Chip/Chip.tsx` — support selected/active visual state without broker-specific concepts.
- Modify if DS changed: corresponding DS tests and stories.
- Modify: `docs/features/broker-dashboard-redesign/tasks.md` — mark implementation tasks as completed while working.
## Data Sources
- Portfolio: `useBrokerAccountContext().portfolio` from `BrokerAccountLayout`.
- Events: `useBrokerEvents(accountId, { from, to, types })` from `entities/broker-event`.
- Operations: `useBrokerOperations(accountId, { from, to, operationTypes, cursor, limit: 10 })` from `entities/broker-operation`.
- Analytics: `useBrokerAnalytics(accountId)` from `entities/broker-analytics`.
- Allocation: `buildBrokerAllocation(portfolio)` from `entities/broker-position` or existing `BrokerAllocationChart` internals.
## Tasks
### Task 1: Income Helper
**Files:**
- Create: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts`
- Create: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts`
- [ ] **Step 1: Write failing tests for income filtering and totals**
Create `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts`:
```ts
import type { BrokerOperation } from '@/shared/api'
import { getDashboardIncomeRows, isDashboardIncomeOperation, sumDashboardIncome } from './dashboardIncome'
function operation(type: string, value: number | null): BrokerOperation {
return {
cursor: null,
accountId: 'acc-1',
id: type,
parentOperationId: null,
date: '2026-06-01T00:00:00.000Z',
type,
category: 'income',
description: null,
name: 'Apple Inc.',
state: 'OPERATION_STATE_EXECUTED',
instrumentUid: null,
figi: null,
ticker: 'AAPL',
classCode: null,
instrumentType: 'share',
payment: value === null ? null : { currency: 'RUB', units: String(Math.trunc(value)), nano: 0, value },
price: null,
commission: null,
yield: null,
accruedInt: null,
quantity: null,
quantityDone: null,
}
}
describe('dashboardIncome', () => {
it('detects dividend and coupon operation types', () => {
expect(isDashboardIncomeOperation(operation('OPERATION_TYPE_DIVIDEND', 10))).toBe(true)
expect(isDashboardIncomeOperation(operation('OPERATION_TYPE_DIV_EXT', 10))).toBe(true)
expect(isDashboardIncomeOperation(operation('OPERATION_TYPE_COUPON', 10))).toBe(true)
expect(isDashboardIncomeOperation(operation('OPERATION_TYPE_BUY', -10))).toBe(false)
})
it('returns only displayable income rows with payments', () => {
const rows = getDashboardIncomeRows([
operation('OPERATION_TYPE_DIVIDEND', 10),
operation('OPERATION_TYPE_BUY', -10),
operation('OPERATION_TYPE_COUPON', null),
])
expect(rows).toHaveLength(1)
expect(rows[0].typeLabel).toBe('Дивиденд')
expect(rows[0].amount.value).toBe(10)
})
it('sums displayed income rows by currency', () => {
const rows = getDashboardIncomeRows([
operation('OPERATION_TYPE_DIVIDEND', 10),
operation('OPERATION_TYPE_COUPON', 2.5),
])
expect(sumDashboardIncome(rows)).toEqual({ currency: 'RUB', value: 12.5 })
})
})
```
- [ ] **Step 2: Run the failing test**
Run: `rtk npm run test:frontend -- --run src/widgets/broker-dashboard/lib/dashboardIncome.test.ts`
Expected: FAIL because `dashboardIncome.ts` does not exist.
- [ ] **Step 3: Implement income helpers**
Create `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts`:
```ts
import type { BrokerMoney, BrokerOperation } from '@/shared/api'
const INCOME_TYPES = new Set([
'OPERATION_TYPE_DIVIDEND',
'OPERATION_TYPE_DIV_EXT',
'OPERATION_TYPE_COUPON',
])
export type DashboardIncomeRow = {
id: string
date: string | null
instrument: string
typeLabel: 'Дивиденд' | 'Купон'
amount: BrokerMoney
}
export function isDashboardIncomeOperation(operation: BrokerOperation): boolean {
return INCOME_TYPES.has(operation.type) && operation.payment !== null
}
function typeLabel(type: string): DashboardIncomeRow['typeLabel'] {
return type === 'OPERATION_TYPE_COUPON' ? 'Купон' : 'Дивиденд'
}
export function getDashboardIncomeRows(operations: BrokerOperation[]): DashboardIncomeRow[] {
return operations.filter(isDashboardIncomeOperation).map((operation) => ({
id: String(operation.id ?? operation.cursor ?? `${operation.type}-${operation.date}`),
date: typeof operation.date === 'string' ? operation.date : null,
instrument:
typeof operation.ticker === 'string'
? operation.ticker
: typeof operation.name === 'string'
? operation.name
: typeof operation.description === 'string'
? operation.description
: '—',
typeLabel: typeLabel(operation.type),
amount: operation.payment!,
}))
}
export function sumDashboardIncome(rows: DashboardIncomeRow[]): { currency: string; value: number } | null {
if (rows.length === 0) return null
const currency = rows[0].amount.currency
const value = rows.reduce((sum, row) => sum + row.amount.value, 0)
return { currency, value }
}
```
- [ ] **Step 4: Verify helper tests pass**
Run: `rtk npm run test:frontend -- --run src/widgets/broker-dashboard/lib/dashboardIncome.test.ts`
Expected: PASS.
### Task 2: Dashboard Formatting Helpers
**Files:**
- Create: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts`
- Create: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts`
- [ ] **Step 1: Add filter helpers**
Create `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts`:
```ts
import dayjs from 'dayjs'
export const DASHBOARD_EVENT_TYPES = ['dividend', 'coupon', 'maturity', 'offer'] as const
export const DASHBOARD_INCOME_TYPES = ['dividend', 'coupon'] as const
export type DashboardEventType = (typeof DASHBOARD_EVENT_TYPES)[number]
export type DashboardIncomeType = (typeof DASHBOARD_INCOME_TYPES)[number]
export type DashboardDatePreset = '7d' | '30d' | '90d' | '1y' | 'all'
export type DashboardFilterState<T extends string> = {
from: string
to: string
types: T[]
}
export function defaultEventsFilters(): DashboardFilterState<DashboardEventType> {
const now = dayjs()
return {
from: now.subtract(7, 'day').format('YYYY-MM-DD'),
to: now.add(7, 'day').format('YYYY-MM-DD'),
types: [...DASHBOARD_EVENT_TYPES],
}
}
export function defaultIncomeFilters(): DashboardFilterState<DashboardIncomeType> {
const now = dayjs()
return {
from: now.startOf('year').format('YYYY-MM-DD'),
to: now.format('YYYY-MM-DD'),
types: [...DASHBOARD_INCOME_TYPES],
}
}
export function applyDatePreset<T extends string>(
filters: DashboardFilterState<T>,
preset: DashboardDatePreset,
): DashboardFilterState<T> {
const now = dayjs()
if (preset === 'all') return { ...filters, from: '', to: now.format('YYYY-MM-DD') }
const amount = preset === '1y' ? 1 : Number.parseInt(preset, 10)
const unit = preset === '1y' ? 'year' : 'day'
return { ...filters, from: now.subtract(amount, unit).format('YYYY-MM-DD'), to: now.format('YYYY-MM-DD') }
}
export function validateDashboardFilters<T extends string>(filters: DashboardFilterState<T>): string {
if (filters.types.length === 0) return 'Выберите хотя бы один тип'
if (filters.from && filters.to && dayjs(filters.to).isBefore(dayjs(filters.from))) {
return 'Дата окончания не может быть раньше даты начала'
}
return ''
}
export function incomeTypesToOperationTypes(types: DashboardIncomeType[]): string {
const operationTypes = new Set<string>()
if (types.includes('dividend')) {
operationTypes.add('OPERATION_TYPE_DIVIDEND')
operationTypes.add('OPERATION_TYPE_DIV_EXT')
}
if (types.includes('coupon')) operationTypes.add('OPERATION_TYPE_COUPON')
return [...operationTypes].join(',')
}
```
**Files:**
- Create: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts`
- [ ] **Step 2: Add event and KPI presentation helpers**
Create `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts`:
```ts
import type { BrokerEventItem } from '@/shared/api'
export function dashboardValue(value: string | null | undefined): string {
return value && value.trim().length > 0 ? value : '—'
}
export function eventTypeLabel(type: BrokerEventItem['type']): string {
switch (type) {
case 'dividend':
return 'Дивиденд'
case 'coupon':
return 'Купон'
case 'maturity':
return 'Погашение'
case 'offer':
return 'Оферта'
}
}
export function eventStatusLabel(event: BrokerEventItem): string {
if (event.source === 'actual') return 'Поступило'
if (event.type === 'offer') return 'Оферта'
return 'Прогноз'
}
```
- [ ] **Step 3: Use helpers from dashboard components in later tasks**
Expected: no command yet; helpers are compiled by frontend tests in later tasks.
### Task 3: Dashboard Card Pattern
**Files:**
- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx`
- [ ] **Step 1: Create a local card component**
Create `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx`:
```tsx
import { Heading } from '@moex-vibe/design-system'
import { Box, type SxProps, type Theme } from '@mui/material'
import type { ReactNode } from 'react'
type BrokerDashboardCardProps = {
title: string
action?: ReactNode
filters?: ReactNode
children: ReactNode
sx?: SxProps<Theme>
}
export function BrokerDashboardCard({ title, action, filters, children, sx }: BrokerDashboardCardProps) {
return (
<Box
component="section"
sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: 3,
bgcolor: 'background.paper',
p: 2,
minWidth: 0,
boxShadow: '0 1px 3px rgba(15, 23, 42, 0.08)',
...sx,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2, mb: 1.5 }}>
<Heading level={2}>{title}</Heading>
{action}
</Box>
{filters && <Box sx={{ mb: 1.5 }}>{filters}</Box>}
{children}
</Box>
)
}
```
- [ ] **Step 2: Prefer local pattern over DS changes unless repeated needs emerge**
Expected: no DS files changed in this task.
### Task 4: Dashboard Hero
**Files:**
- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx`
- [ ] **Step 1: Implement hero KPI block**
Create `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx`:
```tsx
import { Metric, Text } from '@moex-vibe/design-system'
import { Box } from '@mui/material'
import type { BrokerAnalytics, BrokerPortfolio } from '@/shared/api'
import { formatBrokerMoney, formatBrokerPercent } from '@/shared/lib/formatters'
type BrokerDashboardHeroProps = {
portfolio: BrokerPortfolio
analytics: BrokerAnalytics | undefined
}
function percentValue(value: unknown): string {
return typeof value === 'number' ? formatBrokerPercent(value) : '—'
}
export function BrokerDashboardHero({ portfolio, analytics }: BrokerDashboardHeroProps) {
const accountName = portfolio.account.name || 'Брокерский счёт'
const totalReceived = analytics
? `${analytics.totalReceived.toLocaleString('ru-RU', { maximumFractionDigits: 2 })} ${analytics.currency}`
: '—'
const returnPercent = analytics?.totalReturnPercent ?? portfolio.yields.expectedPercent
return (
<Box
component="section"
aria-label="Ключевые показатели брокерского счёта"
sx={{
border: '1px solid',
borderColor: 'success.light',
borderRadius: 4,
bgcolor: 'rgba(46, 125, 50, 0.06)',
p: { xs: 2, md: 3 },
display: 'grid',
gap: 2,
gridTemplateColumns: { xs: '1fr', md: 'minmax(240px, 1fr) repeat(3, auto)' },
alignItems: 'center',
}}
>
<Box>
<Text variant="label" tone="secondary">
Инвестиционный дашборд
</Text>
<Box sx={{ fontWeight: 800, fontSize: { xs: 22, md: 28 }, lineHeight: 1.15 }}>{accountName}</Box>
</Box>
<Metric label="Стоимость портфеля" value={formatBrokerMoney(portfolio.totals.portfolio)} />
<Metric
label="Доходность"
value={percentValue(returnPercent)}
supportingText={`За день: ${formatBrokerMoney(portfolio.yields.daily)}`}
/>
<Metric label="Всего доходов" value={totalReceived} />
</Box>
)
}
```
- [ ] **Step 2: Run type-aware frontend tests later through the composition test**
Expected: no standalone command for this component.
### Task 5: Events Card
**Files:**
- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx`
- [ ] **Step 1: Implement compact events card**
The dashboard composition owns applied filters and local pagination state; the card renders filter controls and calls callbacks supplied by `BrokerDashboard`:
- draft filters: event types, `from`, `to`;
- applied filters: stored in `BrokerDashboard` and passed to `useBrokerEvents`;
- local page index over `data.items`, with page size 10;
- `Показать` applies draft filters and resets local page to 1;
- `Сбросить` restores `defaultEventsFilters()` and resets local page to 1;
- invalid filters show validation text and disable `Показать`.
Create `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx`:
```tsx
import { Chip, Text } from '@moex-vibe/design-system'
import { Box } from '@mui/material'
import { Link } from '@tanstack/react-router'
import type { BrokerEventItem, BrokerEventsData } from '@/shared/api'
import { formatBrokerCurrencyValue, formatBrokerDate } from '@/shared/lib/formatters'
import { eventStatusLabel, eventTypeLabel } from '../lib/dashboardFormatters'
import { BrokerDashboardCard } from './BrokerDashboardCard'
type BrokerDashboardEventsCardProps = {
accountId: string
data: BrokerEventsData | undefined
isLoading: boolean
isError: boolean
page: number
onPreviousPage: () => void
onNextPage: () => void
canGoBack: boolean
canGoForward: boolean
}
function eventAmount(event: BrokerEventItem): string {
const amount = event.source === 'actual' ? event.actualAmount : event.estimatedAmount
if (amount === null || amount === undefined) return '—'
const prefix = event.source === 'actual' ? '+' : '~'
return `${prefix}${formatBrokerCurrencyValue(event.currency ?? 'RUB', amount)}`
}
export function BrokerDashboardEventsCard({ accountId, data, isLoading, isError, page, onPreviousPage, onNextPage, canGoBack, canGoForward }: BrokerDashboardEventsCardProps) {
const events = data?.items ?? []
return (
<BrokerDashboardCard
title="События"
action={<Link to={`/broker/${encodeURIComponent(accountId)}/events`}>Все события</Link>}
>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 1.5 }}>
<Chip label="Дивиденды" tone="success" />
<Chip label="Купоны" tone="info" />
<Chip label="Погашения" tone="warning" />
<Chip label="Оферты" tone="neutral" />
</Box>
{isError ? (
<Text tone="negative">Не удалось загрузить события</Text>
) : isLoading ? (
<Text tone="muted">Загрузка событий…</Text>
) : events.length === 0 ? (
<Text tone="muted">В ближайшем периоде событий нет</Text>
) : (
<Box sx={{ display: 'grid', gap: 1 }}>
<Box sx={{ overflowX: 'auto' }}>
<Box component="table" sx={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<Box component="tbody">
{events.map((event) => (
<Box component="tr" key={event.id}>
<Box component="td" sx={{ py: 1, borderBottom: '1px solid', borderColor: 'divider' }}>
{formatBrokerDate(event.eventDate)}
</Box>
<Box component="td" sx={{ py: 1, borderBottom: '1px solid', borderColor: 'divider', fontWeight: 700 }}>
{event.ticker ?? event.name ?? '—'}
</Box>
<Box component="td" sx={{ py: 1, borderBottom: '1px solid', borderColor: 'divider' }}>
{eventTypeLabel(event.type)}
</Box>
<Box component="td" sx={{ py: 1, borderBottom: '1px solid', borderColor: 'divider', textAlign: 'right', fontWeight: 700 }}>
{eventAmount(event)}
</Box>
<Box component="td" sx={{ py: 1, borderBottom: '1px solid', borderColor: 'divider', textAlign: 'right' }}>
{eventStatusLabel(event)}
</Box>
</Box>
))}
</Box>
</Box>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<button type="button" onClick={onPreviousPage} disabled={!canGoBack}></button>
<span>{page}</span>
<button type="button" onClick={onNextPage} disabled={!canGoForward}></button>
</Box>
</Box>
)}
</BrokerDashboardCard>
)
}
```
- [ ] **Step 2: Verify later through dashboard composition test**
Expected: card handles loading, error, empty and paginated states without throwing. During implementation, use DS `Button` instead of raw `<button>` for pagination controls.
### Task 6: Income Card
**Files:**
- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx`
- [ ] **Step 1: Implement compact income card**
The dashboard composition owns applied filters and cursor pagination state; the card renders filter controls and calls callbacks supplied by `BrokerDashboard`:
- draft filters: income types, `from`, `to`;
- `Показать` applies draft filters and clears the cursor stack;
- `Сбросить` restores `defaultIncomeFilters()` and clears the cursor stack;
- invalid filters show validation text and disable `Показать`;
- operations query uses `limit: 10` and `operationTypes: incomeTypesToOperationTypes(applied.types)`;
- next/previous controls use the existing cursor stack pattern from `useCursorPagination`.
Create `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx`:
```tsx
import { Chip, Text } from '@moex-vibe/design-system'
import { Box } from '@mui/material'
import { Link } from '@tanstack/react-router'
import type { BrokerOperationsPage } from '@/shared/api'
import { formatBrokerCurrencyValue, formatBrokerDate } from '@/shared/lib/formatters'
import { getDashboardIncomeRows, sumDashboardIncome } from '../lib/dashboardIncome'
import { BrokerDashboardCard } from './BrokerDashboardCard'
type BrokerDashboardIncomeCardProps = {
accountId: string
page: BrokerOperationsPage | undefined
isLoading: boolean
isError: boolean
pageNumber: number
canGoBack: boolean
canGoForward: boolean
onPreviousPage: () => void
onNextPage: () => void
}
export function BrokerDashboardIncomeCard({ accountId, page, isLoading, isError, pageNumber, canGoBack, canGoForward, onPreviousPage, onNextPage }: BrokerDashboardIncomeCardProps) {
const rows = getDashboardIncomeRows(page?.items ?? []).slice(0, 10)
const total = sumDashboardIncome(rows)
return (
<BrokerDashboardCard
title="Доходы"
action={<Link to={`/broker/${encodeURIComponent(accountId)}/operations`}>Все операции</Link>}
>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 1.5 }}>
<Chip label="Дивиденды" tone="success" />
<Chip label="Купоны" tone="info" />
</Box>
{isError ? (
<Text tone="negative">Не удалось загрузить доходные операции</Text>
) : isLoading ? (
<Text tone="muted">Загрузка доходов…</Text>
) : rows.length === 0 ? (
<Text tone="muted">Дивидендов и купонов в последних операциях нет</Text>
) : (
<Box sx={{ display: 'grid', gap: 1 }}>
<Box sx={{ overflowX: 'auto' }}>
<Box component="table" sx={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<Box component="tbody">
{rows.map((row) => (
<Box component="tr" key={row.id}>
<Box component="td" sx={{ py: 1, borderBottom: '1px solid', borderColor: 'divider' }}>
{formatBrokerDate(row.date)}
</Box>
<Box component="td" sx={{ py: 1, borderBottom: '1px solid', borderColor: 'divider', fontWeight: 700 }}>
{row.instrument}
</Box>
<Box component="td" sx={{ py: 1, borderBottom: '1px solid', borderColor: 'divider' }}>
{row.typeLabel}
</Box>
<Box component="td" sx={{ py: 1, borderBottom: '1px solid', borderColor: 'divider', textAlign: 'right', fontWeight: 700, color: 'success.main' }}>
+{formatBrokerCurrencyValue(row.amount.currency, row.amount.value)}
</Box>
</Box>
))}
</Box>
</Box>
</Box>
<Text variant="caption" tone="secondary">
Показано: {rows.length} · Итого:{' '}
{total ? formatBrokerCurrencyValue(total.currency, total.value) : '—'}
</Text>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<button type="button" onClick={onPreviousPage} disabled={!canGoBack}></button>
<span>{pageNumber}</span>
<button type="button" onClick={onNextPage} disabled={!canGoForward}></button>
</Box>
</Box>
)}
</BrokerDashboardCard>
)
}
```
- [ ] **Step 2: Confirm operations endpoint suffices**
Run the app locally and inspect `/broker/:accountId`; if recent operations do not contain enough income rows, document this limitation in the PR notes rather than adding backend API in this feature.
- [ ] **Step 3: Replace raw pagination buttons with DS Button during implementation**
Expected: final implementation uses `Button` from `@moex-vibe/design-system` for pagination and apply/reset actions.
### Task 7: Analytics and Allocation Cards
**Files:**
- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx`
- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx`
- [ ] **Step 1: Implement analytics card**
Create `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx`:
```tsx
import { Metric, Text } from '@moex-vibe/design-system'
import { Box } from '@mui/material'
import type { BrokerAnalytics } from '@/shared/api'
import { BrokerDashboardCard } from './BrokerDashboardCard'
function amount(value: number, currency: string): string {
return `${value.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}`
}
export function BrokerDashboardAnalyticsCard({ data, isLoading, isError }: { data: BrokerAnalytics | undefined; isLoading: boolean; isError: boolean }) {
return (
<BrokerDashboardCard title="Аналитика доходности">
{isError ? (
<Text tone="negative">Не удалось загрузить аналитику</Text>
) : isLoading ? (
<Text tone="muted">Загрузка аналитики…</Text>
) : !data ? (
<Text tone="muted">Нет данных для аналитики</Text>
) : (
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' }, gap: 2 }}>
<Metric label="Пополнения" value={amount(data.totalDeposits, data.currency)} />
<Metric label="Выводы" value={`${amount(data.totalWithdrawn, data.currency)}`} />
<Metric label="Нетто" value={amount(data.netInvested, data.currency)} />
<Metric label="Дивиденды" value={amount(data.totalDividends, data.currency)} />
<Metric label="Купоны" value={amount(data.totalCoupons, data.currency)} />
<Metric label="Всего получено" value={amount(data.totalReceived, data.currency)} />
</Box>
)}
</BrokerDashboardCard>
)
}
```
- [ ] **Step 2: Implement allocation card**
Create `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx`:
```tsx
import { Text } from '@moex-vibe/design-system'
import { Box } from '@mui/material'
import type { BrokerPortfolio } from '@/shared/api'
import { formatBrokerMoney } from '@/shared/lib/formatters'
import { BrokerAllocationChart } from '@/widgets/broker-allocation-chart'
import { BrokerDashboardCard } from './BrokerDashboardCard'
export function BrokerDashboardAllocationCard({ portfolio }: { portfolio: BrokerPortfolio }) {
return (
<BrokerDashboardCard
title="Аллокация"
action={
<Text variant="numeric">
{formatBrokerMoney(portfolio.totals.portfolio)}
</Text>
}
>
<Box sx={{ minHeight: 220, display: 'flex', alignItems: 'center' }}>
<BrokerAllocationChart portfolio={portfolio} />
</Box>
</BrokerDashboardCard>
)
}
```
- [ ] **Step 3: Run frontend tests after correcting imports/types**
Run: `rtk npm run test:frontend -- --run src/widgets/broker-dashboard`
Expected: PASS after dashboard tests are added in Task 9.
### Task 8: Dashboard Composition
**Files:**
- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx`
- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardSkeleton.tsx`
- Create: `apps/frontend/src/widgets/broker-dashboard/index.ts`
- Modify: `apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx`
- [ ] **Step 1: Implement dashboard skeleton**
Create `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardSkeleton.tsx`:
```tsx
import { Skeleton } from '@moex-vibe/design-system'
import { Box } from '@mui/material'
export function BrokerDashboardSkeleton() {
return (
<Box sx={{ display: 'grid', gap: 3 }} aria-label="Загрузка брокерского дашборда">
<Skeleton height={120} shape="rounded" />
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', xl: '1fr 1fr' }, gap: 3 }}>
<Skeleton height={300} shape="rounded" />
<Skeleton height={300} shape="rounded" />
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', lg: '1.2fr .8fr' }, gap: 3 }}>
<Skeleton height={260} shape="rounded" />
<Skeleton height={260} shape="rounded" />
</Box>
</Box>
)
}
```
- [ ] **Step 2: Implement dashboard composition**
Create `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx`:
```tsx
import { Box } from '@mui/material'
import { useState } from 'react'
import { useBrokerAnalytics } from '@/entities/broker-analytics'
import { useBrokerEvents } from '@/entities/broker-event'
import { useBrokerOperations } from '@/entities/broker-operation'
import type { BrokerPortfolio } from '@/shared/api'
import { useCursorPagination } from '@/shared/lib/useCursorPagination'
import {
defaultEventsFilters,
defaultIncomeFilters,
incomeTypesToOperationTypes,
} from '../lib/dashboardFilters'
import { BrokerDashboardAllocationCard } from './BrokerDashboardAllocationCard'
import { BrokerDashboardAnalyticsCard } from './BrokerDashboardAnalyticsCard'
import { BrokerDashboardEventsCard } from './BrokerDashboardEventsCard'
import { BrokerDashboardHero } from './BrokerDashboardHero'
import { BrokerDashboardIncomeCard } from './BrokerDashboardIncomeCard'
export function BrokerDashboard({ accountId, portfolio }: { accountId: string; portfolio: BrokerPortfolio }) {
const [eventFilters, setEventFilters] = useState(defaultEventsFilters)
const [eventPage, setEventPage] = useState(1)
const [incomeFilters, setIncomeFilters] = useState(defaultIncomeFilters)
const incomePagination = useCursorPagination()
const analytics = useBrokerAnalytics(accountId)
const events = useBrokerEvents(accountId, {
from: eventFilters.from,
to: eventFilters.to,
types: eventFilters.types.join(','),
})
const eventItems = events.data?.items ?? []
const eventPageSize = 10
const eventPageItems = eventItems.slice((eventPage - 1) * eventPageSize, eventPage * eventPageSize)
const operations = useBrokerOperations(accountId, {
from: incomeFilters.from,
to: incomeFilters.to,
operationTypes: incomeTypesToOperationTypes(incomeFilters.types),
cursor: incomePagination.cursor,
limit: 10,
})
return (
<Box sx={{ display: 'grid', gap: 3 }}>
<BrokerDashboardHero portfolio={portfolio} analytics={analytics.data} />
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', xl: '1fr 1fr' }, gap: 3 }}>
<BrokerDashboardEventsCard
accountId={accountId}
data={events.data ? { ...events.data, items: eventPageItems } : undefined}
isLoading={events.isLoading}
isError={events.isError}
page={eventPage}
canGoBack={eventPage > 1}
canGoForward={eventPage * eventPageSize < eventItems.length}
onPreviousPage={() => setEventPage((page) => Math.max(1, page - 1))}
onNextPage={() => setEventPage((page) => page + 1)}
/>
<BrokerDashboardIncomeCard
accountId={accountId}
page={operations.data}
isLoading={operations.isLoading}
isError={operations.isError}
pageNumber={incomePagination.pageNumber}
canGoBack={incomePagination.pageNumber > 1}
canGoForward={operations.data?.hasNext ?? false}
onPreviousPage={incomePagination.handlePrevious}
onNextPage={() => incomePagination.handleNext(operations.data?.nextCursor)}
/>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', lg: '1.2fr .8fr' }, gap: 3 }}>
<BrokerDashboardAnalyticsCard data={analytics.data} isLoading={analytics.isLoading} isError={analytics.isError} />
<BrokerDashboardAllocationCard portfolio={portfolio} />
</Box>
</Box>
)
}
```
- [ ] **Step 3: Export dashboard public API**
Create `apps/frontend/src/widgets/broker-dashboard/index.ts`:
```ts
export { BrokerDashboard } from './ui/BrokerDashboard'
export { BrokerDashboardSkeleton } from './ui/BrokerDashboardSkeleton'
```
- [ ] **Step 4: Replace overview page composition**
Modify `apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx` to:
```tsx
import { Text } from '@moex-vibe/design-system'
import { useBrokerAccountContext } from '@/widgets/broker-account-layout'
import { BrokerDashboard, BrokerDashboardSkeleton } from '@/widgets/broker-dashboard'
export function BrokerAccountOverviewPage() {
const { accountId, portfolio } = useBrokerAccountContext()
if (portfolio.isLoading) return <BrokerDashboardSkeleton />
if (portfolio.error || !portfolio.data) {
return (
<Text component="p" role="alert" tone="negative">
Не удалось загрузить сводку счёта
</Text>
)
}
return <BrokerDashboard accountId={accountId} portfolio={portfolio.data} />
}
```
### Task 9: Dashboard Tests
**Files:**
- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx`
- [ ] **Step 1: Mock entity hooks and write composition test**
Create `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx`:
```tsx
import { render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import type { BrokerPortfolio } from '@/shared/api'
import { BrokerDashboard } from './BrokerDashboard'
vi.mock('@/entities/broker-analytics', () => ({
useBrokerAnalytics: () => ({
data: {
totalDeposits: 1000,
totalWithdrawn: 100,
netInvested: 900,
totalDividends: 25,
totalCoupons: 15,
totalReceived: 40,
totalReturnPercent: 4.44,
currency: 'RUB',
},
isLoading: false,
isError: false,
}),
}))
vi.mock('@/entities/broker-event', () => ({
useBrokerEvents: () => ({
data: { items: [], summary: {}, asOf: '2026-06-26T00:00:00.000Z' },
isLoading: false,
isError: false,
}),
}))
vi.mock('@/entities/broker-operation', () => ({
useBrokerOperations: () => ({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-26T00:00:00.000Z' },
isLoading: false,
isError: false,
}),
}))
vi.mock('@/widgets/broker-allocation-chart', () => ({
BrokerAllocationChart: () => <div>allocation chart</div>,
}))
const portfolio: BrokerPortfolio = {
account: { id: 'acc-1', name: 'Основной счёт', type: 'brokerage', status: 'open', openedAt: null, accessLevel: null },
positionCounts: { shares: 2, bonds: 1, etf: 0, other: 0 },
totals: {
shares: null,
bonds: null,
etf: null,
currencies: null,
futures: null,
options: null,
structuredProducts: null,
dfa: null,
portfolio: { currency: 'RUB', units: '1000', nano: 0, value: 1000 },
},
yields: { expectedPercent: null, daily: null, dailyPercent: null },
cash: [],
blockedCash: [],
asOf: '2026-06-26T00:00:00.000Z',
}
describe('BrokerDashboard', () => {
it('renders the dashboard sections', () => {
render(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
expect(screen.getByText('Основной счёт')).toBeInTheDocument()
expect(screen.getByText('События')).toBeInTheDocument()
expect(screen.getByText('Доходы')).toBeInTheDocument()
expect(screen.getByText('Аналитика доходности')).toBeInTheDocument()
expect(screen.getByText('Аллокация')).toBeInTheDocument()
expect(screen.getByText('allocation chart')).toBeInTheDocument()
})
})
```
- [ ] **Step 2: Run dashboard tests**
Run: `rtk npm run test:frontend -- --run src/widgets/broker-dashboard`
Expected: PASS.
- [ ] **Step 3: Run all frontend tests**
Run: `rtk npm run test:frontend`
Expected: PASS.
### Task 10: Visual and Responsive Verification
**Files:**
- Modify only if verification reveals spacing or mobile readability issues in files created above.
- [ ] **Step 1: Run frontend dev server**
Run: `rtk npm run dev:frontend`
Expected: Vite serves the app without compile errors.
- [ ] **Step 2: Inspect desktop dashboard**
Open `/broker/2084014113` and verify: hero appears first; events and income are side by side; analytics and allocation are below; detailed nav links remain available.
- [ ] **Step 3: Inspect mobile dashboard**
Resize to `390x844` and verify: the dashboard is one column; tables scroll horizontally only when necessary; navigation remains usable.
### Task 11: Final Quality Gate
**Files:**
- Modify: `docs/features/broker-dashboard-redesign/tasks.md`
- [ ] **Step 1: Mark completed task checkboxes in docs**
Update `docs/features/broker-dashboard-redesign/tasks.md` as tasks are completed.
- [ ] **Step 2: Run affected package checks**
Run: `rtk npm run test:frontend && rtk npm run test:design-system && rtk npm run lint -w apps/frontend && rtk npm run build:frontend`
Expected: all commands pass.
- [ ] **Step 3: Update graphify after code changes**
Run: `graphify update .`
Expected: graph update completes or reports no meaningful changes.