codex/broker-dashboard-redesign #49
@ -19,6 +19,5 @@ export function eventTypeLabel(type: BrokerEventItem['type']): string {
|
||||
|
||||
export function eventStatusLabel(event: BrokerEventItem): string {
|
||||
if (event.source === 'actual') return 'Поступило'
|
||||
if (event.type === 'offer') return 'Оферта'
|
||||
return 'Прогноз'
|
||||
return 'Ожидается'
|
||||
}
|
||||
|
||||
@ -64,12 +64,11 @@ describe('dashboardIncome', () => {
|
||||
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)])
|
||||
|
||||
expect(rows[0].instrumentMain).toBe('AAPL')
|
||||
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', () => {
|
||||
@ -78,7 +77,6 @@ describe('dashboardIncome', () => {
|
||||
|
||||
expect(rows[0].instrumentMain).toBe('—')
|
||||
expect(rows[0].instrumentSubtitle).toBeNull()
|
||||
expect(rows[0].instrument).toBe('—')
|
||||
})
|
||||
|
||||
it('does not duplicate subtitle when name equals ticker', () => {
|
||||
|
||||
@ -14,7 +14,6 @@ export type DashboardIncomeRow = {
|
||||
date: string | null
|
||||
instrumentMain: string
|
||||
instrumentSubtitle: string | null
|
||||
instrument: string
|
||||
typeLabel: DashboardIncomeTypeLabel
|
||||
amount: BrokerMoney
|
||||
}
|
||||
@ -41,7 +40,6 @@ export function getDashboardIncomeRows(operations: BrokerOperation[]): Dashboard
|
||||
date: typeof operation.date === 'string' ? operation.date : null,
|
||||
instrumentMain: main,
|
||||
instrumentSubtitle: subtitle,
|
||||
instrument: main,
|
||||
typeLabel: typeLabel(operation.type),
|
||||
amount: operation.payment!,
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
incomeTypeTone,
|
||||
instrumentDisplay,
|
||||
moneyTone,
|
||||
moneyToneToColor,
|
||||
} from './dashboardVisual'
|
||||
|
||||
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', () => {
|
||||
it('maps each income label to a stable tone', () => {
|
||||
expect(incomeTypeTone('Дивиденд')).toBe('success')
|
||||
|
||||
@ -17,6 +17,18 @@ export function moneyTone(value: number | null | undefined, source?: MoneySource
|
||||
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 {
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
|
||||
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 type { ReactNode } from 'react'
|
||||
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'
|
||||
|
||||
const hookMocks = vi.hoisted(() => ({
|
||||
@ -74,6 +74,78 @@ 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({
|
||||
@ -211,4 +283,156 @@ describe('BrokerDashboard', () => {
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -2,9 +2,18 @@ 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 { formatBrokerDate } from '@/shared/lib/formatters'
|
||||
import type { DashboardDatePreset, DashboardEventType } from '../lib/dashboardFilters'
|
||||
import { eventStatusLabel, eventTypeLabel } from '../lib/dashboardFormatters'
|
||||
import {
|
||||
eventTypeTone,
|
||||
formatDashboardCurrency,
|
||||
instrumentDisplay,
|
||||
type MoneyTone,
|
||||
moneyTone,
|
||||
moneyToneToColor,
|
||||
type TypeTone,
|
||||
} from '../lib/dashboardVisual'
|
||||
import { BrokerDashboardCard } from './BrokerDashboardCard'
|
||||
import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter'
|
||||
import { BrokerDashboardTableSkeleton } from './BrokerDashboardTableSkeleton'
|
||||
@ -13,7 +22,7 @@ import { BrokerDashboardTableToolbar } from './BrokerDashboardTableToolbar'
|
||||
const EVENT_FILTERS: Array<{
|
||||
type: DashboardEventType
|
||||
label: string
|
||||
tone: 'success' | 'info' | 'warning' | 'neutral'
|
||||
tone: TypeTone
|
||||
}> = [
|
||||
{ type: 'dividend', label: 'Дивиденды', tone: 'success' },
|
||||
{ type: 'coupon', label: 'Купоны', tone: 'info' },
|
||||
@ -21,6 +30,38 @@ const EVENT_FILTERS: Array<{
|
||||
{ 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 = {
|
||||
accountId: string
|
||||
data: BrokerEventsData | undefined
|
||||
@ -45,11 +86,16 @@ type BrokerDashboardEventsCardProps = {
|
||||
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)}`
|
||||
function eventStatusTone(event: BrokerEventItem): TypeTone {
|
||||
return event.source === 'actual' ? 'success' : 'neutral'
|
||||
}
|
||||
|
||||
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({
|
||||
@ -119,58 +165,96 @@ export function BrokerDashboardEventsCard({
|
||||
) : (
|
||||
<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
|
||||
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 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
|
||||
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)}
|
||||
selected={false}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@ -2,7 +2,7 @@ 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, type MoneyTone, moneyTone } from '../lib/dashboardVisual'
|
||||
import { formatDashboardCurrency, moneyTone, moneyToneToColor } from '../lib/dashboardVisual'
|
||||
|
||||
type BrokerDashboardHeroProps = {
|
||||
portfolio: BrokerPortfolio
|
||||
@ -13,18 +13,6 @@ function percentValue(value: unknown): string {
|
||||
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) {
|
||||
const accountName = portfolio.account.name || 'Брокерский счёт'
|
||||
const returnPercent = analytics?.totalReturnPercent ?? portfolio.yields.expectedPercent
|
||||
@ -66,12 +54,12 @@ export function BrokerDashboardHero({ portfolio, analytics }: BrokerDashboardHer
|
||||
<Metric
|
||||
label="Доходность"
|
||||
value={
|
||||
<Box component="span" sx={{ color: toneToColor(returnTone), fontWeight: 700 }}>
|
||||
<Box component="span" sx={{ color: moneyToneToColor(returnTone), fontWeight: 700 }}>
|
||||
{percentValue(returnPercent)}
|
||||
</Box>
|
||||
}
|
||||
supportingText={
|
||||
<Box component="span" sx={{ color: toneToColor(dailyTone) }}>
|
||||
<Box component="span" sx={{ color: moneyToneToColor(dailyTone) }}>
|
||||
За день: {formatBrokerMoney(portfolio.yields.daily)}
|
||||
</Box>
|
||||
}
|
||||
@ -81,7 +69,10 @@ export function BrokerDashboardHero({ portfolio, analytics }: BrokerDashboardHer
|
||||
<Metric
|
||||
label="Всего доходов"
|
||||
value={
|
||||
<Box component="span" sx={{ color: toneToColor(totalReceivedTone), fontWeight: 700 }}>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ color: moneyToneToColor(totalReceivedTone), fontWeight: 700 }}
|
||||
>
|
||||
{totalReceivedDisplay}
|
||||
</Box>
|
||||
}
|
||||
|
||||
@ -2,9 +2,15 @@ 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 { 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'
|
||||
@ -13,12 +19,44 @@ import { BrokerDashboardTableToolbar } from './BrokerDashboardTableToolbar'
|
||||
const INCOME_FILTERS: Array<{
|
||||
type: DashboardIncomeType
|
||||
label: string
|
||||
tone: 'success' | 'info'
|
||||
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,
|
||||
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 = {
|
||||
accountId: string
|
||||
page: BrokerOperationsPage | undefined
|
||||
@ -111,54 +149,89 @@ export function BrokerDashboardIncomeCard({
|
||||
) : (
|
||||
<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
|
||||
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>
|
||||
<Text variant="caption" tone="secondary">
|
||||
Показано: {rows.length} · Итого:{' '}
|
||||
{total ? formatBrokerCurrencyValue(total.currency, total.value) : '—'}
|
||||
{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}>
|
||||
|
||||
@ -1,26 +1,80 @@
|
||||
import { Skeleton } from '@moex-vibe/design-system'
|
||||
import { Box } from '@mui/material'
|
||||
|
||||
type ColumnWidth = number | string
|
||||
|
||||
type BrokerDashboardTableSkeletonProps = {
|
||||
rows?: 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({
|
||||
rows = 10,
|
||||
rows = 5,
|
||||
columns = 5,
|
||||
columnWidths,
|
||||
}: BrokerDashboardTableSkeletonProps) {
|
||||
const widths = widthsFor(columns, columnWidths)
|
||||
const variants = rowVariantsFor(columns)
|
||||
|
||||
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) => (
|
||||
<Box key={i} sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
{Array.from({ length: columns }, (_, j) => (
|
||||
<Skeleton
|
||||
key={j}
|
||||
height={16}
|
||||
width={j === 0 ? 80 : j === columns - 1 ? 60 : 100}
|
||||
shape="rounded"
|
||||
/>
|
||||
<Box
|
||||
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
|
||||
height={14}
|
||||
width={
|
||||
variants[j] === 'short'
|
||||
? '45%'
|
||||
: variants[j] === 'medium'
|
||||
? '65%'
|
||||
: variants[j] === 'full'
|
||||
? '100%'
|
||||
: '74%'
|
||||
}
|
||||
shape="rounded"
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user