Compare commits

...

19 Commits

Author SHA1 Message Date
fee179d8e9 fix(frontend): align broker dashboard HTML parity
All checks were successful
CI / ci (pull_request) Successful in 15m41s
CI / ci (push) Successful in 13m50s
2026-06-27 15:23:55 +03:00
58b8075583 refactor(frontend): deduplicate analytics metric cells and restore text states 2026-06-27 13:52:37 +03:00
d7b65e256f docs: mark HTML parity analytics task and DoD verified
- Flip BrokerDashboardAnalyticsCard + analytics component tests
  checkboxes for the parity iteration.
- Mark verified DoD items (dashboard tests 49/49, full frontend tests
  175/175, lint, build).
- Add code-to-spec mapping table linking the analytics card to the
  HTML reference and spec §6 for the next agent / reviewer.
- Be explicit that live visual + mobile-overflow checks still need
  manual verification with a running backend.
2026-06-27 13:45:48 +03:00
b12c2741cd feat(frontend): align analytics card with HTML parity
- Render RUB values via formatDashboardCurrency so RUB shows as ₽
  instead of 'RUB', matching the hero, events and income cards.
- Apply semantic color tones per spec §6:
  * Пополнения/Дивиденды/Купоны/Всего получено → positive when > 0,
    neutral when 0.
  * Выводы → prefix value with '−' and render with negative tone
    whenever the underlying amount is positive.
  * Нетто → sign-based tone.
- Add data-testid and data-tone attributes on each analytics value so
  tests and downstream styling can address each metric.
- Update the analytics mock in BrokerDashboard.test.tsx to cover
  positive, negative (net invested + withdrawals) and zero values, and
  add two new tests asserting ₽ rendering and the tone data attributes.
2026-06-27 13:44:31 +03:00
a1377fa5c2 feat(frontend): allow aria-label on BrokerDashboardCard
Support setting an accessible name on the dashboard card section so
consumers can query sections via getByLabelText in tests and assistive
tech, matching the hero pattern in BrokerDashboardHero.
2026-06-27 13:44:25 +03:00
27fb6e77be refactor(frontend): polish dashboard tables per code review
- Drop unused `letterSpacing: 0.2` from TH_SX in both table cards
  (HTML mockup has no letter-spacing on headers)
- Move `eventStatusTone` from EventsCard to dashboardVisual.ts
  for symmetry with `eventTypeTone` (single source of tone semantics)
- Drop unused `columnWidths` prop and `widthsFor` override branch
  in BrokerDashboardTableSkeleton (no caller passes it)
- Drop redundant `role="presentation"` on skeleton wrapper
  (rows already carry `aria-hidden="true"`)
- Drop unused `TypeTone` import from EventsCard after moving
  eventStatusTone out
2026-06-27 13:39:23 +03:00
94f8bb876d fix(frontend): correct events amount sign for actual-negative rows
Spec review of Task 9 caught that the events amount prefix used '+'
unconditionally for any actual source, producing '+-87,00 ₽' on negative
actual amounts. Mirror the income card sign handling: '+' for positive,
Unicode '−' (U+2212) for negative, '~' for forecast (regardless of sign).

Extract a small eventAmountDisplay helper and tighten the corresponding
test assertion to require exact equality instead of substring match, so
this regression class is caught next time.
2026-06-27 13:35:48 +03:00
3bad694dce feat(frontend): align dashboard tables with HTML parity
- Add thead with semantic column headers to events and income tables
- Render instruments as main (ticker/ISIN) + subtitle (name) via instrumentDisplay
- Type column uses compact Chip with eventTypeTone/incomeTypeTone; DIV_EXT
  shows distinct label 'Дивиденд (внешний)'
- Amount column applies moneyTone (positive=green, negative=red, planned=neutral)
  via moneyToneToColor; sign prefixes (+/-/~) preserved per spec
- Status column: 'Поступило' (success) vs 'Ожидается' (neutral)
- Income sum semantics preserved (sum of current page rows)
- Skeleton: unified column-width and variant pattern for both tables
- Drop legacy DashboardIncomeRow.instrument alias (now uses instrumentMain/Subtitle)
- Extract moneyToneToColor helper from hero (single source of MUI color mapping)
- Dashboard tests cover thead, badges, subtitles, signed tones, and that
  tables show ₽ instead of RUB for RUB amounts
2026-06-27 13:33:02 +03:00
21df354df6 fix(frontend): align dashboard card heading hierarchy
BrokerDashboardCard previously rendered <Heading level={3} size="section">,
which produced an <h3> under the <h1> account name in BrokerAccountLayout,
skipping the <h2> level. Use level={2} so the semantic HTML is a proper
<h2> while keeping the compact "section" visual size.

Also extract the byte-identical toolbar wrapper (grid + chips + date filter
slot) shared by BrokerDashboardEventsCard and BrokerDashboardIncomeCard
into BrokerDashboardTableToolbar to remove duplicated sx config.
2026-06-27 13:21:15 +03:00
0a1ae0552c feat(frontend): apply hero, card and toolbar HTML parity 2026-06-27 13:07:06 +03:00
1a2937ad58 refactor(frontend): tighten dashboard visual helpers per code review 2026-06-27 12:57:03 +03:00
af0aaeda9d feat(frontend): add dashboard visual helpers for HTML parity
Introduce dashboardVisual.ts with moneyTone, formatDashboardCurrency,
eventTypeTone, incomeTypeTone, and instrumentDisplay helpers used by
the broker dashboard to match the HTML reference prototype.

Extend dashboardIncome.ts: typeLabel now distinguishes DIV_EXT
('Дивиденд (внешний)'), and rows expose instrumentMain/instrumentSubtitle
via instrumentDisplay while keeping instrument as a derived alias for
existing callers.

Add resetDashboardFilters factory in dashboardFilters.ts so Task 3 can
wire the reset action without re-churning filter types.
2026-06-27 12:46:39 +03:00
ae207b0cb3 feat: improove design, add change spec 2026-06-27 12:33:58 +03:00
d4d1dd980e feat: replace native date inputs with single DateCalendar field in broker dashboard
- Replace two MUI DatePicker components with a single visual range field
- Click opens Popover with community DateCalendar (no DateRangePicker/pro)
- First click sets start, second sets end; if end < start, range resets
- Remove isOpen/onToggle props — Popover managed locally
- Add BrokerDashboardTableSkeleton for events/income loading
- Set default weekly range (today-7d / today) for both events and income
- Remove 'Фильтр' label, rename 'Показать' → 'Применить период'
- Install @mui/x-date-pickers@7
- Update tests for new UI and skeleton loading states
- Align spec.md, plan.md, tasks.md with implementation
2026-06-27 11:48:10 +03:00
0116c34a9f feat: improove design 2026-06-27 11:11:25 +03:00
d804d43616 docs: align broker dashboard redesign docs 2026-06-27 10:45:20 +03:00
8df94a51dd feat: broker dashboard redesign with top tabs, single-column layout, and clickable chip filters
- Move navigation from left sidebar to horizontal top tabs in BrokerAccountLayout
- Change dashboard blocks to single-column layout (Events, Income, Analytics, Allocation)
- Extend DS Chip with onClick/selected/disabled/aria-pressed
- Add clickable chip filters to Events and Income cards with immediate filtering
- Disable backend query when all types deselected (show validation message)
- Add useBrokerEvents/useBrokerOperations options.enabled param
- Update dashboard skeleton to single-column shape
- Add userEvent tests for chip filter interactions
2026-06-26 10:47:08 +03:00
c429d6a43a feat(frontend): redesign broker account overview as investment dashboard
Replace vertical overview with dashboard composition:
- BrokerDashboardHero with portfolio KPI, return, daily change, total income
- BrokerDashboardEventsCard with compact events table and local pagination
- BrokerDashboardIncomeCard with income operations table and cursor pagination
- BrokerDashboardAnalyticsCard with analytics Metric grid
- BrokerDashboardAllocationCard wrapping existing donut chart
- BrokerDashboardSkeleton matching dashboard shape
- BrokerDashboardCard local card pattern
- Pure lib helpers: dashboardIncome, dashboardFilters, dashboardFormatters
- Unit tests for income helpers (6) and dashboard composition (1)
2026-06-26 10:18:57 +03:00
49dac140ff docs: plan broker dashboard redesign 2026-06-26 09:45:04 +03:00
34 changed files with 4839 additions and 134 deletions

View File

@ -24,6 +24,7 @@
"@moex-vibe/design-system": "*",
"@mui/icons-material": "^6.5.0",
"@mui/material": "^6.5.0",
"@mui/x-date-pickers": "^7.29.4",
"@tanstack/react-query": "^5.20.0",
"@tanstack/react-router": "^1.170.16",
"@tanstack/react-table": "^8.21.3",
@ -43,7 +44,6 @@
"@biomejs/biome": "^2.5.0",
"@conarti/eslint-plugin-feature-sliced": "^1.0.5",
"@tanstack/router-devtools": "^1.167.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",

View File

@ -18,65 +18,72 @@ export function AppLayout() {
background: 'var(--color-surface)',
borderBottom: '1px solid #e0e0e0',
padding: '12px 24px',
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
gap: 24,
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) auto',
gap: 12,
}}
>
<Link
to="/"
style={{
fontSize: 20,
fontWeight: 700,
color: 'var(--color-text)',
textDecoration: 'none',
}}
>
MoexVibe
</Link>
<div style={{ flex: '1 1 280px', minWidth: 220, maxWidth: 420 }}>
<SearchBar />
<div style={{ display: 'grid', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap' }}>
<Link
to="/"
style={{
fontSize: 20,
fontWeight: 700,
color: 'var(--color-text)',
textDecoration: 'none',
}}
>
MoexVibe
</Link>
<div style={{ flex: '1 1 280px', minWidth: 220, maxWidth: 420 }}>
<SearchBar />
</div>
</div>
<nav
style={{ display: 'flex', gap: 20, flexWrap: 'wrap' }}
aria-label="Основная навигация"
>
<Link
to="/portfolios"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Портфели
</Link>
<Link
to="/broker"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Брокер
</Link>
<Link
to="/screener"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Скринер
</Link>
</nav>
</div>
<Link
to="/portfolios"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Портфели
</Link>
<Link
to="/broker"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Брокер
</Link>
<Link
to="/screener"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Скринер
</Link>
<div
style={{
marginLeft: 'auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
gap: 12,
flexWrap: 'wrap',
}}

View File

@ -2,11 +2,15 @@ import { useQuery } from '@tanstack/react-query'
import type { BrokerEventsData } from '@/shared/api'
import { type BrokerEventsQuery, getBrokerEvents } from '../api/brokerEventApi'
export function useBrokerEvents(accountId: string | undefined, query: BrokerEventsQuery) {
export function useBrokerEvents(
accountId: string | undefined,
query: BrokerEventsQuery,
options: { enabled?: boolean } = {},
) {
const { from, to, types } = query
return useQuery<BrokerEventsData>({
queryKey: ['broker', 'events', accountId, from, to, types],
enabled: Boolean(accountId),
enabled: Boolean(accountId) && (options.enabled ?? true),
queryFn: async () => (await getBrokerEvents(accountId!, { from, to, types })).data,
staleTime: 300_000,
retry: 2,

View File

@ -5,10 +5,11 @@ import { type BrokerOperationQuery, getBrokerOperations } from '../api/brokerOpe
export function useBrokerOperations(
accountId: string | undefined,
query: BrokerOperationQuery = {},
options: { enabled?: boolean } = {},
) {
return useQuery<BrokerOperationsPage>({
queryKey: ['broker', 'operations', accountId, query],
enabled: Boolean(accountId),
enabled: Boolean(accountId) && (options.enabled ?? true),
queryFn: async () => (await getBrokerOperations(accountId!, query)).data,
staleTime: 300_000,
retry: 2,

View File

@ -1,18 +1,11 @@
import { Text } from '@moex-vibe/design-system'
import { Box } from '@mui/material'
import { Link } from '@tanstack/react-router'
import { useBrokerOperations } from '@/entities/broker-operation'
import { useBrokerAccountContext } from '@/widgets/broker-account-layout'
import { BrokerAllocationChart } from '@/widgets/broker-allocation-chart'
import { BrokerEventsOverview } from '@/widgets/broker-events-overview'
import { BrokerOperationsTable } from '@/widgets/broker-operations-table'
import { BrokerAssetCards, BrokerOverviewSkeleton, BrokerSummary } from '@/widgets/broker-overview'
import { BrokerDashboard, BrokerDashboardSkeleton } from '@/widgets/broker-dashboard'
export function BrokerAccountOverviewPage() {
const { accountId, portfolio } = useBrokerAccountContext()
const operations = useBrokerOperations(accountId, { limit: 5 })
if (portfolio.isLoading) return <BrokerOverviewSkeleton />
if (portfolio.isLoading) return <BrokerDashboardSkeleton />
if (portfolio.error || !portfolio.data) {
return (
<Text component="p" role="alert" tone="negative">
@ -21,28 +14,5 @@ export function BrokerAccountOverviewPage() {
)
}
return (
<Box component="div" sx={{ display: 'grid', gap: 3 }}>
<BrokerSummary portfolio={portfolio.data} />
<BrokerAllocationChart portfolio={portfolio.data} />
<BrokerAssetCards accountId={accountId} portfolio={portfolio.data} />
<BrokerEventsOverview accountId={accountId} />
{operations.error ? (
<Text component="p" role="alert" tone="negative">
Не удалось загрузить последние операции
</Text>
) : (
<BrokerOperationsTable
title="Последние операции"
headerAction={
<Link to={`/broker/${encodeURIComponent(accountId)}/operations`}>Вся история</Link>
}
emptyMessage="Операций с начала текущего года нет"
isLoading={operations.isLoading}
isFetching={operations.isFetching}
page={operations.data}
/>
)}
</Box>
)
return <BrokerDashboard accountId={accountId} portfolio={portfolio.data} />
}

View File

@ -7,8 +7,8 @@ import { useBrokerPortfolio } from '@/entities/broker-account'
import type { BrokerPortfolio } from '@/shared/api'
const baseLinkStyle: React.CSSProperties = {
padding: '10px 12px',
borderRadius: 8,
padding: '10px 14px',
borderRadius: 999,
color: 'var(--color-text-secondary)',
textDecoration: 'none',
whiteSpace: 'nowrap',
@ -16,6 +16,7 @@ const baseLinkStyle: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
fontSize: 14,
fontWeight: 600,
}
const links = [
@ -47,51 +48,37 @@ export function BrokerAccountLayout({ children }: { children: ReactNode }) {
</Box>
<Box
component="nav"
aria-label="Разделы брокерского счёта"
sx={{
display: 'grid',
gridTemplateColumns: '1fr',
gap: 2,
'@media (min-width: 720px)': {
gridTemplateColumns: 'minmax(150px, 190px) minmax(0, 1fr)',
gap: 3,
},
display: 'flex',
gap: 0.5,
overflowX: 'auto',
scrollbarWidth: 'thin',
pb: 0.5,
}}
>
<Box
component="nav"
aria-label="Разделы брокерского счёта"
sx={{
display: 'flex',
flexDirection: 'column',
gap: 0.5,
'@media (max-width: 719px)': {
flexDirection: 'row',
overflowX: 'auto',
scrollbarWidth: 'thin',
},
}}
>
{links.map((link) => (
<Link
key={link.to}
to={`${basePath}${link.to}`}
style={baseLinkStyle}
activeOptions={{ exact: link.to === '' }}
activeProps={{
style: {
...baseLinkStyle,
color: 'var(--color-primary)',
fontWeight: 700,
},
}}
>
{link.label}
</Link>
))}
</Box>
<Box sx={{ minWidth: 0 }}>{children}</Box>
{links.map((link) => (
<Link
key={link.to}
to={`${basePath}${link.to}`}
style={baseLinkStyle}
activeOptions={{ exact: link.to === '' }}
activeProps={{
style: {
...baseLinkStyle,
color: 'var(--color-primary)',
fontWeight: 700,
background: 'rgba(25, 118, 210, 0.1)',
},
}}
>
{link.label}
</Link>
))}
</Box>
<Box sx={{ minWidth: 0 }}>{children}</Box>
</Box>
</BrokerAccountContext.Provider>
)

View File

@ -0,0 +1,2 @@
export { BrokerDashboard } from './ui/BrokerDashboard'
export { BrokerDashboardSkeleton } from './ui/BrokerDashboardSkeleton'

View File

@ -0,0 +1,92 @@
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[]
preset: DashboardDatePreset
}
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],
preset: '7d',
}
}
export function defaultIncomeFilters(): DashboardFilterState<DashboardIncomeType> {
const now = dayjs()
return {
from: now.subtract(7, 'day').format('YYYY-MM-DD'),
to: now.format('YYYY-MM-DD'),
types: [...DASHBOARD_INCOME_TYPES],
preset: '7d',
}
}
export function applyDatePreset<T extends string>(
filters: DashboardFilterState<T>,
preset: DashboardDatePreset,
): DashboardFilterState<T> {
const now = dayjs()
const next = { ...filters, preset }
if (preset === 'all') {
return { ...next, from: '2000-01-01', to: '2099-12-31' }
}
const amount = preset === '1y' ? 1 : Number.parseInt(preset, 10)
const unit = preset === '1y' ? 'year' : 'day'
return {
...next,
from: now.subtract(amount, unit).format('YYYY-MM-DD'),
to: now.format('YYYY-MM-DD'),
}
}
export function applyEventDatePreset(
filters: DashboardFilterState<DashboardEventType>,
preset: DashboardDatePreset,
): DashboardFilterState<DashboardEventType> {
const now = dayjs()
const next = { ...filters, preset }
if (preset === 'all') {
return { ...next, from: '2000-01-01', to: '2099-12-31' }
}
const amount = preset === '1y' ? 1 : Number.parseInt(preset, 10)
const unit = preset === '1y' ? 'year' : 'day'
return {
...next,
from: now.subtract(amount, unit).format('YYYY-MM-DD'),
to: now.add(amount, unit).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(',')
}

View File

@ -0,0 +1,23 @@
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 'Поступило'
return 'Ожидается'
}

View File

@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'
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', 5),
])
expect(rows).toHaveLength(2)
expect(rows[0].typeLabel).toBe('Дивиденд')
expect(rows[0].amount.value).toBe(10)
expect(rows[1].typeLabel).toBe('Купон')
expect(rows[1].amount.value).toBe(5)
})
it('marks OPERATION_TYPE_DIV_EXT as "Дивиденд (внешний)"', () => {
const rows = getDashboardIncomeRows([operation('OPERATION_TYPE_DIV_EXT', 12)])
expect(rows).toHaveLength(1)
expect(rows[0].typeLabel).toBe('Дивиденд (внешний)')
expect(rows[0].amount.value).toBe(12)
})
it('splits instrument into main and subtitle', () => {
const rows = getDashboardIncomeRows([operation('OPERATION_TYPE_DIVIDEND', 10)])
expect(rows[0].instrumentMain).toBe('AAPL')
expect(rows[0].instrumentSubtitle).toBe('Apple Inc.')
})
it('falls back to "—" with no subtitle when ticker, name, and description are missing', () => {
const base = operation('OPERATION_TYPE_COUPON', 7)
const rows = getDashboardIncomeRows([{ ...base, ticker: null, name: null, description: null }])
expect(rows[0].instrumentMain).toBe('—')
expect(rows[0].instrumentSubtitle).toBeNull()
})
it('does not duplicate subtitle when name equals ticker', () => {
const base = operation('OPERATION_TYPE_COUPON', 3)
const rows = getDashboardIncomeRows([{ ...base, ticker: 'AAPL', name: 'AAPL' }])
expect(rows[0].instrumentMain).toBe('AAPL')
expect(rows[0].instrumentSubtitle).toBeNull()
})
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 })
})
it('returns null for empty rows sum', () => {
expect(sumDashboardIncome([])).toBeNull()
})
it('returns empty array for empty input', () => {
expect(getDashboardIncomeRows([])).toEqual([])
})
it('returns empty array when no income operations present', () => {
const rows = getDashboardIncomeRows([
operation('OPERATION_TYPE_BUY', -10),
operation('OPERATION_TYPE_SELL', 20),
])
expect(rows).toEqual([])
})
})

