codex/broker-dashboard-redesign #49
988
docs/features/broker-dashboard-redesign/plan.md
Normal file
988
docs/features/broker-dashboard-redesign/plan.md
Normal file
@ -0,0 +1,988 @@
|
||||
# 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.
|
||||
203
docs/features/broker-dashboard-redesign/spec.md
Normal file
203
docs/features/broker-dashboard-redesign/spec.md
Normal file
@ -0,0 +1,203 @@
|
||||
# Редизайн overview брокерского счёта в инвестиционный дашборд
|
||||
|
||||
Дата: 2026-06-26
|
||||
Статус: согласовано к планированию
|
||||
Эпик: [Портфель брокера](../../epics/BrokerPortfolio.md)
|
||||
|
||||
## Контекст
|
||||
|
||||
Текущий маршрут `/broker/:accountId` показывает корректный overview выбранного брокерского счёта, но
|
||||
визуально остаётся вертикальным набором отдельных блоков: сводка, аллокация, карточки активов,
|
||||
ближайшие события и последние операции. Пользователь подготовил прототип `temp.html`, где тот же домен
|
||||
представлен как более плотный инвестиционный дашборд: hero KPI, события и доходы рядом, аналитика и
|
||||
аллокация в нижнем ряду.
|
||||
|
||||
После обсуждения выбран вариант A: страница `/broker/:accountId` должна стать единым дашбордом,
|
||||
используя структуру и плотность прототипа, но сохраняя текущую светлую тему и компоненты
|
||||
`@moex-vibe/design-system`. Тёмная тема из прототипа не переносится в первую версию.
|
||||
|
||||
## Цель
|
||||
|
||||
Сделать overview брокерского счёта быстрым обзором состояния портфеля, будущих/прошедших событий,
|
||||
полученных доходов, аналитики доходности и аллокации без перехода по вкладкам.
|
||||
|
||||
## Пользовательский результат
|
||||
|
||||
Пользователь может на `/broker/:accountId`:
|
||||
|
||||
- сразу увидеть стоимость портфеля, доходность и сумму полученных доходов;
|
||||
- увидеть ближайшие события по счёту в компактной таблице;
|
||||
- увидеть последние доходные операции по дивидендам и купонам и итог по ним;
|
||||
- оценить вложения, полученные выплаты и доходность по существующей аналитике;
|
||||
- увидеть структуру портфеля через donut-диаграмму и легенду;
|
||||
- перейти в существующие подробные вкладки `Акции`, `Облигации`, `Операции`, `События` и `Аналитика`
|
||||
для drill-down сценариев.
|
||||
|
||||
## Область изменений
|
||||
|
||||
Фича относится только к frontend маршруту `/broker/:accountId` и связанным frontend-компонентам
|
||||
брокерского overview.
|
||||
|
||||
В область входят:
|
||||
|
||||
- новая dashboard-композиция для `BrokerAccountOverviewPage`;
|
||||
- переиспользуемые frontend-паттерны для dashboard card, KPI, compact table и filter chips;
|
||||
- адаптация существующих брокерских widgets под новую компоновку, если это нужно для читаемости;
|
||||
- точечные доработки `@moex-vibe/design-system`, если существующие компоненты блокируют корректное
|
||||
использование текущей светлой DS-темы;
|
||||
- тесты новой композиции и ключевых представлений.
|
||||
|
||||
## Требования
|
||||
|
||||
### 1. Общая композиция
|
||||
|
||||
- `/broker/:accountId` остаётся overview выбранного брокерского счёта.
|
||||
- Overview визуально становится dashboard-страницей, а не вертикальным списком независимых секций.
|
||||
- На desktop первый экран содержит hero KPI и два основных информационных блока рядом: `События` и
|
||||
`Доходы`.
|
||||
- Ниже отображаются `Аналитика доходности` и `Аллокация`.
|
||||
- Существующая навигация счёта сохраняет ссылки на `Обзор`, `Акции`, `Облигации`, `Операции`,
|
||||
`События`, `Аналитика`.
|
||||
- На мобильном viewport дашборд перестраивается в одну колонку с порядком: hero KPI, события, доходы,
|
||||
аналитика, аллокация.
|
||||
|
||||
### 2. Визуальный стиль
|
||||
|
||||
- Используется текущая светлая DS-тема MoexVibe.
|
||||
- Не добавляется dark mode и не переносится тёмная палитра `temp.html`.
|
||||
- Визуальная плотность, структура карточек, KPI-иерархия и компактность таблиц ориентируются на
|
||||
`temp.html`.
|
||||
- Дизайн использует компоненты и токены `@moex-vibe/design-system` там, где они применимы.
|
||||
- Если DS-компонент слишком ограничен, допускается точечно расширить DS API или создать локальный
|
||||
dashboard-pattern в frontend, но не добавлять доменную брокерскую логику в DS.
|
||||
|
||||
### 3. Hero KPI
|
||||
|
||||
Hero показывает:
|
||||
|
||||
- название счёта или fallback `Брокерский счёт`;
|
||||
- стоимость портфеля из `portfolio.totals.portfolio`;
|
||||
- доходность из существующих данных: приоритет `broker analytics.totalReturnPercent`, если загружена,
|
||||
иначе `portfolio.yields.expectedPercent`, если доступна;
|
||||
- дневное изменение из `portfolio.yields.daily` и `portfolio.yields.dailyPercent`, если доступно;
|
||||
- всего полученных доходов из `broker analytics.totalReceived`, если аналитика загружена;
|
||||
- спокойный fallback `—` для недоступных значений.
|
||||
|
||||
### 4. Блок `События`
|
||||
|
||||
- Блок использует существующий источник `useBrokerEvents(accountId, query)`.
|
||||
- По умолчанию применяется период `сегодня - 7 дней` / `сегодня + 7 дней` и типы
|
||||
`dividend,coupon,maturity,offer`, как в существующей вкладке событий.
|
||||
- Блок содержит фильтр типов событий: `Дивиденды`, `Купоны`, `Погашения`, `Оферты`.
|
||||
- Пользователь может выбрать несколько типов событий.
|
||||
- Если пользователь снимает все типы событий, запрос не выполняется, а блок показывает
|
||||
валидационное сообщение.
|
||||
- Блок содержит фильтр периода `from` / `to`.
|
||||
- Изменение черновых фильтров не запускает запрос до нажатия `Показать`.
|
||||
- Блок содержит быстрые пресеты периода `7д`, `30д`, `90д`, `1г`, `Всё` и действие `Сбросить`.
|
||||
- Применённые фильтры dashboard не обязаны синхронизироваться с URL; URL-синхронизация остаётся
|
||||
обязанностью подробной вкладки `События`.
|
||||
- В dashboard отображается не более 10 событий на странице.
|
||||
- Если в выбранном диапазоне больше 10 событий, блок показывает локальную пагинацию по страницам.
|
||||
- Смена применённых фильтров возвращает пагинацию блока на первую страницу.
|
||||
- Таблица показывает дату, инструмент, тип, сумму и статус.
|
||||
- Сумма для `actual` берётся из `actualAmount`, сумма для `forecast` берётся из `estimatedAmount`.
|
||||
- Фактические поступления визуально отмечаются как `Поступило`.
|
||||
- Прогнозные суммы помечаются как оценочные.
|
||||
- Блок содержит ссылку на подробную вкладку `/broker/:accountId/events`.
|
||||
- Ошибка загрузки событий не ломает остальной dashboard.
|
||||
|
||||
### 5. Блок `Доходы`
|
||||
|
||||
- Блок показывает последние доходные операции по дивидендам и купонам из существующего endpoint
|
||||
операций.
|
||||
- В первую версию входят операции с типами дивидендов и купонов, которые уже используются в backend
|
||||
analytics: `OPERATION_TYPE_DIVIDEND`, `OPERATION_TYPE_DIV_EXT`, `OPERATION_TYPE_COUPON`.
|
||||
- Блок содержит фильтр типов доходов: `Дивиденды`, `Купоны`.
|
||||
- Пользователь может выбрать один или оба типа доходов.
|
||||
- Если пользователь снимает все типы доходов, запрос не выполняется, а блок показывает
|
||||
валидационное сообщение.
|
||||
- Блок содержит фильтр периода `from` / `to`.
|
||||
- По умолчанию используется период с начала текущего календарного года до текущей даты, как в разделе
|
||||
операций.
|
||||
- Изменение черновых фильтров не запускает запрос до нажатия `Показать`.
|
||||
- Блок содержит быстрые пресеты периода `7д`, `30д`, `90д`, `1г`, `Всё` и действие `Сбросить`.
|
||||
- Применённые фильтры dashboard не обязаны синхронизироваться с URL; URL-синхронизация остаётся
|
||||
обязанностью подробных разделов.
|
||||
- Для блока используется cursor-пагинация existing operations endpoint с размером страницы 10.
|
||||
- Смена применённых фильтров сбрасывает cursor-пагинацию блока на первую страницу.
|
||||
- Таблица показывает дату, инструмент, тип и сумму.
|
||||
- Блок показывает итог по отображаемым доходным операциям.
|
||||
- Блок содержит ссылку на подробную вкладку `/broker/:accountId/operations`.
|
||||
- Если текущий endpoint операций не позволяет корректно получить доходные операции без изменения
|
||||
backend-контракта, первая реализация должна явно зафиксировать это в `plan.md` перед изменением API.
|
||||
|
||||
### 6. Блок `Аналитика доходности`
|
||||
|
||||
- Блок использует существующий endpoint `/api/v1/broker/accounts/:accountId/analytics`.
|
||||
- Отображаются: пополнения, выводы, нетто вложено, дивиденды, купоны, всего получено, доходность.
|
||||
- При отсутствии analytics data блок показывает спокойное пустое состояние.
|
||||
- Ошибка analytics не ломает остальные блоки.
|
||||
|
||||
### 7. Блок `Аллокация`
|
||||
|
||||
- Используется существующий расчёт `buildBrokerAllocation` и текущая `BrokerAllocationChart` либо её
|
||||
dashboard-адаптация.
|
||||
- Блок показывает итоговую стоимость портфеля и ненулевые секторы с названием, суммой и процентом.
|
||||
- Отрицательные значения отображаются текстом, а не сектором диаграммы.
|
||||
- Информация остаётся понятной без различения цветов.
|
||||
|
||||
### 8. Загрузка, ошибки и пустые состояния
|
||||
|
||||
- Первичная загрузка portfolio показывает dashboard skeleton соответствующей формы.
|
||||
- Ошибка portfolio показывает ошибку overview, потому что без portfolio dashboard не имеет основного
|
||||
контекста.
|
||||
- Ошибка событий, доходов или analytics отображается внутри соответствующей карточки.
|
||||
- Пустые события, пустые доходы и пустая analytics имеют отдельные понятные сообщения.
|
||||
- Недоступные отдельные значения отображаются как `—`, не подменяются нулём.
|
||||
|
||||
## Ограничения
|
||||
|
||||
- Backend остаётся единственным клиентом T-Bank и MOEX.
|
||||
- В первой версии не добавляется график истории стоимости портфеля.
|
||||
- В первой версии не добавляется backend storage/API для снапшотов стоимости портфеля.
|
||||
- Тёмная тема и переключатель темы не входят в область фичи.
|
||||
- Не меняются правила расчёта доходности, событий, операций и аллокации.
|
||||
- Не удаляются существующие detailed вкладки счёта.
|
||||
- Не изменяется URL-структура `/broker/:accountId/*`.
|
||||
|
||||
## Backlog
|
||||
|
||||
Идея `Broker portfolio value history` вынесена в `docs/inbox.md` и `docs/roadmap.md`: хранить снапшоты
|
||||
стоимости брокерского счёта и позже заменить отсутствие графика полноценным блоком `Стоимость портфеля`.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `/broker/:accountId` показывает dashboard-композицию: hero KPI, `События`, `Доходы`,
|
||||
`Аналитика доходности`, `Аллокация`.
|
||||
- На desktop блоки `События` и `Доходы` расположены рядом.
|
||||
- На мобильном viewport dashboard читаемо перестраивается в одну колонку.
|
||||
- Hero показывает стоимость портфеля, доходность или fallback, дневное изменение или fallback, всего
|
||||
доходов или fallback.
|
||||
- Блок `События` использует существующие events data и показывает дату, инструмент, тип, сумму и статус.
|
||||
- Блок `События` поддерживает multi-select фильтр типов, фильтр периода, быстрые пресеты, сброс и
|
||||
локальную пагинацию по 10 событий.
|
||||
- Блок `Доходы` показывает доходные операции дивидендов и купонов и итог по отображаемым строкам.
|
||||
- Блок `Доходы` поддерживает multi-select фильтр типов, фильтр периода, быстрые пресеты, сброс и
|
||||
cursor-пагинацию по 10 операций.
|
||||
- Блок `Аналитика доходности` показывает данные существующего analytics endpoint.
|
||||
- Блок `Аллокация` показывает donut/легенду существующей структуры портфеля.
|
||||
- Ошибка одного вторичного блока не скрывает остальные блоки dashboard.
|
||||
- Существующие detailed вкладки остаются доступны из навигации счёта.
|
||||
- Первая версия не содержит график истории стоимости портфеля и не добавляет API для него.
|
||||
- Дизайн использует светлую DS-тему, а не тёмную тему из `temp.html`.
|
||||
|
||||
## Вне области фичи
|
||||
|
||||
- график стоимости портфеля по датам;
|
||||
- новые исторические снапшоты стоимости;
|
||||
- dark mode;
|
||||
- изменение backend-расчётов доходности;
|
||||
- налоговая аналитика;
|
||||
- экспорт dashboard;
|
||||
- объединение нескольких брокерских счетов в один dashboard.
|
||||
47
docs/features/broker-dashboard-redesign/tasks.md
Normal file
47
docs/features/broker-dashboard-redesign/tasks.md
Normal file
@ -0,0 +1,47 @@
|
||||
# Редизайн overview брокерского счёта в инвестиционный дашборд — задачи
|
||||
|
||||
Дата: 2026-06-26
|
||||
Статус: готово к реализации
|
||||
|
||||
## Документация и pre-flight
|
||||
|
||||
- [x] Выбрать scope: `/broker/:accountId` становится единым дашбордом.
|
||||
- [x] Выбрать визуальный стиль: структура `temp.html`, текущая светлая DS-тема.
|
||||
- [x] Исключить график истории стоимости из первой версии.
|
||||
- [x] Добавить backlog-задачу на историю стоимости брокерского портфеля.
|
||||
- [x] Создать ветку `codex/broker-dashboard-redesign`.
|
||||
- [x] Запустить baseline: `rtk npm run test:backend && rtk npm run test:frontend && rtk npm run test:design-system`.
|
||||
- [x] Написать `spec.md`.
|
||||
- [x] Написать `plan.md`.
|
||||
|
||||
## Реализация
|
||||
|
||||
- [ ] Добавить pure helpers для фильтрации и суммирования доходных операций dashboard.
|
||||
- [ ] Добавить helpers для фильтров дат, пресетов, валидации и mapping income types → operationTypes.
|
||||
- [ ] Добавить dashboard presentation helpers для fallback, event labels и statuses.
|
||||
- [ ] Добавить локальный `BrokerDashboardCard` pattern или точечно расширить DS, если локального pattern недостаточно.
|
||||
- [ ] Добавить `BrokerDashboardHero` с KPI по portfolio и analytics.
|
||||
- [ ] Добавить `BrokerDashboardEventsCard` на основе `useBrokerEvents`.
|
||||
- [ ] Добавить `BrokerDashboardIncomeCard` на основе `useBrokerOperations`.
|
||||
- [ ] Добавить фильтры типов, фильтры периода, пресеты, reset/apply actions для `События`.
|
||||
- [ ] Добавить локальную пагинацию по 10 событий в `События`.
|
||||
- [ ] Добавить фильтры типов, фильтры периода, пресеты, reset/apply actions для `Доходы`.
|
||||
- [ ] Добавить cursor-пагинацию по 10 операций в `Доходы`.
|
||||
- [ ] Добавить `BrokerDashboardAnalyticsCard` на основе `useBrokerAnalytics`.
|
||||
- [ ] Добавить `BrokerDashboardAllocationCard` на основе существующей аллокации.
|
||||
- [ ] Добавить `BrokerDashboardSkeleton`.
|
||||
- [ ] Добавить `BrokerDashboard` как top-level composition widget.
|
||||
- [ ] Заменить текущий vertical overview в `BrokerAccountOverviewPage` на `BrokerDashboard`.
|
||||
- [ ] Добавить unit/component tests для helpers и dashboard composition.
|
||||
- [ ] Проверить desktop layout `/broker/2084014113`.
|
||||
- [ ] Проверить mobile layout `/broker/2084014113`.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- [ ] `rtk npm run test:frontend` проходит.
|
||||
- [ ] `rtk npm run test:design-system` проходит, если DS изменялась.
|
||||
- [ ] `rtk npm run lint -w apps/frontend` проходит.
|
||||
- [ ] `rtk npm run build:frontend` проходит.
|
||||
- [ ] Dashboard соответствует acceptance criteria из `spec.md`.
|
||||
- [ ] Существующие вкладки `Акции`, `Облигации`, `Операции`, `События`, `Аналитика` остаются доступны.
|
||||
- [ ] `graphify update .` выполнен после code changes.
|
||||
@ -166,6 +166,17 @@ cash flow, бюджеты, аналитика, прогнозы и автома
|
||||
|
||||
## Frontend-платформа
|
||||
|
||||
### Добавить историю стоимости брокерского портфеля
|
||||
|
||||
- Сохранять снапшоты полной стоимости брокерского счёта, чтобы строить график динамики портфеля по
|
||||
датам.
|
||||
- Отдельно спроектировать backend storage/API, периодичность обновления, валюту расчёта и правила для
|
||||
пропущенных дней.
|
||||
- На дашборде брокерского счёта заменить временный отказ от графика на полноценный блок `Стоимость
|
||||
портфеля`, когда данные истории будут доступны.
|
||||
- Не реализовывать в первой версии редизайна `/broker/:accountId`: текущая задача использует только
|
||||
уже доступные данные портфеля, событий, операций и аналитики.
|
||||
|
||||
### Перейти к Feature-Sliced Design
|
||||
|
||||
- Постепенно привести frontend к FSD-архитектуре с явными границами между `app`, `pages`, `widgets`,
|
||||
|
||||
@ -130,4 +130,6 @@ Roadmap отражает порядок продуктовой работы, н
|
||||
явный session contract для нескольких клиентских поверхностей
|
||||
- [ ] Contract, type-safety, and frontend delivery hardening (P2/P3) — устранение остаточного `as any`,
|
||||
укрепление API/query boundaries, quality/performance gates и lazy-loading budgets
|
||||
- [ ] Broker portfolio value history (P2/P3) — хранить снапшоты стоимости брокерского счёта и показать
|
||||
график динамики портфеля на дашборде
|
||||
- [x] Broker-events — UX доработки и смешанный календарь.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user