({
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,
diff --git a/apps/frontend/src/entities/broker-operation/model/useBrokerOperations.ts b/apps/frontend/src/entities/broker-operation/model/useBrokerOperations.ts
index 20335c4..80de165 100644
--- a/apps/frontend/src/entities/broker-operation/model/useBrokerOperations.ts
+++ b/apps/frontend/src/entities/broker-operation/model/useBrokerOperations.ts
@@ -5,10 +5,11 @@ import { type BrokerOperationQuery, getBrokerOperations } from '../api/brokerOpe
export function useBrokerOperations(
accountId: string | undefined,
query: BrokerOperationQuery = {},
+ options: { enabled?: boolean } = {},
) {
return useQuery({
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,
diff --git a/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx b/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx
index c5ae061..fdd5398 100644
--- a/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx
+++ b/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx
@@ -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
+ if (portfolio.isLoading) return
if (portfolio.error || !portfolio.data) {
return (
@@ -21,28 +14,5 @@ export function BrokerAccountOverviewPage() {
)
}
- return (
-
-
-
-
-
- {operations.error ? (
-
- Не удалось загрузить последние операции
-
- ) : (
- Вся история
- }
- emptyMessage="Операций с начала текущего года нет"
- isLoading={operations.isLoading}
- isFetching={operations.isFetching}
- page={operations.data}
- />
- )}
-
- )
+ return
}
diff --git a/apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx b/apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx
index e029550..b3cd84d 100644
--- a/apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx
+++ b/apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx
@@ -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 }) {
-
- {links.map((link) => (
-
- {link.label}
-
- ))}
-
-
- {children}
+ {links.map((link) => (
+
+ {link.label}
+
+ ))}
+
+ {children}
)
diff --git a/apps/frontend/src/widgets/broker-dashboard/index.ts b/apps/frontend/src/widgets/broker-dashboard/index.ts
new file mode 100644
index 0000000..2f37b13
--- /dev/null
+++ b/apps/frontend/src/widgets/broker-dashboard/index.ts
@@ -0,0 +1,2 @@
+export { BrokerDashboard } from './ui/BrokerDashboard'
+export { BrokerDashboardSkeleton } from './ui/BrokerDashboardSkeleton'
diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts
new file mode 100644
index 0000000..06ad5d6
--- /dev/null
+++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts
@@ -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 = {
+ from: string
+ to: string
+ types: T[]
+ preset: DashboardDatePreset
+}
+
+export function defaultEventsFilters(): DashboardFilterState {
+ 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 {
+ 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(
+ filters: DashboardFilterState,
+ preset: DashboardDatePreset,
+): DashboardFilterState {
+ 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,
+ preset: DashboardDatePreset,
+): DashboardFilterState {
+ 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(
+ filters: DashboardFilterState,
+): 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()
+ 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(',')
+}
diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts
new file mode 100644
index 0000000..bc05bfb
--- /dev/null
+++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts
@@ -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 'Ожидается'
+}
diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts
new file mode 100644
index 0000000..95f529c
--- /dev/null
+++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts
@@ -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([])
+ })
+})
diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts
new file mode 100644
index 0000000..c377546
--- /dev/null
+++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts
@@ -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 }
+}
diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts
new file mode 100644
index 0000000..17e7de4
--- /dev/null
+++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts
@@ -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,
+ })
+ })
+})
diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts
new file mode 100644
index 0000000..d1fdd00
--- /dev/null
+++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts
@@ -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 }
+}
diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx
new file mode 100644
index 0000000..3624b50
--- /dev/null
+++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx
@@ -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 }) => (
+ {children}
+ ),
+}))
+
+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({ui})
+}
+
+function buildEvent(overrides: Partial = {}): 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 {
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ const skeletons = screen.getAllByTestId('dashboard-table-skeleton')
+ expect(skeletons.length).toBeGreaterThanOrEqual(1)
+ })
+
+ it('does not show skeleton when data is loaded', () => {
+ renderWithProviders()
+
+ expect(screen.queryByTestId('dashboard-table-skeleton')).not.toBeInTheDocument()
+ })
+
+ it('renders events table thead with semantic column headers', () => {
+ mockEventsLoaded([buildEvent()])
+
+ renderWithProviders()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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')
+ })
+})
diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx
new file mode 100644
index 0000000..6069b93
--- /dev/null
+++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx
@@ -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 (
+
+
+ 1}
+ canGoForward={eventPage * eventPageSize < eventItems.length}
+ onPreviousPage={() => setEventPage((page) => Math.max(1, page - 1))}
+ onNextPage={() => setEventPage((page) => page + 1)}
+ />
+ 1}
+ canGoForward={operations.data?.hasNext ?? false}
+ onPreviousPage={incomePagination.handlePrevious}
+ onNextPage={() => incomePagination.handleNext(nextCursor)}
+ />
+
+
+
+ )
+}
diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx
new file mode 100644
index 0000000..d7ed14e
--- /dev/null
+++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx
@@ -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 = {
+ 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 (
+ {formatBrokerMoney(portfolio.totals.portfolio)}}
+ >
+ {sectors.length === 0 && negative.length === 0 ? (
+ Нет данных для распределения
+ ) : (
+
+
+
+ Структура портфеля
+
+
+ {formatBrokerMoney(portfolio.totals.portfolio)}
+
+
+ {sectors.map((sector) => (
+
+
+ {sector.label}
+
+ {formatBrokerCurrencyValue(currency, sector.value)} · {sector.percent.toFixed(1)}%
+
+
+
+
+
+
+ ))}
+ {negative.length > 0 && (
+
+ {negative.map((item) => (
+
+ {item.label}:
+
+ {formatBrokerCurrencyValue(currency, item.value)}
+
+
+ ))}
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx
new file mode 100644
index 0000000..40e9c5e
--- /dev/null
+++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx
@@ -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 (
+
+ {isError ? (
+ Не удалось загрузить аналитику
+ ) : isLoading ? (
+ Загрузка аналитики…
+ ) : !data ? (
+ Нет данных для аналитики
+ ) : (
+
+ {ANALYTICS_METRICS.map(({ field, label, testId }) => {
+ const value = data[field]
+ const tone = analyticsTone(field, value)
+ return (
+
+
+ {analyticsDisplay(field, value, data.currency)}
+
+ }
+ />
+
+ )
+ })}
+
+ )}
+
+ )
+}
diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx
new file mode 100644
index 0000000..0cbe672
--- /dev/null
+++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx
@@ -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
+ ariaLabel?: string
+}
+
+export function BrokerDashboardCard({
+ title,
+ badge,
+ action,
+ filters,
+ children,
+ sx,
+ ariaLabel,
+}: BrokerDashboardCardProps) {
+ const resolvedAriaLabel = ariaLabel ?? title
+ return (
+
+
+
+
+ {title}
+
+ {badge ? (
+
+ {badge}
+
+ ) : null}
+
+ {action}
+
+ {filters && {filters}}
+ {children}
+
+ )
+}
diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx
new file mode 100644
index 0000000..16ea1d8
--- /dev/null
+++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx
@@ -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(null)
+ const [selectingStage, setSelectingStage] = useState<'from' | 'to'>('from')
+ const isOpen = Boolean(anchorEl)
+
+ const handleToggle = useCallback((e: React.MouseEvent) => {
+ 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 (
+
+
+
+
+
+
+ {appliedLabel && (
+
+ {appliedLabel}
+
+ )}
+
+
+
+
+
+
+
+ {PRESETS.map((p) => (
+ handlePresetClick(p)}
+ />
+ ))}
+
+
+ {selectingLabel}
+
+
+
+
+ Сбросить
+
+
+ Применить период
+
+
+
+
+
+ Обновить
+
+
+
+ )
+}
diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx
new file mode 100644
index 0000000..ab9d494
--- /dev/null
+++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx
@@ -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 (
+ 0 ? `${events.length} из ${totalCount ?? events.length}` : undefined}
+ action={Все события}
+ filters={
+ (
+ onToggleType(filter.type)}
+ />
+ ))}
+ >
+
+
+ }
+ >
+ {selectedTypes.length === 0 ? (
+ Выберите хотя бы один тип событий
+ ) : isError ? (
+ Не удалось загрузить события
+ ) : isLoading ? (
+
+ ) : events.length === 0 ? (
+ В ближайшем периоде событий нет
+ ) : (
+
+
+
+
+
+
+ Дата
+
+
+ Инструмент
+
+
+ Тип
+
+
+ Сумма
+
+
+ Статус
+
+
+
+
+ {events.map((event) => {
+ const display = instrumentDisplay({
+ ticker: event.ticker,
+ name: event.name,
+ })
+ const formattedAmount = eventAmountDisplay(event)
+ return (
+
+
+ {formatBrokerDate(event.eventDate) ?? '—'}
+
+
+
+
+ {display.main}
+
+ {display.subtitle ? (
+
+ {display.subtitle}
+
+ ) : null}
+
+
+
+
+
+
+ {formattedAmount}
+
+
+
+
+
+ )
+ })}
+
+
+
+
+
+ Показано {events.length} событий за выбранный период
+
+
+
+
+ {page}
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx
new file mode 100644
index 0000000..349da27
--- /dev/null
+++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx
@@ -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 (
+
+
+
+ Инвестиционный дашборд
+
+
+ {accountName}
+
+
+
+
+
+
+
+ {percentValue(returnPercent)}
+
+ }
+ supportingText={
+
+ За день: {formatBrokerMoney(portfolio.yields.daily)}
+
+ }
+ />
+
+
+
+ {totalReceivedDisplay}
+
+ }
+ />
+
+
+ )
+}
diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx
new file mode 100644
index 0000000..8101cc9
--- /dev/null
+++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx
@@ -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 (
+ 0 ? `${visibleCount} операции` : undefined}
+ action={Все операции}
+ filters={
+ (
+ onToggleType(filter.type)}
+ />
+ ))}
+ >
+
+
+ }
+ >
+ {selectedTypes.length === 0 ? (
+ Выберите хотя бы один тип доходов
+ ) : isError ? (
+ Не удалось загрузить доходные операции
+ ) : isLoading ? (
+
+ ) : rows.length === 0 ? (
+ Дивидендов и купонов в последних операциях нет
+ ) : (
+
+
+
+
+
+
+ Дата
+
+
+ Инструмент
+
+
+ Тип
+
+
+ Сумма
+
+
+
+
+ {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 (
+
+
+ {formatBrokerDate(row.date) ?? '—'}
+
+
+
+
+ {row.instrumentMain}
+
+ {row.instrumentSubtitle ? (
+
+ {row.instrumentSubtitle}
+
+ ) : null}
+
+
+
+
+
+
+ {formattedAmount}
+
+
+ )
+ })}
+
+
+
+
+
+ Показано {rows.length} · Итого:{' '}
+ {total
+ ? `${total.value >= 0 ? '+' : '−'}${formatDashboardCurrency({
+ currency: total.currency,
+ value: Math.abs(total.value),
+ })}`
+ : '—'}
+
+
+
+
+ {pageNumber}
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardSkeleton.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardSkeleton.tsx
new file mode 100644
index 0000000..a9c6181
--- /dev/null
+++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardSkeleton.tsx
@@ -0,0 +1,14 @@
+import { Skeleton } from '@moex-vibe/design-system'
+import { Box } from '@mui/material'
+
+export function BrokerDashboardSkeleton() {
+ return (
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardTableSkeleton.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardTableSkeleton.tsx
new file mode 100644
index 0000000..42c0bd7
--- /dev/null
+++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardTableSkeleton.tsx
@@ -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 (
+
+ {Array.from({ length: rows }, (_, i) => (
+
+ {widths.map((width, j) => (
+
+
+
+ ))}
+
+ ))}
+
+ )
+}
diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardTableToolbar.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardTableToolbar.tsx
new file mode 100644
index 0000000..5493ac4
--- /dev/null
+++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardTableToolbar.tsx
@@ -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 (
+
+
+
+ Тип
+
+ {chips}
+
+ {children}
+
+ )
+}
diff --git a/docs/features/broker-dashboard-redesign/plan.md b/docs/features/broker-dashboard-redesign/plan.md
new file mode 100644
index 0000000..fd78c1d
--- /dev/null
+++ b/docs/features/broker-dashboard-redesign/plan.md
@@ -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 `` не используется; период выбирается через одно визуальное поле и 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 `` не используется; период выбирается через одно визуальное поле и 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.
diff --git a/docs/features/broker-dashboard-redesign/spec.md b/docs/features/broker-dashboard-redesign/spec.md
new file mode 100644
index 0000000..ac37cc3
--- /dev/null
+++ b/docs/features/broker-dashboard-redesign/spec.md
@@ -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 `` не используется.
+- Применённые фильтры 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 `` не используется.
+- Применённые фильтры 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.
diff --git a/docs/features/broker-dashboard-redesign/tasks.md b/docs/features/broker-dashboard-redesign/tasks.md
new file mode 100644
index 0000000..30661d9
--- /dev/null
+++ b/docs/features/broker-dashboard-redesign/tasks.md
@@ -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) |
+|------------------------|------------------------------------------------------------|----------------------------------------------------------------------|
+| `` | `BrokerDashboardCard` с `ariaLabel="Аналитика доходности"` | Секция с заголовком, доступная по aria-label |
+| `
Аналитика доходности
` | Заголовок карточки | Компактный 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`), но фактическая вёрстка в браузере не сверялась с эталоном.
diff --git a/docs/inbox.md b/docs/inbox.md
index 6657334..632d5bb 100644
--- a/docs/inbox.md
+++ b/docs/inbox.md
@@ -166,6 +166,17 @@ cash flow, бюджеты, аналитика, прогнозы и автома
## Frontend-платформа
+### Добавить историю стоимости брокерского портфеля
+
+- Сохранять снапшоты полной стоимости брокерского счёта, чтобы строить график динамики портфеля по
+ датам.
+- Отдельно спроектировать backend storage/API, периодичность обновления, валюту расчёта и правила для
+ пропущенных дней.
+- На дашборде брокерского счёта заменить временный отказ от графика на полноценный блок `Стоимость
+ портфеля`, когда данные истории будут доступны.
+- Не реализовывать в первой версии редизайна `/broker/:accountId`: текущая задача использует только
+ уже доступные данные портфеля, событий, операций и аналитики.
+
### Перейти к Feature-Sliced Design
- Постепенно привести frontend к FSD-архитектуре с явными границами между `app`, `pages`, `widgets`,
diff --git a/docs/research/2026-06-27-broker-account-redesign.html b/docs/research/2026-06-27-broker-account-redesign.html
new file mode 100644
index 0000000..b529407
--- /dev/null
+++ b/docs/research/2026-06-27-broker-account-redesign.html
@@ -0,0 +1,1220 @@
+
+
+
+
+
+ MoexVibe - брокерский счет
+
+
+
+