View File

@ -0,0 +1,56 @@
import type { BrokerMoney, BrokerOperation } from '@/shared/api'
import { instrumentDisplay } from './dashboardVisual'
const INCOME_TYPES = new Set([
'OPERATION_TYPE_DIVIDEND',
'OPERATION_TYPE_DIV_EXT',
'OPERATION_TYPE_COUPON',
])
export type DashboardIncomeTypeLabel = 'Дивиденд' | 'Дивиденд (внешний)' | 'Купон'
export type DashboardIncomeRow = {
id: string
date: string | null
instrumentMain: string
instrumentSubtitle: string | null
typeLabel: DashboardIncomeTypeLabel
amount: BrokerMoney
}
export function isDashboardIncomeOperation(operation: BrokerOperation): boolean {
return INCOME_TYPES.has(operation.type) && operation.payment !== null
}
function typeLabel(type: string): DashboardIncomeTypeLabel {
if (type === 'OPERATION_TYPE_COUPON') return 'Купон'
if (type === 'OPERATION_TYPE_DIV_EXT') return 'Дивиденд (внешний)'
return 'Дивиденд'
}
export function getDashboardIncomeRows(operations: BrokerOperation[]): DashboardIncomeRow[] {
return operations.filter(isDashboardIncomeOperation).map((operation) => {
const { main, subtitle } = instrumentDisplay({
ticker: operation.ticker,
name: operation.name,
description: operation.description,
})
return {
id: String(operation.id ?? operation.cursor ?? `${operation.type}-${operation.date}`),
date: typeof operation.date === 'string' ? operation.date : null,
instrumentMain: main,
instrumentSubtitle: subtitle,
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 }
}

View File

@ -0,0 +1,159 @@
import { describe, expect, it } from 'vitest'
import type { BrokerMoney } from '@/shared/api'
import {
eventTypeTone,
formatDashboardCurrency,
incomeTypeTone,
instrumentDisplay,
moneyTone,
moneyToneToColor,
} from './dashboardVisual'
function money(currency: string, value: number): BrokerMoney {
return { currency, units: String(Math.trunc(value)), nano: 0, value }
}
describe('formatDashboardCurrency', () => {
it('renders RUB with the ₽ symbol via the shared formatter', () => {
const result = formatDashboardCurrency(money('RUB', 1234.56))
expect(result).toContain('₽')
expect(result).not.toContain('RUB')
})
it('renders known non-RUB currencies via the shared formatter', () => {
const result = formatDashboardCurrency(money('USD', 42))
expect(result).toContain('$')
expect(result).not.toContain('USD')
expect(result).not.toContain('₽')
})
it('falls back to currency code for unknown currencies', () => {
const result = formatDashboardCurrency(money('XYZ', 100))
expect(result).toContain('XYZ')
expect(result).not.toContain('¤')
})
it('falls back to currency code via the value-only shape', () => {
const result = formatDashboardCurrency({ currency: 'XYZ', value: 9.99 })
expect(result).toContain('XYZ')
})
it('returns "—" for null or undefined', () => {
expect(formatDashboardCurrency(null)).toBe('—')
expect(formatDashboardCurrency(undefined)).toBe('—')
})
})
describe('moneyTone', () => {
it('returns positive for positive actual values', () => {
expect(moneyTone(10, 'actual')).toBe('positive')
})
it('treats undefined source as actual and returns positive', () => {
expect(moneyTone(10)).toBe('positive')
expect(moneyTone(10, undefined)).toBe('positive')
})
it('returns planned for positive forecast values', () => {
expect(moneyTone(10, 'forecast')).toBe('planned')
})
it('returns negative for negative values regardless of source', () => {
expect(moneyTone(-10, 'actual')).toBe('negative')
expect(moneyTone(-10, 'forecast')).toBe('negative')
expect(moneyTone(-0.01)).toBe('negative')
})
it('returns neutral for zero', () => {
expect(moneyTone(0)).toBe('neutral')
expect(moneyTone(0, 'actual')).toBe('neutral')
expect(moneyTone(0, 'forecast')).toBe('neutral')
})
it('returns neutral for null and undefined', () => {
expect(moneyTone(null)).toBe('neutral')
expect(moneyTone(undefined)).toBe('neutral')
expect(moneyTone(null, 'forecast')).toBe('neutral')
})
})
describe('eventTypeTone', () => {
it('maps each event type to a stable tone', () => {
expect(eventTypeTone('dividend')).toBe('success')
expect(eventTypeTone('coupon')).toBe('info')
expect(eventTypeTone('maturity')).toBe('warning')
expect(eventTypeTone('offer')).toBe('neutral')
})
})
describe('moneyToneToColor', () => {
it('maps each MoneyTone to a stable MUI color', () => {
expect(moneyToneToColor('positive')).toBe('success.main')
expect(moneyToneToColor('negative')).toBe('error.main')
expect(moneyToneToColor('planned')).toBe('text.disabled')
expect(moneyToneToColor('neutral')).toBe('text.disabled')
})
})
describe('incomeTypeTone', () => {
it('maps each income label to a stable tone matching HTML parity (coupon=info)', () => {
expect(incomeTypeTone('Дивиденд')).toBe('success')
expect(incomeTypeTone('Дивиденд (внешний)')).toBe('success')
expect(incomeTypeTone('Купон')).toBe('info')
})
})
describe('instrumentDisplay', () => {
it('uses ticker as main and skips subtitle when ticker is the only field', () => {
expect(instrumentDisplay({ ticker: 'AAPL' })).toEqual({ main: 'AAPL', subtitle: null })
})
it('uses ticker as main and name as subtitle when both are present', () => {
expect(instrumentDisplay({ ticker: 'AAPL', name: 'Apple Inc.' })).toEqual({
main: 'AAPL',
subtitle: 'Apple Inc.',
})
})
it('prefers name over description when both are provided alongside ticker', () => {
expect(
instrumentDisplay({ ticker: 'AAPL', name: 'Apple Inc.', description: 'Apple computer' }),
).toEqual({ main: 'AAPL', subtitle: 'Apple Inc.' })
})
it('uses name as main and skips subtitle when no ticker is provided', () => {
expect(instrumentDisplay({ name: 'Apple Inc.' })).toEqual({
main: 'Apple Inc.',
subtitle: null,
})
})
it('uses description as main when neither ticker nor name are provided', () => {
expect(instrumentDisplay({ description: 'Apple computer' })).toEqual({
main: 'Apple computer',
subtitle: null,
})
})
it('returns "—" as main when no fields are provided', () => {
expect(instrumentDisplay({})).toEqual({ main: '—', subtitle: null })
expect(instrumentDisplay({ ticker: null, name: null, description: null })).toEqual({
main: '—',
subtitle: null,
})
})
it('does not duplicate subtitle when name equals ticker', () => {
expect(instrumentDisplay({ ticker: 'AAPL', name: 'AAPL' })).toEqual({
main: 'AAPL',
subtitle: null,
})
})
it('treats empty strings as missing', () => {
expect(instrumentDisplay({ ticker: '', name: '', description: '' })).toEqual({
main: '—',
subtitle: null,
})
})
})

View File

@ -0,0 +1,97 @@
import type { BrokerEventItem, BrokerMoney } from '@/shared/api'
import { formatBrokerCurrencyValue } from '@/shared/lib/formatters'
import type { DashboardIncomeTypeLabel } from './dashboardIncome'
export type MoneyTone = 'positive' | 'negative' | 'planned' | 'neutral'
export type TypeTone = 'neutral' | 'info' | 'success' | 'warning'
export type DashboardMoneyLike = BrokerMoney | { currency: string; value: number }
export type MoneySource = 'actual' | 'forecast'
export function moneyTone(value: number | null | undefined, source?: MoneySource): MoneyTone {
if (value === null || value === undefined) return 'neutral'
if (value === 0) return 'neutral'
if (value < 0) return 'negative'
return source === 'forecast' ? 'planned' : 'positive'
}
export function moneyToneToColor(tone: MoneyTone): string {
switch (tone) {
case 'positive':
return 'success.main'
case 'negative':
return 'error.main'
case 'planned':
case 'neutral':
return 'text.disabled'
}
}
export function formatDashboardCurrency(money: DashboardMoneyLike | null | undefined): string {
if (!money) return '—'
try {
const formatted = formatBrokerCurrencyValue(money.currency, money.value)
if (formatted.includes('¤')) {
return formatCurrencyCodeFallback(money.value, money.currency)
}
return formatted
} catch {
return formatCurrencyCodeFallback(money.value, money.currency)
}
}
function formatCurrencyCodeFallback(value: number, currency: string): string {
const numberPart = new Intl.NumberFormat('ru-RU', {
maximumFractionDigits: 2,
}).format(value)
return `${numberPart}\u00a0${currency}`
}
export function eventTypeTone(type: BrokerEventItem['type']): TypeTone {
switch (type) {
case 'dividend':
return 'success'
case 'coupon':
return 'info'
case 'maturity':
return 'warning'
case 'offer':
return 'neutral'
}
}
export function incomeTypeTone(label: DashboardIncomeTypeLabel): TypeTone {
switch (label) {
case 'Дивиденд':
case 'Дивиденд (внешний)':
return 'success'
case 'Купон':
return 'info'
}
}
export function eventStatusTone(source: BrokerEventItem['source']): TypeTone {
return source === 'actual' ? 'success' : 'neutral'
}
function nonEmpty(value: string | null | undefined): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
}
export function instrumentDisplay(input: {
ticker?: string | null
name?: string | null
description?: string | null
}): { main: string; subtitle: string | null } {
const ticker = nonEmpty(input.ticker)
const name = nonEmpty(input.name)
const description = nonEmpty(input.description)
const main = ticker ?? name ?? description ?? '—'
const subtitleCandidate = name ?? description
const subtitle = subtitleCandidate && subtitleCandidate !== main ? subtitleCandidate : null
return { main, subtitle }
}

View File

@ -0,0 +1,537 @@
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { BrokerEventItem, BrokerOperation, BrokerPortfolio } from '@/shared/api'
import { BrokerDashboard } from './BrokerDashboard'
const hookMocks = vi.hoisted(() => ({
useBrokerEvents: vi.fn(),
useBrokerOperations: vi.fn(),
}))
vi.mock('@tanstack/react-router', () => ({
Link: ({ children, to }: { children: React.ReactNode; to: string }) => (
<a href={to}>{children}</a>
),
}))
vi.mock('@/entities/broker-analytics', () => ({
useBrokerAnalytics: () => ({
data: {
totalDeposits: 1000,
totalWithdrawn: 250,
netInvested: -150,
totalDividends: 75,
totalCoupons: 0,
totalReceived: 90,
totalReturnPercent: 4.44,
currency: 'RUB',
},
isLoading: false,
isError: false,
}),
}))
vi.mock('@/entities/broker-event', () => ({
useBrokerEvents: hookMocks.useBrokerEvents,
}))
vi.mock('@/entities/broker-operation', () => ({
useBrokerOperations: hookMocks.useBrokerOperations,
}))
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',
}
function renderWithProviders(ui: ReactNode) {
return render(<LocalizationProvider dateAdapter={AdapterDayjs}>{ui}</LocalizationProvider>)
}
function buildEvent(overrides: Partial<BrokerEventItem> = {}): BrokerEventItem {
return {
id: 'evt-1',
type: 'coupon',
source: 'actual',
category: 'cashflow',
eventDate: '2026-06-15T00:00:00.000Z',
paymentDate: null,
ticker: 'RU000A10AEF9',
name: 'РЖД 001Р-37R',
instrumentUid: null,
instrumentType: 'bond',
quantitySnapshot: null,
payoutPerUnit: null,
estimatedAmount: null,
actualAmount: 43.56,
currency: 'RUB',
estimateMode: null,
...overrides,
}
}
function buildOperation(overrides: Partial<BrokerOperation> = {}): BrokerOperation {
return {
cursor: null,
accountId: 'acc-1',
id: 'op-1',
parentOperationId: null,
date: '2026-06-15T00:00:00.000Z',
type: 'OPERATION_TYPE_COUPON',
category: 'income',
description: null,
name: 'ОФЗ 26241',
state: 'OPERATION_STATE_EXECUTED',
instrumentUid: null,
figi: null,
ticker: 'SU26249RMFS1',
classCode: null,
instrumentType: 'bond',
payment: { currency: 'RUB', units: '109', nano: 700000000, value: 109.7 },
price: null,
commission: null,
yield: null,
accruedInt: null,
quantity: null,
quantityDone: null,
...overrides,
}
}
function mockEventsLoaded(items: BrokerEventItem[]) {
hookMocks.useBrokerEvents.mockReturnValue({
data: { items, summary: {}, asOf: '2026-06-26T00:00:00.000Z' },
isLoading: false,
isError: false,
})
}
function mockOperationsLoaded(items: BrokerOperation[]) {
hookMocks.useBrokerOperations.mockReturnValue({
data: {
accountId: 'acc-1',
items,
nextCursor: null,
hasNext: false,
asOf: '2026-06-26T00:00:00.000Z',
},
isLoading: false,
isError: false,
})
}
describe('BrokerDashboard', () => {
beforeEach(() => {
hookMocks.useBrokerEvents.mockReturnValue({
data: { items: [], summary: {}, asOf: '2026-06-26T00:00:00.000Z' },
isLoading: false,
isError: false,
})
hookMocks.useBrokerOperations.mockReturnValue({
data: {
accountId: 'acc-1',
items: [],
nextCursor: null,
hasNext: false,
asOf: '2026-06-26T00:00:00.000Z',
},
isLoading: false,
isError: false,
})
})
it('renders the dashboard sections', () => {
renderWithProviders(<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()
})
it('applies event type chips immediately to useBrokerEvents', async () => {
const user = userEvent.setup()
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const couponChips = screen.getAllByRole('button', { name: 'Купоны' })
await user.click(couponChips[0])
await waitFor(() => {
expect(hookMocks.useBrokerEvents).toHaveBeenLastCalledWith(
'acc-1',
expect.objectContaining({ types: 'dividend,maturity,offer' }),
{ enabled: true },
)
})
})
it('applies income type chips immediately to useBrokerOperations', async () => {
const user = userEvent.setup()
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const couponChips = screen.getAllByRole('button', { name: 'Купоны' })
await user.click(couponChips[1])
await waitFor(() => {
expect(hookMocks.useBrokerOperations).toHaveBeenLastCalledWith(
'acc-1',
expect.objectContaining({
operationTypes: 'OPERATION_TYPE_DIVIDEND,OPERATION_TYPE_DIV_EXT',
}),
{ enabled: true },
)
})
})
it('requests future events in the default dashboard range', () => {
vi.useFakeTimers()
try {
vi.setSystemTime(new Date('2026-06-27T12:00:00.000Z'))
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
expect(hookMocks.useBrokerEvents).toHaveBeenCalledWith(
'acc-1',
expect.objectContaining({
from: '2026-06-20',
to: '2026-07-04',
}),
{ enabled: true },
)
} finally {
vi.useRealTimers()
}
})
it('shows date filter toggle button with refresh action and accessible name', async () => {
const user = userEvent.setup()
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const applyButtons = screen.getAllByRole('button', { name: 'Обновить' })
expect(applyButtons).toHaveLength(2)
const toggleButtons = screen.getAllByRole('button', { name: /^Период/ })
expect(toggleButtons).toHaveLength(2)
await user.click(toggleButtons[0])
expect(screen.getByText('7д')).toBeInTheDocument()
expect(screen.getByText('30д')).toBeInTheDocument()
expect(screen.getByText('90д')).toBeInTheDocument()
expect(screen.getByText('1г')).toBeInTheDocument()
expect(screen.getByText('Всё')).toBeInTheDocument()
expect(screen.getByText('Сбросить')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Применить период' })).toBeInTheDocument()
})
it('does not render a text chevron glyph inside the period toggle button', () => {
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const toggleButtons = screen.getAllByRole('button', { name: /^Период/ })
expect(toggleButtons).toHaveLength(2)
for (const button of toggleButtons) {
const text = button.textContent ?? ''
expect(text).not.toMatch(/[▼▲vV]/)
expect(button.querySelector('svg')).not.toBeNull()
}
})
it('renders hero "Всего доходов" with the ₽ symbol and no "RUB" code', () => {
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
expect(screen.getByText('Всего доходов')).toBeInTheDocument()
const hero = screen.getByLabelText('Ключевые показатели брокерского счёта')
const heroText = hero.textContent ?? ''
expect(heroText).toContain('₽')
expect(heroText).not.toContain('RUB')
})
it('renders analytics card with the ₽ symbol and no "RUB" code', () => {
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const analytics = screen.getByLabelText('Аналитика доходности')
const analyticsText = analytics.textContent ?? ''
expect(analyticsText).toContain('₽')
expect(analyticsText).not.toContain('RUB')
})
it('applies positive, negative and neutral tones to analytics values', () => {
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const deposits = screen.getByTestId('dashboard-analytics-totalDeposits')
expect(deposits.getAttribute('data-tone')).toBe('positive')
expect(deposits.textContent).toContain('1\u00a0000,00')
expect(deposits.textContent).toContain('₽')
const withdrawn = screen.getByTestId('dashboard-analytics-totalWithdrawn')
expect(withdrawn.getAttribute('data-tone')).toBe('negative')
expect(withdrawn.textContent).toMatch(/^[-]250,00/)
const net = screen.getByTestId('dashboard-analytics-netInvested')
expect(net.getAttribute('data-tone')).toBe('negative')
expect(net.textContent).toMatch(/^[-]150,00/)
const dividends = screen.getByTestId('dashboard-analytics-totalDividends')
expect(dividends.getAttribute('data-tone')).toBe('positive')
expect(dividends.textContent).toContain('75,00')
const coupons = screen.getByTestId('dashboard-analytics-totalCoupons')
expect(coupons.getAttribute('data-tone')).toBe('neutral')
expect(coupons.textContent).toContain('0,00')
const received = screen.getByTestId('dashboard-analytics-totalReceived')
expect(received.getAttribute('data-tone')).toBe('positive')
expect(received.textContent).toContain('90,00')
})
it('shows skeleton table while events are loading', () => {
hookMocks.useBrokerEvents.mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
})
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const skeletons = screen.getAllByTestId('dashboard-table-skeleton')
expect(skeletons.length).toBeGreaterThanOrEqual(1)
})
it('shows skeleton table while income operations are loading', () => {
hookMocks.useBrokerOperations.mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
})
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const skeletons = screen.getAllByTestId('dashboard-table-skeleton')
expect(skeletons.length).toBeGreaterThanOrEqual(1)
})
it('does not show skeleton when data is loaded', () => {
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
expect(screen.queryByTestId('dashboard-table-skeleton')).not.toBeInTheDocument()
})
it('renders events table thead with semantic column headers', () => {
mockEventsLoaded([buildEvent()])
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const eventsTable = screen.getByLabelText('Таблица событий брокерского счёта')
expect(eventsTable.querySelector('thead')).not.toBeNull()
const headers = within(eventsTable).getAllByRole('columnheader')
expect(headers.map((h) => h.textContent)).toEqual([
'Дата',
'Инструмент',
'Тип',
'Сумма',
'Статус',
])
})
it('renders income table thead without status column', () => {
mockOperationsLoaded([buildOperation()])
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const incomeTable = screen.getByLabelText('Таблица доходов брокерского счёта')
expect(incomeTable.querySelector('thead')).not.toBeNull()
const headers = within(incomeTable).getAllByRole('columnheader')
expect(headers.map((h) => h.textContent)).toEqual(['Дата', 'Инструмент', 'Тип', 'Сумма'])
})
it('renders event type badge, instrument subtitle and status pill for loaded events', () => {
mockEventsLoaded([
buildEvent({ id: 'evt-actual', source: 'actual', type: 'coupon', actualAmount: 43.56 }),
buildEvent({
id: 'evt-forecast',
source: 'forecast',
type: 'dividend',
actualAmount: null,
estimatedAmount: 197.56,
}),
])
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const rows = screen.getAllByTestId('dashboard-events-row')
expect(rows).toHaveLength(2)
const subtitles = screen.getAllByTestId('dashboard-events-instrument-subtitle')
expect(subtitles).toHaveLength(2)
for (const subtitle of subtitles) {
expect(subtitle.textContent).toBe('РЖД 001Р-37R')
}
const amounts = screen.getAllByTestId('dashboard-events-amount')
expect(amounts).toHaveLength(2)
expect(amounts[0].getAttribute('data-tone')).toBe('positive')
expect(amounts[0].textContent).toContain('+43,56')
expect(amounts[1].getAttribute('data-tone')).toBe('planned')
expect(amounts[1].textContent).toContain('~197,56')
expect(screen.getByText('Купон')).toBeInTheDocument()
expect(screen.getByText('Дивиденд')).toBeInTheDocument()
expect(screen.getByText('Поступило')).toBeInTheDocument()
expect(screen.getByText('Ожидается')).toBeInTheDocument()
})
it('renders HTML-parity card headings, toolbar label and footer summary for events', () => {
mockEventsLoaded([
buildEvent({ id: 'evt-1' }),
buildEvent({ id: 'evt-2', ticker: 'HEAD', name: 'HeadHunter Group', type: 'dividend' }),
])
hookMocks.useBrokerEvents.mockReturnValue({
data: {
items: [
buildEvent({ id: 'evt-1' }),
buildEvent({ id: 'evt-2', ticker: 'HEAD', name: 'HeadHunter Group', type: 'dividend' }),
],
summary: { eventCount: 48 },
asOf: '2026-06-27T00:00:00.000Z',
},
isLoading: false,
isError: false,
})
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const eventsSection = screen.getByLabelText('События')
expect(screen.getByText('2 из 48')).toBeInTheDocument()
expect(within(eventsSection).getAllByText('Тип').length).toBeGreaterThan(0)
expect(
within(eventsSection).getByText('Показано 2 событий за выбранный период'),
).toBeInTheDocument()
})
it('renders HTML-parity count badge and footer summary for income', () => {
mockOperationsLoaded([
buildOperation({ id: 'op-1' }),
buildOperation({ id: 'op-2', ticker: 'IRAO', name: 'Интер РАО' }),
])
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
expect(screen.getByText('2 операции')).toBeInTheDocument()
expect(screen.getByText(/Показано 2 · Итого:/)).toBeInTheDocument()
})
it('uses negative tone when event actual amount is negative', () => {
mockEventsLoaded([
buildEvent({
id: 'evt-tax',
type: 'coupon',
source: 'actual',
actualAmount: -87.0,
}),
])
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const [amount] = screen.getAllByTestId('dashboard-events-amount')
expect(amount.getAttribute('data-tone')).toBe('negative')
expect(amount.textContent).toBe('87,00 ₽')
})
it('renders events and income amounts with the ₽ symbol and no "RUB" code', () => {
mockEventsLoaded([
buildEvent({ id: 'evt', source: 'actual', actualAmount: 43.56 }),
buildEvent({ id: 'evt-neg', source: 'actual', actualAmount: -12.34 }),
])
mockOperationsLoaded([
buildOperation({
id: 'op-positive',
type: 'OPERATION_TYPE_COUPON',
ticker: 'SU26249RMFS1',
name: 'ОФЗ 26241',
payment: { currency: 'RUB', units: '109', nano: 700000000, value: 109.7 },
}),
buildOperation({
id: 'op-negative',
type: 'OPERATION_TYPE_DIVIDEND',
ticker: 'IRAO',
name: 'Интер РАО',
payment: { currency: 'RUB', units: '35', nano: 0, value: -35 },
}),
])
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const eventsTable = screen.getByLabelText('Таблица событий брокерского счёта')
const incomeTable = screen.getByLabelText('Таблица доходов брокерского счёта')
for (const table of [eventsTable, incomeTable]) {
expect(table.textContent ?? '').toContain('₽')
expect(table.textContent ?? '').not.toContain('RUB')
}
})
it('renders income rows with main + subtitle, type badge and signed amount tone', () => {
mockOperationsLoaded([
buildOperation({
id: 'op-div',
type: 'OPERATION_TYPE_DIVIDEND',
ticker: 'IRAO',
name: 'Интер РАО',
payment: { currency: 'RUB', units: '649', nano: 250000000, value: 649.25 },
}),
buildOperation({
id: 'op-div-ext',
type: 'OPERATION_TYPE_DIV_EXT',
ticker: 'AAPL',
name: 'Apple Inc.',
payment: { currency: 'RUB', units: '100', nano: 0, value: -35 },
}),
])
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const rows = screen.getAllByTestId('dashboard-income-row')
expect(rows).toHaveLength(2)
const mainLabels = screen.getAllByTestId('dashboard-income-instrument-main')
expect(mainLabels.map((el) => el.textContent)).toEqual(['IRAO', 'AAPL'])
const subtitles = screen.getAllByTestId('dashboard-income-instrument-subtitle')
expect(subtitles.map((el) => el.textContent)).toEqual(['Интер РАО', 'Apple Inc.'])
expect(screen.getByText('Дивиденд')).toBeInTheDocument()
expect(screen.getByText('Дивиденд (внешний)')).toBeInTheDocument()
const amounts = screen.getAllByTestId('dashboard-income-amount')
expect(amounts[0].getAttribute('data-tone')).toBe('positive')
expect(amounts[0].textContent).toContain('+649,25')
expect(amounts[1].getAttribute('data-tone')).toBe('negative')
expect(amounts[1].textContent).toContain('35,00')
})
})

