38 KiB
Raw Blame History

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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.