feat(frontend): align dashboard tables with HTML parity

- Add thead with semantic column headers to events and income tables
- Render instruments as main (ticker/ISIN) + subtitle (name) via instrumentDisplay
- Type column uses compact Chip with eventTypeTone/incomeTypeTone; DIV_EXT
  shows distinct label 'Дивиденд (внешний)'
- Amount column applies moneyTone (positive=green, negative=red, planned=neutral)
  via moneyToneToColor; sign prefixes (+/-/~) preserved per spec
- Status column: 'Поступило' (success) vs 'Ожидается' (neutral)
- Income sum semantics preserved (sum of current page rows)
- Skeleton: unified column-width and variant pattern for both tables
- Drop legacy DashboardIncomeRow.instrument alias (now uses instrumentMain/Subtitle)
- Extract moneyToneToColor helper from hero (single source of MUI color mapping)
- Dashboard tests cover thead, badges, subtitles, signed tones, and that
  tables show ₽ instead of RUB for RUB amounts
This commit is contained in:
Sergey Krylov 2026-06-27 13:33:02 +03:00
parent 21df354df6
commit 3bad694dce
10 changed files with 580 additions and 137 deletions

View File

@ -19,6 +19,5 @@ export function eventTypeLabel(type: BrokerEventItem['type']): string {
export function eventStatusLabel(event: BrokerEventItem): string { export function eventStatusLabel(event: BrokerEventItem): string {
if (event.source === 'actual') return 'Поступило' if (event.source === 'actual') return 'Поступило'
if (event.type === 'offer') return 'Оферта' return 'Ожидается'
return 'Прогноз'
} }

View File

@ -64,12 +64,11 @@ describe('dashboardIncome', () => {
expect(rows[0].amount.value).toBe(12) expect(rows[0].amount.value).toBe(12)
}) })
it('splits instrument into main and subtitle, keeping the legacy alias', () => { it('splits instrument into main and subtitle', () => {
const rows = getDashboardIncomeRows([operation('OPERATION_TYPE_DIVIDEND', 10)]) const rows = getDashboardIncomeRows([operation('OPERATION_TYPE_DIVIDEND', 10)])
expect(rows[0].instrumentMain).toBe('AAPL') expect(rows[0].instrumentMain).toBe('AAPL')
expect(rows[0].instrumentSubtitle).toBe('Apple Inc.') expect(rows[0].instrumentSubtitle).toBe('Apple Inc.')
expect(rows[0].instrument).toBe('AAPL')
}) })
it('falls back to "—" with no subtitle when ticker, name, and description are missing', () => { it('falls back to "—" with no subtitle when ticker, name, and description are missing', () => {
@ -78,7 +77,6 @@ describe('dashboardIncome', () => {
expect(rows[0].instrumentMain).toBe('—') expect(rows[0].instrumentMain).toBe('—')
expect(rows[0].instrumentSubtitle).toBeNull() expect(rows[0].instrumentSubtitle).toBeNull()
expect(rows[0].instrument).toBe('—')
}) })
it('does not duplicate subtitle when name equals ticker', () => { it('does not duplicate subtitle when name equals ticker', () => {

View File

@ -14,7 +14,6 @@ export type DashboardIncomeRow = {
date: string | null date: string | null
instrumentMain: string instrumentMain: string
instrumentSubtitle: string | null instrumentSubtitle: string | null
instrument: string
typeLabel: DashboardIncomeTypeLabel typeLabel: DashboardIncomeTypeLabel
amount: BrokerMoney amount: BrokerMoney
} }
@ -41,7 +40,6 @@ export function getDashboardIncomeRows(operations: BrokerOperation[]): Dashboard
date: typeof operation.date === 'string' ? operation.date : null, date: typeof operation.date === 'string' ? operation.date : null,
instrumentMain: main, instrumentMain: main,
instrumentSubtitle: subtitle, instrumentSubtitle: subtitle,
instrument: main,
typeLabel: typeLabel(operation.type), typeLabel: typeLabel(operation.type),
amount: operation.payment!, amount: operation.payment!,
} }

View File

@ -6,6 +6,7 @@ import {
incomeTypeTone, incomeTypeTone,
instrumentDisplay, instrumentDisplay,
moneyTone, moneyTone,
moneyToneToColor,
} from './dashboardVisual' } from './dashboardVisual'
function money(currency: string, value: number): BrokerMoney { function money(currency: string, value: number): BrokerMoney {
@ -85,6 +86,15 @@ describe('eventTypeTone', () => {
}) })
}) })
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', () => { describe('incomeTypeTone', () => {
it('maps each income label to a stable tone', () => { it('maps each income label to a stable tone', () => {
expect(incomeTypeTone('Дивиденд')).toBe('success') expect(incomeTypeTone('Дивиденд')).toBe('success')

View File

@ -17,6 +17,18 @@ export function moneyTone(value: number | null | undefined, source?: MoneySource
return source === 'forecast' ? 'planned' : 'positive' 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 { export function formatDashboardCurrency(money: DashboardMoneyLike | null | undefined): string {
if (!money) return '—' if (!money) return '—'
try { try {

View File

@ -1,10 +1,10 @@
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs' import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider' import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'
import { render, screen, waitFor } from '@testing-library/react' import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { BrokerPortfolio } from '@/shared/api' import type { BrokerEventItem, BrokerOperation, BrokerPortfolio } from '@/shared/api'
import { BrokerDashboard } from './BrokerDashboard' import { BrokerDashboard } from './BrokerDashboard'
const hookMocks = vi.hoisted(() => ({ const hookMocks = vi.hoisted(() => ({
@ -74,6 +74,78 @@ function renderWithProviders(ui: ReactNode) {
return render(<LocalizationProvider dateAdapter={AdapterDayjs}>{ui}</LocalizationProvider>) 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', () => { describe('BrokerDashboard', () => {
beforeEach(() => { beforeEach(() => {
hookMocks.useBrokerEvents.mockReturnValue({ hookMocks.useBrokerEvents.mockReturnValue({
@ -211,4 +283,156 @@ describe('BrokerDashboard', () => {
expect(screen.queryByTestId('dashboard-table-skeleton')).not.toBeInTheDocument() 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('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).toContain('-87,00')
})
it('renders events and income amounts with the ₽ symbol and no "RUB" code', () => {
mockEventsLoaded([
buildEvent({ id: 'evt', source: 'actual', actualAmount: 43.56 }),
buildEvent({ id: 'evt-neg', source: 'actual', actualAmount: -12.34 }),
])
mockOperationsLoaded([
buildOperation({
id: 'op-positive',
type: 'OPERATION_TYPE_COUPON',
ticker: 'SU26249RMFS1',
name: 'ОФЗ 26241',
payment: { currency: 'RUB', units: '109', nano: 700000000, value: 109.7 },
}),
buildOperation({
id: 'op-negative',
type: 'OPERATION_TYPE_DIVIDEND',
ticker: 'IRAO',
name: 'Интер РАО',
payment: { currency: 'RUB', units: '35', nano: 0, value: -35 },
}),
])
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const eventsTable = screen.getByLabelText('Таблица событий брокерского счёта')
const incomeTable = screen.getByLabelText('Таблица доходов брокерского счёта')
for (const table of [eventsTable, incomeTable]) {
expect(table.textContent ?? '').toContain('₽')
expect(table.textContent ?? '').not.toContain('RUB')
}
})
it('renders income rows with main + subtitle, type badge and signed amount tone', () => {
mockOperationsLoaded([
buildOperation({
id: 'op-div',
type: 'OPERATION_TYPE_DIVIDEND',
ticker: 'IRAO',
name: 'Интер РАО',
payment: { currency: 'RUB', units: '649', nano: 250000000, value: 649.25 },
}),
buildOperation({
id: 'op-div-ext',
type: 'OPERATION_TYPE_DIV_EXT',
ticker: 'AAPL',
name: 'Apple Inc.',
payment: { currency: 'RUB', units: '100', nano: 0, value: -35 },
}),
])
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const rows = screen.getAllByTestId('dashboard-income-row')
expect(rows).toHaveLength(2)
const mainLabels = screen.getAllByTestId('dashboard-income-instrument-main')
expect(mainLabels.map((el) => el.textContent)).toEqual(['IRAO', 'AAPL'])
const subtitles = screen.getAllByTestId('dashboard-income-instrument-subtitle')
expect(subtitles.map((el) => el.textContent)).toEqual(['Интер РАО', 'Apple Inc.'])
expect(screen.getByText('Дивиденд')).toBeInTheDocument()
expect(screen.getByText('Дивиденд (внешний)')).toBeInTheDocument()
const amounts = screen.getAllByTestId('dashboard-income-amount')
expect(amounts[0].getAttribute('data-tone')).toBe('positive')
expect(amounts[0].textContent).toContain('+649,25')
expect(amounts[1].getAttribute('data-tone')).toBe('negative')
expect(amounts[1].textContent).toContain('35,00')
})
}) })

View File

@ -2,9 +2,18 @@ import { Button, Chip, Text } from '@moex-vibe/design-system'
import { Box } from '@mui/material' import { Box } from '@mui/material'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import type { BrokerEventItem, BrokerEventsData } from '@/shared/api' import type { BrokerEventItem, BrokerEventsData } from '@/shared/api'
import { formatBrokerCurrencyValue, formatBrokerDate } from '@/shared/lib/formatters' import { formatBrokerDate } from '@/shared/lib/formatters'
import type { DashboardDatePreset, DashboardEventType } from '../lib/dashboardFilters' import type { DashboardDatePreset, DashboardEventType } from '../lib/dashboardFilters'
import { eventStatusLabel, eventTypeLabel } from '../lib/dashboardFormatters' import { eventStatusLabel, eventTypeLabel } from '../lib/dashboardFormatters'
import {
eventTypeTone,
formatDashboardCurrency,
instrumentDisplay,
type MoneyTone,
moneyTone,
moneyToneToColor,
type TypeTone,
} from '../lib/dashboardVisual'
import { BrokerDashboardCard } from './BrokerDashboardCard' import { BrokerDashboardCard } from './BrokerDashboardCard'
import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter' import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter'
import { BrokerDashboardTableSkeleton } from './BrokerDashboardTableSkeleton' import { BrokerDashboardTableSkeleton } from './BrokerDashboardTableSkeleton'
@ -13,7 +22,7 @@ import { BrokerDashboardTableToolbar } from './BrokerDashboardTableToolbar'
const EVENT_FILTERS: Array<{ const EVENT_FILTERS: Array<{
type: DashboardEventType type: DashboardEventType
label: string label: string
tone: 'success' | 'info' | 'warning' | 'neutral' tone: TypeTone
}> = [ }> = [
{ type: 'dividend', label: 'Дивиденды', tone: 'success' }, { type: 'dividend', label: 'Дивиденды', tone: 'success' },
{ type: 'coupon', label: 'Купоны', tone: 'info' }, { type: 'coupon', label: 'Купоны', tone: 'info' },
@ -21,6 +30,38 @@ const EVENT_FILTERS: Array<{
{ type: 'offer', label: 'Оферты', tone: 'neutral' }, { 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,
letterSpacing: 0.2,
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 = { type BrokerDashboardEventsCardProps = {
accountId: string accountId: string
data: BrokerEventsData | undefined data: BrokerEventsData | undefined
@ -45,11 +86,16 @@ type BrokerDashboardEventsCardProps = {
canGoForward: boolean canGoForward: boolean
} }
function eventAmount(event: BrokerEventItem): string { function eventStatusTone(event: BrokerEventItem): TypeTone {
const amount = event.source === 'actual' ? event.actualAmount : event.estimatedAmount return event.source === 'actual' ? 'success' : 'neutral'
if (amount === null || amount === undefined) return '\u2014' }
const prefix = event.source === 'actual' ? '+' : '~'
return `${prefix}${formatBrokerCurrencyValue(event.currency ?? 'RUB', amount)}` 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)
} }
export function BrokerDashboardEventsCard({ export function BrokerDashboardEventsCard({
@ -119,58 +165,96 @@ export function BrokerDashboardEventsCard({
) : ( ) : (
<Box sx={{ display: 'grid', gap: 1 }}> <Box sx={{ display: 'grid', gap: 1 }}>
<Box sx={{ overflowX: 'auto' }}> <Box sx={{ overflowX: 'auto' }}>
<Box component="table" sx={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}> <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"> <Box component="tbody">
{events.map((event) => ( {events.map((event) => {
<Box component="tr" key={event.id}> const display = instrumentDisplay({
ticker: event.ticker,
name: event.name,
})
const amount = eventAmountValue(event)
const formattedAmount =
amount === null || amount === undefined
? '—'
: `${event.source === 'actual' ? '+' : '~'}${formatDashboardCurrency({
currency: event.currency ?? 'RUB',
value: amount,
})}`
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 <Box
component="td" sx={{ fontWeight: 700 }}
sx={{ py: 1, borderBottom: '1px solid', borderColor: 'divider' }} data-testid="dashboard-events-instrument-main"
> >
{formatBrokerDate(event.eventDate)} {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>
<Box <Box
component="td" component="td"
sx={{ sx={{
py: 1, ...TD_SX_RIGHT,
borderBottom: '1px solid',
borderColor: 'divider',
fontWeight: 700, fontWeight: 700,
color: moneyToneToColor(eventMoneyTone(event)),
}} }}
data-testid="dashboard-events-amount"
data-tone={eventMoneyTone(event)}
> >
{event.ticker ?? event.name ?? '\u2014'} {formattedAmount}
</Box> </Box>
<Box <Box component="td" sx={TD_SX_RIGHT}>
component="td" <Chip
sx={{ py: 1, borderBottom: '1px solid', borderColor: 'divider' }} label={eventStatusLabel(event)}
> tone={eventStatusTone(event)}
{eventTypeLabel(event.type)} selected={false}
</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>
</Box> </Box>
</Box> </Box>

View File

@ -2,7 +2,7 @@ import { Metric, Text } from '@moex-vibe/design-system'
import { Box } from '@mui/material' import { Box } from '@mui/material'
import type { BrokerAnalytics, BrokerPortfolio } from '@/shared/api' import type { BrokerAnalytics, BrokerPortfolio } from '@/shared/api'
import { formatBrokerMoney, formatBrokerPercent } from '@/shared/lib/formatters' import { formatBrokerMoney, formatBrokerPercent } from '@/shared/lib/formatters'
import { formatDashboardCurrency, type MoneyTone, moneyTone } from '../lib/dashboardVisual' import { formatDashboardCurrency, moneyTone, moneyToneToColor } from '../lib/dashboardVisual'
type BrokerDashboardHeroProps = { type BrokerDashboardHeroProps = {
portfolio: BrokerPortfolio portfolio: BrokerPortfolio
@ -13,18 +13,6 @@ function percentValue(value: unknown): string {
return typeof value === 'number' ? formatBrokerPercent(value) : '—' return typeof value === 'number' ? formatBrokerPercent(value) : '—'
} }
function toneToColor(tone: MoneyTone): string {
switch (tone) {
case 'positive':
return 'success.main'
case 'negative':
return 'error.main'
case 'planned':
case 'neutral':
return 'text.disabled'
}
}
export function BrokerDashboardHero({ portfolio, analytics }: BrokerDashboardHeroProps) { export function BrokerDashboardHero({ portfolio, analytics }: BrokerDashboardHeroProps) {
const accountName = portfolio.account.name || 'Брокерский счёт' const accountName = portfolio.account.name || 'Брокерский счёт'
const returnPercent = analytics?.totalReturnPercent ?? portfolio.yields.expectedPercent const returnPercent = analytics?.totalReturnPercent ?? portfolio.yields.expectedPercent
@ -66,12 +54,12 @@ export function BrokerDashboardHero({ portfolio, analytics }: BrokerDashboardHer
<Metric <Metric
label="Доходность" label="Доходность"
value={ value={
<Box component="span" sx={{ color: toneToColor(returnTone), fontWeight: 700 }}> <Box component="span" sx={{ color: moneyToneToColor(returnTone), fontWeight: 700 }}>
{percentValue(returnPercent)} {percentValue(returnPercent)}
</Box> </Box>
} }
supportingText={ supportingText={
<Box component="span" sx={{ color: toneToColor(dailyTone) }}> <Box component="span" sx={{ color: moneyToneToColor(dailyTone) }}>
За день: {formatBrokerMoney(portfolio.yields.daily)} За день: {formatBrokerMoney(portfolio.yields.daily)}
</Box> </Box>
} }
@ -81,7 +69,10 @@ export function BrokerDashboardHero({ portfolio, analytics }: BrokerDashboardHer
<Metric <Metric
label="Всего доходов" label="Всего доходов"
value={ value={
<Box component="span" sx={{ color: toneToColor(totalReceivedTone), fontWeight: 700 }}> <Box
component="span"
sx={{ color: moneyToneToColor(totalReceivedTone), fontWeight: 700 }}
>
{totalReceivedDisplay} {totalReceivedDisplay}
</Box> </Box>
} }

View File

@ -2,9 +2,15 @@ import { Button, Chip, Text } from '@moex-vibe/design-system'
import { Box } from '@mui/material' import { Box } from '@mui/material'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import type { BrokerOperationsPage } from '@/shared/api' import type { BrokerOperationsPage } from '@/shared/api'
import { formatBrokerCurrencyValue, formatBrokerDate } from '@/shared/lib/formatters' import { formatBrokerDate } from '@/shared/lib/formatters'
import type { DashboardDatePreset, DashboardIncomeType } from '../lib/dashboardFilters' import type { DashboardDatePreset, DashboardIncomeType } from '../lib/dashboardFilters'
import { getDashboardIncomeRows, sumDashboardIncome } from '../lib/dashboardIncome' import { getDashboardIncomeRows, sumDashboardIncome } from '../lib/dashboardIncome'
import {
formatDashboardCurrency,
incomeTypeTone,
moneyToneToColor,
type TypeTone,
} from '../lib/dashboardVisual'
import { BrokerDashboardCard } from './BrokerDashboardCard' import { BrokerDashboardCard } from './BrokerDashboardCard'
import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter' import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter'
import { BrokerDashboardTableSkeleton } from './BrokerDashboardTableSkeleton' import { BrokerDashboardTableSkeleton } from './BrokerDashboardTableSkeleton'
@ -13,12 +19,44 @@ import { BrokerDashboardTableToolbar } from './BrokerDashboardTableToolbar'
const INCOME_FILTERS: Array<{ const INCOME_FILTERS: Array<{
type: DashboardIncomeType type: DashboardIncomeType
label: string label: string
tone: 'success' | 'info' tone: TypeTone
}> = [ }> = [
{ type: 'dividend', label: 'Дивиденды', tone: 'success' }, { type: 'dividend', label: 'Дивиденды', tone: 'success' },
{ type: 'coupon', label: 'Купоны', tone: 'info' }, { 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,
letterSpacing: 0.2,
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 = { type BrokerDashboardIncomeCardProps = {
accountId: string accountId: string
page: BrokerOperationsPage | undefined page: BrokerOperationsPage | undefined
@ -111,54 +149,89 @@ export function BrokerDashboardIncomeCard({
) : ( ) : (
<Box sx={{ display: 'grid', gap: 1 }}> <Box sx={{ display: 'grid', gap: 1 }}>
<Box sx={{ overflowX: 'auto' }}> <Box sx={{ overflowX: 'auto' }}>
<Box component="table" sx={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}> <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"> <Box component="tbody">
{rows.map((row) => ( {rows.map((row) => {
<Box component="tr" key={row.id}> 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 <Box
component="td" sx={{ fontWeight: 700 }}
sx={{ py: 1, borderBottom: '1px solid', borderColor: 'divider' }} data-testid="dashboard-income-instrument-main"
> >
{formatBrokerDate(row.date)} {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>
<Box <Box
component="td" component="td"
sx={{ sx={{
py: 1, ...TD_SX_RIGHT,
borderBottom: '1px solid',
borderColor: 'divider',
fontWeight: 700, fontWeight: 700,
color: moneyToneToColor(amountTone),
}} }}
data-testid="dashboard-income-amount"
data-tone={amountTone}
> >
{row.instrument} {formattedAmount}
</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> </Box>
</Box> </Box>
</Box> </Box>
<Text variant="caption" tone="secondary"> <Text variant="caption" tone="secondary">
Показано: {rows.length} · Итого:{' '} Показано: {rows.length} · Итого:{' '}
{total ? formatBrokerCurrencyValue(total.currency, total.value) : '—'} {total
? `${total.value >= 0 ? '+' : ''}${formatDashboardCurrency({
currency: total.currency,
value: Math.abs(total.value),
})}`
: '—'}
</Text> </Text>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, alignItems: 'center' }}> <Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, alignItems: 'center' }}>
<Button variant="secondary" size="small" onClick={onPreviousPage} disabled={!canGoBack}> <Button variant="secondary" size="small" onClick={onPreviousPage} disabled={!canGoBack}>

View File

@ -1,26 +1,80 @@
import { Skeleton } from '@moex-vibe/design-system' import { Skeleton } from '@moex-vibe/design-system'
import { Box } from '@mui/material' import { Box } from '@mui/material'
type ColumnWidth = number | string
type BrokerDashboardTableSkeletonProps = { type BrokerDashboardTableSkeletonProps = {
rows?: number rows?: number
columns?: number columns?: number
columnWidths?: ColumnWidth[]
}
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, override?: ColumnWidth[]): ColumnWidth[] {
if (override && override.length === columns) return override
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({ export function BrokerDashboardTableSkeleton({
rows = 10, rows = 5,
columns = 5, columns = 5,
columnWidths,
}: BrokerDashboardTableSkeletonProps) { }: BrokerDashboardTableSkeletonProps) {
const widths = widthsFor(columns, columnWidths)
const variants = rowVariantsFor(columns)
return ( return (
<Box sx={{ display: 'grid', gap: 1.5, py: 1 }} data-testid="dashboard-table-skeleton"> <Box
sx={{ display: 'grid', gap: 1, py: 1 }}
data-testid="dashboard-table-skeleton"
role="presentation"
>
{Array.from({ length: rows }, (_, i) => ( {Array.from({ length: rows }, (_, i) => (
<Box key={i} sx={{ display: 'flex', gap: 2, alignItems: 'center' }}> <Box
{Array.from({ length: columns }, (_, j) => ( key={i}
sx={{
display: 'flex',
gap: 2,
alignItems: 'center',
minHeight: 38,
py: 0.5,
}}
aria-hidden="true"
>
{widths.map((width, j) => (
<Box key={j} sx={{ flex: `0 0 ${width}`, minWidth: 0 }}>
<Skeleton <Skeleton
key={j} height={14}
height={16} width={
width={j === 0 ? 80 : j === columns - 1 ? 60 : 100} variants[j] === 'short'
? '45%'
: variants[j] === 'medium'
? '65%'
: variants[j] === 'full'
? '100%'
: '74%'
}
shape="rounded" shape="rounded"
/> />
</Box>
))} ))}
</Box> </Box>
))} ))}