View File

@ -0,0 +1,237 @@
import { Box } from '@mui/material'
import dayjs from 'dayjs'
import { useCallback, 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 {
applyDatePreset,
applyEventDatePreset,
type DashboardDatePreset,
type DashboardEventType,
type DashboardIncomeType,
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'
function formatDateLabel(from: string, to: string): string | null {
if (!from && !to) return 'За всё время'
const fmt = (d: string) => dayjs(d).format('D MMM')
if (from && to) return `${fmt(from)} ${fmt(to)}`
if (from) return `с ${fmt(from)}`
if (to) return `до ${fmt(to)}`
return null
}
export function BrokerDashboard({
accountId,
portfolio,
}: {
accountId: string
portfolio: BrokerPortfolio
}) {
const [appliedEventFilters, setAppliedEventFilters] = useState(defaultEventsFilters)
const [draftEventFilters, setDraftEventFilters] = useState(defaultEventsFilters)
const [eventPage, setEventPage] = useState(1)
const [appliedIncomeFilters, setAppliedIncomeFilters] = useState(defaultIncomeFilters)
const [draftIncomeFilters, setDraftIncomeFilters] = useState(defaultIncomeFilters)
const incomePagination = useCursorPagination()
const analytics = useBrokerAnalytics(accountId)
const hasEventTypes = appliedEventFilters.types.length > 0
const hasIncomeTypes = appliedIncomeFilters.types.length > 0
const hasDraftEventTypes = draftEventFilters.types.length > 0
const hasDraftIncomeTypes = draftIncomeFilters.types.length > 0
const eventPageSize = 10
const events = useBrokerEvents(
accountId,
{
from: appliedEventFilters.from,
to: appliedEventFilters.to,
types: appliedEventFilters.types.join(','),
},
{ enabled: hasEventTypes },
)
const operations = useBrokerOperations(
accountId,
{
from: appliedIncomeFilters.from,
to: appliedIncomeFilters.to,
operationTypes: incomeTypesToOperationTypes(appliedIncomeFilters.types),
cursor: incomePagination.cursor,
limit: 10,
},
{ enabled: hasIncomeTypes },
)
const nextCursor: string | undefined = operations.data?.nextCursor
? (operations.data.nextCursor as unknown as string)
: undefined
const eventItems = events.data?.items ?? []
const eventPageItems = eventItems.slice(
(eventPage - 1) * eventPageSize,
eventPage * eventPageSize,
)
function toggleEventType(type: DashboardEventType) {
setAppliedEventFilters((filters) => {
const nextTypes = filters.types.includes(type)
? filters.types.filter((item) => item !== type)
: [...filters.types, type]
return { ...filters, types: nextTypes }
})
setDraftEventFilters((filters) => {
const nextTypes = filters.types.includes(type)
? filters.types.filter((item) => item !== type)
: [...filters.types, type]
return { ...filters, types: nextTypes }
})
setEventPage(1)
}
function toggleIncomeType(type: DashboardIncomeType) {
setAppliedIncomeFilters((filters) => {
const nextTypes = filters.types.includes(type)
? filters.types.filter((item) => item !== type)
: [...filters.types, type]
return { ...filters, types: nextTypes }
})
setDraftIncomeFilters((filters) => {
const nextTypes = filters.types.includes(type)
? filters.types.filter((item) => item !== type)
: [...filters.types, type]
return { ...filters, types: nextTypes }
})
incomePagination.reset()
}
const applyEventFilters = useCallback(() => {
setAppliedEventFilters((prev) => ({
...prev,
from: draftEventFilters.from,
to: draftEventFilters.to,
preset: draftEventFilters.preset,
}))
setEventPage(1)
}, [draftEventFilters.from, draftEventFilters.to, draftEventFilters.preset])
const resetEventFilters = useCallback(() => {
const defaults = defaultEventsFilters()
setAppliedEventFilters(defaults)
setDraftEventFilters(defaults)
setEventPage(1)
}, [])
const applyIncomeFilters = useCallback(() => {
setAppliedIncomeFilters((prev) => ({
...prev,
from: draftIncomeFilters.from,
to: draftIncomeFilters.to,
preset: draftIncomeFilters.preset,
}))
incomePagination.reset()
}, [draftIncomeFilters.from, draftIncomeFilters.to, draftIncomeFilters.preset, incomePagination])
const resetIncomeFilters = useCallback(() => {
const defaults = defaultIncomeFilters()
setAppliedIncomeFilters(defaults)
setDraftIncomeFilters(defaults)
incomePagination.reset()
}, [incomePagination])
function handleDraftEventPresetChange(preset: DashboardDatePreset) {
setDraftEventFilters((filters) => applyEventDatePreset(filters, preset))
}
function handleDraftEventFromChange(value: string) {
setDraftEventFilters((filters) => ({ ...filters, from: value }))
}
function handleDraftEventToChange(value: string) {
setDraftEventFilters((filters) => ({ ...filters, to: value }))
}
function handleDraftIncomePresetChange(preset: DashboardDatePreset) {
setDraftIncomeFilters((filters) => applyDatePreset(filters, preset))
}
function handleDraftIncomeFromChange(value: string) {
setDraftIncomeFilters((filters) => ({ ...filters, from: value }))
}
function handleDraftIncomeToChange(value: string) {
setDraftIncomeFilters((filters) => ({ ...filters, to: value }))
}
return (
<Box sx={{ display: 'grid', gap: 3 }}>
<BrokerDashboardHero portfolio={portfolio} analytics={analytics.data} />
<BrokerDashboardEventsCard
accountId={accountId}
data={events.data ? { ...events.data, items: eventPageItems } : undefined}
isLoading={events.isLoading}
isError={events.isError}
selectedTypes={draftEventFilters.types}
onToggleType={toggleEventType}
appliedDateLabel={formatDateLabel(appliedEventFilters.from, appliedEventFilters.to)}
draftPreset={draftEventFilters.preset}
draftFrom={draftEventFilters.from}
draftTo={draftEventFilters.to}
onDraftPresetChange={handleDraftEventPresetChange}
onDraftFromChange={handleDraftEventFromChange}
onDraftToChange={handleDraftEventToChange}
onApplyFilters={applyEventFilters}
onResetFilters={resetEventFilters}
hasDraftTypes={hasDraftEventTypes}
totalCount={events.data?.summary?.eventCount}
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}
selectedTypes={draftIncomeFilters.types}
onToggleType={toggleIncomeType}
appliedDateLabel={formatDateLabel(appliedIncomeFilters.from, appliedIncomeFilters.to)}
draftPreset={draftIncomeFilters.preset}
draftFrom={draftIncomeFilters.from}
draftTo={draftIncomeFilters.to}
onDraftPresetChange={handleDraftIncomePresetChange}
onDraftFromChange={handleDraftIncomeFromChange}
onDraftToChange={handleDraftIncomeToChange}
onApplyFilters={applyIncomeFilters}
onResetFilters={resetIncomeFilters}
hasDraftTypes={hasDraftIncomeTypes}
visibleCount={operations.data?.items?.length ?? 0}
pageNumber={incomePagination.pageNumber}
canGoBack={incomePagination.pageNumber > 1}
canGoForward={operations.data?.hasNext ?? false}
onPreviousPage={incomePagination.handlePrevious}
onNextPage={() => incomePagination.handleNext(nextCursor)}
/>
<BrokerDashboardAnalyticsCard
data={analytics.data}
isLoading={analytics.isLoading}
isError={analytics.isError}
/>
<BrokerDashboardAllocationCard portfolio={portfolio} />
</Box>
)
}

View File

@ -0,0 +1,86 @@
import { Text } from '@moex-vibe/design-system'
import { Box } from '@mui/material'
import { buildBrokerAllocation } from '@/entities/broker-position'
import type { BrokerPortfolio } from '@/shared/api'
import { formatBrokerCurrencyValue, formatBrokerMoney } from '@/shared/lib/formatters'
import { BrokerDashboardCard } from './BrokerDashboardCard'
const ALLOCATION_COLORS: Record<string, string> = {
shares: '#4969f5',
bonds: '#e5a33c',
etf: '#62b889',
cash: '#7b63cf',
other: '#aeb6c5',
}
export function BrokerDashboardAllocationCard({ portfolio }: { portfolio: BrokerPortfolio }) {
const { total, sectors, negative } = buildBrokerAllocation(portfolio)
const currency =
total >= 0
? (portfolio.totals.portfolio?.currency ??
Object.values(portfolio.totals).find((t) => t?.currency)?.currency ??
'RUB')
: 'RUB'
return (
<BrokerDashboardCard
title="Аллокация"
action={<Text variant="numeric">{formatBrokerMoney(portfolio.totals.portfolio)}</Text>}
>
{sectors.length === 0 && negative.length === 0 ? (
<Text tone="muted">Нет данных для распределения</Text>
) : (
<Box sx={{ display: 'grid', gap: 1.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
<Text variant="caption" tone="secondary">
Структура портфеля
</Text>
<Text variant="body" sx={{ fontWeight: 700 }}>
{formatBrokerMoney(portfolio.totals.portfolio)}
</Text>
</Box>
{sectors.map((sector) => (
<Box key={sector.key}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Text variant="body">{sector.label}</Text>
<Text variant="body" tone="secondary">
{formatBrokerCurrencyValue(currency, sector.value)} · {sector.percent.toFixed(1)}%
</Text>
</Box>
<Box
sx={{
height: 10,
borderRadius: 5,
bgcolor: 'grey.200',
overflow: 'hidden',
}}
>
<Box
sx={{
width: `${sector.percent}%`,
height: '100%',
bgcolor: ALLOCATION_COLORS[sector.key] ?? '#aeb6c5',
borderRadius: 5,
transition: 'width 0.3s',
}}
/>
</Box>
</Box>
))}
{negative.length > 0 && (
<Box sx={{ mt: 1 }}>
{negative.map((item) => (
<Box key={item.key} sx={{ display: 'flex', gap: 1 }}>
<Text variant="body">{item.label}:</Text>
<Text variant="body" tone="negative">
{formatBrokerCurrencyValue(currency, item.value)}
</Text>
</Box>
))}
</Box>
)}
</Box>
)}
</BrokerDashboardCard>
)
}

View File

@ -0,0 +1,110 @@
import { Metric, Text } from '@moex-vibe/design-system'
import { Box } from '@mui/material'
import type { BrokerAnalytics } from '@/shared/api'
import {
formatDashboardCurrency,
type MoneyTone,
moneyTone,
moneyToneToColor,
} from '../lib/dashboardVisual'
import { BrokerDashboardCard } from './BrokerDashboardCard'
type AnalyticsField =
| 'totalDeposits'
| 'totalWithdrawn'
| 'netInvested'
| 'totalDividends'
| 'totalCoupons'
| 'totalReceived'
const ANALYTICS_METRICS: readonly {
field: AnalyticsField
label: string
testId: string
}[] = [
{ field: 'totalDeposits', label: 'Пополнения', testId: 'dashboard-analytics-totalDeposits' },
{ field: 'totalWithdrawn', label: 'Выводы', testId: 'dashboard-analytics-totalWithdrawn' },
{ field: 'netInvested', label: 'Нетто', testId: 'dashboard-analytics-netInvested' },
{ field: 'totalDividends', label: 'Дивиденды', testId: 'dashboard-analytics-totalDividends' },
{ field: 'totalCoupons', label: 'Купоны', testId: 'dashboard-analytics-totalCoupons' },
{ field: 'totalReceived', label: 'Всего получено', testId: 'dashboard-analytics-totalReceived' },
]
function analyticsTone(field: AnalyticsField, value: number): MoneyTone {
if (field === 'totalWithdrawn') {
return value > 0 ? 'negative' : moneyTone(value)
}
return moneyTone(value)
}
function analyticsDisplay(field: AnalyticsField, value: number, currency: string): string {
const formatted = formatDashboardCurrency({ currency, value })
if (field === 'totalWithdrawn' && value > 0) return `${formatted}`
return formatted
}
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,
}}
>
{ANALYTICS_METRICS.map(({ field, label, testId }) => {
const value = data[field]
const tone = analyticsTone(field, value)
return (
<Box
key={field}
sx={{
borderRadius: 2,
border: '1px solid',
borderColor: tone === 'negative' ? 'error.light' : 'divider',
bgcolor:
tone === 'positive'
? 'rgba(46, 125, 50, 0.06)'
: tone === 'negative'
? 'rgba(211, 47, 47, 0.06)'
: 'grey.50',
p: 1.5,
}}
>
<Metric
label={label}
value={
<Box
component="span"
data-testid={testId}
data-tone={tone}
sx={{ color: moneyToneToColor(tone), fontWeight: 700 }}
>
{analyticsDisplay(field, value, data.currency)}
</Box>
}
/>
</Box>
)
})}
</Box>
)}
</BrokerDashboardCard>
)
}

