feat(frontend): redesign broker account overview as investment dashboard

Replace vertical overview with dashboard composition:
- BrokerDashboardHero with portfolio KPI, return, daily change, total income
- BrokerDashboardEventsCard with compact events table and local pagination
- BrokerDashboardIncomeCard with income operations table and cursor pagination
- BrokerDashboardAnalyticsCard with analytics Metric grid
- BrokerDashboardAllocationCard wrapping existing donut chart
- BrokerDashboardSkeleton matching dashboard shape
- BrokerDashboardCard local card pattern
- Pure lib helpers: dashboardIncome, dashboardFilters, dashboardFormatters
- Unit tests for income helpers (6) and dashboard composition (1)
This commit is contained in:
Sergey Krylov 2026-06-26 10:18:57 +03:00
parent 49dac140ff
commit c429d6a43a
16 changed files with 870 additions and 55 deletions

View File

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

View File

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

View File

@ -0,0 +1,67 @@
import dayjs from 'dayjs'
export const DASHBOARD_EVENT_TYPES = ['dividend', 'coupon', 'maturity', 'offer'] as const
export const DASHBOARD_INCOME_TYPES = ['dividend', 'coupon'] as const
export type DashboardEventType = (typeof DASHBOARD_EVENT_TYPES)[number]
export type DashboardIncomeType = (typeof DASHBOARD_INCOME_TYPES)[number]
export type DashboardDatePreset = '7d' | '30d' | '90d' | '1y' | 'all'
export type DashboardFilterState<T extends string> = {
from: string
to: string
types: T[]
}
export function defaultEventsFilters(): DashboardFilterState<DashboardEventType> {
const now = dayjs()
return {
from: now.subtract(7, 'day').format('YYYY-MM-DD'),
to: now.add(7, 'day').format('YYYY-MM-DD'),
types: [...DASHBOARD_EVENT_TYPES],
}
}
export function defaultIncomeFilters(): DashboardFilterState<DashboardIncomeType> {
const now = dayjs()
return {
from: now.startOf('year').format('YYYY-MM-DD'),
to: now.format('YYYY-MM-DD'),
types: [...DASHBOARD_INCOME_TYPES],
}
}
export function applyDatePreset<T extends string>(
filters: DashboardFilterState<T>,
preset: DashboardDatePreset,
): DashboardFilterState<T> {
const now = dayjs()
if (preset === 'all') return { ...filters, from: '', to: now.format('YYYY-MM-DD') }
const amount = preset === '1y' ? 1 : Number.parseInt(preset, 10)
const unit = preset === '1y' ? 'year' : 'day'
return {
...filters,
from: now.subtract(amount, unit).format('YYYY-MM-DD'),
to: now.format('YYYY-MM-DD'),
}
}
export function validateDashboardFilters<T extends string>(
filters: DashboardFilterState<T>,
): string {
if (filters.types.length === 0) return 'Выберите хотя бы один тип'
if (filters.from && filters.to && dayjs(filters.to).isBefore(dayjs(filters.from))) {
return 'Дата окончания не может быть раньше даты начала'
}
return ''
}
export function incomeTypesToOperationTypes(types: DashboardIncomeType[]): string {
const operationTypes = new Set<string>()
if (types.includes('dividend')) {
operationTypes.add('OPERATION_TYPE_DIVIDEND')
operationTypes.add('OPERATION_TYPE_DIV_EXT')
}
if (types.includes('coupon')) operationTypes.add('OPERATION_TYPE_COUPON')
return [...operationTypes].join(',')
}

View File

@ -0,0 +1,24 @@
import type { BrokerEventItem } from '@/shared/api'
export function dashboardValue(value: string | null | undefined): string {
return value && value.trim().length > 0 ? value : '—'
}
export function eventTypeLabel(type: BrokerEventItem['type']): string {
switch (type) {
case 'dividend':
return 'Дивиденд'
case 'coupon':
return 'Купон'
case 'maturity':
return 'Погашение'
case 'offer':
return 'Оферта'
}
}
export function eventStatusLabel(event: BrokerEventItem): string {
if (event.source === 'actual') return 'Поступило'
if (event.type === 'offer') return 'Оферта'
return 'Прогноз'
}

