codex/broker-dashboard-redesign #49
@ -24,6 +24,7 @@
|
||||
"@moex-vibe/design-system": "*",
|
||||
"@mui/icons-material": "^6.5.0",
|
||||
"@mui/material": "^6.5.0",
|
||||
"@mui/x-date-pickers": "^7.29.4",
|
||||
"@tanstack/react-query": "^5.20.0",
|
||||
"@tanstack/react-router": "^1.170.16",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
@ -43,7 +44,6 @@
|
||||
"@biomejs/biome": "^2.5.0",
|
||||
"@conarti/eslint-plugin-feature-sliced": "^1.0.5",
|
||||
"@tanstack/router-devtools": "^1.167.0",
|
||||
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
|
||||
@ -18,12 +18,13 @@ export function AppLayout() {
|
||||
background: 'var(--color-surface)',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
padding: '12px 24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: 24,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1fr) auto',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap' }}>
|
||||
<Link
|
||||
to="/"
|
||||
style={{
|
||||
@ -38,6 +39,11 @@ export function AppLayout() {
|
||||
<div style={{ flex: '1 1 280px', minWidth: 220, maxWidth: 420 }}>
|
||||
<SearchBar />
|
||||
</div>
|
||||
</div>
|
||||
<nav
|
||||
style={{ display: 'flex', gap: 20, flexWrap: 'wrap' }}
|
||||
aria-label="Основная навигация"
|
||||
>
|
||||
<Link
|
||||
to="/portfolios"
|
||||
style={{
|
||||
@ -71,12 +77,13 @@ export function AppLayout() {
|
||||
>
|
||||
Скринер
|
||||
</Link>
|
||||
|
||||
</nav>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
|
||||
@ -2,11 +2,15 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import type { BrokerEventsData } from '@/shared/api'
|
||||
import { type BrokerEventsQuery, getBrokerEvents } from '../api/brokerEventApi'
|
||||
|
||||
export function useBrokerEvents(accountId: string | undefined, query: BrokerEventsQuery) {
|
||||
export function useBrokerEvents(
|
||||
accountId: string | undefined,
|
||||
query: BrokerEventsQuery,
|
||||
options: { enabled?: boolean } = {},
|
||||
) {
|
||||
const { from, to, types } = query
|
||||
return useQuery<BrokerEventsData>({
|
||||
queryKey: ['broker', 'events', accountId, from, to, types],
|
||||
enabled: Boolean(accountId),
|
||||
enabled: Boolean(accountId) && (options.enabled ?? true),
|
||||
queryFn: async () => (await getBrokerEvents(accountId!, { from, to, types })).data,
|
||||
staleTime: 300_000,
|
||||
retry: 2,
|
||||
|
||||
@ -5,10 +5,11 @@ import { type BrokerOperationQuery, getBrokerOperations } from '../api/brokerOpe
|
||||
export function useBrokerOperations(
|
||||
accountId: string | undefined,
|
||||
query: BrokerOperationQuery = {},
|
||||
options: { enabled?: boolean } = {},
|
||||
) {
|
||||
return useQuery<BrokerOperationsPage>({
|
||||
queryKey: ['broker', 'operations', accountId, query],
|
||||
enabled: Boolean(accountId),
|
||||
enabled: Boolean(accountId) && (options.enabled ?? true),
|
||||
queryFn: async () => (await getBrokerOperations(accountId!, query)).data,
|
||||
staleTime: 300_000,
|
||||
retry: 2,
|
||||
|
||||
@ -1,18 +1,11 @@
|
||||
import { Text } from '@moex-vibe/design-system'
|
||||
import { Box } from '@mui/material'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useBrokerOperations } from '@/entities/broker-operation'
|
||||
import { useBrokerAccountContext } from '@/widgets/broker-account-layout'
|
||||
import { BrokerAllocationChart } from '@/widgets/broker-allocation-chart'
|
||||
import { BrokerEventsOverview } from '@/widgets/broker-events-overview'
|
||||
import { BrokerOperationsTable } from '@/widgets/broker-operations-table'
|
||||
import { BrokerAssetCards, BrokerOverviewSkeleton, BrokerSummary } from '@/widgets/broker-overview'
|
||||
import { BrokerDashboard, BrokerDashboardSkeleton } from '@/widgets/broker-dashboard'
|
||||
|
||||
export function BrokerAccountOverviewPage() {
|
||||
const { accountId, portfolio } = useBrokerAccountContext()
|
||||
const operations = useBrokerOperations(accountId, { limit: 5 })
|
||||
|
||||
if (portfolio.isLoading) return <BrokerOverviewSkeleton />
|
||||
if (portfolio.isLoading) return <BrokerDashboardSkeleton />
|
||||
if (portfolio.error || !portfolio.data) {
|
||||
return (
|
||||
<Text component="p" role="alert" tone="negative">
|
||||
@ -21,28 +14,5 @@ export function BrokerAccountOverviewPage() {
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box component="div" sx={{ display: 'grid', gap: 3 }}>
|
||||
<BrokerSummary portfolio={portfolio.data} />
|
||||
<BrokerAllocationChart portfolio={portfolio.data} />
|
||||
<BrokerAssetCards accountId={accountId} portfolio={portfolio.data} />
|
||||
<BrokerEventsOverview accountId={accountId} />
|
||||
{operations.error ? (
|
||||
<Text component="p" role="alert" tone="negative">
|
||||
Не удалось загрузить последние операции
|
||||
</Text>
|
||||
) : (
|
||||
<BrokerOperationsTable
|
||||
title="Последние операции"
|
||||
headerAction={
|
||||
<Link to={`/broker/${encodeURIComponent(accountId)}/operations`}>Вся история</Link>
|
||||
}
|
||||
emptyMessage="Операций с начала текущего года нет"
|
||||
isLoading={operations.isLoading}
|
||||
isFetching={operations.isFetching}
|
||||
page={operations.data}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
return <BrokerDashboard accountId={accountId} portfolio={portfolio.data} />
|
||||
}
|
||||
|
||||
@ -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 = [
|
||||
@ -46,29 +47,15 @@ export function BrokerAccountLayout({ children }: { children: ReactNode }) {
|
||||
<Heading level={1}>{portfolio.data?.account.name || 'Брокерский счёт'}</Heading>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr',
|
||||
gap: 2,
|
||||
'@media (min-width: 720px)': {
|
||||
gridTemplateColumns: 'minmax(150px, 190px) minmax(0, 1fr)',
|
||||
gap: 3,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="nav"
|
||||
aria-label="Разделы брокерского счёта"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.5,
|
||||
'@media (max-width: 719px)': {
|
||||
flexDirection: 'row',
|
||||
overflowX: 'auto',
|
||||
scrollbarWidth: 'thin',
|
||||
},
|
||||
pb: 0.5,
|
||||
}}
|
||||
>
|
||||
{links.map((link) => (
|
||||
@ -82,6 +69,7 @@ export function BrokerAccountLayout({ children }: { children: ReactNode }) {
|
||||
...baseLinkStyle,
|
||||
color: 'var(--color-primary)',
|
||||
fontWeight: 700,
|
||||
background: 'rgba(25, 118, 210, 0.1)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
@ -92,7 +80,6 @@ export function BrokerAccountLayout({ children }: { children: ReactNode }) {
|
||||
|
||||
<Box sx={{ minWidth: 0 }}>{children}</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</BrokerAccountContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
2
apps/frontend/src/widgets/broker-dashboard/index.ts
Normal file
2
apps/frontend/src/widgets/broker-dashboard/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { BrokerDashboard } from './ui/BrokerDashboard'
|
||||
export { BrokerDashboardSkeleton } from './ui/BrokerDashboardSkeleton'
|
||||
@ -0,0 +1,92 @@
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
export const DASHBOARD_EVENT_TYPES = ['dividend', 'coupon', 'maturity', 'offer'] as const
|
||||
export const DASHBOARD_INCOME_TYPES = ['dividend', 'coupon'] as const
|
||||
|
||||
export type DashboardEventType = (typeof DASHBOARD_EVENT_TYPES)[number]
|
||||
export type DashboardIncomeType = (typeof DASHBOARD_INCOME_TYPES)[number]
|
||||
export type DashboardDatePreset = '7d' | '30d' | '90d' | '1y' | 'all'
|
||||
|
||||
export type DashboardFilterState<T extends string> = {
|
||||
from: string
|
||||
to: string
|
||||
types: T[]
|
||||
preset: DashboardDatePreset
|
||||
}
|
||||
|
||||
export function defaultEventsFilters(): DashboardFilterState<DashboardEventType> {
|
||||
const now = dayjs()
|
||||
return {
|
||||
from: now.subtract(7, 'day').format('YYYY-MM-DD'),
|
||||
to: now.add(7, 'day').format('YYYY-MM-DD'),
|
||||
types: [...DASHBOARD_EVENT_TYPES],
|
||||
preset: '7d',
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultIncomeFilters(): DashboardFilterState<DashboardIncomeType> {
|
||||
const now = dayjs()
|
||||
return {
|
||||
from: now.subtract(7, 'day').format('YYYY-MM-DD'),
|
||||
to: now.format('YYYY-MM-DD'),
|
||||
types: [...DASHBOARD_INCOME_TYPES],
|
||||
preset: '7d',
|
||||
}
|
||||
}
|
||||
|
||||
export function applyDatePreset<T extends string>(
|
||||
filters: DashboardFilterState<T>,
|
||||
preset: DashboardDatePreset,
|
||||
): DashboardFilterState<T> {
|
||||
const now = dayjs()
|
||||
const next = { ...filters, preset }
|
||||
if (preset === 'all') {
|
||||
return { ...next, from: '2000-01-01', to: '2099-12-31' }
|
||||
}
|
||||
const amount = preset === '1y' ? 1 : Number.parseInt(preset, 10)
|
||||
const unit = preset === '1y' ? 'year' : 'day'
|
||||
return {
|
||||
...next,
|
||||
from: now.subtract(amount, unit).format('YYYY-MM-DD'),
|
||||
to: now.format('YYYY-MM-DD'),
|
||||
}
|
||||
}
|
||||
|
||||
export function applyEventDatePreset(
|
||||
filters: DashboardFilterState<DashboardEventType>,
|
||||
preset: DashboardDatePreset,
|
||||
): DashboardFilterState<DashboardEventType> {
|
||||
const now = dayjs()
|
||||
const next = { ...filters, preset }
|
||||
if (preset === 'all') {
|
||||
return { ...next, from: '2000-01-01', to: '2099-12-31' }
|
||||
}
|
||||
|
||||
const amount = preset === '1y' ? 1 : Number.parseInt(preset, 10)
|
||||
const unit = preset === '1y' ? 'year' : 'day'
|
||||
return {
|
||||
...next,
|
||||
from: now.subtract(amount, unit).format('YYYY-MM-DD'),
|
||||
to: now.add(amount, unit).format('YYYY-MM-DD'),
|
||||
}
|
||||
}
|
||||
|
||||
export function validateDashboardFilters<T extends string>(
|
||||
filters: DashboardFilterState<T>,
|
||||
): string {
|
||||
if (filters.types.length === 0) return 'Выберите хотя бы один тип'
|
||||
if (filters.from && filters.to && dayjs(filters.to).isBefore(dayjs(filters.from))) {
|
||||
return 'Дата окончания не может быть раньше даты начала'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function incomeTypesToOperationTypes(types: DashboardIncomeType[]): string {
|
||||
const operationTypes = new Set<string>()
|
||||
if (types.includes('dividend')) {
|
||||
operationTypes.add('OPERATION_TYPE_DIVIDEND')
|
||||
operationTypes.add('OPERATION_TYPE_DIV_EXT')
|
||||
}
|
||||
if (types.includes('coupon')) operationTypes.add('OPERATION_TYPE_COUPON')
|
||||
return [...operationTypes].join(',')
|
||||
}
|
||||
@ -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 'Ожидается'
|
||||
}
|
||||
@ -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([])
|
||||
})
|
||||
})
|
||||
@ -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 }
|
||||
}
|
||||
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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 }
|
||||
}
|
||||
@ -0,0 +1,537 @@
|
||||
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { ReactNode } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { BrokerEventItem, BrokerOperation, BrokerPortfolio } from '@/shared/api'
|
||||
import { BrokerDashboard } from './BrokerDashboard'
|
||||
|
||||
const hookMocks = vi.hoisted(() => ({
|
||||
useBrokerEvents: vi.fn(),
|
||||
useBrokerOperations: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
Link: ({ children, to }: { children: React.ReactNode; to: string }) => (
|
||||
<a href={to}>{children}</a>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/entities/broker-analytics', () => ({
|
||||
useBrokerAnalytics: () => ({
|
||||
data: {
|
||||
totalDeposits: 1000,
|
||||
totalWithdrawn: 250,
|
||||
netInvested: -150,
|
||||
totalDividends: 75,
|
||||
totalCoupons: 0,
|
||||
totalReceived: 90,
|
||||
totalReturnPercent: 4.44,
|
||||
currency: 'RUB',
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/entities/broker-event', () => ({
|
||||
useBrokerEvents: hookMocks.useBrokerEvents,
|
||||
}))
|
||||
|
||||
vi.mock('@/entities/broker-operation', () => ({
|
||||
useBrokerOperations: hookMocks.useBrokerOperations,
|
||||
}))
|
||||
|
||||
const portfolio: BrokerPortfolio = {
|
||||
account: {
|
||||
id: 'acc-1',
|
||||
name: 'Основной счёт',
|
||||
type: 'brokerage',
|
||||
status: 'open',
|
||||
openedAt: null,
|
||||
accessLevel: null,
|
||||
},
|
||||
positionCounts: { shares: 2, bonds: 1, etf: 0, other: 0 },
|
||||
totals: {
|
||||
shares: null,
|
||||
bonds: null,
|
||||
etf: null,
|
||||
currencies: null,
|
||||
futures: null,
|
||||
options: null,
|
||||
structuredProducts: null,
|
||||
dfa: null,
|
||||
portfolio: { currency: 'RUB', units: '1000', nano: 0, value: 1000 },
|
||||
},
|
||||
yields: { expectedPercent: null, daily: null, dailyPercent: null },
|
||||
cash: [],
|
||||
blockedCash: [],
|
||||
asOf: '2026-06-26T00:00:00.000Z',
|
||||
}
|
||||
|
||||
function renderWithProviders(ui: ReactNode) {
|
||||
return render(<LocalizationProvider dateAdapter={AdapterDayjs}>{ui}</LocalizationProvider>)
|
||||
}
|
||||
|
||||
function buildEvent(overrides: Partial<BrokerEventItem> = {}): BrokerEventItem {
|
||||
return {
|
||||
id: 'evt-1',
|
||||
type: 'coupon',
|
||||
source: 'actual',
|
||||
category: 'cashflow',
|
||||
eventDate: '2026-06-15T00:00:00.000Z',
|
||||
paymentDate: null,
|
||||
ticker: 'RU000A10AEF9',
|
||||
name: 'РЖД 001Р-37R',
|
||||
instrumentUid: null,
|
||||
instrumentType: 'bond',
|
||||
quantitySnapshot: null,
|
||||
payoutPerUnit: null,
|
||||
estimatedAmount: null,
|
||||
actualAmount: 43.56,
|
||||
currency: 'RUB',
|
||||
estimateMode: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function buildOperation(overrides: Partial<BrokerOperation> = {}): BrokerOperation {
|
||||
return {
|
||||
cursor: null,
|
||||
accountId: 'acc-1',
|
||||
id: 'op-1',
|
||||
parentOperationId: null,
|
||||
date: '2026-06-15T00:00:00.000Z',
|
||||
type: 'OPERATION_TYPE_COUPON',
|
||||
category: 'income',
|
||||
description: null,
|
||||
name: 'ОФЗ 26241',
|
||||
state: 'OPERATION_STATE_EXECUTED',
|
||||
instrumentUid: null,
|
||||
figi: null,
|
||||
ticker: 'SU26249RMFS1',
|
||||
classCode: null,
|
||||
instrumentType: 'bond',
|
||||
payment: { currency: 'RUB', units: '109', nano: 700000000, value: 109.7 },
|
||||
price: null,
|
||||
commission: null,
|
||||
yield: null,
|
||||
accruedInt: null,
|
||||
quantity: null,
|
||||
quantityDone: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function mockEventsLoaded(items: BrokerEventItem[]) {
|
||||
hookMocks.useBrokerEvents.mockReturnValue({
|
||||
data: { items, summary: {}, asOf: '2026-06-26T00:00:00.000Z' },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
})
|
||||
}
|
||||
|
||||
function mockOperationsLoaded(items: BrokerOperation[]) {
|
||||
hookMocks.useBrokerOperations.mockReturnValue({
|
||||
data: {
|
||||
accountId: 'acc-1',
|
||||
items,
|
||||
nextCursor: null,
|
||||
hasNext: false,
|
||||
asOf: '2026-06-26T00:00:00.000Z',
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
})
|
||||
}
|
||||
|
||||
describe('BrokerDashboard', () => {
|
||||
beforeEach(() => {
|
||||
hookMocks.useBrokerEvents.mockReturnValue({
|
||||
data: { items: [], summary: {}, asOf: '2026-06-26T00:00:00.000Z' },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
})
|
||||
hookMocks.useBrokerOperations.mockReturnValue({
|
||||
data: {
|
||||
accountId: 'acc-1',
|
||||
items: [],
|
||||
nextCursor: null,
|
||||
hasNext: false,
|
||||
asOf: '2026-06-26T00:00:00.000Z',
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the dashboard sections', () => {
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
expect(screen.getByText('Основной счёт')).toBeInTheDocument()
|
||||
expect(screen.getByText('События')).toBeInTheDocument()
|
||||
expect(screen.getByText('Доходы')).toBeInTheDocument()
|
||||
expect(screen.getByText('Аналитика доходности')).toBeInTheDocument()
|
||||
expect(screen.getByText('Аллокация')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies event type chips immediately to useBrokerEvents', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
const couponChips = screen.getAllByRole('button', { name: 'Купоны' })
|
||||
await user.click(couponChips[0])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hookMocks.useBrokerEvents).toHaveBeenLastCalledWith(
|
||||
'acc-1',
|
||||
expect.objectContaining({ types: 'dividend,maturity,offer' }),
|
||||
{ enabled: true },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('applies income type chips immediately to useBrokerOperations', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
const couponChips = screen.getAllByRole('button', { name: 'Купоны' })
|
||||
await user.click(couponChips[1])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hookMocks.useBrokerOperations).toHaveBeenLastCalledWith(
|
||||
'acc-1',
|
||||
expect.objectContaining({
|
||||
operationTypes: 'OPERATION_TYPE_DIVIDEND,OPERATION_TYPE_DIV_EXT',
|
||||
}),
|
||||
{ enabled: true },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('requests future events in the default dashboard range', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
vi.setSystemTime(new Date('2026-06-27T12:00:00.000Z'))
|
||||
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
expect(hookMocks.useBrokerEvents).toHaveBeenCalledWith(
|
||||
'acc-1',
|
||||
expect.objectContaining({
|
||||
from: '2026-06-20',
|
||||
to: '2026-07-04',
|
||||
}),
|
||||
{ enabled: true },
|
||||
)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows date filter toggle button with refresh action and accessible name', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
const applyButtons = screen.getAllByRole('button', { name: 'Обновить' })
|
||||
expect(applyButtons).toHaveLength(2)
|
||||
|
||||
const toggleButtons = screen.getAllByRole('button', { name: /^Период/ })
|
||||
expect(toggleButtons).toHaveLength(2)
|
||||
|
||||
await user.click(toggleButtons[0])
|
||||
|
||||
expect(screen.getByText('7д')).toBeInTheDocument()
|
||||
expect(screen.getByText('30д')).toBeInTheDocument()
|
||||
expect(screen.getByText('90д')).toBeInTheDocument()
|
||||
expect(screen.getByText('1г')).toBeInTheDocument()
|
||||
expect(screen.getByText('Всё')).toBeInTheDocument()
|
||||
expect(screen.getByText('Сбросить')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Применить период' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render a text chevron glyph inside the period toggle button', () => {
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
const toggleButtons = screen.getAllByRole('button', { name: /^Период/ })
|
||||
expect(toggleButtons).toHaveLength(2)
|
||||
for (const button of toggleButtons) {
|
||||
const text = button.textContent ?? ''
|
||||
expect(text).not.toMatch(/[▼▲vV]/)
|
||||
expect(button.querySelector('svg')).not.toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('renders hero "Всего доходов" with the ₽ symbol and no "RUB" code', () => {
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
expect(screen.getByText('Всего доходов')).toBeInTheDocument()
|
||||
const hero = screen.getByLabelText('Ключевые показатели брокерского счёта')
|
||||
const heroText = hero.textContent ?? ''
|
||||
expect(heroText).toContain('₽')
|
||||
expect(heroText).not.toContain('RUB')
|
||||
})
|
||||
|
||||
it('renders analytics card with the ₽ symbol and no "RUB" code', () => {
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
const analytics = screen.getByLabelText('Аналитика доходности')
|
||||
const analyticsText = analytics.textContent ?? ''
|
||||
expect(analyticsText).toContain('₽')
|
||||
expect(analyticsText).not.toContain('RUB')
|
||||
})
|
||||
|
||||
it('applies positive, negative and neutral tones to analytics values', () => {
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
const deposits = screen.getByTestId('dashboard-analytics-totalDeposits')
|
||||
expect(deposits.getAttribute('data-tone')).toBe('positive')
|
||||
expect(deposits.textContent).toContain('1\u00a0000,00')
|
||||
expect(deposits.textContent).toContain('₽')
|
||||
|
||||
const withdrawn = screen.getByTestId('dashboard-analytics-totalWithdrawn')
|
||||
expect(withdrawn.getAttribute('data-tone')).toBe('negative')
|
||||
expect(withdrawn.textContent).toMatch(/^[−-]250,00/)
|
||||
|
||||
const net = screen.getByTestId('dashboard-analytics-netInvested')
|
||||
expect(net.getAttribute('data-tone')).toBe('negative')
|
||||
expect(net.textContent).toMatch(/^[−-]150,00/)
|
||||
|
||||
const dividends = screen.getByTestId('dashboard-analytics-totalDividends')
|
||||
expect(dividends.getAttribute('data-tone')).toBe('positive')
|
||||
expect(dividends.textContent).toContain('75,00')
|
||||
|
||||
const coupons = screen.getByTestId('dashboard-analytics-totalCoupons')
|
||||
expect(coupons.getAttribute('data-tone')).toBe('neutral')
|
||||
expect(coupons.textContent).toContain('0,00')
|
||||
|
||||
const received = screen.getByTestId('dashboard-analytics-totalReceived')
|
||||
expect(received.getAttribute('data-tone')).toBe('positive')
|
||||
expect(received.textContent).toContain('90,00')
|
||||
})
|
||||
|
||||
it('shows skeleton table while events are loading', () => {
|
||||
hookMocks.useBrokerEvents.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
})
|
||||
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
const skeletons = screen.getAllByTestId('dashboard-table-skeleton')
|
||||
expect(skeletons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('shows skeleton table while income operations are loading', () => {
|
||||
hookMocks.useBrokerOperations.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
})
|
||||
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
const skeletons = screen.getAllByTestId('dashboard-table-skeleton')
|
||||
expect(skeletons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('does not show skeleton when data is loaded', () => {
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
expect(screen.queryByTestId('dashboard-table-skeleton')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders events table thead with semantic column headers', () => {
|
||||
mockEventsLoaded([buildEvent()])
|
||||
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
const eventsTable = screen.getByLabelText('Таблица событий брокерского счёта')
|
||||
expect(eventsTable.querySelector('thead')).not.toBeNull()
|
||||
const headers = within(eventsTable).getAllByRole('columnheader')
|
||||
expect(headers.map((h) => h.textContent)).toEqual([
|
||||
'Дата',
|
||||
'Инструмент',
|
||||
'Тип',
|
||||
'Сумма',
|
||||
'Статус',
|
||||
])
|
||||
})
|
||||
|
||||
it('renders income table thead without status column', () => {
|
||||
mockOperationsLoaded([buildOperation()])
|
||||
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
const incomeTable = screen.getByLabelText('Таблица доходов брокерского счёта')
|
||||
expect(incomeTable.querySelector('thead')).not.toBeNull()
|
||||
const headers = within(incomeTable).getAllByRole('columnheader')
|
||||
expect(headers.map((h) => h.textContent)).toEqual(['Дата', 'Инструмент', 'Тип', 'Сумма'])
|
||||
})
|
||||
|
||||
it('renders event type badge, instrument subtitle and status pill for loaded events', () => {
|
||||
mockEventsLoaded([
|
||||
buildEvent({ id: 'evt-actual', source: 'actual', type: 'coupon', actualAmount: 43.56 }),
|
||||
buildEvent({
|
||||
id: 'evt-forecast',
|
||||
source: 'forecast',
|
||||
type: 'dividend',
|
||||
actualAmount: null,
|
||||
estimatedAmount: 197.56,
|
||||
}),
|
||||
])
|
||||
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
const rows = screen.getAllByTestId('dashboard-events-row')
|
||||
expect(rows).toHaveLength(2)
|
||||
|
||||
const subtitles = screen.getAllByTestId('dashboard-events-instrument-subtitle')
|
||||
expect(subtitles).toHaveLength(2)
|
||||
for (const subtitle of subtitles) {
|
||||
expect(subtitle.textContent).toBe('РЖД 001Р-37R')
|
||||
}
|
||||
|
||||
const amounts = screen.getAllByTestId('dashboard-events-amount')
|
||||
expect(amounts).toHaveLength(2)
|
||||
expect(amounts[0].getAttribute('data-tone')).toBe('positive')
|
||||
expect(amounts[0].textContent).toContain('+43,56')
|
||||
expect(amounts[1].getAttribute('data-tone')).toBe('planned')
|
||||
expect(amounts[1].textContent).toContain('~197,56')
|
||||
|
||||
expect(screen.getByText('Купон')).toBeInTheDocument()
|
||||
expect(screen.getByText('Дивиденд')).toBeInTheDocument()
|
||||
expect(screen.getByText('Поступило')).toBeInTheDocument()
|
||||
expect(screen.getByText('Ожидается')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders HTML-parity card headings, toolbar label and footer summary for events', () => {
|
||||
mockEventsLoaded([
|
||||
buildEvent({ id: 'evt-1' }),
|
||||
buildEvent({ id: 'evt-2', ticker: 'HEAD', name: 'HeadHunter Group', type: 'dividend' }),
|
||||
])
|
||||
hookMocks.useBrokerEvents.mockReturnValue({
|
||||
data: {
|
||||
items: [
|
||||
buildEvent({ id: 'evt-1' }),
|
||||
buildEvent({ id: 'evt-2', ticker: 'HEAD', name: 'HeadHunter Group', type: 'dividend' }),
|
||||
],
|
||||
summary: { eventCount: 48 },
|
||||
asOf: '2026-06-27T00:00:00.000Z',
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
})
|
||||
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
const eventsSection = screen.getByLabelText('События')
|
||||
expect(screen.getByText('2 из 48')).toBeInTheDocument()
|
||||
expect(within(eventsSection).getAllByText('Тип').length).toBeGreaterThan(0)
|
||||
expect(
|
||||
within(eventsSection).getByText('Показано 2 событий за выбранный период'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders HTML-parity count badge and footer summary for income', () => {
|
||||
mockOperationsLoaded([
|
||||
buildOperation({ id: 'op-1' }),
|
||||
buildOperation({ id: 'op-2', ticker: 'IRAO', name: 'Интер РАО' }),
|
||||
])
|
||||
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
expect(screen.getByText('2 операции')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Показано 2 · Итого:/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses negative tone when event actual amount is negative', () => {
|
||||
mockEventsLoaded([
|
||||
buildEvent({
|
||||
id: 'evt-tax',
|
||||
type: 'coupon',
|
||||
source: 'actual',
|
||||
actualAmount: -87.0,
|
||||
}),
|
||||
])
|
||||
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
const [amount] = screen.getAllByTestId('dashboard-events-amount')
|
||||
expect(amount.getAttribute('data-tone')).toBe('negative')
|
||||
expect(amount.textContent).toBe('−87,00 ₽')
|
||||
})
|
||||
|
||||
it('renders events and income amounts with the ₽ symbol and no "RUB" code', () => {
|
||||
mockEventsLoaded([
|
||||
buildEvent({ id: 'evt', source: 'actual', actualAmount: 43.56 }),
|
||||
buildEvent({ id: 'evt-neg', source: 'actual', actualAmount: -12.34 }),
|
||||
])
|
||||
mockOperationsLoaded([
|
||||
buildOperation({
|
||||
id: 'op-positive',
|
||||
type: 'OPERATION_TYPE_COUPON',
|
||||
ticker: 'SU26249RMFS1',
|
||||
name: 'ОФЗ 26241',
|
||||
payment: { currency: 'RUB', units: '109', nano: 700000000, value: 109.7 },
|
||||
}),
|
||||
buildOperation({
|
||||
id: 'op-negative',
|
||||
type: 'OPERATION_TYPE_DIVIDEND',
|
||||
ticker: 'IRAO',
|
||||
name: 'Интер РАО',
|
||||
payment: { currency: 'RUB', units: '35', nano: 0, value: -35 },
|
||||
}),
|
||||
])
|
||||
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
const eventsTable = screen.getByLabelText('Таблица событий брокерского счёта')
|
||||
const incomeTable = screen.getByLabelText('Таблица доходов брокерского счёта')
|
||||
for (const table of [eventsTable, incomeTable]) {
|
||||
expect(table.textContent ?? '').toContain('₽')
|
||||
expect(table.textContent ?? '').not.toContain('RUB')
|
||||
}
|
||||
})
|
||||
|
||||
it('renders income rows with main + subtitle, type badge and signed amount tone', () => {
|
||||
mockOperationsLoaded([
|
||||
buildOperation({
|
||||
id: 'op-div',
|
||||
type: 'OPERATION_TYPE_DIVIDEND',
|
||||
ticker: 'IRAO',
|
||||
name: 'Интер РАО',
|
||||
payment: { currency: 'RUB', units: '649', nano: 250000000, value: 649.25 },
|
||||
}),
|
||||
buildOperation({
|
||||
id: 'op-div-ext',
|
||||
type: 'OPERATION_TYPE_DIV_EXT',
|
||||
ticker: 'AAPL',
|
||||
name: 'Apple Inc.',
|
||||
payment: { currency: 'RUB', units: '100', nano: 0, value: -35 },
|
||||
}),
|
||||
])
|
||||
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
const rows = screen.getAllByTestId('dashboard-income-row')
|
||||
expect(rows).toHaveLength(2)
|
||||
|
||||
const mainLabels = screen.getAllByTestId('dashboard-income-instrument-main')
|
||||
expect(mainLabels.map((el) => el.textContent)).toEqual(['IRAO', 'AAPL'])
|
||||
|
||||
const subtitles = screen.getAllByTestId('dashboard-income-instrument-subtitle')
|
||||
expect(subtitles.map((el) => el.textContent)).toEqual(['Интер РАО', 'Apple Inc.'])
|
||||
|
||||
expect(screen.getByText('Дивиденд')).toBeInTheDocument()
|
||||
expect(screen.getByText('Дивиденд (внешний)')).toBeInTheDocument()
|
||||
|
||||
const amounts = screen.getAllByTestId('dashboard-income-amount')
|
||||
expect(amounts[0].getAttribute('data-tone')).toBe('positive')
|
||||
expect(amounts[0].textContent).toContain('+649,25')
|
||||
expect(amounts[1].getAttribute('data-tone')).toBe('negative')
|
||||
expect(amounts[1].textContent).toContain('−35,00')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,237 @@
|
||||
import { Box } from '@mui/material'
|
||||
import dayjs from 'dayjs'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useBrokerAnalytics } from '@/entities/broker-analytics'
|
||||
import { useBrokerEvents } from '@/entities/broker-event'
|
||||
import { useBrokerOperations } from '@/entities/broker-operation'
|
||||
import type { BrokerPortfolio } from '@/shared/api'
|
||||
import { useCursorPagination } from '@/shared/lib/useCursorPagination'
|
||||
import {
|
||||
applyDatePreset,
|
||||
applyEventDatePreset,
|
||||
type DashboardDatePreset,
|
||||
type DashboardEventType,
|
||||
type DashboardIncomeType,
|
||||
defaultEventsFilters,
|
||||
defaultIncomeFilters,
|
||||
incomeTypesToOperationTypes,
|
||||
} from '../lib/dashboardFilters'
|
||||
import { BrokerDashboardAllocationCard } from './BrokerDashboardAllocationCard'
|
||||
import { BrokerDashboardAnalyticsCard } from './BrokerDashboardAnalyticsCard'
|
||||
import { BrokerDashboardEventsCard } from './BrokerDashboardEventsCard'
|
||||
import { BrokerDashboardHero } from './BrokerDashboardHero'
|
||||
import { BrokerDashboardIncomeCard } from './BrokerDashboardIncomeCard'
|
||||
|
||||
function formatDateLabel(from: string, to: string): string | null {
|
||||
if (!from && !to) return 'За всё время'
|
||||
const fmt = (d: string) => dayjs(d).format('D MMM')
|
||||
if (from && to) return `${fmt(from)} – ${fmt(to)}`
|
||||
if (from) return `с ${fmt(from)}`
|
||||
if (to) return `до ${fmt(to)}`
|
||||
return null
|
||||
}
|
||||
|
||||
export function BrokerDashboard({
|
||||
accountId,
|
||||
portfolio,
|
||||
}: {
|
||||
accountId: string
|
||||
portfolio: BrokerPortfolio
|
||||
}) {
|
||||
const [appliedEventFilters, setAppliedEventFilters] = useState(defaultEventsFilters)
|
||||
const [draftEventFilters, setDraftEventFilters] = useState(defaultEventsFilters)
|
||||
const [eventPage, setEventPage] = useState(1)
|
||||
|
||||
const [appliedIncomeFilters, setAppliedIncomeFilters] = useState(defaultIncomeFilters)
|
||||
const [draftIncomeFilters, setDraftIncomeFilters] = useState(defaultIncomeFilters)
|
||||
const incomePagination = useCursorPagination()
|
||||
|
||||
const analytics = useBrokerAnalytics(accountId)
|
||||
const hasEventTypes = appliedEventFilters.types.length > 0
|
||||
const hasIncomeTypes = appliedIncomeFilters.types.length > 0
|
||||
const hasDraftEventTypes = draftEventFilters.types.length > 0
|
||||
const hasDraftIncomeTypes = draftIncomeFilters.types.length > 0
|
||||
const eventPageSize = 10
|
||||
|
||||
const events = useBrokerEvents(
|
||||
accountId,
|
||||
{
|
||||
from: appliedEventFilters.from,
|
||||
to: appliedEventFilters.to,
|
||||
types: appliedEventFilters.types.join(','),
|
||||
},
|
||||
{ enabled: hasEventTypes },
|
||||
)
|
||||
|
||||
const operations = useBrokerOperations(
|
||||
accountId,
|
||||
{
|
||||
from: appliedIncomeFilters.from,
|
||||
to: appliedIncomeFilters.to,
|
||||
operationTypes: incomeTypesToOperationTypes(appliedIncomeFilters.types),
|
||||
cursor: incomePagination.cursor,
|
||||
limit: 10,
|
||||
},
|
||||
{ enabled: hasIncomeTypes },
|
||||
)
|
||||
|
||||
const nextCursor: string | undefined = operations.data?.nextCursor
|
||||
? (operations.data.nextCursor as unknown as string)
|
||||
: undefined
|
||||
|
||||
const eventItems = events.data?.items ?? []
|
||||
const eventPageItems = eventItems.slice(
|
||||
(eventPage - 1) * eventPageSize,
|
||||
eventPage * eventPageSize,
|
||||
)
|
||||
|
||||
function toggleEventType(type: DashboardEventType) {
|
||||
setAppliedEventFilters((filters) => {
|
||||
const nextTypes = filters.types.includes(type)
|
||||
? filters.types.filter((item) => item !== type)
|
||||
: [...filters.types, type]
|
||||
return { ...filters, types: nextTypes }
|
||||
})
|
||||
setDraftEventFilters((filters) => {
|
||||
const nextTypes = filters.types.includes(type)
|
||||
? filters.types.filter((item) => item !== type)
|
||||
: [...filters.types, type]
|
||||
return { ...filters, types: nextTypes }
|
||||
})
|
||||
setEventPage(1)
|
||||
}
|
||||
|
||||
function toggleIncomeType(type: DashboardIncomeType) {
|
||||
setAppliedIncomeFilters((filters) => {
|
||||
const nextTypes = filters.types.includes(type)
|
||||
? filters.types.filter((item) => item !== type)
|
||||
: [...filters.types, type]
|
||||
return { ...filters, types: nextTypes }
|
||||
})
|
||||
setDraftIncomeFilters((filters) => {
|
||||
const nextTypes = filters.types.includes(type)
|
||||
? filters.types.filter((item) => item !== type)
|
||||
: [...filters.types, type]
|
||||
return { ...filters, types: nextTypes }
|
||||
})
|
||||
incomePagination.reset()
|
||||
}
|
||||
|
||||
const applyEventFilters = useCallback(() => {
|
||||
setAppliedEventFilters((prev) => ({
|
||||
...prev,
|
||||
from: draftEventFilters.from,
|
||||
to: draftEventFilters.to,
|
||||
preset: draftEventFilters.preset,
|
||||
}))
|
||||
setEventPage(1)
|
||||
}, [draftEventFilters.from, draftEventFilters.to, draftEventFilters.preset])
|
||||
|
||||
const resetEventFilters = useCallback(() => {
|
||||
const defaults = defaultEventsFilters()
|
||||
setAppliedEventFilters(defaults)
|
||||
setDraftEventFilters(defaults)
|
||||
setEventPage(1)
|
||||
}, [])
|
||||
|
||||
const applyIncomeFilters = useCallback(() => {
|
||||
setAppliedIncomeFilters((prev) => ({
|
||||
...prev,
|
||||
from: draftIncomeFilters.from,
|
||||
to: draftIncomeFilters.to,
|
||||
preset: draftIncomeFilters.preset,
|
||||
}))
|
||||
incomePagination.reset()
|
||||
}, [draftIncomeFilters.from, draftIncomeFilters.to, draftIncomeFilters.preset, incomePagination])
|
||||
|
||||
const resetIncomeFilters = useCallback(() => {
|
||||
const defaults = defaultIncomeFilters()
|
||||
setAppliedIncomeFilters(defaults)
|
||||
setDraftIncomeFilters(defaults)
|
||||
incomePagination.reset()
|
||||
}, [incomePagination])
|
||||
|
||||
function handleDraftEventPresetChange(preset: DashboardDatePreset) {
|
||||
setDraftEventFilters((filters) => applyEventDatePreset(filters, preset))
|
||||
}
|
||||
|
||||
function handleDraftEventFromChange(value: string) {
|
||||
setDraftEventFilters((filters) => ({ ...filters, from: value }))
|
||||
}
|
||||
|
||||
function handleDraftEventToChange(value: string) {
|
||||
setDraftEventFilters((filters) => ({ ...filters, to: value }))
|
||||
}
|
||||
|
||||
function handleDraftIncomePresetChange(preset: DashboardDatePreset) {
|
||||
setDraftIncomeFilters((filters) => applyDatePreset(filters, preset))
|
||||
}
|
||||
|
||||
function handleDraftIncomeFromChange(value: string) {
|
||||
setDraftIncomeFilters((filters) => ({ ...filters, from: value }))
|
||||
}
|
||||
|
||||
function handleDraftIncomeToChange(value: string) {
|
||||
setDraftIncomeFilters((filters) => ({ ...filters, to: value }))
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'grid', gap: 3 }}>
|
||||
<BrokerDashboardHero portfolio={portfolio} analytics={analytics.data} />
|
||||
<BrokerDashboardEventsCard
|
||||
accountId={accountId}
|
||||
data={events.data ? { ...events.data, items: eventPageItems } : undefined}
|
||||
isLoading={events.isLoading}
|
||||
isError={events.isError}
|
||||
selectedTypes={draftEventFilters.types}
|
||||
onToggleType={toggleEventType}
|
||||
appliedDateLabel={formatDateLabel(appliedEventFilters.from, appliedEventFilters.to)}
|
||||
draftPreset={draftEventFilters.preset}
|
||||
draftFrom={draftEventFilters.from}
|
||||
draftTo={draftEventFilters.to}
|
||||
onDraftPresetChange={handleDraftEventPresetChange}
|
||||
onDraftFromChange={handleDraftEventFromChange}
|
||||
onDraftToChange={handleDraftEventToChange}
|
||||
onApplyFilters={applyEventFilters}
|
||||
onResetFilters={resetEventFilters}
|
||||
hasDraftTypes={hasDraftEventTypes}
|
||||
totalCount={events.data?.summary?.eventCount}
|
||||
page={eventPage}
|
||||
canGoBack={eventPage > 1}
|
||||
canGoForward={eventPage * eventPageSize < eventItems.length}
|
||||
onPreviousPage={() => setEventPage((page) => Math.max(1, page - 1))}
|
||||
onNextPage={() => setEventPage((page) => page + 1)}
|
||||
/>
|
||||
<BrokerDashboardIncomeCard
|
||||
accountId={accountId}
|
||||
page={operations.data}
|
||||
isLoading={operations.isLoading}
|
||||
isError={operations.isError}
|
||||
selectedTypes={draftIncomeFilters.types}
|
||||
onToggleType={toggleIncomeType}
|
||||
appliedDateLabel={formatDateLabel(appliedIncomeFilters.from, appliedIncomeFilters.to)}
|
||||
draftPreset={draftIncomeFilters.preset}
|
||||
draftFrom={draftIncomeFilters.from}
|
||||
draftTo={draftIncomeFilters.to}
|
||||
onDraftPresetChange={handleDraftIncomePresetChange}
|
||||
onDraftFromChange={handleDraftIncomeFromChange}
|
||||
onDraftToChange={handleDraftIncomeToChange}
|
||||
onApplyFilters={applyIncomeFilters}
|
||||
onResetFilters={resetIncomeFilters}
|
||||
hasDraftTypes={hasDraftIncomeTypes}
|
||||
visibleCount={operations.data?.items?.length ?? 0}
|
||||
pageNumber={incomePagination.pageNumber}
|
||||
canGoBack={incomePagination.pageNumber > 1}
|
||||
canGoForward={operations.data?.hasNext ?? false}
|
||||
onPreviousPage={incomePagination.handlePrevious}
|
||||
onNextPage={() => incomePagination.handleNext(nextCursor)}
|
||||
/>
|
||||
<BrokerDashboardAnalyticsCard
|
||||
data={analytics.data}
|
||||
isLoading={analytics.isLoading}
|
||||
isError={analytics.isError}
|
||||
/>
|
||||
<BrokerDashboardAllocationCard portfolio={portfolio} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,86 @@
|
||||
import { Text } from '@moex-vibe/design-system'
|
||||
import { Box } from '@mui/material'
|
||||
import { buildBrokerAllocation } from '@/entities/broker-position'
|
||||
import type { BrokerPortfolio } from '@/shared/api'
|
||||
import { formatBrokerCurrencyValue, formatBrokerMoney } from '@/shared/lib/formatters'
|
||||
import { BrokerDashboardCard } from './BrokerDashboardCard'
|
||||
|
||||
const ALLOCATION_COLORS: Record<string, string> = {
|
||||
shares: '#4969f5',
|
||||
bonds: '#e5a33c',
|
||||
etf: '#62b889',
|
||||
cash: '#7b63cf',
|
||||
other: '#aeb6c5',
|
||||
}
|
||||
|
||||
export function BrokerDashboardAllocationCard({ portfolio }: { portfolio: BrokerPortfolio }) {
|
||||
const { total, sectors, negative } = buildBrokerAllocation(portfolio)
|
||||
const currency =
|
||||
total >= 0
|
||||
? (portfolio.totals.portfolio?.currency ??
|
||||
Object.values(portfolio.totals).find((t) => t?.currency)?.currency ??
|
||||
'RUB')
|
||||
: 'RUB'
|
||||
|
||||
return (
|
||||
<BrokerDashboardCard
|
||||
title="Аллокация"
|
||||
action={<Text variant="numeric">{formatBrokerMoney(portfolio.totals.portfolio)}</Text>}
|
||||
>
|
||||
{sectors.length === 0 && negative.length === 0 ? (
|
||||
<Text tone="muted">Нет данных для распределения</Text>
|
||||
) : (
|
||||
<Box sx={{ display: 'grid', gap: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Text variant="caption" tone="secondary">
|
||||
Структура портфеля
|
||||
</Text>
|
||||
<Text variant="body" sx={{ fontWeight: 700 }}>
|
||||
{formatBrokerMoney(portfolio.totals.portfolio)}
|
||||
</Text>
|
||||
</Box>
|
||||
{sectors.map((sector) => (
|
||||
<Box key={sector.key}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Text variant="body">{sector.label}</Text>
|
||||
<Text variant="body" tone="secondary">
|
||||
{formatBrokerCurrencyValue(currency, sector.value)} · {sector.percent.toFixed(1)}%
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
bgcolor: 'grey.200',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: `${sector.percent}%`,
|
||||
height: '100%',
|
||||
bgcolor: ALLOCATION_COLORS[sector.key] ?? '#aeb6c5',
|
||||
borderRadius: 5,
|
||||
transition: 'width 0.3s',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
{negative.length > 0 && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{negative.map((item) => (
|
||||
<Box key={item.key} sx={{ display: 'flex', gap: 1 }}>
|
||||
<Text variant="body">{item.label}:</Text>
|
||||
<Text variant="body" tone="negative">
|
||||
{formatBrokerCurrencyValue(currency, item.value)}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</BrokerDashboardCard>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,110 @@
|
||||
import { Metric, Text } from '@moex-vibe/design-system'
|
||||
import { Box } from '@mui/material'
|
||||
import type { BrokerAnalytics } from '@/shared/api'
|
||||
import {
|
||||
formatDashboardCurrency,
|
||||
type MoneyTone,
|
||||
moneyTone,
|
||||
moneyToneToColor,
|
||||
} from '../lib/dashboardVisual'
|
||||
import { BrokerDashboardCard } from './BrokerDashboardCard'
|
||||
|
||||
type AnalyticsField =
|
||||
| 'totalDeposits'
|
||||
| 'totalWithdrawn'
|
||||
| 'netInvested'
|
||||
| 'totalDividends'
|
||||
| 'totalCoupons'
|
||||
| 'totalReceived'
|
||||
|
||||
const ANALYTICS_METRICS: readonly {
|
||||
field: AnalyticsField
|
||||
label: string
|
||||
testId: string
|
||||
}[] = [
|
||||
{ field: 'totalDeposits', label: 'Пополнения', testId: 'dashboard-analytics-totalDeposits' },
|
||||
{ field: 'totalWithdrawn', label: 'Выводы', testId: 'dashboard-analytics-totalWithdrawn' },
|
||||
{ field: 'netInvested', label: 'Нетто', testId: 'dashboard-analytics-netInvested' },
|
||||
{ field: 'totalDividends', label: 'Дивиденды', testId: 'dashboard-analytics-totalDividends' },
|
||||
{ field: 'totalCoupons', label: 'Купоны', testId: 'dashboard-analytics-totalCoupons' },
|
||||
{ field: 'totalReceived', label: 'Всего получено', testId: 'dashboard-analytics-totalReceived' },
|
||||
]
|
||||
|
||||
function analyticsTone(field: AnalyticsField, value: number): MoneyTone {
|
||||
if (field === 'totalWithdrawn') {
|
||||
return value > 0 ? 'negative' : moneyTone(value)
|
||||
}
|
||||
return moneyTone(value)
|
||||
}
|
||||
|
||||
function analyticsDisplay(field: AnalyticsField, value: number, currency: string): string {
|
||||
const formatted = formatDashboardCurrency({ currency, value })
|
||||
if (field === 'totalWithdrawn' && value > 0) return `−${formatted}`
|
||||
return formatted
|
||||
}
|
||||
|
||||
export function BrokerDashboardAnalyticsCard({
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
}: {
|
||||
data: BrokerAnalytics | undefined
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
}) {
|
||||
return (
|
||||
<BrokerDashboardCard title="Аналитика доходности">
|
||||
{isError ? (
|
||||
<Text tone="negative">Не удалось загрузить аналитику</Text>
|
||||
) : isLoading ? (
|
||||
<Text tone="muted">Загрузка аналитики…</Text>
|
||||
) : !data ? (
|
||||
<Text tone="muted">Нет данных для аналитики</Text>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{ANALYTICS_METRICS.map(({ field, label, testId }) => {
|
||||
const value = data[field]
|
||||
const tone = analyticsTone(field, value)
|
||||
return (
|
||||
<Box
|
||||
key={field}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: tone === 'negative' ? 'error.light' : 'divider',
|
||||
bgcolor:
|
||||
tone === 'positive'
|
||||
? 'rgba(46, 125, 50, 0.06)'
|
||||
: tone === 'negative'
|
||||
? 'rgba(211, 47, 47, 0.06)'
|
||||
: 'grey.50',
|
||||
p: 1.5,
|
||||
}}
|
||||
>
|
||||
<Metric
|
||||
label={label}
|
||||
value={
|
||||
<Box
|
||||
component="span"
|
||||
data-testid={testId}
|
||||
data-tone={tone}
|
||||
sx={{ color: moneyToneToColor(tone), fontWeight: 700 }}
|
||||
>
|
||||
{analyticsDisplay(field, value, data.currency)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</BrokerDashboardCard>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
import { Heading } from '@moex-vibe/design-system'
|
||||
import { Box, type SxProps, type Theme } from '@mui/material'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
type BrokerDashboardCardProps = {
|
||||
title: string
|
||||
badge?: ReactNode
|
||||
action?: ReactNode
|
||||
filters?: ReactNode
|
||||
children: ReactNode
|
||||
sx?: SxProps<Theme>
|
||||
ariaLabel?: string
|
||||
}
|
||||
|
||||
export function BrokerDashboardCard({
|
||||
title,
|
||||
badge,
|
||||
action,
|
||||
filters,
|
||||
children,
|
||||
sx,
|
||||
ariaLabel,
|
||||
}: BrokerDashboardCardProps) {
|
||||
const resolvedAriaLabel = ariaLabel ?? title
|
||||
return (
|
||||
<Box
|
||||
component="section"
|
||||
aria-label={resolvedAriaLabel}
|
||||
sx={{
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 3,
|
||||
bgcolor: 'background.paper',
|
||||
p: 2,
|
||||
minWidth: 0,
|
||||
boxShadow: '0 1px 3px rgba(15, 23, 42, 0.08)',
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2,
|
||||
mb: 1.5,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Heading level={2} size="section">
|
||||
{title}
|
||||
</Heading>
|
||||
{badge ? (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
minHeight: 24,
|
||||
px: 1,
|
||||
borderRadius: 999,
|
||||
bgcolor: 'grey.100',
|
||||
color: 'text.secondary',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{badge}
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
{action}
|
||||
</Box>
|
||||
{filters && <Box sx={{ mb: 1.5 }}>{filters}</Box>}
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,270 @@
|
||||
import { Chip, Text } from '@moex-vibe/design-system'
|
||||
import CalendarTodayRounded from '@mui/icons-material/CalendarTodayRounded'
|
||||
import ExpandMoreRounded from '@mui/icons-material/ExpandMoreRounded'
|
||||
import { Box, Popover } from '@mui/material'
|
||||
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
|
||||
import { DateCalendar } from '@mui/x-date-pickers/DateCalendar'
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'
|
||||
import dayjs from 'dayjs'
|
||||
import { useCallback, useState } from 'react'
|
||||
import type { DashboardDatePreset } from '../lib/dashboardFilters'
|
||||
|
||||
const PRESETS: { key: DashboardDatePreset; label: string }[] = [
|
||||
{ key: '7d', label: '7д' },
|
||||
{ key: '30d', label: '30д' },
|
||||
{ key: '90d', label: '90д' },
|
||||
{ key: '1y', label: '1г' },
|
||||
{ key: 'all', label: 'Всё' },
|
||||
]
|
||||
|
||||
type BrokerDashboardDateFilterProps = {
|
||||
appliedLabel: string | null
|
||||
preset: DashboardDatePreset
|
||||
draftFrom: string
|
||||
draftTo: string
|
||||
hasDraftTypes: boolean
|
||||
onPresetChange: (preset: DashboardDatePreset) => void
|
||||
onFromChange: (value: string) => void
|
||||
onToChange: (value: string) => void
|
||||
onReset: () => void
|
||||
onApply: () => void
|
||||
}
|
||||
|
||||
export function BrokerDashboardDateFilter({
|
||||
appliedLabel,
|
||||
preset,
|
||||
draftFrom,
|
||||
draftTo,
|
||||
hasDraftTypes,
|
||||
onPresetChange,
|
||||
onFromChange,
|
||||
onToChange,
|
||||
onReset,
|
||||
onApply,
|
||||
}: BrokerDashboardDateFilterProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
|
||||
const [selectingStage, setSelectingStage] = useState<'from' | 'to'>('from')
|
||||
const isOpen = Boolean(anchorEl)
|
||||
|
||||
const handleToggle = useCallback((e: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl((prev) => {
|
||||
if (prev) return null
|
||||
return e.currentTarget
|
||||
})
|
||||
setSelectingStage('from')
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setAnchorEl(null)
|
||||
setSelectingStage('from')
|
||||
}, [])
|
||||
|
||||
const handleCalendarChange = useCallback(
|
||||
(date: dayjs.Dayjs | null) => {
|
||||
if (!date) return
|
||||
if (selectingStage === 'from') {
|
||||
onFromChange(date.format('YYYY-MM-DD'))
|
||||
setSelectingStage('to')
|
||||
} else {
|
||||
if (draftFrom && date.isBefore(dayjs(draftFrom))) {
|
||||
onFromChange(date.format('YYYY-MM-DD'))
|
||||
onToChange('')
|
||||
} else {
|
||||
onToChange(date.format('YYYY-MM-DD'))
|
||||
}
|
||||
setSelectingStage('from')
|
||||
}
|
||||
},
|
||||
[selectingStage, draftFrom, onFromChange, onToChange],
|
||||
)
|
||||
|
||||
function handlePresetClick(p: { key: DashboardDatePreset }) {
|
||||
onPresetChange(p.key)
|
||||
setSelectingStage('from')
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
onReset()
|
||||
setSelectingStage('from')
|
||||
}
|
||||
|
||||
function handleApply() {
|
||||
onApply()
|
||||
handleClose()
|
||||
}
|
||||
|
||||
const selectingLabel =
|
||||
selectingStage === 'from' ? 'Выберите начало периода' : 'Выберите конец периода'
|
||||
const periodAriaLabel = appliedLabel ? `Период: ${appliedLabel}` : 'Период'
|
||||
const rangeInverted = Boolean(draftFrom && draftTo) && dayjs(draftTo).isBefore(dayjs(draftFrom))
|
||||
const canApply = hasDraftTypes && !rangeInverted
|
||||
|
||||
return (
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
aria-label={periodAriaLabel}
|
||||
aria-expanded={isOpen}
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
bgcolor: 'background.paper',
|
||||
color: 'text.primary',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
px: 1.25,
|
||||
py: 0.5,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { borderColor: 'primary.main' },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
aria-hidden="true"
|
||||
sx={{ display: 'inline-flex', color: 'text.secondary' }}
|
||||
>
|
||||
<CalendarTodayRounded sx={{ fontSize: 14 }} />
|
||||
</Box>
|
||||
{appliedLabel && (
|
||||
<Box
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.4,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{appliedLabel}
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
component="span"
|
||||
aria-hidden="true"
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
color: 'text.secondary',
|
||||
transform: isOpen ? 'rotate(180deg)' : 'none',
|
||||
transition: 'transform 0.15s ease-in-out',
|
||||
}}
|
||||
>
|
||||
<ExpandMoreRounded sx={{ fontSize: 16 }} />
|
||||
</Box>
|
||||
</Box>
|
||||
<Popover
|
||||
open={isOpen}
|
||||
anchorEl={anchorEl}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
<Box sx={{ p: 2, minWidth: 320 }}>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 1 }}>
|
||||
{PRESETS.map((p) => (
|
||||
<Chip
|
||||
key={p.key}
|
||||
label={p.label}
|
||||
tone={p.key === preset ? 'primary' : 'neutral'}
|
||||
selected={p.key === preset}
|
||||
onClick={() => handlePresetClick(p)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Text variant="caption" tone="secondary" sx={{ mb: 1, display: 'block' }}>
|
||||
{selectingLabel}
|
||||
</Text>
|
||||
<DateCalendar
|
||||
value={draftFrom ? dayjs(draftFrom) : null}
|
||||
onChange={handleCalendarChange}
|
||||
sx={{
|
||||
'& .MuiPickersDay-root': {
|
||||
...(draftFrom && draftTo
|
||||
? {
|
||||
'&.Mui-selected': {
|
||||
bgcolor: 'primary.main',
|
||||
color: 'common.white',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 1 }}
|
||||
>
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
sx={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'text.secondary',
|
||||
fontSize: 12,
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
textUnderlineOffset: 2,
|
||||
p: 0.5,
|
||||
}}
|
||||
>
|
||||
Сбросить
|
||||
</Box>
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={handleApply}
|
||||
disabled={!canApply}
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
bgcolor: canApply ? 'primary.main' : 'grey.400',
|
||||
color: 'common.white',
|
||||
border: 'none',
|
||||
borderRadius: 1,
|
||||
px: 1.5,
|
||||
py: 0.75,
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
cursor: canApply ? 'pointer' : 'default',
|
||||
}}
|
||||
>
|
||||
Применить период
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Popover>
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={onApply}
|
||||
disabled={!canApply}
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
bgcolor: canApply ? 'primary.main' : 'grey.400',
|
||||
color: 'common.white',
|
||||
border: 'none',
|
||||
borderRadius: 1,
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
cursor: canApply ? 'pointer' : 'default',
|
||||
'&:hover': canApply ? { bgcolor: 'primary.dark' } : {},
|
||||
}}
|
||||
>
|
||||
Обновить
|
||||
</Box>
|
||||
</Box>
|
||||
</LocalizationProvider>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,304 @@
|
||||
import { Button, Chip, Text } from '@moex-vibe/design-system'
|
||||
import { Box } from '@mui/material'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import type { BrokerEventItem, BrokerEventsData } from '@/shared/api'
|
||||
import { formatBrokerDate } from '@/shared/lib/formatters'
|
||||
import type { DashboardDatePreset, DashboardEventType } from '../lib/dashboardFilters'
|
||||
import { eventStatusLabel, eventTypeLabel } from '../lib/dashboardFormatters'
|
||||
import {
|
||||
eventStatusTone,
|
||||
eventTypeTone,
|
||||
formatDashboardCurrency,
|
||||
instrumentDisplay,
|
||||
type MoneyTone,
|
||||
moneyTone,
|
||||
moneyToneToColor,
|
||||
type TypeTone,
|
||||
} from '../lib/dashboardVisual'
|
||||
import { BrokerDashboardCard } from './BrokerDashboardCard'
|
||||
import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter'
|
||||
import { BrokerDashboardTableSkeleton } from './BrokerDashboardTableSkeleton'
|
||||
import { BrokerDashboardTableToolbar } from './BrokerDashboardTableToolbar'
|
||||
|
||||
const EVENT_FILTERS: Array<{
|
||||
type: DashboardEventType
|
||||
label: string
|
||||
tone: TypeTone
|
||||
}> = [
|
||||
{ type: 'dividend', label: 'Дивиденды', tone: 'success' },
|
||||
{ type: 'coupon', label: 'Купоны', tone: 'info' },
|
||||
{ type: 'maturity', label: 'Погашения', tone: 'warning' },
|
||||
{ type: 'offer', label: 'Оферты', tone: 'neutral' },
|
||||
]
|
||||
|
||||
const TH_SX = {
|
||||
textAlign: 'left' as const,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
px: 1.25,
|
||||
py: 1,
|
||||
color: 'text.secondary',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
textTransform: 'uppercase' as const,
|
||||
whiteSpace: 'nowrap' as const,
|
||||
}
|
||||
|
||||
const TD_SX_LEFT = {
|
||||
px: 1.25,
|
||||
py: 1,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
verticalAlign: 'middle' as const,
|
||||
}
|
||||
|
||||
const TD_SX_RIGHT = {
|
||||
...TD_SX_LEFT,
|
||||
textAlign: 'right' as const,
|
||||
}
|
||||
|
||||
const TD_SX_INSTRUMENT = {
|
||||
...TD_SX_LEFT,
|
||||
minWidth: 180,
|
||||
}
|
||||
|
||||
type BrokerDashboardEventsCardProps = {
|
||||
accountId: string
|
||||
data: BrokerEventsData | undefined
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
selectedTypes: DashboardEventType[]
|
||||
onToggleType: (type: DashboardEventType) => void
|
||||
appliedDateLabel: string | null
|
||||
draftPreset: DashboardDatePreset
|
||||
draftFrom: string
|
||||
draftTo: string
|
||||
onDraftPresetChange: (preset: DashboardDatePreset) => void
|
||||
onDraftFromChange: (value: string) => void
|
||||
onDraftToChange: (value: string) => void
|
||||
onApplyFilters: () => void
|
||||
onResetFilters: () => void
|
||||
hasDraftTypes: boolean
|
||||
totalCount?: number
|
||||
page: number
|
||||
onPreviousPage: () => void
|
||||
onNextPage: () => void
|
||||
canGoBack: boolean
|
||||
canGoForward: boolean
|
||||
}
|
||||
|
||||
function eventAmountValue(event: BrokerEventItem): number | null {
|
||||
return event.source === 'actual' ? event.actualAmount : event.estimatedAmount
|
||||
}
|
||||
|
||||
function eventMoneyTone(event: BrokerEventItem): MoneyTone {
|
||||
return moneyTone(eventAmountValue(event), event.source)
|
||||
}
|
||||
|
||||
function eventAmountDisplay(event: BrokerEventItem): string {
|
||||
const amount = eventAmountValue(event)
|
||||
if (amount === null || amount === undefined) return '—'
|
||||
const abs = formatDashboardCurrency({
|
||||
currency: event.currency ?? 'RUB',
|
||||
value: Math.abs(amount),
|
||||
})
|
||||
if (event.source === 'forecast') return `~${abs}`
|
||||
if (amount > 0) return `+${abs}`
|
||||
if (amount < 0) return `−${abs}`
|
||||
return abs
|
||||
}
|
||||
|
||||
export function BrokerDashboardEventsCard({
|
||||
accountId,
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
selectedTypes,
|
||||
onToggleType,
|
||||
appliedDateLabel,
|
||||
draftPreset,
|
||||
draftFrom,
|
||||
draftTo,
|
||||
onDraftPresetChange,
|
||||
onDraftFromChange,
|
||||
onDraftToChange,
|
||||
onApplyFilters,
|
||||
onResetFilters,
|
||||
hasDraftTypes,
|
||||
totalCount,
|
||||
page,
|
||||
onPreviousPage,
|
||||
onNextPage,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
}: BrokerDashboardEventsCardProps) {
|
||||
const events = data?.items ?? []
|
||||
|
||||
return (
|
||||
<BrokerDashboardCard
|
||||
title="События"
|
||||
badge={events.length > 0 ? `${events.length} из ${totalCount ?? events.length}` : undefined}
|
||||
action={<Link to={`/broker/${encodeURIComponent(accountId)}/events`}>Все события</Link>}
|
||||
filters={
|
||||
<BrokerDashboardTableToolbar
|
||||
chips={EVENT_FILTERS.map((filter) => (
|
||||
<Chip
|
||||
key={filter.type}
|
||||
label={filter.label}
|
||||
tone={filter.tone}
|
||||
selected={selectedTypes.includes(filter.type)}
|
||||
onClick={() => onToggleType(filter.type)}
|
||||
/>
|
||||
))}
|
||||
>
|
||||
<BrokerDashboardDateFilter
|
||||
appliedLabel={appliedDateLabel}
|
||||
preset={draftPreset}
|
||||
draftFrom={draftFrom}
|
||||
draftTo={draftTo}
|
||||
hasDraftTypes={hasDraftTypes}
|
||||
onPresetChange={onDraftPresetChange}
|
||||
onFromChange={onDraftFromChange}
|
||||
onToChange={onDraftToChange}
|
||||
onReset={onResetFilters}
|
||||
onApply={onApplyFilters}
|
||||
/>
|
||||
</BrokerDashboardTableToolbar>
|
||||
}
|
||||
>
|
||||
{selectedTypes.length === 0 ? (
|
||||
<Text tone="negative">Выберите хотя бы один тип событий</Text>
|
||||
) : isError ? (
|
||||
<Text tone="negative">Не удалось загрузить события</Text>
|
||||
) : isLoading ? (
|
||||
<BrokerDashboardTableSkeleton rows={5} columns={5} />
|
||||
) : events.length === 0 ? (
|
||||
<Text tone="muted">В ближайшем периоде событий нет</Text>
|
||||
) : (
|
||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||
<Box sx={{ overflowX: 'auto' }}>
|
||||
<Box
|
||||
component="table"
|
||||
aria-label="Таблица событий брокерского счёта"
|
||||
sx={{ width: '100%', borderCollapse: 'collapse', fontSize: 14, minWidth: 640 }}
|
||||
>
|
||||
<Box component="thead">
|
||||
<Box component="tr">
|
||||
<Box component="th" sx={TH_SX}>
|
||||
Дата
|
||||
</Box>
|
||||
<Box component="th" sx={TH_SX}>
|
||||
Инструмент
|
||||
</Box>
|
||||
<Box component="th" sx={TH_SX}>
|
||||
Тип
|
||||
</Box>
|
||||
<Box component="th" sx={{ ...TH_SX, textAlign: 'right' }}>
|
||||
Сумма
|
||||
</Box>
|
||||
<Box component="th" sx={{ ...TH_SX, textAlign: 'right' }}>
|
||||
Статус
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box component="tbody">
|
||||
{events.map((event) => {
|
||||
const display = instrumentDisplay({
|
||||
ticker: event.ticker,
|
||||
name: event.name,
|
||||
})
|
||||
const formattedAmount = eventAmountDisplay(event)
|
||||
return (
|
||||
<Box component="tr" key={event.id} data-testid="dashboard-events-row">
|
||||
<Box component="td" sx={TD_SX_LEFT}>
|
||||
{formatBrokerDate(event.eventDate) ?? '—'}
|
||||
</Box>
|
||||
<Box component="td" sx={TD_SX_INSTRUMENT}>
|
||||
<Box sx={{ display: 'grid', gap: 0.25 }}>
|
||||
<Box
|
||||
sx={{ fontWeight: 700 }}
|
||||
data-testid="dashboard-events-instrument-main"
|
||||
>
|
||||
{display.main}
|
||||
</Box>
|
||||
{display.subtitle ? (
|
||||
<Box
|
||||
sx={{ fontSize: 12, color: 'text.secondary' }}
|
||||
data-testid="dashboard-events-instrument-subtitle"
|
||||
>
|
||||
{display.subtitle}
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box component="td" sx={TD_SX_LEFT}>
|
||||
<Chip
|
||||
label={eventTypeLabel(event.type)}
|
||||
tone={eventTypeTone(event.type)}
|
||||
selected={false}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
component="td"
|
||||
sx={{
|
||||
...TD_SX_RIGHT,
|
||||
fontWeight: 700,
|
||||
color: moneyToneToColor(eventMoneyTone(event)),
|
||||
}}
|
||||
data-testid="dashboard-events-amount"
|
||||
data-tone={eventMoneyTone(event)}
|
||||
>
|
||||
{formattedAmount}
|
||||
</Box>
|
||||
<Box component="td" sx={TD_SX_RIGHT}>
|
||||
<Chip
|
||||
label={eventStatusLabel(event)}
|
||||
tone={eventStatusTone(event.source)}
|
||||
selected={false}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: 1,
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Text variant="caption" tone="secondary">
|
||||
Показано {events.length} событий за выбранный период
|
||||
</Text>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={onPreviousPage}
|
||||
disabled={!canGoBack}
|
||||
>
|
||||
←
|
||||
</Button>
|
||||
<Text variant="body" tone="secondary">
|
||||
{page}
|
||||
</Text>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={onNextPage}
|
||||
disabled={!canGoForward}
|
||||
>
|
||||
→
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</BrokerDashboardCard>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
import { Metric, Text } from '@moex-vibe/design-system'
|
||||
import { Box } from '@mui/material'
|
||||
import type { BrokerAnalytics, BrokerPortfolio } from '@/shared/api'
|
||||
import { formatBrokerMoney, formatBrokerPercent } from '@/shared/lib/formatters'
|
||||
import { formatDashboardCurrency, moneyTone, moneyToneToColor } from '../lib/dashboardVisual'
|
||||
|
||||
type BrokerDashboardHeroProps = {
|
||||
portfolio: BrokerPortfolio
|
||||
analytics: BrokerAnalytics | undefined
|
||||
}
|
||||
|
||||
function percentValue(value: unknown): string {
|
||||
return typeof value === 'number' ? formatBrokerPercent(value) : '—'
|
||||
}
|
||||
|
||||
export function BrokerDashboardHero({ portfolio, analytics }: BrokerDashboardHeroProps) {
|
||||
const accountName = portfolio.account.name || 'Брокерский счёт'
|
||||
const returnPercent = analytics?.totalReturnPercent ?? portfolio.yields.expectedPercent
|
||||
const returnTone = moneyTone(typeof returnPercent === 'number' ? returnPercent : null)
|
||||
const dailyTone = moneyTone(portfolio.yields.daily?.value ?? null)
|
||||
const totalReceivedTone = moneyTone(analytics?.totalReceived ?? null)
|
||||
const totalReceivedDisplay = analytics
|
||||
? formatDashboardCurrency({ currency: analytics.currency, value: analytics.totalReceived })
|
||||
: '—'
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="section"
|
||||
aria-label="Ключевые показатели брокерского счёта"
|
||||
sx={{
|
||||
border: '1px solid',
|
||||
borderColor: 'success.light',
|
||||
borderRadius: 4,
|
||||
bgcolor: 'rgba(46, 125, 50, 0.06)',
|
||||
p: { xs: 2, md: 3 },
|
||||
display: 'grid',
|
||||
gap: 2,
|
||||
gridTemplateColumns: { xs: '1fr', md: 'minmax(240px, 1fr) repeat(3, auto)' },
|
||||
alignItems: 'stretch',
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Text variant="label" tone="secondary">
|
||||
Инвестиционный дашборд
|
||||
</Text>
|
||||
<Box sx={{ fontWeight: 800, fontSize: { xs: 22, md: 28 }, lineHeight: 1.15 }}>
|
||||
{accountName}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Metric label="Стоимость портфеля" value={formatBrokerMoney(portfolio.totals.portfolio)} />
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Metric
|
||||
label="Доходность"
|
||||
value={
|
||||
<Box component="span" sx={{ color: moneyToneToColor(returnTone), fontWeight: 700 }}>
|
||||
{percentValue(returnPercent)}
|
||||
</Box>
|
||||
}
|
||||
supportingText={
|
||||
<Box component="span" sx={{ color: moneyToneToColor(dailyTone) }}>
|
||||
За день: {formatBrokerMoney(portfolio.yields.daily)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Metric
|
||||
label="Всего доходов"
|
||||
value={
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ color: moneyToneToColor(totalReceivedTone), fontWeight: 700 }}
|
||||
>
|
||||
{totalReceivedDisplay}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,273 @@
|
||||
import { Button, Chip, Text } from '@moex-vibe/design-system'
|
||||
import { Box } from '@mui/material'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import type { BrokerOperationsPage } from '@/shared/api'
|
||||
import { formatBrokerDate } from '@/shared/lib/formatters'
|
||||
import type { DashboardDatePreset, DashboardIncomeType } from '../lib/dashboardFilters'
|
||||
import { getDashboardIncomeRows, sumDashboardIncome } from '../lib/dashboardIncome'
|
||||
import {
|
||||
formatDashboardCurrency,
|
||||
incomeTypeTone,
|
||||
moneyToneToColor,
|
||||
type TypeTone,
|
||||
} from '../lib/dashboardVisual'
|
||||
import { BrokerDashboardCard } from './BrokerDashboardCard'
|
||||
import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter'
|
||||
import { BrokerDashboardTableSkeleton } from './BrokerDashboardTableSkeleton'
|
||||
import { BrokerDashboardTableToolbar } from './BrokerDashboardTableToolbar'
|
||||
|
||||
const INCOME_FILTERS: Array<{
|
||||
type: DashboardIncomeType
|
||||
label: string
|
||||
tone: TypeTone
|
||||
}> = [
|
||||
{ type: 'dividend', label: 'Дивиденды', tone: 'success' },
|
||||
{ type: 'coupon', label: 'Купоны', tone: 'info' },
|
||||
]
|
||||
|
||||
const TH_SX = {
|
||||
textAlign: 'left' as const,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
px: 1.25,
|
||||
py: 1,
|
||||
color: 'text.secondary',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
textTransform: 'uppercase' as const,
|
||||
whiteSpace: 'nowrap' as const,
|
||||
}
|
||||
|
||||
const TD_SX_LEFT = {
|
||||
px: 1.25,
|
||||
py: 1,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
verticalAlign: 'middle' as const,
|
||||
}
|
||||
|
||||
const TD_SX_RIGHT = {
|
||||
...TD_SX_LEFT,
|
||||
textAlign: 'right' as const,
|
||||
}
|
||||
|
||||
const TD_SX_INSTRUMENT = {
|
||||
...TD_SX_LEFT,
|
||||
minWidth: 200,
|
||||
}
|
||||
|
||||
type BrokerDashboardIncomeCardProps = {
|
||||
accountId: string
|
||||
page: BrokerOperationsPage | undefined
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
selectedTypes: DashboardIncomeType[]
|
||||
onToggleType: (type: DashboardIncomeType) => void
|
||||
appliedDateLabel: string | null
|
||||
draftPreset: DashboardDatePreset
|
||||
draftFrom: string
|
||||
draftTo: string
|
||||
onDraftPresetChange: (preset: DashboardDatePreset) => void
|
||||
onDraftFromChange: (value: string) => void
|
||||
onDraftToChange: (value: string) => void
|
||||
onApplyFilters: () => void
|
||||
onResetFilters: () => void
|
||||
hasDraftTypes: boolean
|
||||
visibleCount: number
|
||||
pageNumber: number
|
||||
canGoBack: boolean
|
||||
canGoForward: boolean
|
||||
onPreviousPage: () => void
|
||||
onNextPage: () => void
|
||||
}
|
||||
|
||||
export function BrokerDashboardIncomeCard({
|
||||
accountId,
|
||||
page,
|
||||
isLoading,
|
||||
isError,
|
||||
selectedTypes,
|
||||
onToggleType,
|
||||
appliedDateLabel,
|
||||
draftPreset,
|
||||
draftFrom,
|
||||
draftTo,
|
||||
onDraftPresetChange,
|
||||
onDraftFromChange,
|
||||
onDraftToChange,
|
||||
onApplyFilters,
|
||||
onResetFilters,
|
||||
hasDraftTypes,
|
||||
visibleCount,
|
||||
pageNumber,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
onPreviousPage,
|
||||
onNextPage,
|
||||
}: BrokerDashboardIncomeCardProps) {
|
||||
const rows = getDashboardIncomeRows(page?.items ?? []).slice(0, 10)
|
||||
const total = sumDashboardIncome(rows)
|
||||
|
||||
return (
|
||||
<BrokerDashboardCard
|
||||
title="Доходы"
|
||||
badge={rows.length > 0 ? `${visibleCount} операции` : undefined}
|
||||
action={<Link to={`/broker/${encodeURIComponent(accountId)}/operations`}>Все операции</Link>}
|
||||
filters={
|
||||
<BrokerDashboardTableToolbar
|
||||
chips={INCOME_FILTERS.map((filter) => (
|
||||
<Chip
|
||||
key={filter.type}
|
||||
label={filter.label}
|
||||
tone={filter.tone}
|
||||
selected={selectedTypes.includes(filter.type)}
|
||||
onClick={() => onToggleType(filter.type)}
|
||||
/>
|
||||
))}
|
||||
>
|
||||
<BrokerDashboardDateFilter
|
||||
appliedLabel={appliedDateLabel}
|
||||
preset={draftPreset}
|
||||
draftFrom={draftFrom}
|
||||
draftTo={draftTo}
|
||||
hasDraftTypes={hasDraftTypes}
|
||||
onPresetChange={onDraftPresetChange}
|
||||
onFromChange={onDraftFromChange}
|
||||
onToChange={onDraftToChange}
|
||||
onReset={onResetFilters}
|
||||
onApply={onApplyFilters}
|
||||
/>
|
||||
</BrokerDashboardTableToolbar>
|
||||
}
|
||||
>
|
||||
{selectedTypes.length === 0 ? (
|
||||
<Text tone="negative">Выберите хотя бы один тип доходов</Text>
|
||||
) : isError ? (
|
||||
<Text tone="negative">Не удалось загрузить доходные операции</Text>
|
||||
) : isLoading ? (
|
||||
<BrokerDashboardTableSkeleton rows={5} columns={4} />
|
||||
) : rows.length === 0 ? (
|
||||
<Text tone="muted">Дивидендов и купонов в последних операциях нет</Text>
|
||||
) : (
|
||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||
<Box sx={{ overflowX: 'auto' }}>
|
||||
<Box
|
||||
component="table"
|
||||
aria-label="Таблица доходов брокерского счёта"
|
||||
sx={{ width: '100%', borderCollapse: 'collapse', fontSize: 14, minWidth: 520 }}
|
||||
>
|
||||
<Box component="thead">
|
||||
<Box component="tr">
|
||||
<Box component="th" sx={TH_SX}>
|
||||
Дата
|
||||
</Box>
|
||||
<Box component="th" sx={TH_SX}>
|
||||
Инструмент
|
||||
</Box>
|
||||
<Box component="th" sx={TH_SX}>
|
||||
Тип
|
||||
</Box>
|
||||
<Box component="th" sx={{ ...TH_SX, textAlign: 'right' }}>
|
||||
Сумма
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box component="tbody">
|
||||
{rows.map((row) => {
|
||||
const formattedAmount = `${row.amount.value >= 0 ? '+' : '−'}${formatDashboardCurrency(
|
||||
{ currency: row.amount.currency, value: Math.abs(row.amount.value) },
|
||||
)}`
|
||||
const amountTone = row.amount.value >= 0 ? 'positive' : 'negative'
|
||||
return (
|
||||
<Box component="tr" key={row.id} data-testid="dashboard-income-row">
|
||||
<Box component="td" sx={TD_SX_LEFT}>
|
||||
{formatBrokerDate(row.date) ?? '—'}
|
||||
</Box>
|
||||
<Box component="td" sx={TD_SX_INSTRUMENT}>
|
||||
<Box sx={{ display: 'grid', gap: 0.25 }}>
|
||||
<Box
|
||||
sx={{ fontWeight: 700 }}
|
||||
data-testid="dashboard-income-instrument-main"
|
||||
>
|
||||
{row.instrumentMain}
|
||||
</Box>
|
||||
{row.instrumentSubtitle ? (
|
||||
<Box
|
||||
sx={{ fontSize: 12, color: 'text.secondary' }}
|
||||
data-testid="dashboard-income-instrument-subtitle"
|
||||
>
|
||||
{row.instrumentSubtitle}
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box component="td" sx={TD_SX_LEFT}>
|
||||
<Chip
|
||||
label={row.typeLabel}
|
||||
tone={incomeTypeTone(row.typeLabel)}
|
||||
selected={false}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
component="td"
|
||||
sx={{
|
||||
...TD_SX_RIGHT,
|
||||
fontWeight: 700,
|
||||
color: moneyToneToColor(amountTone),
|
||||
}}
|
||||
data-testid="dashboard-income-amount"
|
||||
data-tone={amountTone}
|
||||
>
|
||||
{formattedAmount}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: 1,
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Text variant="caption" tone="secondary">
|
||||
Показано {rows.length} · Итого:{' '}
|
||||
{total
|
||||
? `${total.value >= 0 ? '+' : '−'}${formatDashboardCurrency({
|
||||
currency: total.currency,
|
||||
value: Math.abs(total.value),
|
||||
})}`
|
||||
: '—'}
|
||||
</Text>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, alignItems: 'center' }}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={onPreviousPage}
|
||||
disabled={!canGoBack}
|
||||
>
|
||||
←
|
||||
</Button>
|
||||
<Text variant="body" tone="secondary">
|
||||
{pageNumber}
|
||||
</Text>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={onNextPage}
|
||||
disabled={!canGoForward}
|
||||
>
|
||||
→
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</BrokerDashboardCard>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
import { Skeleton } from '@moex-vibe/design-system'
|
||||
import { Box } from '@mui/material'
|
||||
|
||||
export function BrokerDashboardSkeleton() {
|
||||
return (
|
||||
<Box sx={{ display: 'grid', gap: 3 }} aria-label="Загрузка брокерского дашборда">
|
||||
<Skeleton height={120} shape="rounded" />
|
||||
<Skeleton height={300} shape="rounded" />
|
||||
<Skeleton height={300} shape="rounded" />
|
||||
<Skeleton height={260} shape="rounded" />
|
||||
<Skeleton height={260} shape="rounded" />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
import { Skeleton } from '@moex-vibe/design-system'
|
||||
import { Box } from '@mui/material'
|
||||
|
||||
type ColumnWidth = string
|
||||
|
||||
type BrokerDashboardTableSkeletonProps = {
|
||||
rows?: number
|
||||
columns?: number
|
||||
}
|
||||
|
||||
const DEFAULT_EVENT_WIDTHS: ColumnWidth[] = ['14%', '32%', '18%', '18%', '18%']
|
||||
const DEFAULT_INCOME_WIDTHS: ColumnWidth[] = ['18%', '40%', '22%', '20%']
|
||||
|
||||
const EVENT_ROW_VARIANTS: Array<'short' | 'long' | 'full'> = [
|
||||
'short',
|
||||
'long',
|
||||
'medium',
|
||||
'short',
|
||||
'medium',
|
||||
]
|
||||
const INCOME_ROW_VARIANTS: Array<'short' | 'long' | 'full'> = ['short', 'long', 'medium', 'short']
|
||||
|
||||
function widthsFor(columns: number): ColumnWidth[] {
|
||||
if (columns === 4) return DEFAULT_INCOME_WIDTHS
|
||||
if (columns === 5) return DEFAULT_EVENT_WIDTHS
|
||||
return Array.from({ length: columns }, () => '100%')
|
||||
}
|
||||
|
||||
function rowVariantsFor(columns: number): Array<'short' | 'long' | 'full' | 'medium'> {
|
||||
if (columns === 4) return INCOME_ROW_VARIANTS
|
||||
return EVENT_ROW_VARIANTS.slice(0, columns)
|
||||
}
|
||||
|
||||
export function BrokerDashboardTableSkeleton({
|
||||
rows = 5,
|
||||
columns = 5,
|
||||
}: BrokerDashboardTableSkeletonProps) {
|
||||
const widths = widthsFor(columns)
|
||||
const variants = rowVariantsFor(columns)
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{ display: 'grid', gap: 1, py: 1 }}
|
||||
data-testid="dashboard-table-skeleton"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{Array.from({ length: rows }, (_, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
alignItems: 'center',
|
||||
minHeight: 38,
|
||||
py: 0.5,
|
||||
}}
|
||||
>
|
||||
{widths.map((width, j) => (
|
||||
<Box key={j} sx={{ flex: `0 0 ${width}`, minWidth: 0 }}>
|
||||
<Skeleton
|
||||
height={14}
|
||||
width={
|
||||
variants[j] === 'short'
|
||||
? '45%'
|
||||
: variants[j] === 'medium'
|
||||
? '65%'
|
||||
: variants[j] === 'full'
|
||||
? '100%'
|
||||
: '74%'
|
||||
}
|
||||
shape="rounded"
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
import { Box } from '@mui/material'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
type BrokerDashboardTableToolbarProps = {
|
||||
chips: ReactNode
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function BrokerDashboardTableToolbar({ chips, children }: BrokerDashboardTableToolbarProps) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', md: 'minmax(0, 1fr) auto auto' },
|
||||
gap: 1.25,
|
||||
alignItems: 'center',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'grey.50',
|
||||
borderRadius: 2,
|
||||
p: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'grid', gap: 0.75, minWidth: 0 }}>
|
||||
<Box sx={{ color: 'text.secondary', fontSize: 12, fontWeight: 700, lineHeight: 1 }}>
|
||||
Тип
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, minWidth: 0 }}>{chips}</Box>
|
||||
</Box>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
304
docs/features/broker-dashboard-redesign/plan.md
Normal file
304
docs/features/broker-dashboard-redesign/plan.md
Normal file
@ -0,0 +1,304 @@
|
||||
# План реализации редизайна обзора брокерского счёта
|
||||
|
||||
> **Для агентных исполнителей:** ОБЯЗАТЕЛЬНЫЙ SUB-SKILL: использовать `superpowers:subagent-driven-development` (предпочтительно) или `superpowers:executing-plans` для пошагового выполнения. Шаги ведутся чекбоксами `- [ ]`.
|
||||
|
||||
**Цель:** превратить `/broker/:accountId` в компактный инвестиционный дашборд на текущей светлой теме без изменения backend-контрактов и URL-структуры.
|
||||
|
||||
**Архитектура:** маршрут и FSD-границы остаются прежними. Композиция собирается в `widgets/broker-dashboard`, данные продолжают приходить из существующих `entities/*` hooks. Навигация счёта остаётся в `BrokerAccountLayout`, а dashboard использует только локальные presentation/helpers без выноса брокерской логики в design system.
|
||||
|
||||
**Технологии:** React 18, TanStack Router, TanStack Query, MUI через `@moex-vibe/design-system`, MUI X DateCalendar community (`@mui/x-date-pickers`) с Day.js, Vitest, Testing Library.
|
||||
|
||||
**HTML parity update:** согласованный визуальный эталон находится в `docs/research/2026-06-27-broker-account-redesign.html`.
|
||||
Следующая итерация переносит его детали в реальную страницу без изменения backend-контрактов.
|
||||
|
||||
---
|
||||
|
||||
## Область реализации
|
||||
|
||||
### Файлы
|
||||
|
||||
- Modify: `apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx` — горизонтальные вкладки над контентом обзора и подробных разделов.
|
||||
- Modify: `apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx` — точка входа обзора через dashboard.
|
||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/index.ts` — публичный API dashboard-виджета.
|
||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx` — верхнеуровневая композиция и управление filter state.
|
||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx` — hero KPI с fallback-значениями и выравниванием `Metric`.
|
||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx` — локальный паттерн карточки.
|
||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx` — карточка событий.
|
||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx` — переиспользуемое управление периодом с draft/apply поведением.
|
||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx` — карточка доходов.
|
||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx` — карточка аналитики доходности.
|
||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx` — карточка аллокации с горизонтальными барами.
|
||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardSkeleton.tsx` — skeleton-форма dashboard.
|
||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx` — компонентные тесты композиции и фильтров.
|
||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts` — чистые helpers доходных операций.
|
||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts` — unit-тесты income helpers.
|
||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts` — пресеты дат, validate и mapping income types.
|
||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts` — локальные formatter/helpers для fallback и event labels.
|
||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts` — локальные helpers визуальной семантики dashboard: tone сумм, tone типов, отображение инструмента, символ валюты.
|
||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts` — unit-тесты визуальных helpers.
|
||||
- Modify: `apps/frontend/package.json` — добавить community-пакет `@mui/x-date-pickers`, если зависимость ещё не подключена; `@mui/x-date-pickers-pro` не добавлять.
|
||||
- Modify: `docs/features/broker-dashboard-redesign/tasks.md` — фиксация статусов выполнения.
|
||||
|
||||
### Источники данных
|
||||
|
||||
- Портфель: `useBrokerAccountContext().portfolio`.
|
||||
- События: `useBrokerEvents(accountId, { from, to, types })`.
|
||||
- Операции: `useBrokerOperations(accountId, { from, to, operationTypes, cursor, limit: 10 })`.
|
||||
- Аналитика: `useBrokerAnalytics(accountId)`.
|
||||
- Аллокация: `buildBrokerAllocation(portfolio)`.
|
||||
|
||||
## Технические решения
|
||||
|
||||
### 1. Композиция страницы
|
||||
|
||||
- Dashboard на desktop и mobile строится в одну колонку: `Hero`, `События`, `Доходы`, `Аналитика доходности`, `Аллокация`.
|
||||
- Двухколоночный layout из прототипа не переносится.
|
||||
- Навигация счёта находится над контентом в `BrokerAccountLayout` и не дублируется внутри dashboard.
|
||||
|
||||
### 2. Hero KPI
|
||||
|
||||
- Hero показывает название счёта, стоимость портфеля, доходность, дневное изменение и всего полученных доходов.
|
||||
- Приоритет доходности: `analytics.totalReturnPercent`, затем `portfolio.yields.expectedPercent`, иначе `—`.
|
||||
- Дневное изменение берётся из `portfolio.yields.daily` и `portfolio.yields.dailyPercent`, при недоступности показывается `—`.
|
||||
- Все `Metric` должны иметь одинаковую высоту; supporting text не должен ломать вертикальный ритм.
|
||||
|
||||
### 3. События
|
||||
|
||||
- Типы событий переключаются chip-фильтрами с немедленным применением.
|
||||
- Диапазон дат по умолчанию: `сегодня - 7 дней` / `сегодня + 7 дней`, чтобы обзор сразу показывал
|
||||
ближайшие будущие события.
|
||||
- Диапазон дат редактируется отдельно от применённого состояния: draft state меняется локально, запрос уходит только по действию применения периода.
|
||||
- В шапке управления периодом не используется слово `Фильтр`; роль управления считывается через иконку календаря, применённый диапазон, раскрытие панели и пресеты.
|
||||
- Native `<input type="date">` не используется; период выбирается через одно визуальное поле и popover с community `DateCalendar` из `@mui/x-date-pickers`.
|
||||
- `DateRangePicker` и `@mui/x-date-pickers-pro` не используются; выбор начала/конца периода реализуется локальной логикой dashboard.
|
||||
- При пустом выборе типов запрос отключается, карточка показывает валидационное сообщение.
|
||||
- Пагинация локальная, по 10 событий на страницу, сбрасывается при смене применённых фильтров.
|
||||
- При загрузке карточка показывает skeleton таблицы событий с колонками дата, инструмент, тип, сумма, статус.
|
||||
- В toolbar карточки используются count badge, label `Тип`, кнопка `Обновить` и footer-summary по паттерну HTML-эталона.
|
||||
|
||||
### 4. Доходы
|
||||
|
||||
- Доходы строятся на существующем endpoint операций только для `OPERATION_TYPE_DIVIDEND`, `OPERATION_TYPE_DIV_EXT`, `OPERATION_TYPE_COUPON`.
|
||||
- Типы доходов переключаются chip-фильтрами с немедленным применением.
|
||||
- Диапазон дат по умолчанию: `сегодня - 7 дней` / `сегодня`, чтобы не загружать большой объём операций на первом открытии.
|
||||
- Диапазон дат использует тот же draft/apply паттерн, что и события.
|
||||
- В шапке управления периодом не используется слово `Фильтр`; действие применения периода не называется `Показать`.
|
||||
- Native `<input type="date">` не используется; период выбирается через одно визуальное поле и popover с community `DateCalendar` из `@mui/x-date-pickers`.
|
||||
- `DateRangePicker` и `@mui/x-date-pickers-pro` не используются; выбор начала/конца периода реализуется локальной логикой dashboard.
|
||||
- Пагинация cursor-based, размер страницы 10, сбрасывается при смене применённых фильтров.
|
||||
- При загрузке карточка показывает skeleton таблицы доходов с колонками дата, инструмент, тип, сумма.
|
||||
|
||||
### 5. Аналитика и аллокация
|
||||
|
||||
- Карточка аналитики использует существующий analytics endpoint и показывает спокойное empty state при отсутствии данных.
|
||||
- Карточка аллокации не использует donut chart. Она строит список горизонтальных bar rows по `buildBrokerAllocation`.
|
||||
- Отрицательные значения показываются текстом без полосы.
|
||||
|
||||
### 6. Ошибки и пустые состояния
|
||||
|
||||
- Ошибка `portfolio` роняет весь обзор.
|
||||
- Ошибки `events`, `income`, `analytics` локальны соответствующим карточкам.
|
||||
- Пустые данные показываются отдельными сообщениями, а не нулевыми значениями.
|
||||
|
||||
### 7. HTML parity visual layer
|
||||
|
||||
- Реальная страница `/broker/:accountId` должна визуально соответствовать `docs/research/2026-06-27-broker-account-redesign.html`,
|
||||
но использовать существующие React-компоненты и FSD-границы.
|
||||
- Не менять backend и OpenAPI: блок `Доходы` остаётся на текущем endpoint операций и текущем наборе
|
||||
income-типов. Отрицательный tone должен поддерживаться для строк, которые уже отображаются или будут
|
||||
отображаться без расширения контракта.
|
||||
- Ввести локальные helpers в `widgets/broker-dashboard/lib/dashboardVisual.ts`:
|
||||
`moneyTone(value, source?) -> 'positive' | 'negative' | 'planned' | 'neutral'`,
|
||||
`eventTypeTone(type)`, `incomeTypeTone(typeLabel)`, `formatDashboardCurrency(moneyOrValue)`,
|
||||
`instrumentDisplay({ ticker, name, description })`.
|
||||
- `formatDashboardCurrency` для RUB должен выводить `₽`. Для неизвестных валют использовать код валюты.
|
||||
- `instrumentDisplay` должен возвращать основную строку и опциональную подпись: для событий приоритет
|
||||
`ticker/isin` как main и `name` как subtitle; для операций приоритет `ticker` как main и
|
||||
`name/description` как subtitle. Если ticker отсутствует, main берётся из name/description, subtitle не
|
||||
дублируется.
|
||||
- `BrokerDashboardCard` должен поддержать компактный заголовок карточки уровня HTML-прототипа, не
|
||||
используя крупный `Heading size="title"`.
|
||||
- `BrokerDashboardDateFilter` должен использовать иконку раскрытия вместо текстового символа и сохранять
|
||||
единый toolbar-паттерн для `События` и `Доходы`.
|
||||
- Для `События` period presets должны поддерживать будущую часть диапазона, а не обрезаться текущим днём.
|
||||
- Таблицы `События` и `Доходы` должны иметь `thead`, type badges, двухстрочный инструмент при наличии
|
||||
названия и semantic amount colors.
|
||||
- `BrokerDashboardAnalyticsCard` должен окрашивать KPI-карточки по смыслу и показывать RUB через `₽`.
|
||||
- Skeleton таблиц событий и доходов должен использовать один компонент/паттерн и различаться только
|
||||
числом колонок.
|
||||
|
||||
## Задачи
|
||||
|
||||
### Задача 1: Базовые helpers и локальные dashboard-patterns
|
||||
|
||||
**Файлы:**
|
||||
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx`
|
||||
|
||||
- [ ] Убедиться, что income helpers покрывают только допустимые типы доходов и умеют считать итог отображаемых строк.
|
||||
- [ ] Убедиться, что filter helpers содержат default state, date presets, validate и mapping income type -> operation types.
|
||||
- [ ] Убедиться, что formatters покрывают fallback-значения, label типов событий и label статусов.
|
||||
- [ ] Использовать локальный `BrokerDashboardCard` как основной контейнер карточек; design system расширять только если без этого нельзя реализовать требования спецификации.
|
||||
|
||||
### Задача 2: Hero и layout overview
|
||||
|
||||
**Файлы:**
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardSkeleton.tsx`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx`
|
||||
- `apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx`
|
||||
- `apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx`
|
||||
|
||||
- [ ] Собрать обзор через `BrokerDashboard` и `BrokerDashboardSkeleton`.
|
||||
- [ ] Оставить навигацию счёта в `BrokerAccountLayout` как горизонтальные вкладки над контентом.
|
||||
- [ ] Исправить hero так, чтобы все `Metric` были одной высоты и поддерживающий текст не поднимал одну ячейку выше остальных.
|
||||
- [ ] Проверить, что dashboard остаётся одноколоночным и на desktop, и на mobile.
|
||||
|
||||
### Задача 3: Карточка событий
|
||||
|
||||
**Файлы:**
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts`
|
||||
- `apps/frontend/package.json`
|
||||
|
||||
- [ ] Добавить или подтвердить зависимость community-пакета `@mui/x-date-pickers` и использовать Day.js adapter.
|
||||
- [ ] Не добавлять `@mui/x-date-pickers-pro` и не использовать `DateRangePicker`.
|
||||
- [ ] Обновить `BrokerDashboardDateFilter`: убрать слово `Фильтр` из пользовательского текста, заменить `Показать` на понятное действие применения периода и визуально собрать chips, диапазон, сброс и применение в аккуратную шапку.
|
||||
- [ ] Заменить native date inputs на одно визуальное поле периода, которое открывает MUI `Popover` с community `DateCalendar`.
|
||||
- [ ] Реализовать локальную логику выбора диапазона: первый клик задаёт начало, второй — конец; если конец раньше начала, диапазон пересобирается от выбранной даты.
|
||||
- [ ] Настроить default range событий на `сегодня - 7 дней` / `сегодня + 7 дней`.
|
||||
- [ ] Подключить для событий draft/applied state: типы применяются сразу, даты только по действию применения периода.
|
||||
- [ ] Сохранять локальную пагинацию по 10 событий и сбрасывать её при смене применённых фильтров.
|
||||
- [ ] Заменить текстовую загрузку событий на skeleton таблицы.
|
||||
- [ ] Оставить локальные error/empty states внутри карточки.
|
||||
|
||||
### Задача 4: Карточка доходов
|
||||
|
||||
**Файлы:**
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts`
|
||||
|
||||
- [ ] Подключить тот же `BrokerDashboardDateFilter` для доходов с draft/applied state.
|
||||
- [ ] Настроить default range доходов на `сегодня - 7 дней` / `сегодня` вместо периода с начала года.
|
||||
- [ ] Оставить chip-фильтры типов доходов с немедленным применением.
|
||||
- [ ] Сохранить cursor pagination по 10 операций и сбрасывать её при смене применённых фильтров.
|
||||
- [ ] Заменить текстовую загрузку доходов на skeleton таблицы.
|
||||
- [ ] Если endpoint операций даёт недостаточно релевантных строк для dashboard, зафиксировать ограничение в заметках по реализации, а не расширять backend в рамках этой фичи.
|
||||
|
||||
### Задача 5: Карточки аналитики и аллокации
|
||||
|
||||
**Файлы:**
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx`
|
||||
|
||||
- [ ] Довести карточку аналитики до соответствия спецификации по полям и состояниям.
|
||||
- [ ] Заменить donut chart на горизонтальные бары, построенные из `buildBrokerAllocation`.
|
||||
- [ ] Отрицательные значения аллокации выводить отдельно текстом без bar.
|
||||
|
||||
### Задача 6: Тесты и верификация
|
||||
|
||||
**Файлы:**
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx`
|
||||
- `docs/features/broker-dashboard-redesign/tasks.md`
|
||||
|
||||
- [ ] Обновить компонентные тесты dashboard так, чтобы они покрывали текущую композицию, фильтры и основные empty/error states.
|
||||
- [ ] Обновить тесты, которые ищут `Фильтр дат` или `Показать`, под новые тексты и accessible labels единого поля периода.
|
||||
- [ ] Добавить/обновить тесты default weekly range для событий и доходов.
|
||||
- [ ] Добавить/обновить тесты skeleton-таблиц для loading state событий и доходов.
|
||||
- [ ] Прогнать `rtk npm run test:frontend -- --run src/widgets/broker-dashboard`.
|
||||
- [ ] Прогнать `rtk npm run test:frontend`.
|
||||
- [ ] Прогнать `rtk npm run test:design-system && rtk npm run lint -w apps/frontend && rtk npm run build:frontend`.
|
||||
- [ ] Проверить вручную desktop layout `/broker/2084014113`.
|
||||
- [ ] Проверить вручную mobile layout `/broker/2084014113` на viewport `390x844`.
|
||||
- [ ] После завершения обновить `docs/features/broker-dashboard-redesign/tasks.md` и выполнить `graphify update .`.
|
||||
|
||||
### Задача 7: Visual helpers для HTML parity
|
||||
|
||||
**Файлы:**
|
||||
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts`
|
||||
|
||||
- [ ] Добавить `moneyTone`, который возвращает `negative` для отрицательных значений, `positive` для
|
||||
положительных фактических значений, `planned` для прогнозных/нефактических значений и `neutral` для
|
||||
нуля/недоступного значения.
|
||||
- [ ] Добавить `formatDashboardCurrency`, который для `RUB` выводит `₽`, а для неизвестной валюты
|
||||
оставляет код валюты.
|
||||
- [ ] Добавить `instrumentDisplay` с приоритетами main/subtitle из технического решения 7.
|
||||
- [ ] Добавить type tone helpers для event types и income labels.
|
||||
- [ ] Расширить `DashboardIncomeRow`: хранить `instrumentMain` и `instrumentSubtitle`, сохранив
|
||||
совместимость через существующий `instrument` только если это нужно текущим тестам.
|
||||
- [ ] Покрыть helpers unit-тестами: RUB symbol, unknown currency fallback, negative/positive/planned
|
||||
tones, event/income type tones, отсутствие дублирования subtitle.
|
||||
|
||||
### Задача 8: Hero, карточка и toolbar parity
|
||||
|
||||
**Файлы:**
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx`
|
||||
|
||||
- [ ] Применить semantic color к hero: отрицательная доходность красная, положительная зелёная,
|
||||
дневное изменение окрашивается по знаку, `Всего доходов` зелёный при значении больше нуля.
|
||||
- [ ] Сделать заголовки dashboard-card компактными, соответствующими HTML-прототипу.
|
||||
- [ ] Собрать filters area карточек в единый toolbar: слева типы, справа поле периода и действие.
|
||||
- [ ] Заменить текстовую стрелку раскрытия периода на иконку: использовать уже подключённые
|
||||
`CalendarTodayRounded` и `ExpandMoreRounded` из `@mui/icons-material`, без добавления новой icon
|
||||
dependency.
|
||||
- [ ] Обновить component tests: проверять отсутствие текста `RUB` для RUB-значений, отсутствие символа
|
||||
`v` в period button и наличие accessible name у управления периодом.
|
||||
|
||||
### Задача 9: Таблицы событий и доходов как в HTML
|
||||
|
||||
**Файлы:**
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardTableSkeleton.tsx`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx`
|
||||
|
||||
- [ ] Добавить `thead` в обе dashboard-таблицы с колонками из спецификации.
|
||||
- [ ] В колонке инструмента выводить main и subtitle из `instrumentDisplay`.
|
||||
- [ ] Заменить текстовые типы на компактные бейджи с tone из visual helpers.
|
||||
- [ ] Окрашивать суммы через `moneyTone`: поступления зелёным, списания красным, прогноз/ожидание серым.
|
||||
- [ ] Окрашивать статусные бейджи: `Поступило` зелёный, прогноз/ожидание серый.
|
||||
- [ ] Сохранить горизонтальный scroll только внутри таблицы на mobile, без общего page overflow.
|
||||
- [ ] Обновить skeleton так, чтобы `События` и `Доходы` использовали один визуальный паттерн строк.
|
||||
- [ ] Обновить tests на наличие type badges, subtitle инструмента, semantic amount classes и skeleton.
|
||||
|
||||
### Задача 10: Analytics parity и визуальная проверка
|
||||
|
||||
**Файлы:**
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx`
|
||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx`
|
||||
- `docs/features/broker-dashboard-redesign/tasks.md`
|
||||
|
||||
- [ ] Отображать RUB как `₽` во всех analytics KPI.
|
||||
- [ ] Окрашивать analytics cards: positive для пополнений/дивидендов/купонов/всего получено,
|
||||
negative для выводов и отрицательного нетто, neutral для нулевых/недоступных значений.
|
||||
- [ ] Обновить component tests для analytics: `₽` вместо `RUB`, positive/negative tone cards.
|
||||
- [ ] Прогнать `rtk npm run test:frontend -- --run src/widgets/broker-dashboard`.
|
||||
- [ ] Прогнать `rtk npm run test:frontend`.
|
||||
- [ ] Прогнать `rtk npm run lint -w apps/frontend && rtk npm run build:frontend`.
|
||||
- [ ] Проверить `/broker/2084014113` вручную на desktop и mobile `390x844` против
|
||||
`docs/research/2026-06-27-broker-account-redesign.html`.
|
||||
- [ ] После проверки обновить `docs/features/broker-dashboard-redesign/tasks.md`.
|
||||
|
||||
## Проверка покрытия спецификации
|
||||
|
||||
- Общая композиция и горизонтальная навигация: задачи 2 и 6.
|
||||
- Hero KPI и equal-height `Metric`: задача 2.
|
||||
- События с chip filters, date filters и локальной пагинацией: задача 3.
|
||||
- Доходы с chip filters, date filters и cursor pagination: задача 4.
|
||||
- Community DateCalendar range control, weekly default range и skeleton loading tables: задачи 3, 4 и 6.
|
||||
- Аналитика и аллокация с горизонтальными барами: задача 5.
|
||||
- Ошибки, empty states, тесты и ручная верификация: задача 6.
|
||||
- HTML parity: visual helpers — задача 7, hero/card/toolbar — задача 8, dashboard tables — задача 9,
|
||||
analytics — задача 10.
|
||||
292
docs/features/broker-dashboard-redesign/spec.md
Normal file
292
docs/features/broker-dashboard-redesign/spec.md
Normal file
@ -0,0 +1,292 @@
|
||||
# Редизайн обзора брокерского счёта в инвестиционный дашборд
|
||||
|
||||
Дата: 2026-06-26 (обновлено 2026-06-27, HTML parity)
|
||||
Статус: согласовано к планированию
|
||||
Эпик: [Портфель брокера](../../epics/BrokerPortfolio.md)
|
||||
|
||||
## Контекст
|
||||
|
||||
Текущий маршрут `/broker/:accountId` показывает корректный overview выбранного брокерского счёта, но
|
||||
визуально остаётся вертикальным набором отдельных блоков: сводка, аллокация, карточки активов,
|
||||
ближайшие события и последние операции. Пользователь подготовил прототип `temp.html`, где тот же домен
|
||||
представлен как более плотный инвестиционный дашборд с hero KPI, компактными таблицами и аналитическими
|
||||
карточками.
|
||||
|
||||
После обсуждения выбран вариант A: страница `/broker/:accountId` должна стать единым дашбордом,
|
||||
используя структуру и плотность прототипа, но сохраняя текущую светлую тему и компоненты
|
||||
`@moex-vibe/design-system`. Тёмная тема из прототипа не переносится в первую версию. При этом
|
||||
двухколоночная desktop-композиция прототипа сознательно не переносится: и на desktop, и на mobile блоки
|
||||
dashboard идут по одному на строке в фиксированном порядке.
|
||||
|
||||
После интерактивной дизайн-итерации согласован статический эталон
|
||||
`docs/research/2026-06-27-broker-account-redesign.html`. Реальная React-страница должна визуально соответствовать этому HTML:
|
||||
компактные заголовки карточек, единая шапка таблиц, бейджи типов, подписи инструментов, семантические
|
||||
цвета денежных значений и единый skeleton таблиц.
|
||||
|
||||
## Цель
|
||||
|
||||
Сделать overview брокерского счёта быстрым обзором состояния портфеля, будущих/прошедших событий,
|
||||
полученных доходов, аналитики доходности и аллокации без перехода по вкладкам.
|
||||
|
||||
## Пользовательский результат
|
||||
|
||||
Пользователь может на `/broker/:accountId`:
|
||||
|
||||
- сразу увидеть стоимость портфеля, доходность и сумму полученных доходов;
|
||||
- увидеть ближайшие события по счёту в компактной таблице;
|
||||
- увидеть последние доходные операции по дивидендам и купонам и итог по ним;
|
||||
- оценить вложения, полученные выплаты и доходность по существующей аналитике;
|
||||
- увидеть структуру портфеля через горизонтальные полосы аллокации и подписи к ним;
|
||||
- перейти в существующие подробные вкладки `Акции`, `Облигации`, `Операции`, `События` и `Аналитика`
|
||||
для drill-down сценариев.
|
||||
|
||||
## Область изменений
|
||||
|
||||
Фича относится только к frontend-маршруту `/broker/:accountId` и связанным frontend-компонентам
|
||||
брокерского overview.
|
||||
|
||||
В область входят:
|
||||
|
||||
- новая dashboard-композиция для `BrokerAccountOverviewPage`;
|
||||
- переиспользуемые frontend-паттерны для dashboard card, KPI, compact table и filter chips;
|
||||
- адаптация существующих брокерских widgets под новую компоновку, если это нужно для читаемости;
|
||||
- точечные доработки `@moex-vibe/design-system`, если существующие компоненты блокируют корректное
|
||||
использование текущей светлой DS-темы;
|
||||
- тесты новой композиции и ключевых представлений.
|
||||
|
||||
## Требования
|
||||
|
||||
### 1. Общая композиция
|
||||
|
||||
- `/broker/:accountId` остаётся overview выбранного брокерского счёта.
|
||||
- Overview визуально становится dashboard-страницей, а не вертикальным списком независимых секций.
|
||||
- Существующая навигация счёта отображается горизонтальными вкладками над контентом, чтобы не занимать
|
||||
левую колонку и оставить больше пространства для таблиц dashboard.
|
||||
- На desktop и mobile блоки dashboard отображаются по одному блоку на строке: hero KPI, `События`,
|
||||
`Доходы`, `Аналитика доходности`, `Аллокация`.
|
||||
- Существующая навигация счёта сохраняет ссылки на `Обзор`, `Акции`, `Облигации`, `Операции`,
|
||||
`События`, `Аналитика`.
|
||||
- На мобильном viewport сохраняется тот же порядок блоков в одну колонку.
|
||||
|
||||
### 2. Визуальный стиль
|
||||
|
||||
- Используется текущая светлая DS-тема MoexVibe.
|
||||
- Не добавляется dark mode и не переносится тёмная палитра `temp.html`.
|
||||
- Визуальная плотность, структура карточек, KPI-иерархия и компактность таблиц ориентируются на
|
||||
`docs/research/2026-06-27-broker-account-redesign.html`.
|
||||
- Дизайн использует компоненты и токены `@moex-vibe/design-system` там, где они применимы.
|
||||
- Если DS-компонент слишком ограничен, допускается точечно расширить DS API или создать локальный
|
||||
dashboard-pattern в frontend, но не добавлять доменную брокерскую логику в DS.
|
||||
- Заголовки dashboard-карточек не должны использовать page-level размер `Heading size="title"`;
|
||||
визуально они должны соответствовать компактному card heading из HTML-прототипа.
|
||||
- В карточках `События` и `Доходы` фильтры должны быть собраны в единый toolbar: группа типов, единое
|
||||
поле периода с иконкой календаря и chevron-иконкой, действие обновления/применения периода.
|
||||
- В поле периода не используется текстовый символ `v`; раскрытие обозначается иконкой.
|
||||
- Валюта в dashboard отображается символом `₽`, а не строкой `RUB`, кроме случаев, где backend вернул
|
||||
другую валюту и formatter проекта не знает её символ.
|
||||
|
||||
### 3. Hero KPI
|
||||
|
||||
Hero показывает:
|
||||
|
||||
- название счёта или fallback `Брокерский счёт`;
|
||||
- стоимость портфеля из `portfolio.totals.portfolio`;
|
||||
- доходность из существующих данных: приоритет `broker analytics.totalReturnPercent`, если загружена,
|
||||
иначе `portfolio.yields.expectedPercent`, если доступна;
|
||||
- дневное изменение из `portfolio.yields.daily` и `portfolio.yields.dailyPercent`, если доступно;
|
||||
- всего полученных доходов из `broker analytics.totalReceived`, если аналитика загружена;
|
||||
- спокойный fallback `—` для недоступных значений.
|
||||
- Все Metric-блоки имеют одинаковую высоту в grid-ряду, даже если у некоторых есть supportingText. Поддерживающий текст остаётся внутри Metric, но все Metrics растягиваются на полную высоту grid-ячейки с выравниванием от верхнего края.
|
||||
- Доходность в hero имеет цветовую семантику: положительная — зелёная, отрицательная — красная,
|
||||
недоступная/нулевая — нейтральная.
|
||||
- Дневное изменение в supporting text hero тоже окрашивается по знаку значения.
|
||||
- `Всего доходов` окрашивается как положительный финансовый показатель, если значение больше нуля.
|
||||
|
||||
### 4. Блок `События`
|
||||
|
||||
- Блок использует существующий источник `useBrokerEvents(accountId, query)`.
|
||||
- По умолчанию применяется период `сегодня - 7 дней` / `сегодня + 7 дней` и типы
|
||||
`dividend,coupon,maturity,offer`, как в существующей вкладке событий.
|
||||
- Блок содержит кликабельные chip-фильтры типов событий: `Дивиденды`, `Купоны`, `Погашения`, `Оферты`.
|
||||
- Пользователь может выбрать несколько типов событий.
|
||||
- Если пользователь снимает все типы событий, запрос не выполняется, а блок показывает
|
||||
валидационное сообщение.
|
||||
- Изменение chip-фильтров типов событий применяется сразу и возвращает локальную пагинацию на первую
|
||||
страницу.
|
||||
- Блок содержит фильтр периода `from` / `to`.
|
||||
- По умолчанию применяется недельный период `сегодня - 7 дней` / `сегодня + 7 дней`, чтобы обзор
|
||||
сразу показывал ближайшие будущие события.
|
||||
- Изменение черновых фильтров не запускает запрос до применения периода пользователем.
|
||||
- В пользовательском тексте шапки периода не используется слово `Фильтр`; UI должен считываться как
|
||||
управление периодом за счёт иконки календаря, применённого диапазона, пресетов и affordance раскрытия.
|
||||
- Блок содержит быстрые пресеты периода `7д`, `30д`, `90д`, `1г`, `Всё`, действие `Сбросить` и
|
||||
основное действие применения периода с более понятным текстом, чем `Показать`.
|
||||
- Период отображается как одно визуальное поле/кнопка с диапазоном, например `19 июн – 26 июн`.
|
||||
- По клику на поле периода открывается popover с community-компонентом MUI `DateCalendar` из
|
||||
`@mui/x-date-pickers` и ручной логикой выбора начала/конца периода.
|
||||
- `DateRangePicker` и пакет `@mui/x-date-pickers-pro` не используются.
|
||||
- Native `<input type="date">` не используется.
|
||||
- Применённые фильтры dashboard не обязаны синхронизироваться с URL; URL-синхронизация остаётся
|
||||
обязанностью подробной вкладки `События`.
|
||||
- В dashboard отображается не более 10 событий на странице.
|
||||
- Если в выбранном диапазоне больше 10 событий, блок показывает локальную пагинацию по страницам.
|
||||
- Смена применённых фильтров возвращает пагинацию блока на первую страницу.
|
||||
- Таблица показывает дату, инструмент, тип, сумму и статус.
|
||||
- Таблица содержит компактную строку заголовков колонок.
|
||||
- В колонке `Инструмент` показывается тикер/ISIN и дополнительная строка с названием инструмента, если
|
||||
оно доступно из `event.name`; если названия нет, дополнительная строка не занимает место.
|
||||
- В колонке `Тип` значения отображаются небольшими бейджами с разными тонами для купона, дивиденда,
|
||||
погашения и оферты.
|
||||
- Сумма для `actual` берётся из `actualAmount`, сумма для `forecast` берётся из `estimatedAmount`.
|
||||
- Фактические поступления визуально отмечаются как `Поступило`.
|
||||
- Прогнозные суммы помечаются как оценочные.
|
||||
- Суммы окрашиваются по смыслу: фактическое положительное поступление зелёным, отрицательное списание
|
||||
красным, прогноз/план серым. Цвет не является единственным носителем смысла: прогнозные строки также
|
||||
имеют статусный текст.
|
||||
- Статус отображается компактным бейджем. `Поступило` использует зелёный тон, прогноз/ожидание —
|
||||
нейтральный серый тон.
|
||||
- Блок содержит ссылку на подробную вкладку `/broker/:accountId/events`.
|
||||
- Ошибка загрузки событий не ломает остальной dashboard.
|
||||
|
||||
### 5. Блок `Доходы`
|
||||
|
||||
- Блок показывает последние доходные операции по дивидендам и купонам из существующего endpoint
|
||||
операций.
|
||||
- В первую версию входят операции с типами дивидендов и купонов, которые уже используются в backend
|
||||
analytics: `OPERATION_TYPE_DIVIDEND`, `OPERATION_TYPE_DIV_EXT`, `OPERATION_TYPE_COUPON`.
|
||||
- Блок содержит кликабельные chip-фильтры типов доходов: `Дивиденды`, `Купоны`.
|
||||
- Пользователь может выбрать один или оба типа доходов.
|
||||
- Если пользователь снимает все типы доходов, запрос не выполняется, а блок показывает
|
||||
валидационное сообщение.
|
||||
- Изменение chip-фильтров типов доходов применяется сразу и сбрасывает cursor-пагинацию.
|
||||
- Блок содержит фильтр периода `from` / `to`.
|
||||
- По умолчанию применяется недельный период `сегодня - 7 дней` / `сегодня`, чтобы dashboard не загружал
|
||||
слишком много операций при первом открытии.
|
||||
- Изменение черновых фильтров не запускает запрос до применения периода пользователем.
|
||||
- В пользовательском тексте шапки периода не используется слово `Фильтр`; UI должен считываться как
|
||||
управление периодом за счёт иконки календаря, применённого диапазона, пресетов и affordance раскрытия.
|
||||
- Блок содержит быстрые пресеты периода `7д`, `30д`, `90д`, `1г`, `Всё`, действие `Сбросить` и
|
||||
основное действие применения периода с более понятным текстом, чем `Показать`.
|
||||
- Период отображается как одно визуальное поле/кнопка с диапазоном, например `19 июн – 26 июн`.
|
||||
- По клику на поле периода открывается popover с community-компонентом MUI `DateCalendar` из
|
||||
`@mui/x-date-pickers` и ручной логикой выбора начала/конца периода.
|
||||
- `DateRangePicker` и пакет `@mui/x-date-pickers-pro` не используются.
|
||||
- Native `<input type="date">` не используется.
|
||||
- Применённые фильтры dashboard не обязаны синхронизироваться с URL; URL-синхронизация остаётся
|
||||
обязанностью подробных разделов.
|
||||
- Для блока используется cursor-пагинация existing operations endpoint с размером страницы 10.
|
||||
- Смена применённых фильтров сбрасывает cursor-пагинацию блока на первую страницу.
|
||||
- Таблица показывает дату, инструмент, тип и сумму.
|
||||
- Таблица содержит компактную строку заголовков колонок.
|
||||
- В колонке `Инструмент` показывается тикер и дополнительная строка с названием операции/инструмента,
|
||||
если оно доступно из `operation.name` или `operation.description`.
|
||||
- В колонке `Тип` значения отображаются небольшими бейджами.
|
||||
- Суммы окрашиваются по знаку: положительные поступления зелёным, отрицательные списания красным,
|
||||
плановые/нефактические значения серым, если такие строки отображаются в блоке.
|
||||
- Блок показывает итог по отображаемым доходным операциям.
|
||||
- Блок содержит ссылку на подробную вкладку `/broker/:accountId/operations`.
|
||||
- Если текущий endpoint операций не позволяет корректно получить доходные операции без изменения
|
||||
backend-контракта, первая реализация должна явно зафиксировать это в `plan.md` перед изменением API.
|
||||
- В этой итерации блок `Доходы` не расширяется до полной истории комиссий/налогов; цветовая семантика
|
||||
отрицательных сумм должна быть готова для отображаемых строк, но API-контракт не меняется.
|
||||
|
||||
### 6. Блок `Аналитика доходности`
|
||||
|
||||
- Блок использует существующий endpoint `/api/v1/broker/accounts/:accountId/analytics`.
|
||||
- Отображаются: пополнения, выводы, нетто вложено, дивиденды, купоны, всего получено, доходность.
|
||||
- Денежные значения analytics отображаются с символом валюты `₽` для RUB.
|
||||
- Карточки analytics используют цветовую семантику: положительные потоки и полученные доходы —
|
||||
зелёный тон, отрицательные выводы и отрицательное нетто — красный тон, нейтральные/нулевые значения —
|
||||
нейтральный тон.
|
||||
- При отсутствии analytics data блок показывает спокойное пустое состояние.
|
||||
- Ошибка analytics не ломает остальные блоки.
|
||||
|
||||
### 7. Блок `Аллокация`
|
||||
|
||||
- Используется существующий расчёт `buildBrokerAllocation`.
|
||||
- Вместо donut-диаграммы используется горизонтальный bar chart: каждый сектор — полоса с процентом,
|
||||
подписью и суммой.
|
||||
- Сверху блока показывается итоговая стоимость портфеля.
|
||||
- Каждая полоса содержит: название сектора, долю в процентах, сумму в валюте.
|
||||
- Цвета полос соответствуют существующей палитре аллокации (акции, облигации, ETF, деньги, прочие).
|
||||
- Отрицательные значения отображаются текстом без полосы.
|
||||
- Текст подписей контрастный и читаемый на всех цветах фона.
|
||||
- Информация остаётся понятной без различения цветов.
|
||||
|
||||
### 8. Загрузка, ошибки и пустые состояния
|
||||
|
||||
- Первичная загрузка portfolio показывает dashboard skeleton соответствующей формы.
|
||||
- Ошибка portfolio показывает ошибку overview, потому что без portfolio dashboard не имеет основного
|
||||
контекста.
|
||||
- Загрузка событий и доходов внутри карточек показывает skeleton таблицы соответствующей структуры, а не
|
||||
только текстовую строку загрузки.
|
||||
- Skeleton таблиц событий и доходов должен иметь единый визуальный паттерн: строки соответствуют
|
||||
геометрии таблицы, колонка инструмента шире остальных, для `Доходы` используется тот же паттерн без
|
||||
лишней колонки статуса.
|
||||
- Ошибка событий, доходов или analytics отображается внутри соответствующей карточки.
|
||||
- Пустые события, пустые доходы и пустая analytics имеют отдельные понятные сообщения.
|
||||
- Недоступные отдельные значения отображаются как `—`, не подменяются нулём.
|
||||
|
||||
## Ограничения
|
||||
|
||||
- Backend остаётся единственным клиентом T-Bank и MOEX.
|
||||
- В первой версии не добавляется график истории стоимости портфеля.
|
||||
- В первой версии не добавляется backend storage/API для снапшотов стоимости портфеля.
|
||||
- Тёмная тема и переключатель темы не входят в область фичи.
|
||||
- Не меняются правила расчёта доходности, событий, операций и аллокации.
|
||||
- Не удаляются существующие detailed вкладки счёта.
|
||||
- Не изменяется URL-структура `/broker/:accountId/*`.
|
||||
|
||||
## Backlog
|
||||
|
||||
Идея `Broker portfolio value history` вынесена в `docs/inbox.md` и `docs/roadmap.md`: хранить снапшоты
|
||||
стоимости брокерского счёта и позже заменить отсутствие графика полноценным блоком `Стоимость портфеля`.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `/broker/:accountId` показывает dashboard-композицию: hero KPI, `События`, `Доходы`,
|
||||
`Аналитика доходности`, `Аллокация`.
|
||||
- Навигация счёта отображается горизонтальными вкладками над dashboard-контентом.
|
||||
- На desktop и mobile блоки `События`, `Доходы`, `Аналитика доходности`, `Аллокация` расположены по
|
||||
одному блоку на строке.
|
||||
- На мобильном viewport dashboard читаемо перестраивается в одну колонку.
|
||||
- Hero показывает стоимость портфеля, доходность или fallback, дневное изменение или fallback, всего
|
||||
доходов или fallback.
|
||||
- Все Metric-блоки hero имеют одинаковую высоту; supportingText не создаёт перекоса.
|
||||
- Hero KPI использует цветовую семантику для доходности, дневного изменения и всего полученных доходов.
|
||||
- Блок `События` использует существующие events data и показывает дату, инструмент, тип, сумму и статус.
|
||||
- В таблице `События` инструмент отображается двумя строками при наличии названия, тип отображается
|
||||
бейджем, сумма и статус имеют семантические цвета.
|
||||
- Блок `События` поддерживает multi-select chip-фильтр типов и локальную пагинацию по 10 событий.
|
||||
- Блок `События` по умолчанию запрашивает период `сегодня - 7 дней` / `сегодня + 7 дней` и использует одно поле
|
||||
периода с popover-календарём на базе community `DateCalendar`.
|
||||
- Блок `Доходы` показывает доходные операции дивидендов и купонов и итог по отображаемым строкам.
|
||||
- В таблице `Доходы` инструмент отображается двумя строками при наличии названия, тип отображается
|
||||
бейджем, сумма имеет семантический цвет.
|
||||
- Блок `Доходы` поддерживает multi-select chip-фильтр типов и cursor-пагинацию по 10 операций.
|
||||
- Блок `Доходы` по умолчанию запрашивает период `сегодня - 7 дней` / `сегодня` и использует одно поле
|
||||
периода с popover-календарём на базе community `DateCalendar`.
|
||||
- В шапке управления периодом не отображается слово `Фильтр`, а действие применения периода не называется
|
||||
`Показать`.
|
||||
- Шапки фильтров `События` и `Доходы` визуально соответствуют единому toolbar из
|
||||
`docs/research/2026-06-27-broker-account-redesign.html`.
|
||||
- Поле периода использует chevron-иконку, а не текстовый символ `v`.
|
||||
- Загрузка событий и доходов отображается skeleton-таблицей.
|
||||
- Блок `Аналитика доходности` показывает данные существующего analytics endpoint.
|
||||
- Блок `Аналитика доходности` отображает RUB как `₽` и использует цветовую семантику карточек.
|
||||
- Блок `Аллокация` показывает горизонтальные бары секторов с названием, долей и суммой, а также
|
||||
итоговую стоимость портфеля.
|
||||
- Ошибка одного вторичного блока не скрывает остальные блоки dashboard.
|
||||
- Существующие detailed вкладки остаются доступны из навигации счёта.
|
||||
- Первая версия не содержит график истории стоимости портфеля и не добавляет API для него.
|
||||
- Дизайн использует светлую DS-тему, а не тёмную тему из `temp.html`.
|
||||
|
||||
## Вне области фичи
|
||||
|
||||
- график стоимости портфеля по датам;
|
||||
- новые исторические снапшоты стоимости;
|
||||
- dark mode;
|
||||
- изменение backend-расчётов доходности;
|
||||
- налоговая аналитика;
|
||||
- экспорт dashboard;
|
||||
- объединение нескольких брокерских счетов в один dashboard.
|
||||
139
docs/features/broker-dashboard-redesign/tasks.md
Normal file
139
docs/features/broker-dashboard-redesign/tasks.md
Normal file
@ -0,0 +1,139 @@
|
||||
# Редизайн обзора брокерского счёта в инвестиционный дашборд — задачи
|
||||
|
||||
Дата: 2026-06-26 (обновлено 2026-06-27)
|
||||
Статус: в реализации, итерация HTML parity
|
||||
|
||||
## Документация и pre-flight
|
||||
|
||||
- [x] Выбрать scope: `/broker/:accountId` становится единым дашбордом.
|
||||
- [x] Выбрать визуальный стиль: структура `temp.html`, текущая светлая DS-тема.
|
||||
- [x] Исключить график истории стоимости из первой версии.
|
||||
- [x] Добавить backlog-задачу на историю стоимости брокерского портфеля.
|
||||
- [x] Создать ветку `codex/broker-dashboard-redesign`.
|
||||
- [x] Запустить baseline: `rtk npm run test:backend && rtk npm run test:frontend && rtk npm run test:design-system`.
|
||||
- [x] Написать `spec.md`.
|
||||
- [x] Написать `plan.md`.
|
||||
|
||||
## Реализация
|
||||
|
||||
- [x] Добавить pure helpers для фильтрации и суммирования доходных операций dashboard.
|
||||
- [x] Добавить helpers для фильтров дат, пресетов, валидации и mapping income types → operationTypes.
|
||||
- [x] Добавить dashboard presentation helpers для fallback, event labels и statuses.
|
||||
- [x] Добавить локальный `BrokerDashboardCard` pattern или точечно расширить DS, если локального pattern недостаточно.
|
||||
- [x] Добавить `BrokerDashboardHero` с KPI по portfolio и analytics.
|
||||
- [x] Добавить `BrokerDashboardEventsCard` на основе `useBrokerEvents`.
|
||||
- [x] Добавить `BrokerDashboardIncomeCard` на основе `useBrokerOperations`.
|
||||
- [x] Перенести навигацию счёта из левой колонки в горизонтальные вкладки над контентом.
|
||||
- [x] Перестроить dashboard на один блок на строке для `События`, `Доходы`, `Аналитика доходности`, `Аллокация`.
|
||||
- [x] Добавить кликабельные chip-фильтры типов для `События`.
|
||||
- [x] Добавить `BrokerDashboardDateFilter` — переиспользуемый expandable-компонент фильтра дат (пресеты 7д/30д/90д/1г/Всё, from/to поля, Сбросить/Показать).
|
||||
- [x] Подключить `BrokerDashboardDateFilter` в `События` с draft/applied состоянием.
|
||||
- [x] Добавить локальную пагинацию по 10 событий в `События`.
|
||||
- [x] Добавить кликабельные chip-фильтры типов для `Доходы`.
|
||||
- [x] Подключить `BrokerDashboardDateFilter` в `Доходы` с draft/applied состоянием.
|
||||
- [x] Добавить cursor-пагинацию по 10 операций в `Доходы`.
|
||||
- [x] Добавить `BrokerDashboardAnalyticsCard` на основе `useBrokerAnalytics`.
|
||||
- [x] Добавить `BrokerDashboardAllocationCard` на основе существующей аллокации.
|
||||
- [x] Добавить `BrokerDashboardSkeleton`.
|
||||
- [x] Добавить `BrokerDashboard` как top-level composition widget.
|
||||
- [x] Заменить текущий вертикальный обзор в `BrokerAccountOverviewPage` на `BrokerDashboard`.
|
||||
- [x] Добавить unit/component tests для helpers и базовой dashboard composition.
|
||||
- [x] Выровнять hero KPI: все Metric одной высоты, supportingText не раздвигает "Доходность" выше соседей.
|
||||
- [x] Заменить donut-диаграмму аллокации на горизонтальные бары в `BrokerDashboardAllocationCard`.
|
||||
- [x] Убрать слово `Фильтр` из пользовательского текста шапки периода в dashboard-карточках.
|
||||
- [x] Переименовать действие `Показать` в управлении периодом и визуально улучшить шапку фильтров.
|
||||
- [x] Заменить native date inputs на одно поле периода с popover и community `DateCalendar` из `@mui/x-date-pickers`.
|
||||
- [x] Не использовать `@mui/x-date-pickers-pro` и `DateRangePicker`.
|
||||
- [x] Настроить default range событий на `сегодня - 7 дней` / `сегодня + 7 дней`, доходов — на
|
||||
`сегодня - 7 дней` / `сегодня`.
|
||||
- [x] Заменить текстовую загрузку `События` на skeleton таблицы.
|
||||
- [x] Заменить текстовую загрузку `Доходы` на skeleton таблицы.
|
||||
- [x] Обновить component tests под новые тексты, единое поле периода, popover-календарь и skeleton loading states.
|
||||
- [ ] Проверить desktop layout `/broker/2084014113`.
|
||||
- [ ] Проверить mobile layout `/broker/2084014113`.
|
||||
|
||||
## Итерация HTML parity
|
||||
|
||||
- [x] Согласовать статический визуальный эталон `docs/research/2026-06-27-broker-account-redesign.html`.
|
||||
- [x] Обновить `spec.md` под HTML parity: компактные заголовки, единый toolbar, бейджи, цвета, подписи инструментов, `₽`.
|
||||
- [x] Обновить `plan.md` под перенос HTML parity в React-компоненты.
|
||||
- [x] Добавить `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts` с helpers для money tone, type tone, currency symbol и instrument display.
|
||||
- [x] Добавить `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts`.
|
||||
- [ ] Расширить income helpers так, чтобы строки доходов могли отдавать main/subtitle инструмента без дублирования текста.
|
||||
- [x] Обновить `BrokerDashboardHero`: semantic colors для доходности, дневного изменения и всего полученных доходов.
|
||||
- [x] Обновить `BrokerDashboardCard`: компактный card heading вместо крупного page-level heading.
|
||||
- [x] Обновить `BrokerDashboardDateFilter`: единый toolbar-паттерн и chevron-иконка вместо текстового `v`.
|
||||
- [x] Обновить `BrokerDashboardEventsCard`: `thead`, двухстрочный инструмент, type badges, semantic amount colors, status badges.
|
||||
- [x] Обновить `BrokerDashboardIncomeCard`: `thead`, двухстрочный инструмент, type badges, semantic amount colors.
|
||||
Бейдж `Купон` приведён к `info` (синий) согласно HTML-эталону `.type-badge.coupon` (`incomeTypeTone` в `dashboardVisual.ts`),
|
||||
`Дивиденд (внешний)` отнесён к `success` (семейство дивидендов).
|
||||
- [x] Обновить `BrokerDashboardTableSkeleton`: общий skeleton-паттерн для событий и доходов с корректной геометрией колонок.
|
||||
- [x] Обновить `BrokerDashboardAnalyticsCard`: `₽` для RUB и positive/negative tone карточек.
|
||||
- [x] Обновить component tests dashboard под HTML parity (analytics tones).
|
||||
- [x] Проверить, что блок `Доходы` не расширяет backend/API и остаётся в рамках текущих income-типов.
|
||||
Исправлен пресет «Всё»: `applyDatePreset('all')` теперь отдаёт широкий диапазон `2000-01-01`–`2099-12-31`
|
||||
вместо пустых `from`/`to`, которые backend (`BrokerEventsQueryDto @Matches`) отвергал с 400. В DateFilter
|
||||
кнопка «Применить период» блокируется при инвертированном диапазоне (`to < from`), чтобы избежать
|
||||
молчаливо пустого ответа (например, будущий `from` при дефолтном `to=сегодня`).
|
||||
- [x] Исправить default-range `События`, чтобы dashboard по умолчанию и через пресеты мог загружать будущие события.
|
||||
- [x] Подтянуть card headers, toolbar label `Тип`, count badges и footer summaries ближе к
|
||||
`docs/research/2026-06-27-broker-account-redesign.html`.
|
||||
- [ ] Проверить desktop layout `/broker/2084014113` против `docs/research/2026-06-27-broker-account-redesign.html`.
|
||||
- [ ] Проверить mobile layout `/broker/2084014113` на viewport `390x844` против `docs/research/2026-06-27-broker-account-redesign.html`.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- [x] `rtk npm run test:frontend` проходит.
|
||||
- [x] `rtk npm run test:design-system` проходит.
|
||||
- [x] `rtk npm run lint -w apps/frontend` проходит.
|
||||
- [x] `rtk npm run build:frontend` проходит.
|
||||
- [ ] Dashboard соответствует acceptance criteria из `spec.md`.
|
||||
- [x] Существующие вкладки `Акции`, `Облигации`, `Операции`, `События`, `Аналитика` остаются доступны.
|
||||
- [x] `graphify update .` выполнен после code changes.
|
||||
|
||||
## Definition of Done для HTML parity
|
||||
|
||||
- [x] `rtk npm run test:frontend -- --run src/widgets/broker-dashboard` проходит. (49/49)
|
||||
- [x] `rtk npm run test:frontend` проходит после HTML parity изменений. (175/175)
|
||||
- [x] `rtk npm run lint -w apps/frontend` проходит после HTML parity изменений.
|
||||
- [x] `rtk npm run build:frontend` проходит после HTML parity изменений.
|
||||
- [ ] На `/broker/2084014113` заголовки карточек, toolbar таблиц, бейджи типов, подписи инструментов,
|
||||
цвета сумм, analytics colors и skeleton визуально соответствуют `docs/research/2026-06-27-broker-account-redesign.html`.
|
||||
Проверено только по тестам и code-to-spec mapping внизу файла — live dev server не запускался
|
||||
в этой итерации (нет backend/auth в среде). Необходима ручная проверка пользователем.
|
||||
- [ ] Нет общего горизонтального overflow на mobile; горизонтальный scroll допускается только внутри таблиц.
|
||||
Требует live dev server + ручной проверки на viewport `390x844`.
|
||||
- [x] `docs/features/broker-dashboard-redesign/tasks.md` обновлён по факту выполнения.
|
||||
|
||||
### Code-to-spec mapping для analytics (HTML parity)
|
||||
|
||||
`BrokerDashboardAnalyticsCard.tsx` (`apps/frontend/src/widgets/broker-dashboard/ui/`) против
|
||||
HTML-эталона (`docs/research/2026-06-27-broker-account-redesign.html:1145-1158`):
|
||||
|
||||
| HTML reference element | React-компонент / data-testid | Спецификация §6 (spec.md:165-180) |
|
||||
|------------------------|------------------------------------------------------------|----------------------------------------------------------------------|
|
||||
| `<section aria-labelledby="analytics-title">` | `BrokerDashboardCard` с `ariaLabel="Аналитика доходности"` | Секция с заголовком, доступная по aria-label |
|
||||
| `<h2>Аналитика доходности</h2>` | Заголовок карточки | Компактный card heading (§2) |
|
||||
| `.analytics-item.positive .analytics-value` | `data-testid="dashboard-analytics-totalDeposits"` (tone `positive`) | Пополнения — positive (§6, AC `Блок Аналитика доходности`) |
|
||||
| `.analytics-item.negative .analytics-value` для Выводы | `data-testid="dashboard-analytics-totalWithdrawn"` (tone `negative`, префикс `−`) | Выводы — negative, всегда со знаком `−` (§6) |
|
||||
| `.analytics-item.negative .analytics-value` для Нетто (если нетто<0) | `data-testid="dashboard-analytics-netInvested"` (tone `negative` при `value<0`) | Нетто — sign-based (§6) |
|
||||
| `.analytics-item.positive .analytics-value` для Дивиденды/Купоны/Всего получено | `data-testid="dashboard-analytics-totalDividends"`, `…-totalCoupons`, `…-totalReceived` | Positive если `value > 0`, neutral если `value === 0` (§6) |
|
||||
| `113 773,03 ₽` (валюта) | `formatDashboardCurrency` через `shared/lib/formatters` | RUB отображается как `₽` (AC, §2) |
|
||||
|
||||
Тесты в `BrokerDashboard.test.tsx`:
|
||||
|
||||
- `renders analytics card with the ₽ symbol and no "RUB" code` — подтверждает замену `RUB` на `₽`.
|
||||
- `applies positive, negative and neutral tones to analytics values` — подтверждает tone-атрибуты
|
||||
для каждого поля с разнообразными значениями (`totalDeposits: 1000`, `totalWithdrawn: 250`,
|
||||
`netInvested: -150`, `totalDividends: 75`, `totalCoupons: 0`, `totalReceived: 90`).
|
||||
|
||||
### Что НЕ было проверено в этой итерации
|
||||
|
||||
- Реальный визуальный рендеринг `/broker/2084014113` на desktop и `390x844` mobile.
|
||||
Требуется ручная проверка пользователем с поднятым backend (нужны реальные auth и T-Bank/MOEX
|
||||
прокси). Dev server не запускался.
|
||||
- Поведение отсутствующего/ошибочного analytics под live-нагрузкой. Логика в карточке покрыта
|
||||
тестами, но проверка UX-сообщений и skeleton-states в браузере не делалась.
|
||||
- Реальный viewport на `390x844` для подтверждения отсутствия общего горизонтального overflow.
|
||||
Геометрия таблиц уже переключена на внутренний `overflowX: 'auto'` (`BrokerDashboardEventsCard.tsx:176`,
|
||||
`BrokerDashboardIncomeCard.tsx`), но фактическая вёрстка в браузере не сверялась с эталоном.
|
||||
@ -166,6 +166,17 @@ cash flow, бюджеты, аналитика, прогнозы и автома
|
||||
|
||||
## Frontend-платформа
|
||||
|
||||
### Добавить историю стоимости брокерского портфеля
|
||||
|
||||
- Сохранять снапшоты полной стоимости брокерского счёта, чтобы строить график динамики портфеля по
|
||||
датам.
|
||||
- Отдельно спроектировать backend storage/API, периодичность обновления, валюту расчёта и правила для
|
||||
пропущенных дней.
|
||||
- На дашборде брокерского счёта заменить временный отказ от графика на полноценный блок `Стоимость
|
||||
портфеля`, когда данные истории будут доступны.
|
||||
- Не реализовывать в первой версии редизайна `/broker/:accountId`: текущая задача использует только
|
||||
уже доступные данные портфеля, событий, операций и аналитики.
|
||||
|
||||
### Перейти к Feature-Sliced Design
|
||||
|
||||
- Постепенно привести frontend к FSD-архитектуре с явными границами между `app`, `pages`, `widgets`,
|
||||
|
||||
1220
docs/research/2026-06-27-broker-account-redesign.html
Normal file
1220
docs/research/2026-06-27-broker-account-redesign.html
Normal file
File diff suppressed because it is too large
Load Diff
@ -130,4 +130,6 @@ Roadmap отражает порядок продуктовой работы, н
|
||||
явный session contract для нескольких клиентских поверхностей
|
||||
- [ ] Contract, type-safety, and frontend delivery hardening (P2/P3) — устранение остаточного `as any`,
|
||||
укрепление API/query boundaries, quality/performance gates и lazy-loading budgets
|
||||
- [ ] Broker portfolio value history (P2/P3) — хранить снапшоты стоимости брокерского счёта и показать
|
||||
график динамики портфеля на дашборде
|
||||
- [x] Broker-events — UX доработки и смешанный календарь.
|
||||
|
||||
87
package-lock.json
generated
87
package-lock.json
generated
@ -92,6 +92,7 @@
|
||||
"@moex-vibe/design-system": "*",
|
||||
"@mui/icons-material": "^6.5.0",
|
||||
"@mui/material": "^6.5.0",
|
||||
"@mui/x-date-pickers": "^7.29.4",
|
||||
"@tanstack/react-query": "^5.20.0",
|
||||
"@tanstack/react-router": "^1.170.16",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
@ -7426,6 +7427,92 @@
|
||||
"integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@mui/x-date-pickers": {
|
||||
"version": "7.29.4",
|
||||
"resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.29.4.tgz",
|
||||
"integrity": "sha512-wJ3tsqk/y6dp+mXGtT9czciAMEO5Zr3IIAHg9x6IL0Eqanqy0N3chbmQQZv3iq0m2qUpQDLvZ4utZBUTJdjNzw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.25.7",
|
||||
"@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0",
|
||||
"@mui/x-internals": "7.29.0",
|
||||
"@types/react-transition-group": "^4.4.11",
|
||||
"clsx": "^2.1.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react-transition-group": "^4.4.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mui-org"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/react": "^11.9.0",
|
||||
"@emotion/styled": "^11.8.1",
|
||||
"@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0",
|
||||
"@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0",
|
||||
"date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0",
|
||||
"date-fns-jalali": "^2.13.0-0 || ^3.2.0-0 || ^4.0.0-0",
|
||||
"dayjs": "^1.10.7",
|
||||
"luxon": "^3.0.2",
|
||||
"moment": "^2.29.4",
|
||||
"moment-hijri": "^2.1.2 || ^3.0.0",
|
||||
"moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0",
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@emotion/styled": {
|
||||
"optional": true
|
||||
},
|
||||
"date-fns": {
|
||||
"optional": true
|
||||
},
|
||||
"date-fns-jalali": {
|
||||
"optional": true
|
||||
},
|
||||
"dayjs": {
|
||||
"optional": true
|
||||
},
|
||||
"luxon": {
|
||||
"optional": true
|
||||
},
|
||||
"moment": {
|
||||
"optional": true
|
||||
},
|
||||
"moment-hijri": {
|
||||
"optional": true
|
||||
},
|
||||
"moment-jalaali": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/x-internals": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.29.0.tgz",
|
||||
"integrity": "sha512-+Gk6VTZIFD70XreWvdXBwKd8GZ2FlSCuecQFzm6znwqXg1ZsndavrhG9tkxpxo2fM1Zf7Tk8+HcOO0hCbhTQFA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.25.7",
|
||||
"@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mui-org"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||
|
||||
@ -59,4 +59,22 @@ describe('Chip', () => {
|
||||
renderWithTheme(<Chip label="Static" />);
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClick when clickable chip clicked', async () => {
|
||||
const handleClick = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderWithTheme(<Chip label="Clickable" onClick={handleClick} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Clickable' }));
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders unselected clickable chip as outlined', () => {
|
||||
renderWithTheme(<Chip label="Inactive" onClick={() => {}} selected={false} />);
|
||||
|
||||
const chip = screen.getByText('Inactive').closest('.MuiChip-root')!;
|
||||
expect(chip.classList.contains('MuiChip-outlined')).toBe(true);
|
||||
expect(chip).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Chip as MuiChip } from '@mui/material';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import { Chip as MuiChip } from '@mui/material';
|
||||
|
||||
type Tone = 'neutral' | 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
@ -15,14 +15,28 @@ export interface ChipProps {
|
||||
label: string;
|
||||
tone?: Tone;
|
||||
onDelete?: () => void;
|
||||
onClick?: () => void;
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function Chip({ label, tone = 'neutral', onDelete }: ChipProps) {
|
||||
export function Chip({
|
||||
label,
|
||||
tone = 'neutral',
|
||||
onDelete,
|
||||
onClick,
|
||||
selected = true,
|
||||
disabled,
|
||||
}: ChipProps) {
|
||||
return (
|
||||
<MuiChip
|
||||
label={label}
|
||||
color={TONE_MAP[tone]}
|
||||
variant={selected ? 'filled' : 'outlined'}
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
disabled={disabled}
|
||||
aria-pressed={onClick ? selected : undefined}
|
||||
{...(onDelete ? { deleteIcon: <CancelIcon aria-label={`Remove ${label}`} /> } : {})}
|
||||
/>
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user