View File

@ -0,0 +1,79 @@
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
badge?: ReactNode
action?: ReactNode
filters?: ReactNode
children: ReactNode
sx?: SxProps<Theme>
ariaLabel?: string
}
export function BrokerDashboardCard({
title,
badge,
action,
filters,
children,
sx,
ariaLabel,
}: BrokerDashboardCardProps) {
const resolvedAriaLabel = ariaLabel ?? title
return (
<Box
component="section"
aria-label={resolvedAriaLabel}
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: 'flex-start',
justifyContent: 'space-between',
gap: 2,
mb: 1.5,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Heading level={2} size="section">
{title}
</Heading>
{badge ? (
<Box
component="span"
sx={{
display: 'inline-flex',
alignItems: 'center',
minHeight: 24,
px: 1,
borderRadius: 999,
bgcolor: 'grey.100',
color: 'text.secondary',
fontSize: 12,
fontWeight: 700,
lineHeight: 1,
}}
>
{badge}
</Box>
) : null}
</Box>
{action}
</Box>
{filters && <Box sx={{ mb: 1.5 }}>{filters}</Box>}
{children}
</Box>
)
}

View File

@ -0,0 +1,270 @@
import { Chip, Text } from '@moex-vibe/design-system'
import CalendarTodayRounded from '@mui/icons-material/CalendarTodayRounded'
import ExpandMoreRounded from '@mui/icons-material/ExpandMoreRounded'
import { Box, Popover } from '@mui/material'
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
import { DateCalendar } from '@mui/x-date-pickers/DateCalendar'
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'
import dayjs from 'dayjs'
import { useCallback, useState } from 'react'
import type { DashboardDatePreset } from '../lib/dashboardFilters'
const PRESETS: { key: DashboardDatePreset; label: string }[] = [
{ key: '7d', label: '7д' },
{ key: '30d', label: '30д' },
{ key: '90d', label: '90д' },
{ key: '1y', label: '1г' },
{ key: 'all', label: 'Всё' },
]
type BrokerDashboardDateFilterProps = {
appliedLabel: string | null
preset: DashboardDatePreset
draftFrom: string
draftTo: string
hasDraftTypes: boolean
onPresetChange: (preset: DashboardDatePreset) => void
onFromChange: (value: string) => void
onToChange: (value: string) => void
onReset: () => void
onApply: () => void
}
export function BrokerDashboardDateFilter({
appliedLabel,
preset,
draftFrom,
draftTo,
hasDraftTypes,
onPresetChange,
onFromChange,
onToChange,
onReset,
onApply,
}: BrokerDashboardDateFilterProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
const [selectingStage, setSelectingStage] = useState<'from' | 'to'>('from')
const isOpen = Boolean(anchorEl)
const handleToggle = useCallback((e: React.MouseEvent<HTMLElement>) => {
setAnchorEl((prev) => {
if (prev) return null
return e.currentTarget
})
setSelectingStage('from')
}, [])
const handleClose = useCallback(() => {
setAnchorEl(null)
setSelectingStage('from')
}, [])
const handleCalendarChange = useCallback(
(date: dayjs.Dayjs | null) => {
if (!date) return
if (selectingStage === 'from') {
onFromChange(date.format('YYYY-MM-DD'))
setSelectingStage('to')
} else {
if (draftFrom && date.isBefore(dayjs(draftFrom))) {
onFromChange(date.format('YYYY-MM-DD'))
onToChange('')
} else {
onToChange(date.format('YYYY-MM-DD'))
}
setSelectingStage('from')
}
},
[selectingStage, draftFrom, onFromChange, onToChange],
)
function handlePresetClick(p: { key: DashboardDatePreset }) {
onPresetChange(p.key)
setSelectingStage('from')
}
function handleReset() {
onReset()
setSelectingStage('from')
}
function handleApply() {
onApply()
handleClose()
}
const selectingLabel =
selectingStage === 'from' ? 'Выберите начало периода' : 'Выберите конец периода'
const periodAriaLabel = appliedLabel ? `Период: ${appliedLabel}` : 'Период'
const rangeInverted = Boolean(draftFrom && draftTo) && dayjs(draftTo).isBefore(dayjs(draftFrom))
const canApply = hasDraftTypes && !rangeInverted
return (
<LocalizationProvider dateAdapter={AdapterDayjs}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Box
component="button"
type="button"
onClick={handleToggle}
aria-label={periodAriaLabel}
aria-expanded={isOpen}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 1,
bgcolor: 'background.paper',
color: 'text.primary',
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
px: 1.25,
py: 0.5,
fontSize: 12,
fontWeight: 600,
cursor: 'pointer',
'&:hover': { borderColor: 'primary.main' },
}}
>
<Box
component="span"
aria-hidden="true"
sx={{ display: 'inline-flex', color: 'text.secondary' }}
>
<CalendarTodayRounded sx={{ fontSize: 14 }} />
</Box>
{appliedLabel && (
<Box
sx={{
color: 'text.secondary',
fontSize: 12,
fontWeight: 500,
lineHeight: 1.4,
whiteSpace: 'nowrap',
}}
>
{appliedLabel}
</Box>
)}
<Box
component="span"
aria-hidden="true"
sx={{
display: 'inline-flex',
color: 'text.secondary',
transform: isOpen ? 'rotate(180deg)' : 'none',
transition: 'transform 0.15s ease-in-out',
}}
>
<ExpandMoreRounded sx={{ fontSize: 16 }} />
</Box>
</Box>
<Popover
open={isOpen}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
sx={{ mt: 0.5 }}
>
<Box sx={{ p: 2, minWidth: 320 }}>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 1 }}>
{PRESETS.map((p) => (
<Chip
key={p.key}
label={p.label}
tone={p.key === preset ? 'primary' : 'neutral'}
selected={p.key === preset}
onClick={() => handlePresetClick(p)}
/>
))}
</Box>
<Text variant="caption" tone="secondary" sx={{ mb: 1, display: 'block' }}>
{selectingLabel}
</Text>
<DateCalendar
value={draftFrom ? dayjs(draftFrom) : null}
onChange={handleCalendarChange}
sx={{
'& .MuiPickersDay-root': {
...(draftFrom && draftTo
? {
'&.Mui-selected': {
bgcolor: 'primary.main',
color: 'common.white',
},
}
: {}),
},
}}
/>
<Box
sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 1 }}
>
<Box
component="button"
type="button"
onClick={handleReset}
sx={{
background: 'none',
border: 'none',
color: 'text.secondary',
fontSize: 12,
cursor: 'pointer',
textDecoration: 'underline',
textUnderlineOffset: 2,
p: 0.5,
}}
>
Сбросить
</Box>
<Box
component="button"
type="button"
onClick={handleApply}
disabled={!canApply}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 1,
bgcolor: canApply ? 'primary.main' : 'grey.400',
color: 'common.white',
border: 'none',
borderRadius: 1,
px: 1.5,
py: 0.75,
fontSize: 12,
fontWeight: 700,
cursor: canApply ? 'pointer' : 'default',
}}
>
Применить период
</Box>
</Box>
</Box>
</Popover>
<Box
component="button"
type="button"
onClick={onApply}
disabled={!canApply}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 1,
bgcolor: canApply ? 'primary.main' : 'grey.400',
color: 'common.white',
border: 'none',
borderRadius: 1,
px: 1.5,
py: 0.5,
fontSize: 12,
fontWeight: 700,
cursor: canApply ? 'pointer' : 'default',
'&:hover': canApply ? { bgcolor: 'primary.dark' } : {},
}}
>
Обновить
</Box>
</Box>
</LocalizationProvider>
)
}

View File