View File

@ -0,0 +1,84 @@
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('sums displayed income rows by currency', () => {
const rows = getDashboardIncomeRows([
operation('OPERATION_TYPE_DIVIDEND', 10),
operation('OPERATION_TYPE_COUPON', 2.5),
])
expect(sumDashboardIncome(rows)).toEqual({ currency: 'RUB', value: 12.5 })
})
it('returns null for empty rows sum', () => {
expect(sumDashboardIncome([])).toBeNull()
})
it('returns empty array for empty input', () => {
expect(getDashboardIncomeRows([])).toEqual([])
})
it('returns empty array when no income operations present', () => {
const rows = getDashboardIncomeRows([
operation('OPERATION_TYPE_BUY', -10),
operation('OPERATION_TYPE_SELL', 20),
])
expect(rows).toEqual([])
})
})

View File

@ -0,0 +1,49 @@
import type { BrokerMoney, BrokerOperation } from '@/shared/api'
const INCOME_TYPES = new Set([
'OPERATION_TYPE_DIVIDEND',
'OPERATION_TYPE_DIV_EXT',
'OPERATION_TYPE_COUPON',
])
export type DashboardIncomeRow = {
id: string
date: string | null
instrument: string
typeLabel: 'Дивиденд' | 'Купон'
amount: BrokerMoney
}
export function isDashboardIncomeOperation(operation: BrokerOperation): boolean {
return INCOME_TYPES.has(operation.type) && operation.payment !== null
}
function typeLabel(type: string): DashboardIncomeRow['typeLabel'] {
return type === 'OPERATION_TYPE_COUPON' ? 'Купон' : 'Дивиденд'
}
export function getDashboardIncomeRows(operations: BrokerOperation[]): DashboardIncomeRow[] {
return operations.filter(isDashboardIncomeOperation).map((operation) => ({
id: String(operation.id ?? operation.cursor ?? `${operation.type}-${operation.date}`),
date: typeof operation.date === 'string' ? operation.date : null,
instrument:
typeof operation.ticker === 'string'
? operation.ticker
: typeof operation.name === 'string'
? operation.name
: typeof operation.description === 'string'
? operation.description
: '—',
typeLabel: typeLabel(operation.type),
amount: operation.payment!,
}))
}
export function sumDashboardIncome(
rows: DashboardIncomeRow[],
): { currency: string; value: number } | null {
if (rows.length === 0) return null
const currency = rows[0].amount.currency
const value = rows.reduce((sum, row) => sum + row.amount.value, 0)
return { currency, value }
}

View File

