feat(frontend): apply hero, card and toolbar HTML parity

This commit is contained in:
Sergey Krylov 2026-06-27 13:07:06 +03:00
parent 1a2937ad58
commit 0a1ae0552c
7 changed files with 133 additions and 35 deletions

View File

@ -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,

View File

@ -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])

View File

@ -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>}

View File

@ -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' } : {},
}}

View File

@ -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}

View File

@ -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>
)

View File

@ -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}