codex/broker-dashboard-redesign #49
@ -138,14 +138,14 @@ describe('BrokerDashboard', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows date filter toggle button with apply action', async () => {
|
||||
it('shows date filter toggle button with apply action and accessible name', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
const applyButtons = screen.getAllByRole('button', { name: /Применить период/ })
|
||||
expect(applyButtons).toHaveLength(2)
|
||||
|
||||
const toggleButtons = screen.getAllByRole('button', { name: /📅/ })
|
||||
const toggleButtons = screen.getAllByRole('button', { name: /^Период/ })
|
||||
expect(toggleButtons).toHaveLength(2)
|
||||
|
||||
await user.click(toggleButtons[0])
|
||||
@ -158,6 +158,28 @@ describe('BrokerDashboard', () => {
|
||||
expect(screen.getByText('Сбросить')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render a text chevron glyph inside the period toggle button', () => {
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
const toggleButtons = screen.getAllByRole('button', { name: /^Период/ })
|
||||
expect(toggleButtons).toHaveLength(2)
|
||||
for (const button of toggleButtons) {
|
||||
const text = button.textContent ?? ''
|
||||
expect(text).not.toMatch(/[▼▲vV]/)
|
||||
expect(button.querySelector('svg')).not.toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('renders hero "Всего доходов" with the ₽ symbol and no "RUB" code', () => {
|
||||
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
expect(screen.getByText('Всего доходов')).toBeInTheDocument()
|
||||
const hero = screen.getByLabelText('Ключевые показатели брокерского счёта')
|
||||
const heroText = hero.textContent ?? ''
|
||||
expect(heroText).toContain('₽')
|
||||
expect(heroText).not.toContain('RUB')
|
||||
})
|
||||
|
||||
it('shows skeleton table while events are loading', () => {
|
||||
hookMocks.useBrokerEvents.mockReturnValue({
|
||||
data: undefined,
|
||||
|
||||
@ -123,7 +123,6 @@ export function BrokerDashboard({
|
||||
to: draftEventFilters.to,
|
||||
preset: draftEventFilters.preset,
|
||||
}))
|
||||
setEventFilterPanelOpen(false)
|
||||
setEventPage(1)
|
||||
}, [draftEventFilters.from, draftEventFilters.to, draftEventFilters.preset])
|
||||
|
||||
@ -131,7 +130,6 @@ export function BrokerDashboard({
|
||||
const defaults = defaultEventsFilters()
|
||||
setAppliedEventFilters(defaults)
|
||||
setDraftEventFilters(defaults)
|
||||
setEventFilterPanelOpen(false)
|
||||
setEventPage(1)
|
||||
}, [])
|
||||
|
||||
@ -142,7 +140,6 @@ export function BrokerDashboard({
|
||||
to: draftIncomeFilters.to,
|
||||
preset: draftIncomeFilters.preset,
|
||||
}))
|
||||
setIncomeFilterPanelOpen(false)
|
||||
incomePagination.reset()
|
||||
}, [draftIncomeFilters.from, draftIncomeFilters.to, draftIncomeFilters.preset, incomePagination])
|
||||
|
||||
@ -150,7 +147,6 @@ export function BrokerDashboard({
|
||||
const defaults = defaultIncomeFilters()
|
||||
setAppliedIncomeFilters(defaults)
|
||||
setDraftIncomeFilters(defaults)
|
||||
setIncomeFilterPanelOpen(false)
|
||||
incomePagination.reset()
|
||||
}, [incomePagination])
|
||||
|
||||
|
||||
@ -40,7 +40,9 @@ export function BrokerDashboardCard({
|
||||
mb: 1.5,
|
||||
}}
|
||||
>
|
||||
<Heading level={2}>{title}</Heading>
|
||||
<Heading level={3} size="section">
|
||||
{title}
|
||||
</Heading>
|
||||
{action}
|
||||
</Box>
|
||||
{filters && <Box sx={{ mb: 1.5 }}>{filters}</Box>}
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { Chip, Text } from '@moex-vibe/design-system'
|
||||
import CalendarTodayRounded from '@mui/icons-material/CalendarTodayRounded'
|
||||
import ExpandMoreRounded from '@mui/icons-material/ExpandMoreRounded'
|
||||
import { Box, Popover } from '@mui/material'
|
||||
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
|
||||
import { DateCalendar } from '@mui/x-date-pickers/DateCalendar'
|
||||
@ -88,6 +90,7 @@ export function BrokerDashboardDateFilter({
|
||||
|
||||
const selectingLabel =
|
||||
selectingStage === 'from' ? 'Выберите начало периода' : 'Выберите конец периода'
|
||||
const periodAriaLabel = appliedLabel ? `Период: ${appliedLabel}` : 'Период'
|
||||
|
||||
return (
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||
@ -96,30 +99,38 @@ export function BrokerDashboardDateFilter({
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
aria-label={periodAriaLabel}
|
||||
aria-expanded={isOpen}
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
bgcolor: 'grey.800',
|
||||
color: 'common.white',
|
||||
border: 'none',
|
||||
borderRadius: 1.5,
|
||||
px: 1.5,
|
||||
py: 0.75,
|
||||
fontSize: 13,
|
||||
bgcolor: 'background.paper',
|
||||
color: 'text.primary',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
px: 1.25,
|
||||
py: 0.5,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: 'grey.700' },
|
||||
'&:hover': { borderColor: 'primary.main' },
|
||||
}}
|
||||
>
|
||||
📅
|
||||
<Box
|
||||
component="span"
|
||||
aria-hidden="true"
|
||||
sx={{ display: 'inline-flex', color: 'text.secondary' }}
|
||||
>
|
||||
<CalendarTodayRounded sx={{ fontSize: 14 }} />
|
||||
</Box>
|
||||
{appliedLabel && (
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'grey.600',
|
||||
borderRadius: 10,
|
||||
px: 0.75,
|
||||
py: 0.125,
|
||||
fontSize: 11,
|
||||
color: 'text.secondary',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.4,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
@ -127,7 +138,18 @@ export function BrokerDashboardDateFilter({
|
||||
{appliedLabel}
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ fontSize: 10, ml: 0.25, color: 'grey.400' }}>{isOpen ? '▲' : '▼'}</Box>
|
||||
<Box
|
||||
component="span"
|
||||
aria-hidden="true"
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
color: 'text.secondary',
|
||||
transform: isOpen ? 'rotate(180deg)' : 'none',
|
||||
transition: 'transform 0.15s ease-in-out',
|
||||
}}
|
||||
>
|
||||
<ExpandMoreRounded sx={{ fontSize: 16 }} />
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
component="button"
|
||||
@ -141,10 +163,11 @@ export function BrokerDashboardDateFilter({
|
||||
bgcolor: hasDraftTypes ? 'primary.main' : 'grey.400',
|
||||
color: 'common.white',
|
||||
border: 'none',
|
||||
borderRadius: 1.5,
|
||||
borderRadius: 1,
|
||||
px: 1.5,
|
||||
py: 0.75,
|
||||
fontSize: 13,
|
||||
py: 0.5,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: hasDraftTypes ? 'pointer' : 'default',
|
||||
'&:hover': hasDraftTypes ? { bgcolor: 'primary.dark' } : {},
|
||||
}}
|
||||
|
||||
@ -81,8 +81,20 @@ export function BrokerDashboardEventsCard({
|
||||
title="События"
|
||||
action={<Link to={`/broker/${encodeURIComponent(accountId)}/events`}>Все события</Link>}
|
||||
filters={
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', md: 'minmax(0, 1fr) auto' },
|
||||
gap: 1.25,
|
||||
alignItems: 'center',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'grey.50',
|
||||
borderRadius: 2,
|
||||
p: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, minWidth: 0 }}>
|
||||
{EVENT_FILTERS.map((filter) => (
|
||||
<Chip
|
||||
key={filter.type}
|
||||
|
||||
@ -2,6 +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'
|
||||
|
||||
type BrokerDashboardHeroProps = {
|
||||
portfolio: BrokerPortfolio
|
||||
@ -12,12 +13,27 @@ 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 totalReceived = analytics
|
||||
? `${analytics.totalReceived.toLocaleString('ru-RU', { maximumFractionDigits: 2 })} ${analytics.currency}`
|
||||
: '—'
|
||||
const returnPercent = analytics?.totalReturnPercent ?? portfolio.yields.expectedPercent
|
||||
const returnTone = moneyTone(typeof returnPercent === 'number' ? returnPercent : null)
|
||||
const dailyTone = moneyTone(portfolio.yields.daily?.value ?? null)
|
||||
const totalReceivedTone = moneyTone(analytics?.totalReceived ?? null)
|
||||
const totalReceivedDisplay = analytics
|
||||
? formatDashboardCurrency({ currency: analytics.currency, value: analytics.totalReceived })
|
||||
: '—'
|
||||
|
||||
return (
|
||||
<Box
|
||||
@ -49,12 +65,27 @@ export function BrokerDashboardHero({ portfolio, analytics }: BrokerDashboardHer
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Metric
|
||||
label="Доходность"
|
||||
value={percentValue(returnPercent)}
|
||||
supportingText={`За день: ${formatBrokerMoney(portfolio.yields.daily)}`}
|
||||
value={
|
||||
<Box component="span" sx={{ color: toneToColor(returnTone), fontWeight: 700 }}>
|
||||
{percentValue(returnPercent)}
|
||||
</Box>
|
||||
}
|
||||
supportingText={
|
||||
<Box component="span" sx={{ color: toneToColor(dailyTone) }}>
|
||||
За день: {formatBrokerMoney(portfolio.yields.daily)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Metric label="Всего доходов" value={totalReceived} />
|
||||
<Metric
|
||||
label="Всего доходов"
|
||||
value={
|
||||
<Box component="span" sx={{ color: toneToColor(totalReceivedTone), fontWeight: 700 }}>
|
||||
{totalReceivedDisplay}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@ -73,8 +73,20 @@ export function BrokerDashboardIncomeCard({
|
||||
title="Доходы"
|
||||
action={<Link to={`/broker/${encodeURIComponent(accountId)}/operations`}>Все операции</Link>}
|
||||
filters={
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', md: 'minmax(0, 1fr) auto' },
|
||||
gap: 1.25,
|
||||
alignItems: 'center',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'grey.50',
|
||||
borderRadius: 2,
|
||||
p: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, minWidth: 0 }}>
|
||||
{INCOME_FILTERS.map((filter) => (
|
||||
<Chip
|
||||
key={filter.type}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user