codex/broker-dashboard-redesign #49

Merged
ksv741 merged 19 commits from codex/broker-dashboard-redesign into main 2026-06-27 16:55:04 +03:00
7 changed files with 133 additions and 35 deletions
Showing only changes of commit 0a1ae0552c - Show all commits

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() const user = userEvent.setup()
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />) renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const applyButtons = screen.getAllByRole('button', { name: /Применить период/ }) const applyButtons = screen.getAllByRole('button', { name: /Применить период/ })
expect(applyButtons).toHaveLength(2) expect(applyButtons).toHaveLength(2)
const toggleButtons = screen.getAllByRole('button', { name: /📅/ }) const toggleButtons = screen.getAllByRole('button', { name: /^Период/ })
expect(toggleButtons).toHaveLength(2) expect(toggleButtons).toHaveLength(2)
await user.click(toggleButtons[0]) await user.click(toggleButtons[0])
@ -158,6 +158,28 @@ describe('BrokerDashboard', () => {
expect(screen.getByText('Сбросить')).toBeInTheDocument() 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', () => { it('shows skeleton table while events are loading', () => {
hookMocks.useBrokerEvents.mockReturnValue({ hookMocks.useBrokerEvents.mockReturnValue({
data: undefined, data: undefined,

View File

@ -123,7 +123,6 @@ export function BrokerDashboard({
to: draftEventFilters.to, to: draftEventFilters.to,
preset: draftEventFilters.preset, preset: draftEventFilters.preset,
})) }))
setEventFilterPanelOpen(false)
setEventPage(1) setEventPage(1)
}, [draftEventFilters.from, draftEventFilters.to, draftEventFilters.preset]) }, [draftEventFilters.from, draftEventFilters.to, draftEventFilters.preset])
@ -131,7 +130,6 @@ export function BrokerDashboard({
const defaults = defaultEventsFilters() const defaults = defaultEventsFilters()
setAppliedEventFilters(defaults) setAppliedEventFilters(defaults)
setDraftEventFilters(defaults) setDraftEventFilters(defaults)
setEventFilterPanelOpen(false)
setEventPage(1) setEventPage(1)
}, []) }, [])
@ -142,7 +140,6 @@ export function BrokerDashboard({
to: draftIncomeFilters.to, to: draftIncomeFilters.to,
preset: draftIncomeFilters.preset, preset: draftIncomeFilters.preset,
})) }))
setIncomeFilterPanelOpen(false)
incomePagination.reset() incomePagination.reset()
}, [draftIncomeFilters.from, draftIncomeFilters.to, draftIncomeFilters.preset, incomePagination]) }, [draftIncomeFilters.from, draftIncomeFilters.to, draftIncomeFilters.preset, incomePagination])
@ -150,7 +147,6 @@ export function BrokerDashboard({
const defaults = defaultIncomeFilters() const defaults = defaultIncomeFilters()
setAppliedIncomeFilters(defaults) setAppliedIncomeFilters(defaults)
setDraftIncomeFilters(defaults) setDraftIncomeFilters(defaults)
setIncomeFilterPanelOpen(false)
incomePagination.reset() incomePagination.reset()
}, [incomePagination]) }, [incomePagination])

View File

@ -40,7 +40,9 @@ export function BrokerDashboardCard({
mb: 1.5, mb: 1.5,
}} }}
> >
<Heading level={2}>{title}</Heading> <Heading level={3} size="section">
{title}
</Heading>
{action} {action}
</Box> </Box>
{filters && <Box sx={{ mb: 1.5 }}>{filters}</Box>} {filters && <Box sx={{ mb: 1.5 }}>{filters}</Box>}

View File