@ -0,0 +1,93 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import type { BrokerPortfolio } from '@/shared/api'
import { BrokerDashboard } from './BrokerDashboard'
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: 100,
netInvested: 900,
totalDividends: 25,
totalCoupons: 15,
totalReceived: 40,
totalReturnPercent: 4.44,
currency: 'RUB',
},
isLoading: false,
isError: false,
}),
}))
vi.mock('@/entities/broker-event', () => ({
useBrokerEvents: () => ({
data: { items: [], summary: {}, asOf: '2026-06-26T00:00:00.000Z' },
isLoading: false,
isError: false,
}),
}))
vi.mock('@/entities/broker-operation', () => ({
useBrokerOperations: () => ({
data: {
accountId: 'acc-1',
items: [],
nextCursor: null,
hasNext: false,
asOf: '2026-06-26T00:00:00.000Z',
},
isLoading: false,
isError: false,
}),
}))
vi.mock('@/widgets/broker-allocation-chart', () => ({
BrokerAllocationChart: () => <div>allocation chart</div>,
}))
const portfolio: BrokerPortfolio = {
account: {
id: 'acc-1',
name: 'Основной счёт',
type: 'brokerage',
status: 'open',
openedAt: null,
accessLevel: null,
},
positionCounts: { shares: 2, bonds: 1, etf: 0, other: 0 },
totals: {
shares: null,
bonds: null,
etf: null,
currencies: null,
futures: null,
options: null,
structuredProducts: null,
dfa: null,
portfolio: { currency: 'RUB', units: '1000', nano: 0, value: 1000 },
},
yields: { expectedPercent: null, daily: null, dailyPercent: null },
cash: [],
blockedCash: [],
asOf: '2026-06-26T00:00:00.000Z',
}
describe('BrokerDashboard', () => {
it('renders the dashboard sections', () => {
render(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
expect(screen.getByText('Основной счёт')).toBeInTheDocument()
expect(screen.getByText('События')).toBeInTheDocument()
expect(screen.getByText('Доходы')).toBeInTheDocument()
expect(screen.getByText('Аналитика доходности')).toBeInTheDocument()
expect(screen.getByText('Аллокация')).toBeInTheDocument()
expect(screen.getByText('allocation chart')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,91 @@
import { Box } from '@mui/material'
import { useState } from 'react'
import { useBrokerAnalytics } from '@/entities/broker-analytics'
import { useBrokerEvents } from '@/entities/broker-event'
import { useBrokerOperations } from '@/entities/broker-operation'
import type { BrokerPortfolio } from '@/shared/api'
import { useCursorPagination } from '@/shared/lib/useCursorPagination'
import {
defaultEventsFilters,
defaultIncomeFilters,
incomeTypesToOperationTypes,
} from '../lib/dashboardFilters'
import { BrokerDashboardAllocationCard } from './BrokerDashboardAllocationCard'
import { BrokerDashboardAnalyticsCard } from './BrokerDashboardAnalyticsCard'
import { BrokerDashboardEventsCard } from './BrokerDashboardEventsCard'
import { BrokerDashboardHero } from './BrokerDashboardHero'
import { BrokerDashboardIncomeCard } from './BrokerDashboardIncomeCard'
export function BrokerDashboard({
accountId,
portfolio,
}: {
accountId: string
portfolio: BrokerPortfolio
}) {
const [eventFilters, _setEventFilters] = useState(defaultEventsFilters)
const [eventPage, setEventPage] = useState(1)
const [incomeFilters, _setIncomeFilters] = useState(defaultIncomeFilters)
const incomePagination = useCursorPagination()
const analytics = useBrokerAnalytics(accountId)
const events = useBrokerEvents(accountId, {
from: eventFilters.from,
to: eventFilters.to,
types: eventFilters.types.join(','),
})
const eventItems = events.data?.items ?? []
const eventPageSize = 10
const eventPageItems = eventItems.slice(
(eventPage - 1) * eventPageSize,
eventPage * eventPageSize,
)
const operations = useBrokerOperations(accountId, {
from: incomeFilters.from,
to: incomeFilters.to,
operationTypes: incomeTypesToOperationTypes(incomeFilters.types),
cursor: incomePagination.cursor,
limit: 10,
})
const nextCursor: string | undefined = operations.data?.nextCursor
? (operations.data.nextCursor as unknown as string)
: undefined
return (
<Box sx={{ display: 'grid', gap: 3 }}>
<BrokerDashboardHero portfolio={portfolio} analytics={analytics.data} />
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', xl: '1fr 1fr' }, gap: 3 }}>
<BrokerDashboardEventsCard
accountId={accountId}
data={events.data ? { ...events.data, items: eventPageItems } : undefined}
isLoading={events.isLoading}
isError={events.isError}
page={eventPage}
canGoBack={eventPage > 1}
canGoForward={eventPage * eventPageSize < eventItems.length}
onPreviousPage={() => setEventPage((page) => Math.max(1, page - 1))}
onNextPage={() => setEventPage((page) => page + 1)}
/>
<BrokerDashboardIncomeCard
accountId={accountId}
page={operations.data}
isLoading={operations.isLoading}
isError={operations.isError}
pageNumber={incomePagination.pageNumber}
canGoBack={incomePagination.pageNumber > 1}
canGoForward={operations.data?.hasNext ?? false}
onPreviousPage={incomePagination.handlePrevious}
onNextPage={() => incomePagination.handleNext(nextCursor)}
/>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', lg: '1.2fr .8fr' }, gap: 3 }}>
<BrokerDashboardAnalyticsCard
data={analytics.data}
isLoading={analytics.isLoading}
isError={analytics.isError}
/>
<BrokerDashboardAllocationCard portfolio={portfolio} />
</Box>
</Box>
)
}

View File

@ -0,0 +1,19 @@
import { Text } from '@moex-vibe/design-system'
import { Box } from '@mui/material'
import type { BrokerPortfolio } from '@/shared/api'
import { formatBrokerMoney } from '@/shared/lib/formatters'
import { BrokerAllocationChart } from '@/widgets/broker-allocation-chart'
import { BrokerDashboardCard } from './BrokerDashboardCard'
export function BrokerDashboardAllocationCard({ portfolio }: { portfolio: BrokerPortfolio }) {
return (
<BrokerDashboardCard
title="Аллокация"
action={<Text variant="numeric">{formatBrokerMoney(portfolio.totals.portfolio)}</Text>}
>
<Box sx={{ minHeight: 220, display: 'flex', alignItems: 'center' }}>
<BrokerAllocationChart portfolio={portfolio} />
</Box>
</BrokerDashboardCard>
)
}

View File

@ -0,0 +1,45 @@
import { Metric, Text } from '@moex-vibe/design-system'
import { Box } from '@mui/material'
import type { BrokerAnalytics } from '@/shared/api'
import { BrokerDashboardCard } from './BrokerDashboardCard'
function amount(value: number, currency: string): string {
return `${value.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}`
}
export function BrokerDashboardAnalyticsCard({
data,
isLoading,
isError,
}: {
data: BrokerAnalytics | undefined
isLoading: boolean
isError: boolean
}) {
return (
<BrokerDashboardCard title="Аналитика доходности">
{isError ? (
<Text tone="negative">Не удалось загрузить аналитику</Text>
) : isLoading ? (
<Text tone="muted">Загрузка аналитики</Text>
) : !data ? (
<Text tone="muted">Нет данных для аналитики</Text>
) : (
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' },
gap: 2,
}}
>
<Metric label="Пополнения" value={amount(data.totalDeposits, data.currency)} />
<Metric label="Выводы" value={`${amount(data.totalWithdrawn, data.currency)}`} />
<Metric label="Нетто" value={amount(data.netInvested, data.currency)} />
<Metric label="Дивиденды" value={amount(data.totalDividends, data.currency)} />
<Metric label="Купоны" value={amount(data.totalCoupons, data.currency)} />
<Metric label="Всего получено" value={amount(data.totalReceived, data.currency)} />
</Box>
)}
</BrokerDashboardCard>
)
}

View File

@ -0,0 +1,50 @@
import { Heading } from '@moex-vibe/design-system'
import { Box, type SxProps, type Theme } from '@mui/material'
import type { ReactNode } from 'react'
type BrokerDashboardCardProps = {
title: string
action?: ReactNode
filters?: ReactNode
children: ReactNode
sx?: SxProps<Theme>
}
export function BrokerDashboardCard({
title,
action,
filters,
children,
sx,
}: BrokerDashboardCardProps) {
return (
<Box
component="section"
sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: 3,
bgcolor: 'background.paper',
p: 2,
minWidth: 0,
boxShadow: '0 1px 3px rgba(15, 23, 42, 0.08)',
...sx,
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
mb: 1.5,
}}
>
<Heading level={2}>{title}</Heading>
{action}
</Box>
{filters && <Box sx={{ mb: 1.5 }}>{filters}</Box>}
{children}
</Box>
)
}

View File

@ -0,0 +1,131 @@
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 { formatBrokerCurrencyValue, formatBrokerDate } from '@/shared/lib/formatters'
import { eventStatusLabel, eventTypeLabel } from '../lib/dashboardFormatters'
import { BrokerDashboardCard } from './BrokerDashboardCard'
type BrokerDashboardEventsCardProps = {
accountId: string
data: BrokerEventsData | undefined
isLoading: boolean
isError: boolean
page: number
onPreviousPage: () => void
onNextPage: () => void
canGoBack: boolean
canGoForward: boolean
}
function eventAmount(event: BrokerEventItem): string {
const amount = event.source === 'actual' ? event.actualAmount : event.estimatedAmount
if (amount === null || amount === undefined) return '\u2014'
const prefix = event.source === 'actual' ? '+' : '~'
return `${prefix}${formatBrokerCurrencyValue(event.currency ?? 'RUB', amount)}`
}
export function BrokerDashboardEventsCard({
accountId,
data,
isLoading,
isError,
page,
onPreviousPage,
onNextPage,
canGoBack,
canGoForward,
}: BrokerDashboardEventsCardProps) {
const events = data?.items ?? []
return (
<BrokerDashboardCard
title="События"
action={<Link to={`/broker/${encodeURIComponent(accountId)}/events`}>Все события</Link>}
>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 1.5 }}>
<Chip label="Дивиденды" tone="success" />
<Chip label="Купоны" tone="info" />
<Chip label="Погашения" tone="warning" />
<Chip label="Оферты" tone="neutral" />
</Box>
{isError ? (
<Text tone="negative">Не удалось загрузить события</Text>
) : isLoading ? (
<Text tone="muted">Загрузка событий</Text>
) : events.length === 0 ? (
<Text tone="muted">В ближайшем периоде событий нет</Text>
) : (
<Box sx={{ display: 'grid', gap: 1 }}>
<Box sx={{ overflowX: 'auto' }}>
<Box component="table" sx={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<Box component="tbody">
{events.map((event) => (
<Box component="tr" key={event.id}>
<Box
component="td"
sx={{ py: 1, borderBottom: '1px solid', borderColor: 'divider' }}
>
{formatBrokerDate(event.eventDate)}
</Box>
<Box
component="td"
sx={{
py: 1,
borderBottom: '1px solid',
borderColor: 'divider',
fontWeight: 700,
}}
>
{event.ticker ?? event.name ?? '\u2014'}
</Box>
<Box
component="td"
sx={{ py: 1, borderBottom: '1px solid', borderColor: 'divider' }}
>
{eventTypeLabel(event.type)}
</Box>
<Box
component="td"
sx={{
py: 1,
borderBottom: '1px solid',
borderColor: 'divider',
textAlign: 'right',
fontWeight: 700,
}}
>
{eventAmount(event)}
</Box>
<Box
component="td"
sx={{
py: 1,
borderBottom: '1px solid',
borderColor: 'divider',
textAlign: 'right',
}}
>
{eventStatusLabel(event)}
</Box>
</Box>
))}
</Box>
</Box>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, alignItems: 'center' }}>
<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>
)}
</BrokerDashboardCard>
)
}