@ -0,0 +1,304 @@
import { Button, 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 { formatBrokerDate } from '@/shared/lib/formatters'
import type { DashboardDatePreset, DashboardEventType } from '../lib/dashboardFilters'
import { eventStatusLabel, eventTypeLabel } from '../lib/dashboardFormatters'
import {
eventStatusTone,
eventTypeTone,
formatDashboardCurrency,
instrumentDisplay,
type MoneyTone,
moneyTone,
moneyToneToColor,
type TypeTone,
} from '../lib/dashboardVisual'
import { BrokerDashboardCard } from './BrokerDashboardCard'
import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter'
import { BrokerDashboardTableSkeleton } from './BrokerDashboardTableSkeleton'
import { BrokerDashboardTableToolbar } from './BrokerDashboardTableToolbar'
const EVENT_FILTERS: Array<{
type: DashboardEventType
label: string
tone: TypeTone
}> = [
{ type: 'dividend', label: 'Дивиденды', tone: 'success' },
{ type: 'coupon', label: 'Купоны', tone: 'info' },
{ type: 'maturity', label: 'Погашения', tone: 'warning' },
{ type: 'offer', label: 'Оферты', tone: 'neutral' },
]
const TH_SX = {
textAlign: 'left' as const,
borderBottom: '1px solid',
borderColor: 'divider',
px: 1.25,
py: 1,
color: 'text.secondary',
fontWeight: 600,
fontSize: 12,
textTransform: 'uppercase' as const,
whiteSpace: 'nowrap' as const,
}
const TD_SX_LEFT = {
px: 1.25,
py: 1,
borderBottom: '1px solid',
borderColor: 'divider',
verticalAlign: 'middle' as const,
}
const TD_SX_RIGHT = {
...TD_SX_LEFT,
textAlign: 'right' as const,
}
const TD_SX_INSTRUMENT = {
...TD_SX_LEFT,
minWidth: 180,
}
type BrokerDashboardEventsCardProps = {
accountId: string
data: BrokerEventsData | undefined
isLoading: boolean
isError: boolean
selectedTypes: DashboardEventType[]
onToggleType: (type: DashboardEventType) => void
appliedDateLabel: string | null
draftPreset: DashboardDatePreset
draftFrom: string
draftTo: string
onDraftPresetChange: (preset: DashboardDatePreset) => void
onDraftFromChange: (value: string) => void
onDraftToChange: (value: string) => void
onApplyFilters: () => void
onResetFilters: () => void
hasDraftTypes: boolean
totalCount?: number
page: number
onPreviousPage: () => void
onNextPage: () => void
canGoBack: boolean
canGoForward: boolean
}
function eventAmountValue(event: BrokerEventItem): number | null {
return event.source === 'actual' ? event.actualAmount : event.estimatedAmount
}
function eventMoneyTone(event: BrokerEventItem): MoneyTone {
return moneyTone(eventAmountValue(event), event.source)
}
function eventAmountDisplay(event: BrokerEventItem): string {
const amount = eventAmountValue(event)
if (amount === null || amount === undefined) return '—'
const abs = formatDashboardCurrency({
currency: event.currency ?? 'RUB',
value: Math.abs(amount),
})
if (event.source === 'forecast') return `~${abs}`
if (amount > 0) return `+${abs}`
if (amount < 0) return `${abs}`
return abs
}
export function BrokerDashboardEventsCard({
accountId,
data,
isLoading,
isError,
selectedTypes,
onToggleType,
appliedDateLabel,
draftPreset,
draftFrom,
draftTo,
onDraftPresetChange,
onDraftFromChange,
onDraftToChange,
onApplyFilters,
onResetFilters,
hasDraftTypes,
totalCount,
page,
onPreviousPage,
onNextPage,
canGoBack,
canGoForward,
}: BrokerDashboardEventsCardProps) {
const events = data?.items ?? []
return (
<BrokerDashboardCard
title="События"
badge={events.length > 0 ? `${events.length} из ${totalCount ?? events.length}` : undefined}
action={<Link to={`/broker/${encodeURIComponent(accountId)}/events`}>Все события</Link>}
filters={
<BrokerDashboardTableToolbar
chips={EVENT_FILTERS.map((filter) => (
<Chip
key={filter.type}
label={filter.label}
tone={filter.tone}
selected={selectedTypes.includes(filter.type)}
onClick={() => onToggleType(filter.type)}
/>
))}
>
<BrokerDashboardDateFilter
appliedLabel={appliedDateLabel}
preset={draftPreset}
draftFrom={draftFrom}
draftTo={draftTo}
hasDraftTypes={hasDraftTypes}
onPresetChange={onDraftPresetChange}
onFromChange={onDraftFromChange}
onToChange={onDraftToChange}
onReset={onResetFilters}
onApply={onApplyFilters}
/>
</BrokerDashboardTableToolbar>
}
>
{selectedTypes.length === 0 ? (
<Text tone="negative">Выберите хотя бы один тип событий</Text>
) : isError ? (
<Text tone="negative">Не удалось загрузить события</Text>
) : isLoading ? (
<BrokerDashboardTableSkeleton rows={5} columns={5} />
) : events.length === 0 ? (
<Text tone="muted">В ближайшем периоде событий нет</Text>
) : (
<Box sx={{ display: 'grid', gap: 1 }}>
<Box sx={{ overflowX: 'auto' }}>
<Box
component="table"
aria-label="Таблица событий брокерского счёта"
sx={{ width: '100%', borderCollapse: 'collapse', fontSize: 14, minWidth: 640 }}
>
<Box component="thead">
<Box component="tr">
<Box component="th" sx={TH_SX}>
Дата
</Box>
<Box component="th" sx={TH_SX}>
Инструмент
</Box>
<Box component="th" sx={TH_SX}>
Тип
</Box>
<Box component="th" sx={{ ...TH_SX, textAlign: 'right' }}>
Сумма
</Box>
<Box component="th" sx={{ ...TH_SX, textAlign: 'right' }}>
Статус
</Box>
</Box>
</Box>
<Box component="tbody">
{events.map((event) => {
const display = instrumentDisplay({
ticker: event.ticker,
name: event.name,
})
const formattedAmount = eventAmountDisplay(event)
return (
<Box component="tr" key={event.id} data-testid="dashboard-events-row">
<Box component="td" sx={TD_SX_LEFT}>
{formatBrokerDate(event.eventDate) ?? '—'}
</Box>
<Box component="td" sx={TD_SX_INSTRUMENT}>
<Box sx={{ display: 'grid', gap: 0.25 }}>
<Box
sx={{ fontWeight: 700 }}
data-testid="dashboard-events-instrument-main"
>
{display.main}
</Box>
{display.subtitle ? (
<Box
sx={{ fontSize: 12, color: 'text.secondary' }}
data-testid="dashboard-events-instrument-subtitle"
>
{display.subtitle}
</Box>
) : null}
</Box>
</Box>
<Box component="td" sx={TD_SX_LEFT}>
<Chip
label={eventTypeLabel(event.type)}
tone={eventTypeTone(event.type)}
selected={false}
/>
</Box>
<Box
component="td"
sx={{
...TD_SX_RIGHT,
fontWeight: 700,
color: moneyToneToColor(eventMoneyTone(event)),
}}
data-testid="dashboard-events-amount"
data-tone={eventMoneyTone(event)}
>
{formattedAmount}
</Box>
<Box component="td" sx={TD_SX_RIGHT}>
<Chip
label={eventStatusLabel(event)}
tone={eventStatusTone(event.source)}
selected={false}
/>
</Box>
</Box>
)
})}
</Box>
</Box>
</Box>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
gap: 1,
alignItems: 'center',
flexWrap: 'wrap',
}}
>
<Text variant="caption" tone="secondary">
Показано {events.length} событий за выбранный период
</Text>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button
variant="secondary"
size="small"
onClick={onPreviousPage}
disabled={!canGoBack}
>
</Button>
<Text variant="body" tone="secondary">
{page}
</Text>
<Button
variant="secondary"
size="small"
onClick={onNextPage}
disabled={!canGoForward}
>
</Button>
</Box>
</Box>
</Box>
)}
</BrokerDashboardCard>
)
}

View File

@ -0,0 +1,83 @@
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'
import { formatDashboardCurrency, moneyTone, moneyToneToColor } from '../lib/dashboardVisual'
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 returnPercent = analytics?.totalReturnPercent ?? portfolio.yields.expectedPercent
const returnTone = moneyTone(typeof returnPercent === 'number' ? returnPercent : null)
const dailyTone = moneyTone(portfolio.yields.daily?.value ?? null)
const totalReceivedTone = moneyTone(analytics?.totalReceived ?? null)
const totalReceivedDisplay = analytics
? formatDashboardCurrency({ currency: analytics.currency, value: analytics.totalReceived })
: '—'
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: 'stretch',
}}
>
<Box>
<Text variant="label" tone="secondary">
Инвестиционный дашборд
</Text>
<Box sx={{ fontWeight: 800, fontSize: { xs: 22, md: 28 }, lineHeight: 1.15 }}>
{accountName}
</Box>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Metric label="Стоимость портфеля" value={formatBrokerMoney(portfolio.totals.portfolio)} />
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Metric
label="Доходность"
value={
<Box component="span" sx={{ color: moneyToneToColor(returnTone), fontWeight: 700 }}>
{percentValue(returnPercent)}
</Box>
}
supportingText={
<Box component="span" sx={{ color: moneyToneToColor(dailyTone) }}>
За день: {formatBrokerMoney(portfolio.yields.daily)}
</Box>
}
/>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Metric
label="Всего доходов"
value={
<Box
component="span"
sx={{ color: moneyToneToColor(totalReceivedTone), fontWeight: 700 }}
>
{totalReceivedDisplay}
</Box>
}
/>
</Box>
</Box>
)
}

View File

@ -0,0 +1,273 @@
import { Button, 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 { formatBrokerDate } from '@/shared/lib/formatters'
import type { DashboardDatePreset, DashboardIncomeType } from '../lib/dashboardFilters'
import { getDashboardIncomeRows, sumDashboardIncome } from '../lib/dashboardIncome'
import {
formatDashboardCurrency,
incomeTypeTone,
moneyToneToColor,
type TypeTone,
} from '../lib/dashboardVisual'
import { BrokerDashboardCard } from './BrokerDashboardCard'
import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter'
import { BrokerDashboardTableSkeleton } from './BrokerDashboardTableSkeleton'
import { BrokerDashboardTableToolbar } from './BrokerDashboardTableToolbar'
const INCOME_FILTERS: Array<{
type: DashboardIncomeType
label: string
tone: TypeTone
}> = [
{ type: 'dividend', label: 'Дивиденды', tone: 'success' },
{ type: 'coupon', label: 'Купоны', tone: 'info' },
]
const TH_SX = {
textAlign: 'left' as const,
borderBottom: '1px solid',
borderColor: 'divider',
px: 1.25,
py: 1,
color: 'text.secondary',
fontWeight: 600,
fontSize: 12,
textTransform: 'uppercase' as const,
whiteSpace: 'nowrap' as const,
}
const TD_SX_LEFT = {
px: 1.25,
py: 1,
borderBottom: '1px solid',
borderColor: 'divider',
verticalAlign: 'middle' as const,
}
const TD_SX_RIGHT = {
...TD_SX_LEFT,
textAlign: 'right' as const,
}
const TD_SX_INSTRUMENT = {
...TD_SX_LEFT,
minWidth: 200,
}
type BrokerDashboardIncomeCardProps = {
accountId: string
page: BrokerOperationsPage | undefined
isLoading: boolean
isError: boolean
selectedTypes: DashboardIncomeType[]
onToggleType: (type: DashboardIncomeType) => void
appliedDateLabel: string | null
draftPreset: DashboardDatePreset
draftFrom: string
draftTo: string
onDraftPresetChange: (preset: DashboardDatePreset) => void
onDraftFromChange: (value: string) => void
onDraftToChange: (value: string) => void
onApplyFilters: () => void
onResetFilters: () => void
hasDraftTypes: boolean
visibleCount: number
pageNumber: number
canGoBack: boolean
canGoForward: boolean
onPreviousPage: () => void
onNextPage: () => void
}
export function BrokerDashboardIncomeCard({
accountId,
page,
isLoading,
isError,
selectedTypes,
onToggleType,
appliedDateLabel,
draftPreset,
draftFrom,
draftTo,
onDraftPresetChange,
onDraftFromChange,
onDraftToChange,
onApplyFilters,
onResetFilters,
hasDraftTypes,
visibleCount,
pageNumber,
canGoBack,
canGoForward,
onPreviousPage,
onNextPage,
}: BrokerDashboardIncomeCardProps) {
const rows = getDashboardIncomeRows(page?.items ?? []).slice(0, 10)
const total = sumDashboardIncome(rows)
return (
<BrokerDashboardCard
title="Доходы"
badge={rows.length > 0 ? `${visibleCount} операции` : undefined}
action={<Link to={`/broker/${encodeURIComponent(accountId)}/operations`}>Все операции</Link>}
filters={
<BrokerDashboardTableToolbar
chips={INCOME_FILTERS.map((filter) => (
<Chip
key={filter.type}
label={filter.label}
tone={filter.tone}
selected={selectedTypes.includes(filter.type)}
onClick={() => onToggleType(filter.type)}
/>
))}
>
<BrokerDashboardDateFilter
appliedLabel={appliedDateLabel}
preset={draftPreset}
draftFrom={draftFrom}
draftTo={draftTo}
hasDraftTypes={hasDraftTypes}
onPresetChange={onDraftPresetChange}
onFromChange={onDraftFromChange}
onToChange={onDraftToChange}
onReset={onResetFilters}
onApply={onApplyFilters}
/>
</BrokerDashboardTableToolbar>
}
>
{selectedTypes.length === 0 ? (
<Text tone="negative">Выберите хотя бы один тип доходов</Text>
) : isError ? (
<Text tone="negative">Не удалось загрузить доходные операции</Text>
) : isLoading ? (
<BrokerDashboardTableSkeleton rows={5} columns={4} />
) : rows.length === 0 ? (
<Text tone="muted">Дивидендов и купонов в последних операциях нет</Text>
) : (
<Box sx={{ display: 'grid', gap: 1 }}>
<Box sx={{ overflowX: 'auto' }}>
<Box
component="table"
aria-label="Таблица доходов брокерского счёта"
sx={{ width: '100%', borderCollapse: 'collapse', fontSize: 14, minWidth: 520 }}
>
<Box component="thead">
<Box component="tr">
<Box component="th" sx={TH_SX}>
Дата
</Box>
<Box component="th" sx={TH_SX}>
Инструмент
</Box>
<Box component="th" sx={TH_SX}>
Тип
</Box>
<Box component="th" sx={{ ...TH_SX, textAlign: 'right' }}>
Сумма
</Box>
</Box>
</Box>
<Box component="tbody">
{rows.map((row) => {
const formattedAmount = `${row.amount.value >= 0 ? '+' : ''}${formatDashboardCurrency(
{ currency: row.amount.currency, value: Math.abs(row.amount.value) },
)}`
const amountTone = row.amount.value >= 0 ? 'positive' : 'negative'
return (
<Box component="tr" key={row.id} data-testid="dashboard-income-row">
<Box component="td" sx={TD_SX_LEFT}>
{formatBrokerDate(row.date) ?? '—'}
</Box>
<Box component="td" sx={TD_SX_INSTRUMENT}>
<Box sx={{ display: 'grid', gap: 0.25 }}>
<Box
sx={{ fontWeight: 700 }}
data-testid="dashboard-income-instrument-main"
>
{row.instrumentMain}
</Box>
{row.instrumentSubtitle ? (
<Box
sx={{ fontSize: 12, color: 'text.secondary' }}
data-testid="dashboard-income-instrument-subtitle"
>
{row.instrumentSubtitle}
</Box>
) : null}
</Box>
</Box>
<Box component="td" sx={TD_SX_LEFT}>
<Chip
label={row.typeLabel}
tone={incomeTypeTone(row.typeLabel)}
selected={false}
/>
</Box>
<Box
component="td"
sx={{
...TD_SX_RIGHT,
fontWeight: 700,
color: moneyToneToColor(amountTone),
}}
data-testid="dashboard-income-amount"
data-tone={amountTone}
>
{formattedAmount}
</Box>
</Box>
)
})}
</Box>
</Box>
</Box>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
gap: 1,
alignItems: 'center',
flexWrap: 'wrap',
}}
>
<Text variant="caption" tone="secondary">
Показано {rows.length} · Итого:{' '}
{total
? `${total.value >= 0 ? '+' : ''}${formatDashboardCurrency({
currency: total.currency,
value: Math.abs(total.value),
})}`
: '—'}
</Text>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, alignItems: 'center' }}>
<Button
variant="secondary"
size="small"
onClick={onPreviousPage}
disabled={!canGoBack}
>
</Button>
<Text variant="body" tone="secondary">
{pageNumber}
</Text>
<Button
variant="secondary"
size="small"
onClick={onNextPage}
disabled={!canGoForward}
>
</Button>
</Box>
</Box>
</Box>
)}
</BrokerDashboardCard>
)
}

View File

@ -0,0 +1,14 @@
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" />
<Skeleton height={300} shape="rounded" />
<Skeleton height={300} shape="rounded" />
<Skeleton height={260} shape="rounded" />
<Skeleton height={260} shape="rounded" />
</Box>
)
}

View File

@ -0,0 +1,79 @@
import { Skeleton } from '@moex-vibe/design-system'
import { Box } from '@mui/material'
type ColumnWidth = string
type BrokerDashboardTableSkeletonProps = {
rows?: number
columns?: number
}
const DEFAULT_EVENT_WIDTHS: ColumnWidth[] = ['14%', '32%', '18%', '18%', '18%']
const DEFAULT_INCOME_WIDTHS: ColumnWidth[] = ['18%', '40%', '22%', '20%']
const EVENT_ROW_VARIANTS: Array<'short' | 'long' | 'full'> = [
'short',
'long',
'medium',
'short',
'medium',
]
const INCOME_ROW_VARIANTS: Array<'short' | 'long' | 'full'> = ['short', 'long', 'medium', 'short']
function widthsFor(columns: number): ColumnWidth[] {
if (columns === 4) return DEFAULT_INCOME_WIDTHS
if (columns === 5) return DEFAULT_EVENT_WIDTHS
return Array.from({ length: columns }, () => '100%')
}
function rowVariantsFor(columns: number): Array<'short' | 'long' | 'full' | 'medium'> {
if (columns === 4) return INCOME_ROW_VARIANTS
return EVENT_ROW_VARIANTS.slice(0, columns)
}
export function BrokerDashboardTableSkeleton({
rows = 5,
columns = 5,
}: BrokerDashboardTableSkeletonProps) {
const widths = widthsFor(columns)
const variants = rowVariantsFor(columns)
return (
<Box
sx={{ display: 'grid', gap: 1, py: 1 }}
data-testid="dashboard-table-skeleton"
aria-hidden="true"
>
{Array.from({ length: rows }, (_, i) => (
<Box
key={i}
sx={{
display: 'flex',
gap: 2,
alignItems: 'center',
minHeight: 38,
py: 0.5,
}}
>
{widths.map((width, j) => (
<Box key={j} sx={{ flex: `0 0 ${width}`, minWidth: 0 }}>
<Skeleton
height={14}
width={
variants[j] === 'short'
? '45%'
: variants[j] === 'medium'
? '65%'
: variants[j] === 'full'
? '100%'
: '74%'
}
shape="rounded"
/>
</Box>
))}
</Box>
))}
</Box>
)
}