@ -1,4 +1,6 @@
import { Chip, Text } from '@moex-vibe/design-system' 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 { Box, Popover } from '@mui/material'
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs' import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
import { DateCalendar } from '@mui/x-date-pickers/DateCalendar' import { DateCalendar } from '@mui/x-date-pickers/DateCalendar'
@ -88,6 +90,7 @@ export function BrokerDashboardDateFilter({
const selectingLabel = const selectingLabel =
selectingStage === 'from' ? 'Выберите начало периода' : 'Выберите конец периода' selectingStage === 'from' ? 'Выберите начало периода' : 'Выберите конец периода'
const periodAriaLabel = appliedLabel ? `Период: ${appliedLabel}` : 'Период'
return ( return (
<LocalizationProvider dateAdapter={AdapterDayjs}> <LocalizationProvider dateAdapter={AdapterDayjs}>
@ -96,30 +99,38 @@ export function BrokerDashboardDateFilter({
component="button" component="button"
type="button" type="button"
onClick={handleToggle} onClick={handleToggle}
aria-label={periodAriaLabel}
aria-expanded={isOpen}
sx={{ sx={{
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
gap: 1, gap: 1,
bgcolor: 'grey.800', bgcolor: 'background.paper',
color: 'common.white', color: 'text.primary',
border: 'none', border: '1px solid',
borderRadius: 1.5, borderColor: 'divider',
px: 1.5, borderRadius: 1,
py: 0.75, px: 1.25,
fontSize: 13, py: 0.5,
fontSize: 12,
fontWeight: 600,
cursor: 'pointer', 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 && ( {appliedLabel && (
<Box <Box
sx={{ sx={{
bgcolor: 'grey.600', color: 'text.secondary',
borderRadius: 10, fontSize: 12,
px: 0.75, fontWeight: 500,
py: 0.125,
fontSize: 11,
lineHeight: 1.4, lineHeight: 1.4,
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}} }}
@ -127,7 +138,18 @@ export function BrokerDashboardDateFilter({
{appliedLabel} {appliedLabel}
</Box> </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>
<Box <Box
component="button" component="button"
@ -141,10 +163,11 @@ export function BrokerDashboardDateFilter({
bgcolor: hasDraftTypes ? 'primary.main' : 'grey.400', bgcolor: hasDraftTypes ? 'primary.main' : 'grey.400',
color: 'common.white', color: 'common.white',
border: 'none', border: 'none',
borderRadius: 1.5, borderRadius: 1,
px: 1.5, px: 1.5,
py: 0.75, py: 0.5,
fontSize: 13, fontSize: 12,
fontWeight: 600,
cursor: hasDraftTypes ? 'pointer' : 'default', cursor: hasDraftTypes ? 'pointer' : 'default',
'&:hover': hasDraftTypes ? { bgcolor: 'primary.dark' } : {}, '&:hover': hasDraftTypes ? { bgcolor: 'primary.dark' } : {},
}} }}

View File

@ -81,8 +81,20 @@ export function BrokerDashboardEventsCard({
title="События" title="События"
action={<Link to={`/broker/${encodeURIComponent(accountId)}/events`}>Все события</Link>} action={<Link to={`/broker/${encodeURIComponent(accountId)}/events`}>Все события</Link>}
filters={ filters={
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}> <Box
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}> 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) => ( {EVENT_FILTERS.map((filter) => (
<Chip <Chip
key={filter.type} key={filter.type}

View File

@ -2,6 +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'
type BrokerDashboardHeroProps = { type BrokerDashboardHeroProps = {
portfolio: BrokerPortfolio portfolio: BrokerPortfolio
@ -12,12 +13,27 @@ 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 totalReceived = analytics
? `${analytics.totalReceived.toLocaleString('ru-RU', { maximumFractionDigits: 2 })} ${analytics.currency}`
: '—'
const returnPercent = analytics?.totalReturnPercent ?? portfolio.yields.expectedPercent 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 ( return (
<Box <Box
@ -49,12 +65,27 @@ export function BrokerDashboardHero({ portfolio, analytics }: BrokerDashboardHer
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}> <Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Metric <Metric
label="Доходность" label="Доходность"
value={percentValue(returnPercent)} value={
supportingText={`За день: ${formatBrokerMoney(portfolio.yields.daily)}`} <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>
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}> <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>
</Box> </Box>
) )

View File

@ -73,8 +73,20 @@ export function BrokerDashboardIncomeCard({
title="Доходы" title="Доходы"
action={<Link to={`/broker/${encodeURIComponent(accountId)}/operations`}>Все операции</Link>} action={<Link to={`/broker/${encodeURIComponent(accountId)}/operations`}>Все операции</Link>}
filters={ filters={
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}> <Box
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}> 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) => ( {INCOME_FILTERS.map((filter) => (
<Chip <Chip
key={filter.type} key={filter.type}