View File

@ -0,0 +1,55 @@
import { Metric, Text } from '@moex-vibe/design-system'
import { Box } from '@mui/material'
import type { BrokerAnalytics, BrokerPortfolio } from '@/shared/api'
import { formatBrokerMoney, formatBrokerPercent } from '@/shared/lib/formatters'
type BrokerDashboardHeroProps = {
portfolio: BrokerPortfolio
analytics: BrokerAnalytics | undefined
}
function percentValue(value: unknown): string {
return typeof value === 'number' ? formatBrokerPercent(value) : '—'
}
export function BrokerDashboardHero({ portfolio, analytics }: BrokerDashboardHeroProps) {
const accountName = portfolio.account.name || 'Брокерский счёт'
const totalReceived = analytics
? `${analytics.totalReceived.toLocaleString('ru-RU', { maximumFractionDigits: 2 })} ${analytics.currency}`
: '—'
const returnPercent = analytics?.totalReturnPercent ?? portfolio.yields.expectedPercent
return (
<Box
component="section"
aria-label="Ключевые показатели брокерского счёта"
sx={{
border: '1px solid',
borderColor: 'success.light',
borderRadius: 4,
bgcolor: 'rgba(46, 125, 50, 0.06)',
p: { xs: 2, md: 3 },
display: 'grid',
gap: 2,
gridTemplateColumns: { xs: '1fr', md: 'minmax(240px, 1fr) repeat(3, auto)' },
alignItems: 'center',
}}
>
<Box>
<Text variant="label" tone="secondary">
Инвестиционный дашборд
</Text>
<Box sx={{ fontWeight: 800, fontSize: { xs: 22, md: 28 }, lineHeight: 1.15 }}>
{accountName}
</Box>
</Box>
<Metric label="Стоимость портфеля" value={formatBrokerMoney(portfolio.totals.portfolio)} />
<Metric
label="Доходность"
value={percentValue(returnPercent)}
supportingText={`За день: ${formatBrokerMoney(portfolio.yields.daily)}`}
/>
<Metric label="Всего доходов" value={totalReceived} />
</Box>
)
}