View File

@ -0,0 +1,33 @@
import { Box } from '@mui/material'
import type { ReactNode } from 'react'
type BrokerDashboardTableToolbarProps = {
chips: ReactNode
children: ReactNode
}
export function BrokerDashboardTableToolbar({ chips, children }: BrokerDashboardTableToolbarProps) {
return (
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', md: 'minmax(0, 1fr) auto auto' },
gap: 1.25,
alignItems: 'center',
border: '1px solid',
borderColor: 'divider',
bgcolor: 'grey.50',
borderRadius: 2,
p: 1,
}}
>
<Box sx={{ display: 'grid', gap: 0.75, minWidth: 0 }}>
<Box sx={{ color: 'text.secondary', fontSize: 12, fontWeight: 700, lineHeight: 1 }}>
Тип
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, minWidth: 0 }}>{chips}</Box>
</Box>
{children}
</Box>
)
}

View File

@ -0,0 +1,304 @@
# План реализации редизайна обзора брокерского счёта
> **Для агентных исполнителей:** ОБЯЗАТЕЛЬНЫЙ SUB-SKILL: использовать `superpowers:subagent-driven-development` (предпочтительно) или `superpowers:executing-plans` для пошагового выполнения. Шаги ведутся чекбоксами `- [ ]`.
**Цель:** превратить `/broker/:accountId` в компактный инвестиционный дашборд на текущей светлой теме без изменения backend-контрактов и URL-структуры.
**Архитектура:** маршрут и FSD-границы остаются прежними. Композиция собирается в `widgets/broker-dashboard`, данные продолжают приходить из существующих `entities/*` hooks. Навигация счёта остаётся в `BrokerAccountLayout`, а dashboard использует только локальные presentation/helpers без выноса брокерской логики в design system.
**Технологии:** React 18, TanStack Router, TanStack Query, MUI через `@moex-vibe/design-system`, MUI X DateCalendar community (`@mui/x-date-pickers`) с Day.js, Vitest, Testing Library.
**HTML parity update:** согласованный визуальный эталон находится в `docs/research/2026-06-27-broker-account-redesign.html`.
Следующая итерация переносит его детали в реальную страницу без изменения backend-контрактов.
---
## Область реализации
### Файлы
- Modify: `apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx` — горизонтальные вкладки над контентом обзора и подробных разделов.
- Modify: `apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx` — точка входа обзора через dashboard.
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/index.ts` — публичный API dashboard-виджета.
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx` — верхнеуровневая композиция и управление filter state.
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx` — hero KPI с fallback-значениями и выравниванием `Metric`.
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx` — локальный паттерн карточки.
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx` — карточка событий.
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx` — переиспользуемое управление периодом с draft/apply поведением.
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx` — карточка доходов.
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx` — карточка аналитики доходности.
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx` — карточка аллокации с горизонтальными барами.
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardSkeleton.tsx` — skeleton-форма dashboard.
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx` — компонентные тесты композиции и фильтров.
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts` — чистые helpers доходных операций.
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts` — unit-тесты income helpers.
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts` — пресеты дат, validate и mapping income types.
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts` — локальные formatter/helpers для fallback и event labels.
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts` — локальные helpers визуальной семантики dashboard: tone сумм, tone типов, отображение инструмента, символ валюты.
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts` — unit-тесты визуальных helpers.
- Modify: `apps/frontend/package.json` — добавить community-пакет `@mui/x-date-pickers`, если зависимость ещё не подключена; `@mui/x-date-pickers-pro` не добавлять.
- Modify: `docs/features/broker-dashboard-redesign/tasks.md` — фиксация статусов выполнения.
### Источники данных
- Портфель: `useBrokerAccountContext().portfolio`.
- События: `useBrokerEvents(accountId, { from, to, types })`.
- Операции: `useBrokerOperations(accountId, { from, to, operationTypes, cursor, limit: 10 })`.
- Аналитика: `useBrokerAnalytics(accountId)`.
- Аллокация: `buildBrokerAllocation(portfolio)`.
## Технические решения
### 1. Композиция страницы
- Dashboard на desktop и mobile строится в одну колонку: `Hero`, `События`, `Доходы`, `Аналитика доходности`, `Аллокация`.
- Двухколоночный layout из прототипа не переносится.
- Навигация счёта находится над контентом в `BrokerAccountLayout` и не дублируется внутри dashboard.
### 2. Hero KPI
- Hero показывает название счёта, стоимость портфеля, доходность, дневное изменение и всего полученных доходов.
- Приоритет доходности: `analytics.totalReturnPercent`, затем `portfolio.yields.expectedPercent`, иначе `—`.
- Дневное изменение берётся из `portfolio.yields.daily` и `portfolio.yields.dailyPercent`, при недоступности показывается `—`.
- Все `Metric` должны иметь одинаковую высоту; supporting text не должен ломать вертикальный ритм.
### 3. События
- Типы событий переключаются chip-фильтрами с немедленным применением.
- Диапазон дат по умолчанию: `сегодня - 7 дней` / `сегодня + 7 дней`, чтобы обзор сразу показывал
ближайшие будущие события.
- Диапазон дат редактируется отдельно от применённого состояния: draft state меняется локально, запрос уходит только по действию применения периода.
- В шапке управления периодом не используется слово `Фильтр`; роль управления считывается через иконку календаря, применённый диапазон, раскрытие панели и пресеты.
- Native `<input type="date">` не используется; период выбирается через одно визуальное поле и popover с community `DateCalendar` из `@mui/x-date-pickers`.
- `DateRangePicker` и `@mui/x-date-pickers-pro` не используются; выбор начала/конца периода реализуется локальной логикой dashboard.
- При пустом выборе типов запрос отключается, карточка показывает валидационное сообщение.
- Пагинация локальная, по 10 событий на страницу, сбрасывается при смене применённых фильтров.
- При загрузке карточка показывает skeleton таблицы событий с колонками дата, инструмент, тип, сумма, статус.
- В toolbar карточки используются count badge, label `Тип`, кнопка `Обновить` и footer-summary по паттерну HTML-эталона.
### 4. Доходы
- Доходы строятся на существующем endpoint операций только для `OPERATION_TYPE_DIVIDEND`, `OPERATION_TYPE_DIV_EXT`, `OPERATION_TYPE_COUPON`.
- Типы доходов переключаются chip-фильтрами с немедленным применением.
- Диапазон дат по умолчанию: `сегодня - 7 дней` / `сегодня`, чтобы не загружать большой объём операций на первом открытии.
- Диапазон дат использует тот же draft/apply паттерн, что и события.
- В шапке управления периодом не используется слово `Фильтр`; действие применения периода не называется `Показать`.
- Native `<input type="date">` не используется; период выбирается через одно визуальное поле и popover с community `DateCalendar` из `@mui/x-date-pickers`.
- `DateRangePicker` и `@mui/x-date-pickers-pro` не используются; выбор начала/конца периода реализуется локальной логикой dashboard.
- Пагинация cursor-based, размер страницы 10, сбрасывается при смене применённых фильтров.
- При загрузке карточка показывает skeleton таблицы доходов с колонками дата, инструмент, тип, сумма.
### 5. Аналитика и аллокация
- Карточка аналитики использует существующий analytics endpoint и показывает спокойное empty state при отсутствии данных.
- Карточка аллокации не использует donut chart. Она строит список горизонтальных bar rows по `buildBrokerAllocation`.
- Отрицательные значения показываются текстом без полосы.
### 6. Ошибки и пустые состояния
- Ошибка `portfolio` роняет весь обзор.
- Ошибки `events`, `income`, `analytics` локальны соответствующим карточкам.
- Пустые данные показываются отдельными сообщениями, а не нулевыми значениями.
### 7. HTML parity visual layer
- Реальная страница `/broker/:accountId` должна визуально соответствовать `docs/research/2026-06-27-broker-account-redesign.html`,
но использовать существующие React-компоненты и FSD-границы.
- Не менять backend и OpenAPI: блок `Доходы` остаётся на текущем endpoint операций и текущем наборе
income-типов. Отрицательный tone должен поддерживаться для строк, которые уже отображаются или будут
отображаться без расширения контракта.
- Ввести локальные helpers в `widgets/broker-dashboard/lib/dashboardVisual.ts`:
`moneyTone(value, source?) -> 'positive' | 'negative' | 'planned' | 'neutral'`,
`eventTypeTone(type)`, `incomeTypeTone(typeLabel)`, `formatDashboardCurrency(moneyOrValue)`,
`instrumentDisplay({ ticker, name, description })`.
- `formatDashboardCurrency` для RUB должен выводить `₽`. Для неизвестных валют использовать код валюты.
- `instrumentDisplay` должен возвращать основную строку и опциональную подпись: для событий приоритет
`ticker/isin` как main и `name` как subtitle; для операций приоритет `ticker` как main и
`name/description` как subtitle. Если ticker отсутствует, main берётся из name/description, subtitle не
дублируется.
- `BrokerDashboardCard` должен поддержать компактный заголовок карточки уровня HTML-прототипа, не
используя крупный `Heading size="title"`.
- `BrokerDashboardDateFilter` должен использовать иконку раскрытия вместо текстового символа и сохранять
единый toolbar-паттерн для `События` и `Доходы`.
- Для `События` period presets должны поддерживать будущую часть диапазона, а не обрезаться текущим днём.
- Таблицы `События` и `Доходы` должны иметь `thead`, type badges, двухстрочный инструмент при наличии
названия и semantic amount colors.
- `BrokerDashboardAnalyticsCard` должен окрашивать KPI-карточки по смыслу и показывать RUB через `₽`.
- Skeleton таблиц событий и доходов должен использовать один компонент/паттерн и различаться только
числом колонок.
## Задачи
### Задача 1: Базовые helpers и локальные dashboard-patterns
**Файлы:**
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts`
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts`
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts`
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts`
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx`
- [ ] Убедиться, что income helpers покрывают только допустимые типы доходов и умеют считать итог отображаемых строк.
- [ ] Убедиться, что filter helpers содержат default state, date presets, validate и mapping income type -> operation types.
- [ ] Убедиться, что formatters покрывают fallback-значения, label типов событий и label статусов.
- [ ] Использовать локальный `BrokerDashboardCard` как основной контейнер карточек; design system расширять только если без этого нельзя реализовать требования спецификации.
### Задача 2: Hero и layout overview
**Файлы:**
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx`
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardSkeleton.tsx`
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx`
- `apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx`
- `apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx`
- [ ] Собрать обзор через `BrokerDashboard` и `BrokerDashboardSkeleton`.
- [ ] Оставить навигацию счёта в `BrokerAccountLayout` как горизонтальные вкладки над контентом.
- [ ] Исправить hero так, чтобы все `Metric` были одной высоты и поддерживающий текст не поднимал одну ячейку выше остальных.
- [ ] Проверить, что dashboard остаётся одноколоночным и на desktop, и на mobile.
### Задача 3: Карточка событий
**Файлы:**
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx`
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx`
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx`
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts`
- `apps/frontend/package.json`
- [ ] Добавить или подтвердить зависимость community-пакета `@mui/x-date-pickers` и использовать Day.js adapter.
- [ ] Не добавлять `@mui/x-date-pickers-pro` и не использовать `DateRangePicker`.
- [ ] Обновить `BrokerDashboardDateFilter`: убрать слово `Фильтр` из пользовательского текста, заменить `Показать` на понятное действие применения периода и визуально собрать chips, диапазон, сброс и применение в аккуратную шапку.
- [ ] Заменить native date inputs на одно визуальное поле периода, которое открывает MUI `Popover` с community `DateCalendar`.
- [ ] Реализовать локальную логику выбора диапазона: первый клик задаёт начало, второй — конец; если конец раньше начала, диапазон пересобирается от выбранной даты.
- [ ] Настроить default range событий на `сегодня - 7 дней` / `сегодня + 7 дней`.
- [ ] Подключить для событий draft/applied state: типы применяются сразу, даты только по действию применения периода.
- [ ] Сохранять локальную пагинацию по 10 событий и сбрасывать её при смене применённых фильтров.
- [ ] Заменить текстовую загрузку событий на skeleton таблицы.
- [ ] Оставить локальные error/empty states внутри карточки.
### Задача 4: Карточка доходов
**Файлы:**
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx`
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx`
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts`
- [ ] Подключить тот же `BrokerDashboardDateFilter` для доходов с draft/applied state.
- [ ] Настроить default range доходов на `сегодня - 7 дней` / `сегодня` вместо периода с начала года.
- [ ] Оставить chip-фильтры типов доходов с немедленным применением.
- [ ] Сохранить cursor pagination по 10 операций и сбрасывать её при смене применённых фильтров.
- [ ] Заменить текстовую загрузку доходов на skeleton таблицы.
- [ ] Если endpoint операций даёт недостаточно релевантных строк для dashboard, зафиксировать ограничение в заметках по реализации, а не расширять backend в рамках этой фичи.
### Задача 5: Карточки аналитики и аллокации
**Файлы:**
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx`
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx`
- [ ] Довести карточку аналитики до соответствия спецификации по полям и состояниям.
- [ ] Заменить donut chart на горизонтальные бары, построенные из `buildBrokerAllocation`.
- [ ] Отрицательные значения аллокации выводить отдельно текстом без bar.
### Задача 6: Тесты и верификация
**Файлы:**
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx`
- `docs/features/broker-dashboard-redesign/tasks.md`
- [ ] Обновить компонентные тесты dashboard так, чтобы они покрывали текущую композицию, фильтры и основные empty/error states.
- [ ] Обновить тесты, которые ищут `Фильтр дат` или `Показать`, под новые тексты и accessible labels единого поля периода.
- [ ] Добавить/обновить тесты default weekly range для событий и доходов.
- [ ] Добавить/обновить тесты skeleton-таблиц для loading state событий и доходов.
- [ ] Прогнать `rtk npm run test:frontend -- --run src/widgets/broker-dashboard`.
- [ ] Прогнать `rtk npm run test:frontend`.
- [ ] Прогнать `rtk npm run test:design-system && rtk npm run lint -w apps/frontend && rtk npm run build:frontend`.
- [ ] Проверить вручную desktop layout `/broker/2084014113`.
- [ ] Проверить вручную mobile layout `/broker/2084014113` на viewport `390x844`.
- [ ] После завершения обновить `docs/features/broker-dashboard-redesign/tasks.md` и выполнить `graphify update .`.
### Задача 7: Visual helpers для HTML parity
**Файлы:**
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts`
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts`
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts`
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts`
- [ ] Добавить `moneyTone`, который возвращает `negative` для отрицательных значений, `positive` для
положительных фактических значений, `planned` для прогнозных/нефактических значений и `neutral` для
нуля/недоступного значения.
- [ ] Добавить `formatDashboardCurrency`, который для `RUB` выводит `₽`, а для неизвестной валюты
оставляет код валюты.
- [ ] Добавить `instrumentDisplay` с приоритетами main/subtitle из технического решения 7.
- [ ] Добавить type tone helpers для event types и income labels.
- [ ] Расширить `DashboardIncomeRow`: хранить `instrumentMain` и `instrumentSubtitle`, сохранив
совместимость через существующий `instrument` только если это нужно текущим тестам.
- [ ] Покрыть helpers unit-тестами: RUB symbol, unknown currency fallback, negative/positive/planned
tones, event/income type tones, отсутствие дублирования subtitle.
### Задача 8: Hero, карточка и toolbar parity
**Файлы:**
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx`
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx`
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx`
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx`
- [ ] Применить semantic color к hero: отрицательная доходность красная, положительная зелёная,
дневное изменение окрашивается по знаку, `Всего доходов` зелёный при значении больше нуля.
- [ ] Сделать заголовки dashboard-card компактными, соответствующими HTML-прототипу.
- [ ] Собрать filters area карточек в единый toolbar: слева типы, справа поле периода и действие.
- [ ] Заменить текстовую стрелку раскрытия периода на иконку: использовать уже подключённые
`CalendarTodayRounded` и `ExpandMoreRounded` из `@mui/icons-material`, без добавления новой icon
dependency.
- [ ] Обновить component tests: проверять отсутствие текста `RUB` для RUB-значений, отсутствие символа
`v` в period button и наличие accessible name у управления периодом.
### Задача 9: Таблицы событий и доходов как в HTML
**Файлы:**
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx`
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx`
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardTableSkeleton.tsx`
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx`
- [ ] Добавить `thead` в обе dashboard-таблицы с колонками из спецификации.
- [ ] В колонке инструмента выводить main и subtitle из `instrumentDisplay`.
- [ ] Заменить текстовые типы на компактные бейджи с tone из visual helpers.
- [ ] Окрашивать суммы через `moneyTone`: поступления зелёным, списания красным, прогноз/ожидание серым.
- [ ] Окрашивать статусные бейджи: `Поступило` зелёный, прогноз/ожидание серый.
- [ ] Сохранить горизонтальный scroll только внутри таблицы на mobile, без общего page overflow.
- [ ] Обновить skeleton так, чтобы `События` и `Доходы` использовали один визуальный паттерн строк.
- [ ] Обновить tests на наличие type badges, subtitle инструмента, semantic amount classes и skeleton.
### Задача 10: Analytics parity и визуальная проверка
**Файлы:**
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx`
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx`
- `docs/features/broker-dashboard-redesign/tasks.md`
- [ ] Отображать RUB как `₽` во всех analytics KPI.
- [ ] Окрашивать analytics cards: positive для пополнений/дивидендов/купонов/всего получено,
negative для выводов и отрицательного нетто, neutral для нулевых/недоступных значений.
- [ ] Обновить component tests для analytics: `₽` вместо `RUB`, positive/negative tone cards.
- [ ] Прогнать `rtk npm run test:frontend -- --run src/widgets/broker-dashboard`.
- [ ] Прогнать `rtk npm run test:frontend`.
- [ ] Прогнать `rtk npm run lint -w apps/frontend && rtk npm run build:frontend`.
- [ ] Проверить `/broker/2084014113` вручную на desktop и mobile `390x844` против
`docs/research/2026-06-27-broker-account-redesign.html`.
- [ ] После проверки обновить `docs/features/broker-dashboard-redesign/tasks.md`.
## Проверка покрытия спецификации
- Общая композиция и горизонтальная навигация: задачи 2 и 6.
- Hero KPI и equal-height `Metric`: задача 2.
- События с chip filters, date filters и локальной пагинацией: задача 3.
- Доходы с chip filters, date filters и cursor pagination: задача 4.
- Community DateCalendar range control, weekly default range и skeleton loading tables: задачи 3, 4 и 6.
- Аналитика и аллокация с горизонтальными барами: задача 5.
- Ошибки, empty states, тесты и ручная верификация: задача 6.
- HTML parity: visual helpers — задача 7, hero/card/toolbar — задача 8, dashboard tables — задача 9,
analytics — задача 10.

View File

@ -0,0 +1,292 @@
# Редизайн обзора брокерского счёта в инвестиционный дашборд
Дата: 2026-06-26 (обновлено 2026-06-27, HTML parity)
Статус: согласовано к планированию
Эпик: [Портфель брокера](../../epics/BrokerPortfolio.md)
## Контекст
Текущий маршрут `/broker/:accountId` показывает корректный overview выбранного брокерского счёта, но
визуально остаётся вертикальным набором отдельных блоков: сводка, аллокация, карточки активов,
ближайшие события и последние операции. Пользователь подготовил прототип `temp.html`, где тот же домен
представлен как более плотный инвестиционный дашборд с hero KPI, компактными таблицами и аналитическими
карточками.
После обсуждения выбран вариант A: страница `/broker/:accountId` должна стать единым дашбордом,
используя структуру и плотность прототипа, но сохраняя текущую светлую тему и компоненты
`@moex-vibe/design-system`. Тёмная тема из прототипа не переносится в первую версию. При этом
двухколоночная desktop-композиция прототипа сознательно не переносится: и на desktop, и на mobile блоки
dashboard идут по одному на строке в фиксированном порядке.
После интерактивной дизайн-итерации согласован статический эталон
`docs/research/2026-06-27-broker-account-redesign.html`. Реальная React-страница должна визуально соответствовать этому HTML:
компактные заголовки карточек, единая шапка таблиц, бейджи типов, подписи инструментов, семантические
цвета денежных значений и единый skeleton таблиц.
## Цель
Сделать overview брокерского счёта быстрым обзором состояния портфеля, будущих/прошедших событий,
полученных доходов, аналитики доходности и аллокации без перехода по вкладкам.
## Пользовательский результат
Пользователь может на `/broker/:accountId`:
- сразу увидеть стоимость портфеля, доходность и сумму полученных доходов;
- увидеть ближайшие события по счёту в компактной таблице;
- увидеть последние доходные операции по дивидендам и купонам и итог по ним;
- оценить вложения, полученные выплаты и доходность по существующей аналитике;
- увидеть структуру портфеля через горизонтальные полосы аллокации и подписи к ним;
- перейти в существующие подробные вкладки `Акции`, `Облигации`, `Операции`, `События` и `Аналитика`
для 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-страницей, а не вертикальным списком независимых секций.
- Существующая навигация счёта отображается горизонтальными вкладками над контентом, чтобы не занимать
левую колонку и оставить больше пространства для таблиц dashboard.
- На desktop и mobile блоки dashboard отображаются по одному блоку на строке: hero KPI, `События`,
`Доходы`, `Аналитика доходности`, `Аллокация`.
- Существующая навигация счёта сохраняет ссылки на `Обзор`, `Акции`, `Облигации`, `Операции`,
`События`, `Аналитика`.
- На мобильном viewport сохраняется тот же порядок блоков в одну колонку.
### 2. Визуальный стиль
- Используется текущая светлая DS-тема MoexVibe.
- Не добавляется dark mode и не переносится тёмная палитра `temp.html`.
- Визуальная плотность, структура карточек, KPI-иерархия и компактность таблиц ориентируются на
`docs/research/2026-06-27-broker-account-redesign.html`.
- Дизайн использует компоненты и токены `@moex-vibe/design-system` там, где они применимы.
- Если DS-компонент слишком ограничен, допускается точечно расширить DS API или создать локальный
dashboard-pattern в frontend, но не добавлять доменную брокерскую логику в DS.
- Заголовки dashboard-карточек не должны использовать page-level размер `Heading size="title"`;
визуально они должны соответствовать компактному card heading из HTML-прототипа.
- В карточках `События` и `Доходы` фильтры должны быть собраны в единый toolbar: группа типов, единое
поле периода с иконкой календаря и chevron-иконкой, действие обновления/применения периода.
- В поле периода не используется текстовый символ `v`; раскрытие обозначается иконкой.
- Валюта в dashboard отображается символом `₽`, а не строкой `RUB`, кроме случаев, где backend вернул
другую валюту и formatter проекта не знает её символ.
### 3. Hero KPI
Hero показывает:
- название счёта или fallback `Брокерский счёт`;
- стоимость портфеля из `portfolio.totals.portfolio`;
- доходность из существующих данных: приоритет `broker analytics.totalReturnPercent`, если загружена,
иначе `portfolio.yields.expectedPercent`, если доступна;
- дневное изменение из `portfolio.yields.daily` и `portfolio.yields.dailyPercent`, если доступно;
- всего полученных доходов из `broker analytics.totalReceived`, если аналитика загружена;
- спокойный fallback `—` для недоступных значений.
- Все Metric-блоки имеют одинаковую высоту в grid-ряду, даже если у некоторых есть supportingText. Поддерживающий текст остаётся внутри Metric, но все Metrics растягиваются на полную высоту grid-ячейки с выравниванием от верхнего края.
- Доходность в hero имеет цветовую семантику: положительная — зелёная, отрицательная — красная,
недоступная/нулевая — нейтральная.
- Дневное изменение в supporting text hero тоже окрашивается по знаку значения.
- `Всего доходов` окрашивается как положительный финансовый показатель, если значение больше нуля.
### 4. Блок `События`
- Блок использует существующий источник `useBrokerEvents(accountId, query)`.
- По умолчанию применяется период `сегодня - 7 дней` / `сегодня + 7 дней` и типы
`dividend,coupon,maturity,offer`, как в существующей вкладке событий.
- Блок содержит кликабельные chip-фильтры типов событий: `Дивиденды`, `Купоны`, `Погашения`, `Оферты`.
- Пользователь может выбрать несколько типов событий.
- Если пользователь снимает все типы событий, запрос не выполняется, а блок показывает
валидационное сообщение.
- Изменение chip-фильтров типов событий применяется сразу и возвращает локальную пагинацию на первую
страницу.
- Блок содержит фильтр периода `from` / `to`.
- По умолчанию применяется недельный период `сегодня - 7 дней` / `сегодня + 7 дней`, чтобы обзор
сразу показывал ближайшие будущие события.
- Изменение черновых фильтров не запускает запрос до применения периода пользователем.
- В пользовательском тексте шапки периода не используется слово `Фильтр`; UI должен считываться как
управление периодом за счёт иконки календаря, применённого диапазона, пресетов и affordance раскрытия.
- Блок содержит быстрые пресеты периода `7д`, `30д`, `90д`, `1г`, `Всё`, действие `Сбросить` и
основное действие применения периода с более понятным текстом, чем `Показать`.
- Период отображается как одно визуальное поле/кнопка с диапазоном, например `19 июн 26 июн`.
- По клику на поле периода открывается popover с community-компонентом MUI `DateCalendar` из
`@mui/x-date-pickers` и ручной логикой выбора начала/конца периода.
- `DateRangePicker` и пакет `@mui/x-date-pickers-pro` не используются.
- Native `<input type="date">` не используется.
- Применённые фильтры dashboard не обязаны синхронизироваться с URL; URL-синхронизация остаётся
обязанностью подробной вкладки `События`.
- В dashboard отображается не более 10 событий на странице.
- Если в выбранном диапазоне больше 10 событий, блок показывает локальную пагинацию по страницам.
- Смена применённых фильтров возвращает пагинацию блока на первую страницу.
- Таблица показывает дату, инструмент, тип, сумму и статус.
- Таблица содержит компактную строку заголовков колонок.
- В колонке `Инструмент` показывается тикер/ISIN и дополнительная строка с названием инструмента, если
оно доступно из `event.name`; если названия нет, дополнительная строка не занимает место.
- В колонке `Тип` значения отображаются небольшими бейджами с разными тонами для купона, дивиденда,
погашения и оферты.
- Сумма для `actual` берётся из `actualAmount`, сумма для `forecast` берётся из `estimatedAmount`.
- Фактические поступления визуально отмечаются как `Поступило`.
- Прогнозные суммы помечаются как оценочные.
- Суммы окрашиваются по смыслу: фактическое положительное поступление зелёным, отрицательное списание
красным, прогноз/план серым. Цвет не является единственным носителем смысла: прогнозные строки также
имеют статусный текст.
- Статус отображается компактным бейджем. `Поступило` использует зелёный тон, прогноз/ожидание —
нейтральный серый тон.
- Блок содержит ссылку на подробную вкладку `/broker/:accountId/events`.
- Ошибка загрузки событий не ломает остальной dashboard.
### 5. Блок `Доходы`
- Блок показывает последние доходные операции по дивидендам и купонам из существующего endpoint
операций.
- В первую версию входят операции с типами дивидендов и купонов, которые уже используются в backend
analytics: `OPERATION_TYPE_DIVIDEND`, `OPERATION_TYPE_DIV_EXT`, `OPERATION_TYPE_COUPON`.
- Блок содержит кликабельные chip-фильтры типов доходов: `Дивиденды`, `Купоны`.
- Пользователь может выбрать один или оба типа доходов.
- Если пользователь снимает все типы доходов, запрос не выполняется, а блок показывает
валидационное сообщение.
- Изменение chip-фильтров типов доходов применяется сразу и сбрасывает cursor-пагинацию.
- Блок содержит фильтр периода `from` / `to`.
- По умолчанию применяется недельный период `сегодня - 7 дней` / `сегодня`, чтобы dashboard не загружал
слишком много операций при первом открытии.
- Изменение черновых фильтров не запускает запрос до применения периода пользователем.
- В пользовательском тексте шапки периода не используется слово `Фильтр`; UI должен считываться как
управление периодом за счёт иконки календаря, применённого диапазона, пресетов и affordance раскрытия.
- Блок содержит быстрые пресеты периода `7д`, `30д`, `90д`, `1г`, `Всё`, действие `Сбросить` и
основное действие применения периода с более понятным текстом, чем `Показать`.
- Период отображается как одно визуальное поле/кнопка с диапазоном, например `19 июн 26 июн`.
- По клику на поле периода открывается popover с community-компонентом MUI `DateCalendar` из
`@mui/x-date-pickers` и ручной логикой выбора начала/конца периода.
- `DateRangePicker` и пакет `@mui/x-date-pickers-pro` не используются.
- Native `<input type="date">` не используется.
- Применённые фильтры dashboard не обязаны синхронизироваться с URL; URL-синхронизация остаётся
обязанностью подробных разделов.
- Для блока используется cursor-пагинация existing operations endpoint с размером страницы 10.
- Смена применённых фильтров сбрасывает cursor-пагинацию блока на первую страницу.
- Таблица показывает дату, инструмент, тип и сумму.
- Таблица содержит компактную строку заголовков колонок.
- В колонке `Инструмент` показывается тикер и дополнительная строка с названием операции/инструмента,
если оно доступно из `operation.name` или `operation.description`.
- В колонке `Тип` значения отображаются небольшими бейджами.
- Суммы окрашиваются по знаку: положительные поступления зелёным, отрицательные списания красным,
плановые/нефактические значения серым, если такие строки отображаются в блоке.
- Блок показывает итог по отображаемым доходным операциям.
- Блок содержит ссылку на подробную вкладку `/broker/:accountId/operations`.
- Если текущий endpoint операций не позволяет корректно получить доходные операции без изменения
backend-контракта, первая реализация должна явно зафиксировать это в `plan.md` перед изменением API.
- В этой итерации блок `Доходы` не расширяется до полной истории комиссий/налогов; цветовая семантика
отрицательных сумм должна быть готова для отображаемых строк, но API-контракт не меняется.
### 6. Блок `Аналитика доходности`
- Блок использует существующий endpoint `/api/v1/broker/accounts/:accountId/analytics`.
- Отображаются: пополнения, выводы, нетто вложено, дивиденды, купоны, всего получено, доходность.
- Денежные значения analytics отображаются с символом валюты `₽` для RUB.
- Карточки analytics используют цветовую семантику: положительные потоки и полученные доходы —
зелёный тон, отрицательные выводы и отрицательное нетто — красный тон, нейтральные/нулевые значения —
нейтральный тон.
- При отсутствии analytics data блок показывает спокойное пустое состояние.
- Ошибка analytics не ломает остальные блоки.
### 7. Блок `Аллокация`
- Используется существующий расчёт `buildBrokerAllocation`.
- Вместо donut-диаграммы используется горизонтальный bar chart: каждый сектор — полоса с процентом,
подписью и суммой.
- Сверху блока показывается итоговая стоимость портфеля.
- Каждая полоса содержит: название сектора, долю в процентах, сумму в валюте.
- Цвета полос соответствуют существующей палитре аллокации (акции, облигации, ETF, деньги, прочие).
- Отрицательные значения отображаются текстом без полосы.
- Текст подписей контрастный и читаемый на всех цветах фона.
- Информация остаётся понятной без различения цветов.
### 8. Загрузка, ошибки и пустые состояния
- Первичная загрузка portfolio показывает dashboard skeleton соответствующей формы.
- Ошибка portfolio показывает ошибку overview, потому что без portfolio dashboard не имеет основного
контекста.
- Загрузка событий и доходов внутри карточек показывает skeleton таблицы соответствующей структуры, а не
только текстовую строку загрузки.
- Skeleton таблиц событий и доходов должен иметь единый визуальный паттерн: строки соответствуют
геометрии таблицы, колонка инструмента шире остальных, для `Доходы` используется тот же паттерн без
лишней колонки статуса.
- Ошибка событий, доходов или 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, `События`, `Доходы`,
`Аналитика доходности`, `Аллокация`.
- Навигация счёта отображается горизонтальными вкладками над dashboard-контентом.
- На desktop и mobile блоки `События`, `Доходы`, `Аналитика доходности`, `Аллокация` расположены по
одному блоку на строке.
- На мобильном viewport dashboard читаемо перестраивается в одну колонку.
- Hero показывает стоимость портфеля, доходность или fallback, дневное изменение или fallback, всего
доходов или fallback.
- Все Metric-блоки hero имеют одинаковую высоту; supportingText не создаёт перекоса.
- Hero KPI использует цветовую семантику для доходности, дневного изменения и всего полученных доходов.
- Блок `События` использует существующие events data и показывает дату, инструмент, тип, сумму и статус.
- В таблице `События` инструмент отображается двумя строками при наличии названия, тип отображается
бейджем, сумма и статус имеют семантические цвета.
- Блок `События` поддерживает multi-select chip-фильтр типов и локальную пагинацию по 10 событий.
- Блок `События` по умолчанию запрашивает период `сегодня - 7 дней` / `сегодня + 7 дней` и использует одно поле
периода с popover-календарём на базе community `DateCalendar`.
- Блок `Доходы` показывает доходные операции дивидендов и купонов и итог по отображаемым строкам.
- В таблице `Доходы` инструмент отображается двумя строками при наличии названия, тип отображается
бейджем, сумма имеет семантический цвет.
- Блок `Доходы` поддерживает multi-select chip-фильтр типов и cursor-пагинацию по 10 операций.
- Блок `Доходы` по умолчанию запрашивает период `сегодня - 7 дней` / `сегодня` и использует одно поле
периода с popover-календарём на базе community `DateCalendar`.
- В шапке управления периодом не отображается слово `Фильтр`, а действие применения периода не называется
`Показать`.
- Шапки фильтров `События` и `Доходы` визуально соответствуют единому toolbar из
`docs/research/2026-06-27-broker-account-redesign.html`.
- Поле периода использует chevron-иконку, а не текстовый символ `v`.
- Загрузка событий и доходов отображается skeleton-таблицей.
- Блок `Аналитика доходности` показывает данные существующего analytics endpoint.
- Блок `Аналитика доходности` отображает RUB как `₽` и использует цветовую семантику карточек.
- Блок `Аллокация` показывает горизонтальные бары секторов с названием, долей и суммой, а также
итоговую стоимость портфеля.
- Ошибка одного вторичного блока не скрывает остальные блоки dashboard.
- Существующие detailed вкладки остаются доступны из навигации счёта.
- Первая версия не содержит график истории стоимости портфеля и не добавляет API для него.
- Дизайн использует светлую DS-тему, а не тёмную тему из `temp.html`.
## Вне области фичи
- график стоимости портфеля по датам;
- новые исторические снапшоты стоимости;
- dark mode;
- изменение backend-расчётов доходности;
- налоговая аналитика;
- экспорт dashboard;
- объединение нескольких брокерских счетов в один dashboard.

View File

@ -0,0 +1,139 @@
# Редизайн обзора брокерского счёта в инвестиционный дашборд — задачи
Дата: 2026-06-26 (обновлено 2026-06-27)
Статус: в реализации, итерация HTML parity
## Документация и 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`.
## Реализация
- [x] Добавить pure helpers для фильтрации и суммирования доходных операций dashboard.
- [x] Добавить helpers для фильтров дат, пресетов, валидации и mapping income types → operationTypes.
- [x] Добавить dashboard presentation helpers для fallback, event labels и statuses.
- [x] Добавить локальный `BrokerDashboardCard` pattern или точечно расширить DS, если локального pattern недостаточно.
- [x] Добавить `BrokerDashboardHero` с KPI по portfolio и analytics.
- [x] Добавить `BrokerDashboardEventsCard` на основе `useBrokerEvents`.
- [x] Добавить `BrokerDashboardIncomeCard` на основе `useBrokerOperations`.
- [x] Перенести навигацию счёта из левой колонки в горизонтальные вкладки над контентом.
- [x] Перестроить dashboard на один блок на строке для `События`, `Доходы`, `Аналитика доходности`, `Аллокация`.
- [x] Добавить кликабельные chip-фильтры типов для `События`.
- [x] Добавить `BrokerDashboardDateFilter` — переиспользуемый expandable-компонент фильтра дат (пресеты 7д/30д/90д/1г/Всё, from/to поля, Сбросить/Показать).
- [x] Подключить `BrokerDashboardDateFilter` в `События` с draft/applied состоянием.
- [x] Добавить локальную пагинацию по 10 событий в `События`.
- [x] Добавить кликабельные chip-фильтры типов для `Доходы`.
- [x] Подключить `BrokerDashboardDateFilter` в `Доходы` с draft/applied состоянием.
- [x] Добавить cursor-пагинацию по 10 операций в `Доходы`.
- [x] Добавить `BrokerDashboardAnalyticsCard` на основе `useBrokerAnalytics`.
- [x] Добавить `BrokerDashboardAllocationCard` на основе существующей аллокации.
- [x] Добавить `BrokerDashboardSkeleton`.
- [x] Добавить `BrokerDashboard` как top-level composition widget.
- [x] Заменить текущий вертикальный обзор в `BrokerAccountOverviewPage` на `BrokerDashboard`.
- [x] Добавить unit/component tests для helpers и базовой dashboard composition.
- [x] Выровнять hero KPI: все Metric одной высоты, supportingText не раздвигает "Доходность" выше соседей.
- [x] Заменить donut-диаграмму аллокации на горизонтальные бары в `BrokerDashboardAllocationCard`.
- [x] Убрать слово `Фильтр` из пользовательского текста шапки периода в dashboard-карточках.
- [x] Переименовать действие `Показать` в управлении периодом и визуально улучшить шапку фильтров.
- [x] Заменить native date inputs на одно поле периода с popover и community `DateCalendar` из `@mui/x-date-pickers`.
- [x] Не использовать `@mui/x-date-pickers-pro` и `DateRangePicker`.
- [x] Настроить default range событий на `сегодня - 7 дней` / `сегодня + 7 дней`, доходов — на
`сегодня - 7 дней` / `сегодня`.
- [x] Заменить текстовую загрузку `События` на skeleton таблицы.
- [x] Заменить текстовую загрузку `Доходы` на skeleton таблицы.
- [x] Обновить component tests под новые тексты, единое поле периода, popover-календарь и skeleton loading states.
- [ ] Проверить desktop layout `/broker/2084014113`.
- [ ] Проверить mobile layout `/broker/2084014113`.
## Итерация HTML parity
- [x] Согласовать статический визуальный эталон `docs/research/2026-06-27-broker-account-redesign.html`.
- [x] Обновить `spec.md` под HTML parity: компактные заголовки, единый toolbar, бейджи, цвета, подписи инструментов, `₽`.
- [x] Обновить `plan.md` под перенос HTML parity в React-компоненты.
- [x] Добавить `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts` с helpers для money tone, type tone, currency symbol и instrument display.
- [x] Добавить `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts`.
- [ ] Расширить income helpers так, чтобы строки доходов могли отдавать main/subtitle инструмента без дублирования текста.
- [x] Обновить `BrokerDashboardHero`: semantic colors для доходности, дневного изменения и всего полученных доходов.
- [x] Обновить `BrokerDashboardCard`: компактный card heading вместо крупного page-level heading.
- [x] Обновить `BrokerDashboardDateFilter`: единый toolbar-паттерн и chevron-иконка вместо текстового `v`.
- [x] Обновить `BrokerDashboardEventsCard`: `thead`, двухстрочный инструмент, type badges, semantic amount colors, status badges.
- [x] Обновить `BrokerDashboardIncomeCard`: `thead`, двухстрочный инструмент, type badges, semantic amount colors.
Бейдж `Купон` приведён к `info` (синий) согласно HTML-эталону `.type-badge.coupon` (`incomeTypeTone` в `dashboardVisual.ts`),
`Дивиденд (внешний)` отнесён к `success` (семейство дивидендов).
- [x] Обновить `BrokerDashboardTableSkeleton`: общий skeleton-паттерн для событий и доходов с корректной геометрией колонок.
- [x] Обновить `BrokerDashboardAnalyticsCard`: `₽` для RUB и positive/negative tone карточек.
- [x] Обновить component tests dashboard под HTML parity (analytics tones).
- [x] Проверить, что блок `Доходы` не расширяет backend/API и остаётся в рамках текущих income-типов.
Исправлен пресет «Всё»: `applyDatePreset('all')` теперь отдаёт широкий диапазон `2000-01-01``2099-12-31`
вместо пустых `from`/`to`, которые backend (`BrokerEventsQueryDto @Matches`) отвергал с 400. В DateFilter
кнопка «Применить период» блокируется при инвертированном диапазоне (`to < from`), чтобы избежать
молчаливо пустого ответа (например, будущий `from` при дефолтном `to=сегодня`).
- [x] Исправить default-range `События`, чтобы dashboard по умолчанию и через пресеты мог загружать будущие события.
- [x] Подтянуть card headers, toolbar label `Тип`, count badges и footer summaries ближе к
`docs/research/2026-06-27-broker-account-redesign.html`.
- [ ] Проверить desktop layout `/broker/2084014113` против `docs/research/2026-06-27-broker-account-redesign.html`.
- [ ] Проверить mobile layout `/broker/2084014113` на viewport `390x844` против `docs/research/2026-06-27-broker-account-redesign.html`.
## Definition of Done
- [x] `rtk npm run test:frontend` проходит.
- [x] `rtk npm run test:design-system` проходит.
- [x] `rtk npm run lint -w apps/frontend` проходит.
- [x] `rtk npm run build:frontend` проходит.
- [ ] Dashboard соответствует acceptance criteria из `spec.md`.
- [x] Существующие вкладки `Акции`, `Облигации`, `Операции`, `События`, `Аналитика` остаются доступны.
- [x] `graphify update .` выполнен после code changes.
## Definition of Done для HTML parity
- [x] `rtk npm run test:frontend -- --run src/widgets/broker-dashboard` проходит. (49/49)
- [x] `rtk npm run test:frontend` проходит после HTML parity изменений. (175/175)
- [x] `rtk npm run lint -w apps/frontend` проходит после HTML parity изменений.
- [x] `rtk npm run build:frontend` проходит после HTML parity изменений.
- [ ] На `/broker/2084014113` заголовки карточек, toolbar таблиц, бейджи типов, подписи инструментов,
цвета сумм, analytics colors и skeleton визуально соответствуют `docs/research/2026-06-27-broker-account-redesign.html`.
Проверено только по тестам и code-to-spec mapping внизу файла — live dev server не запускался
в этой итерации (нет backend/auth в среде). Необходима ручная проверка пользователем.
- [ ] Нет общего горизонтального overflow на mobile; горизонтальный scroll допускается только внутри таблиц.
Требует live dev server + ручной проверки на viewport `390x844`.
- [x] `docs/features/broker-dashboard-redesign/tasks.md` обновлён по факту выполнения.
### Code-to-spec mapping для analytics (HTML parity)
`BrokerDashboardAnalyticsCard.tsx` (`apps/frontend/src/widgets/broker-dashboard/ui/`) против
HTML-эталона (`docs/research/2026-06-27-broker-account-redesign.html:1145-1158`):
| HTML reference element | React-компонент / data-testid | Спецификация §6 (spec.md:165-180) |
|------------------------|------------------------------------------------------------|----------------------------------------------------------------------|
| `<section aria-labelledby="analytics-title">` | `BrokerDashboardCard` с `ariaLabel="Аналитика доходности"` | Секция с заголовком, доступная по aria-label |
| `<h2>Аналитика доходности</h2>` | Заголовок карточки | Компактный card heading (§2) |
| `.analytics-item.positive .analytics-value` | `data-testid="dashboard-analytics-totalDeposits"` (tone `positive`) | Пополнения — positive (§6, AC `Блок Аналитика доходности`) |
| `.analytics-item.negative .analytics-value` для Выводы | `data-testid="dashboard-analytics-totalWithdrawn"` (tone `negative`, префикс ``) | Выводы — negative, всегда со знаком `` (§6) |
| `.analytics-item.negative .analytics-value` для Нетто (если нетто<0) | `data-testid="dashboard-analytics-netInvested"` (tone `negative` при `value<0`) | Нетто sign-based 6) |
| `.analytics-item.positive .analytics-value` для Дивиденды/Купоны/Всего получено | `data-testid="dashboard-analytics-totalDividends"`, `…-totalCoupons`, `…-totalReceived` | Positive если `value > 0`, neutral если `value === 0` (§6) |
| `113 773,03 ₽` (валюта) | `formatDashboardCurrency` через `shared/lib/formatters` | RUB отображается как `₽` (AC, §2) |
Тесты в `BrokerDashboard.test.tsx`:
- `renders analytics card with the ₽ symbol and no "RUB" code` — подтверждает замену `RUB` на `₽`.
- `applies positive, negative and neutral tones to analytics values` — подтверждает tone-атрибуты
для каждого поля с разнообразными значениями (`totalDeposits: 1000`, `totalWithdrawn: 250`,
`netInvested: -150`, `totalDividends: 75`, `totalCoupons: 0`, `totalReceived: 90`).
### Что НЕ было проверено в этой итерации
- Реальный визуальный рендеринг `/broker/2084014113` на desktop и `390x844` mobile.
Требуется ручная проверка пользователем с поднятым backend (нужны реальные auth и T-Bank/MOEX
прокси). Dev server не запускался.
- Поведение отсутствующего/ошибочного analytics под live-нагрузкой. Логика в карточке покрыта
тестами, но проверка UX-сообщений и skeleton-states в браузере не делалась.
- Реальный viewport на `390x844` для подтверждения отсутствия общего горизонтального overflow.
Геометрия таблиц уже переключена на внутренний `overflowX: 'auto'` (`BrokerDashboardEventsCard.tsx:176`,
`BrokerDashboardIncomeCard.tsx`), но фактическая вёрстка в браузере не сверялась с эталоном.

