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()
|
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,
|
||||||
|
|||||||
@ -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])
|
||||||
|
|
||||||
|
|||||||
@ -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>}
|
||||||
|
|||||||
@ -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' } : {},
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user