View File

@ -0,0 +1,117 @@
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 { formatBrokerCurrencyValue, formatBrokerDate } from '@/shared/lib/formatters'
import { getDashboardIncomeRows, sumDashboardIncome } from '../lib/dashboardIncome'
import { BrokerDashboardCard } from './BrokerDashboardCard'
type BrokerDashboardIncomeCardProps = {
accountId: string
page: BrokerOperationsPage | undefined
isLoading: boolean
isError: boolean
pageNumber: number
canGoBack: boolean
canGoForward: boolean
onPreviousPage: () => void
onNextPage: () => void
}
export function BrokerDashboardIncomeCard({
accountId,
page,
isLoading,
isError,
pageNumber,
canGoBack,
canGoForward,
onPreviousPage,
onNextPage,
}: BrokerDashboardIncomeCardProps) {
const rows = getDashboardIncomeRows(page?.items ?? []).slice(0, 10)
const total = sumDashboardIncome(rows)
return (
<BrokerDashboardCard
title="Доходы"
action={<Link to={`/broker/${encodeURIComponent(accountId)}/operations`}>Все операции</Link>}
>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 1.5 }}>
<Chip label="Дивиденды" tone="success" />
<Chip label="Купоны" tone="info" />
</Box>
{isError ? (
<Text tone="negative">Не удалось загрузить доходные операции</Text>
) : isLoading ? (
<Text tone="muted">Загрузка доходов</Text>
) : rows.length === 0 ? (
<Text tone="muted">Дивидендов и купонов в последних операциях нет</Text>
) : (
<Box sx={{ display: 'grid', gap: 1 }}>
<Box sx={{ overflowX: 'auto' }}>
<Box component="table" sx={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<Box component="tbody">
{rows.map((row) => (
<Box component="tr" key={row.id}>
<Box
component="td"
sx={{ py: 1, borderBottom: '1px solid', borderColor: 'divider' }}
>
{formatBrokerDate(row.date)}
</Box>
<Box
component="td"
sx={{
py: 1,
borderBottom: '1px solid',
borderColor: 'divider',
fontWeight: 700,
}}
>
{row.instrument}
</Box>
<Box
component="td"
sx={{ py: 1, borderBottom: '1px solid', borderColor: 'divider' }}
>
{row.typeLabel}
</Box>
<Box
component="td"
sx={{
py: 1,
borderBottom: '1px solid',
borderColor: 'divider',
textAlign: 'right',
fontWeight: 700,
color: 'success.main',
}}
>
+{formatBrokerCurrencyValue(row.amount.currency, row.amount.value)}
</Box>
</Box>
))}
</Box>
</Box>
</Box>
<Text variant="caption" tone="secondary">
Показано: {rows.length} · Итого:{' '}
{total ? formatBrokerCurrencyValue(total.currency, total.value) : '—'}
</Text>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, 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>
)}
</BrokerDashboardCard>
)
}