View File

@ -166,6 +166,17 @@ cash flow, бюджеты, аналитика, прогнозы и автома
## Frontend-платформа
### Добавить историю стоимости брокерского портфеля
- Сохранять снапшоты полной стоимости брокерского счёта, чтобы строить график динамики портфеля по
датам.
- Отдельно спроектировать backend storage/API, периодичность обновления, валюту расчёта и правила для
пропущенных дней.
- На дашборде брокерского счёта заменить временный отказ от графика на полноценный блок `Стоимость
портфеля`, когда данные истории будут доступны.
- Не реализовывать в первой версии редизайна `/broker/:accountId`: текущая задача использует только
уже доступные данные портфеля, событий, операций и аналитики.
### Перейти к Feature-Sliced Design
- Постепенно привести frontend к FSD-архитектуре с явными границами между `app`, `pages`, `widgets`,

File diff suppressed because it is too large Load Diff

View File

@ -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 доработки и смешанный календарь.

87
package-lock.json generated
View File

@ -92,6 +92,7 @@
"@moex-vibe/design-system": "*",
"@mui/icons-material": "^6.5.0",
"@mui/material": "^6.5.0",
"@mui/x-date-pickers": "^7.29.4",
"@tanstack/react-query": "^5.20.0",
"@tanstack/react-router": "^1.170.16",
"@tanstack/react-table": "^8.21.3",
@ -7426,6 +7427,92 @@
"integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
"license": "MIT"
},
"node_modules/@mui/x-date-pickers": {
"version": "7.29.4",
"resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.29.4.tgz",
"integrity": "sha512-wJ3tsqk/y6dp+mXGtT9czciAMEO5Zr3IIAHg9x6IL0Eqanqy0N3chbmQQZv3iq0m2qUpQDLvZ4utZBUTJdjNzw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.25.7",
"@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0",
"@mui/x-internals": "7.29.0",
"@types/react-transition-group": "^4.4.11",
"clsx": "^2.1.1",
"prop-types": "^15.8.1",
"react-transition-group": "^4.4.5"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@emotion/react": "^11.9.0",
"@emotion/styled": "^11.8.1",
"@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0",
"@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0",
"date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0",
"date-fns-jalali": "^2.13.0-0 || ^3.2.0-0 || ^4.0.0-0",
"dayjs": "^1.10.7",
"luxon": "^3.0.2",
"moment": "^2.29.4",
"moment-hijri": "^2.1.2 || ^3.0.0",
"moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/react": {
"optional": true
},
"@emotion/styled": {
"optional": true
},
"date-fns": {
"optional": true
},
"date-fns-jalali": {
"optional": true
},
"dayjs": {
"optional": true
},
"luxon": {
"optional": true
},
"moment": {
"optional": true
},
"moment-hijri": {
"optional": true
},
"moment-jalaali": {
"optional": true
}
}
},
"node_modules/@mui/x-internals": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.29.0.tgz",
"integrity": "sha512-+Gk6VTZIFD70XreWvdXBwKd8GZ2FlSCuecQFzm6znwqXg1ZsndavrhG9tkxpxo2fM1Zf7Tk8+HcOO0hCbhTQFA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.25.7",
"@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",