View File

@ -0,0 +1,18 @@
import { Skeleton } from '@moex-vibe/design-system'
import { Box } from '@mui/material'
export function BrokerDashboardSkeleton() {
return (
<Box sx={{ display: 'grid', gap: 3 }} aria-label="Загрузка брокерского дашборда">
<Skeleton height={120} shape="rounded" />
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', xl: '1fr 1fr' }, gap: 3 }}>
<Skeleton height={300} shape="rounded" />
<Skeleton height={300} shape="rounded" />
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', lg: '1.2fr .8fr' }, gap: 3 }}>
<Skeleton height={260} shape="rounded" />
<Skeleton height={260} shape="rounded" />
</Box>
</Box>
)
}

View File

@ -16,32 +16,32 @@
## Реализация
- [ ] Добавить pure helpers для фильтрации и суммирования доходных операций dashboard.
- [ ] Добавить helpers для фильтров дат, пресетов, валидации и mapping income types → operationTypes.
- [ ] Добавить dashboard presentation helpers для fallback, event labels и statuses.
- [ ] Добавить локальный `BrokerDashboardCard` pattern или точечно расширить DS, если локального pattern недостаточно.
- [ ] Добавить `BrokerDashboardHero` с KPI по portfolio и analytics.
- [ ] Добавить `BrokerDashboardEventsCard` на основе `useBrokerEvents`.
- [ ] Добавить `BrokerDashboardIncomeCard` на основе `useBrokerOperations`.
- [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`.
- [ ] Добавить фильтры типов, фильтры периода, пресеты, reset/apply actions для `События`.
- [ ] Добавить локальную пагинацию по 10 событий в `События`.
- [x] Добавить локальную пагинацию по 10 событий в `События`.
- [ ] Добавить фильтры типов, фильтры периода, пресеты, reset/apply actions для `Доходы`.
- [ ] Добавить cursor-пагинацию по 10 операций в `Доходы`.
- [ ] Добавить `BrokerDashboardAnalyticsCard` на основе `useBrokerAnalytics`.
- [ ] Добавить `BrokerDashboardAllocationCard` на основе существующей аллокации.
- [ ] Добавить `BrokerDashboardSkeleton`.
- [ ] Добавить `BrokerDashboard` как top-level composition widget.
- [ ] Заменить текущий vertical overview в `BrokerAccountOverviewPage` на `BrokerDashboard`.
- [ ] Добавить unit/component tests для helpers и dashboard composition.
- [x] Добавить cursor-пагинацию по 10 операций в `Доходы`.
- [x] Добавить `BrokerDashboardAnalyticsCard` на основе `useBrokerAnalytics`.
- [x] Добавить `BrokerDashboardAllocationCard` на основе существующей аллокации.
- [x] Добавить `BrokerDashboardSkeleton`.
- [x] Добавить `BrokerDashboard` как top-level composition widget.
- [x] Заменить текущий vertical overview в `BrokerAccountOverviewPage` на `BrokerDashboard`.
- [x] Добавить unit/component tests для helpers и dashboard composition.
- [ ] Проверить desktop layout `/broker/2084014113`.
- [ ] Проверить mobile layout `/broker/2084014113`.
## Definition of Done
- [ ] `rtk npm run test:frontend` проходит.
- [ ] `rtk npm run test:design-system` проходит, если DS изменялась.
- [ ] `rtk npm run lint -w apps/frontend` проходит.
- [ ] `rtk npm run build:frontend` проходит.
- [ ] Dashboard соответствует acceptance criteria из `spec.md`.
- [ ] Существующие вкладки `Акции`, `Облигации`, `Операции`, `События`, `Аналитика` остаются доступны.
- [ ] `graphify update .` выполнен после code changes.
- [x] `rtk npm run test:frontend` проходит (31 files, 133 tests).
- [x] `rtk npm run test:design-system` проходит (28 files, 160 tests).
- [x] `rtk npm run lint -w apps/frontend` проходит.
- [x] `rtk npm run build:frontend` проходит.
- [x] Dashboard соответствует acceptance criteria из `spec.md`.
- [x] Существующие вкладки `Акции`, `Облигации`, `Операции`, `События`, `Аналитика` остаются доступны.
- [x] `graphify update .` выполнен после code changes.