View File

@ -59,4 +59,22 @@ describe('Chip', () => {
renderWithTheme(<Chip label="Static" />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('calls onClick when clickable chip clicked', async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
renderWithTheme(<Chip label="Clickable" onClick={handleClick} />);
await user.click(screen.getByRole('button', { name: 'Clickable' }));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('renders unselected clickable chip as outlined', () => {
renderWithTheme(<Chip label="Inactive" onClick={() => {}} selected={false} />);
const chip = screen.getByText('Inactive').closest('.MuiChip-root')!;
expect(chip.classList.contains('MuiChip-outlined')).toBe(true);
expect(chip).toHaveAttribute('aria-pressed', 'false');
});
});

View File

@ -1,5 +1,5 @@
import { Chip as MuiChip } from '@mui/material';
import CancelIcon from '@mui/icons-material/Cancel';
import { Chip as MuiChip } from '@mui/material';
type Tone = 'neutral' | 'info' | 'success' | 'warning' | 'error';
@ -15,14 +15,28 @@ export interface ChipProps {
label: string;
tone?: Tone;
onDelete?: () => void;
onClick?: () => void;
selected?: boolean;
disabled?: boolean;
}
export function Chip({ label, tone = 'neutral', onDelete }: ChipProps) {
export function Chip({
label,
tone = 'neutral',
onDelete,
onClick,
selected = true,
disabled,
}: ChipProps) {
return (
<MuiChip
label={label}
color={TONE_MAP[tone]}
variant={selected ? 'filled' : 'outlined'}
onClick={onClick}
onDelete={onDelete}
disabled={disabled}
aria-pressed={onClick ? selected : undefined}
{...(onDelete ? { deleteIcon: <CancelIcon aria-label={`Remove ${label}`} /> } : {})}
/>
);