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
17 changed files with 388 additions and 154 deletions
Showing only changes of commit fee179d8e9 - Show all commits

View File

@ -18,65 +18,72 @@ export function AppLayout() {
background: 'var(--color-surface)', background: 'var(--color-surface)',
borderBottom: '1px solid #e0e0e0', borderBottom: '1px solid #e0e0e0',
padding: '12px 24px', padding: '12px 24px',
display: 'flex', display: 'grid',
alignItems: 'center', gridTemplateColumns: 'minmax(0, 1fr) auto',
flexWrap: 'wrap', gap: 12,
gap: 24,
}} }}
> >
<Link <div style={{ display: 'grid', gap: 12 }}>
to="/" <div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap' }}>
style={{ <Link
fontSize: 20, to="/"
fontWeight: 700, style={{
color: 'var(--color-text)', fontSize: 20,
textDecoration: 'none', fontWeight: 700,
}} color: 'var(--color-text)',
> textDecoration: 'none',
MoexVibe }}
</Link> >
<div style={{ flex: '1 1 280px', minWidth: 220, maxWidth: 420 }}> MoexVibe
<SearchBar /> </Link>
<div style={{ flex: '1 1 280px', minWidth: 220, maxWidth: 420 }}>
<SearchBar />
</div>
</div>
<nav
style={{ display: 'flex', gap: 20, flexWrap: 'wrap' }}
aria-label="Основная навигация"
>
<Link
to="/portfolios"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Портфели
</Link>
<Link
to="/broker"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Брокер
</Link>
<Link
to="/screener"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Скринер
</Link>
</nav>
</div> </div>
<Link
to="/portfolios"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Портфели
</Link>
<Link
to="/broker"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Брокер
</Link>
<Link
to="/screener"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Скринер
</Link>
<div <div
style={{ style={{
marginLeft: 'auto',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'flex-end',
gap: 12, gap: 12,
flexWrap: 'wrap', flexWrap: 'wrap',
}} }}

View File

@ -7,8 +7,8 @@ import { useBrokerPortfolio } from '@/entities/broker-account'
import type { BrokerPortfolio } from '@/shared/api' import type { BrokerPortfolio } from '@/shared/api'
const baseLinkStyle: React.CSSProperties = { const baseLinkStyle: React.CSSProperties = {
padding: '10px 12px', padding: '10px 14px',
borderRadius: 8, borderRadius: 999,
color: 'var(--color-text-secondary)', color: 'var(--color-text-secondary)',
textDecoration: 'none', textDecoration: 'none',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
@ -16,6 +16,7 @@ const baseLinkStyle: React.CSSProperties = {
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
fontSize: 14, fontSize: 14,
fontWeight: 600,
} }
const links = [ const links = [
@ -54,8 +55,6 @@ export function BrokerAccountLayout({ children }: { children: ReactNode }) {
gap: 0.5, gap: 0.5,
overflowX: 'auto', overflowX: 'auto',
scrollbarWidth: 'thin', scrollbarWidth: 'thin',
borderBottom: '1px solid',
borderColor: 'divider',
pb: 0.5, pb: 0.5,
}} }}
> >
@ -70,7 +69,7 @@ export function BrokerAccountLayout({ children }: { children: ReactNode }) {
...baseLinkStyle, ...baseLinkStyle,
color: 'var(--color-primary)', color: 'var(--color-primary)',
fontWeight: 700, fontWeight: 700,
background: 'rgba(25, 118, 210, 0.08)', background: 'rgba(25, 118, 210, 0.1)',
}, },
}} }}
> >

View File

@ -18,7 +18,7 @@ export function defaultEventsFilters(): DashboardFilterState<DashboardEventType>
const now = dayjs() const now = dayjs()
return { return {
from: now.subtract(7, 'day').format('YYYY-MM-DD'), from: now.subtract(7, 'day').format('YYYY-MM-DD'),
to: now.format('YYYY-MM-DD'), to: now.add(7, 'day').format('YYYY-MM-DD'),
types: [...DASHBOARD_EVENT_TYPES], types: [...DASHBOARD_EVENT_TYPES],
preset: '7d', preset: '7d',
} }
@ -40,7 +40,9 @@ export function applyDatePreset<T extends string>(
): DashboardFilterState<T> { ): DashboardFilterState<T> {
const now = dayjs() const now = dayjs()
const next = { ...filters, preset } const next = { ...filters, preset }
if (preset === 'all') return { ...next, from: '', to: '' } if (preset === 'all') {
return { ...next, from: '2000-01-01', to: '2099-12-31' }
}
const amount = preset === '1y' ? 1 : Number.parseInt(preset, 10) const amount = preset === '1y' ? 1 : Number.parseInt(preset, 10)
const unit = preset === '1y' ? 'year' : 'day' const unit = preset === '1y' ? 'year' : 'day'
return { return {
@ -50,6 +52,25 @@ export function applyDatePreset<T extends string>(
} }
} }
export function applyEventDatePreset(
filters: DashboardFilterState<DashboardEventType>,
preset: DashboardDatePreset,
): DashboardFilterState<DashboardEventType> {
const now = dayjs()
const next = { ...filters, preset }
if (preset === 'all') {
return { ...next, from: '2000-01-01', to: '2099-12-31' }
}
const amount = preset === '1y' ? 1 : Number.parseInt(preset, 10)
const unit = preset === '1y' ? 'year' : 'day'
return {
...next,
from: now.subtract(amount, unit).format('YYYY-MM-DD'),
to: now.add(amount, unit).format('YYYY-MM-DD'),
}
}
export function validateDashboardFilters<T extends string>( export function validateDashboardFilters<T extends string>(
filters: DashboardFilterState<T>, filters: DashboardFilterState<T>,
): string { ): string {

View File

@ -96,10 +96,10 @@ describe('moneyToneToColor', () => {
}) })
describe('incomeTypeTone', () => { describe('incomeTypeTone', () => {
it('maps each income label to a stable tone', () => { it('maps each income label to a stable tone matching HTML parity (coupon=info)', () => {
expect(incomeTypeTone('Дивиденд')).toBe('success') expect(incomeTypeTone('Дивиденд')).toBe('success')
expect(incomeTypeTone('Дивиденд (внешний)')).toBe('info') expect(incomeTypeTone('Дивиденд (внешний)')).toBe('success')
expect(incomeTypeTone('Купон')).toBe('warning') expect(incomeTypeTone('Купон')).toBe('info')
}) })
}) })

View File

@ -65,11 +65,10 @@ export function eventTypeTone(type: BrokerEventItem['type']): TypeTone {
export function incomeTypeTone(label: DashboardIncomeTypeLabel): TypeTone { export function incomeTypeTone(label: DashboardIncomeTypeLabel): TypeTone {
switch (label) { switch (label) {
case 'Дивиденд': case 'Дивиденд':
return 'success'
case 'Дивиденд (внешний)': case 'Дивиденд (внешний)':
return 'info' return 'success'
case 'Купон': case 'Купон':
return 'warning' return 'info'
} }
} }

View File

@ -210,11 +210,31 @@ describe('BrokerDashboard', () => {
}) })
}) })
it('shows date filter toggle button with apply action and accessible name', async () => { it('requests future events in the default dashboard range', () => {
vi.useFakeTimers()
try {
vi.setSystemTime(new Date('2026-06-27T12:00:00.000Z'))
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
expect(hookMocks.useBrokerEvents).toHaveBeenCalledWith(
'acc-1',
expect.objectContaining({
from: '2026-06-20',
to: '2026-07-04',
}),
{ enabled: true },
)
} finally {
vi.useRealTimers()
}
})
it('shows date filter toggle button with refresh 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: /^Период/ })
@ -228,6 +248,7 @@ describe('BrokerDashboard', () => {
expect(screen.getByText('1г')).toBeInTheDocument() expect(screen.getByText('1г')).toBeInTheDocument()
expect(screen.getByText('Всё')).toBeInTheDocument() expect(screen.getByText('Всё')).toBeInTheDocument()
expect(screen.getByText('Сбросить')).toBeInTheDocument() expect(screen.getByText('Сбросить')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Применить период' })).toBeInTheDocument()
}) })
it('does not render a text chevron glyph inside the period toggle button', () => { it('does not render a text chevron glyph inside the period toggle button', () => {
@ -386,6 +407,46 @@ describe('BrokerDashboard', () => {
expect(screen.getByText('Ожидается')).toBeInTheDocument() expect(screen.getByText('Ожидается')).toBeInTheDocument()
}) })
it('renders HTML-parity card headings, toolbar label and footer summary for events', () => {
mockEventsLoaded([
buildEvent({ id: 'evt-1' }),
buildEvent({ id: 'evt-2', ticker: 'HEAD', name: 'HeadHunter Group', type: 'dividend' }),
])
hookMocks.useBrokerEvents.mockReturnValue({
data: {
items: [
buildEvent({ id: 'evt-1' }),
buildEvent({ id: 'evt-2', ticker: 'HEAD', name: 'HeadHunter Group', type: 'dividend' }),
],
summary: { eventCount: 48 },
asOf: '2026-06-27T00:00:00.000Z',
},
isLoading: false,
isError: false,
})
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const eventsSection = screen.getByLabelText('События')
expect(screen.getByText('2 из 48')).toBeInTheDocument()
expect(within(eventsSection).getAllByText('Тип').length).toBeGreaterThan(0)
expect(
within(eventsSection).getByText('Показано 2 событий за выбранный период'),
).toBeInTheDocument()
})
it('renders HTML-parity count badge and footer summary for income', () => {
mockOperationsLoaded([
buildOperation({ id: 'op-1' }),
buildOperation({ id: 'op-2', ticker: 'IRAO', name: 'Интер РАО' }),
])
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
expect(screen.getByText('2 операции')).toBeInTheDocument()
expect(screen.getByText(/Показано 2 · Итого:/)).toBeInTheDocument()
})
it('uses negative tone when event actual amount is negative', () => { it('uses negative tone when event actual amount is negative', () => {
mockEventsLoaded([ mockEventsLoaded([
buildEvent({ buildEvent({

View File

@ -8,6 +8,7 @@ import type { BrokerPortfolio } from '@/shared/api'
import { useCursorPagination } from '@/shared/lib/useCursorPagination' import { useCursorPagination } from '@/shared/lib/useCursorPagination'
import { import {
applyDatePreset, applyDatePreset,
applyEventDatePreset,
type DashboardDatePreset, type DashboardDatePreset,
type DashboardEventType, type DashboardEventType,
type DashboardIncomeType, type DashboardIncomeType,
@ -151,7 +152,7 @@ export function BrokerDashboard({
}, [incomePagination]) }, [incomePagination])
function handleDraftEventPresetChange(preset: DashboardDatePreset) { function handleDraftEventPresetChange(preset: DashboardDatePreset) {
setDraftEventFilters((filters) => applyDatePreset(filters, preset)) setDraftEventFilters((filters) => applyEventDatePreset(filters, preset))
} }
function handleDraftEventFromChange(value: string) { function handleDraftEventFromChange(value: string) {
@ -194,6 +195,7 @@ export function BrokerDashboard({
onApplyFilters={applyEventFilters} onApplyFilters={applyEventFilters}
onResetFilters={resetEventFilters} onResetFilters={resetEventFilters}
hasDraftTypes={hasDraftEventTypes} hasDraftTypes={hasDraftEventTypes}
totalCount={events.data?.summary?.eventCount}
page={eventPage} page={eventPage}
canGoBack={eventPage > 1} canGoBack={eventPage > 1}
canGoForward={eventPage * eventPageSize < eventItems.length} canGoForward={eventPage * eventPageSize < eventItems.length}
@ -217,6 +219,7 @@ export function BrokerDashboard({
onApplyFilters={applyIncomeFilters} onApplyFilters={applyIncomeFilters}
onResetFilters={resetIncomeFilters} onResetFilters={resetIncomeFilters}
hasDraftTypes={hasDraftIncomeTypes} hasDraftTypes={hasDraftIncomeTypes}
visibleCount={operations.data?.items?.length ?? 0}
pageNumber={incomePagination.pageNumber} pageNumber={incomePagination.pageNumber}
canGoBack={incomePagination.pageNumber > 1} canGoBack={incomePagination.pageNumber > 1}
canGoForward={operations.data?.hasNext ?? false} canGoForward={operations.data?.hasNext ?? false}

View File

@ -31,6 +31,14 @@ export function BrokerDashboardAllocationCard({ portfolio }: { portfolio: Broker
<Text tone="muted">Нет данных для распределения</Text> <Text tone="muted">Нет данных для распределения</Text>
) : ( ) : (
<Box sx={{ display: 'grid', gap: 1.5 }}> <Box sx={{ display: 'grid', gap: 1.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
<Text variant="caption" tone="secondary">
Структура портфеля
</Text>
<Text variant="body" sx={{ fontWeight: 700 }}>
{formatBrokerMoney(portfolio.totals.portfolio)}
</Text>
</Box>
{sectors.map((sector) => ( {sectors.map((sector) => (
<Box key={sector.key}> <Box key={sector.key}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>

View File

@ -72,20 +72,35 @@ export function BrokerDashboardAnalyticsCard({
const value = data[field] const value = data[field]
const tone = analyticsTone(field, value) const tone = analyticsTone(field, value)
return ( return (
<Metric <Box
key={field} key={field}
label={label} sx={{
value={ borderRadius: 2,
<Box border: '1px solid',
component="span" borderColor: tone === 'negative' ? 'error.light' : 'divider',
data-testid={testId} bgcolor:
data-tone={tone} tone === 'positive'
sx={{ color: moneyToneToColor(tone), fontWeight: 700 }} ? 'rgba(46, 125, 50, 0.06)'
> : tone === 'negative'
{analyticsDisplay(field, value, data.currency)} ? 'rgba(211, 47, 47, 0.06)'
</Box> : 'grey.50',
} p: 1.5,
/> }}
>
<Metric
label={label}
value={
<Box
component="span"
data-testid={testId}
data-tone={tone}
sx={{ color: moneyToneToColor(tone), fontWeight: 700 }}
>
{analyticsDisplay(field, value, data.currency)}
</Box>
}
/>
</Box>
) )
})} })}
</Box> </Box>

View File

@ -4,6 +4,7 @@ import type { ReactNode } from 'react'
type BrokerDashboardCardProps = { type BrokerDashboardCardProps = {
title: string title: string
badge?: ReactNode
action?: ReactNode action?: ReactNode
filters?: ReactNode filters?: ReactNode
children: ReactNode children: ReactNode
@ -13,6 +14,7 @@ type BrokerDashboardCardProps = {
export function BrokerDashboardCard({ export function BrokerDashboardCard({
title, title,
badge,
action, action,
filters, filters,
children, children,
@ -38,15 +40,36 @@ export function BrokerDashboardCard({
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'flex-start',
justifyContent: 'space-between', justifyContent: 'space-between',
gap: 2, gap: 2,
mb: 1.5, mb: 1.5,
}} }}
> >
<Heading level={2} size="section"> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
{title} <Heading level={2} size="section">
</Heading> {title}
</Heading>
{badge ? (
<Box
component="span"
sx={{
display: 'inline-flex',
alignItems: 'center',
minHeight: 24,
px: 1,
borderRadius: 999,
bgcolor: 'grey.100',
color: 'text.secondary',
fontSize: 12,
fontWeight: 700,
lineHeight: 1,
}}
>
{badge}
</Box>
) : null}
</Box>
{action} {action}
</Box> </Box>
{filters && <Box sx={{ mb: 1.5 }}>{filters}</Box>} {filters && <Box sx={{ mb: 1.5 }}>{filters}</Box>}

View File

@ -88,9 +88,16 @@ export function BrokerDashboardDateFilter({
setSelectingStage('from') setSelectingStage('from')
} }
function handleApply() {
onApply()
handleClose()
}
const selectingLabel = const selectingLabel =
selectingStage === 'from' ? 'Выберите начало периода' : 'Выберите конец периода' selectingStage === 'from' ? 'Выберите начало периода' : 'Выберите конец периода'
const periodAriaLabel = appliedLabel ? `Период: ${appliedLabel}` : 'Период' const periodAriaLabel = appliedLabel ? `Период: ${appliedLabel}` : 'Период'
const rangeInverted = Boolean(draftFrom && draftTo) && dayjs(draftTo).isBefore(dayjs(draftFrom))
const canApply = hasDraftTypes && !rangeInverted
return ( return (
<LocalizationProvider dateAdapter={AdapterDayjs}> <LocalizationProvider dateAdapter={AdapterDayjs}>
@ -151,29 +158,6 @@ export function BrokerDashboardDateFilter({
<ExpandMoreRounded sx={{ fontSize: 16 }} /> <ExpandMoreRounded sx={{ fontSize: 16 }} />
</Box> </Box>
</Box> </Box>
<Box
component="button"
type="button"
onClick={onApply}
disabled={!hasDraftTypes}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 1,
bgcolor: hasDraftTypes ? 'primary.main' : 'grey.400',
color: 'common.white',
border: 'none',
borderRadius: 1,
px: 1.5,
py: 0.5,
fontSize: 12,
fontWeight: 600,
cursor: hasDraftTypes ? 'pointer' : 'default',
'&:hover': hasDraftTypes ? { bgcolor: 'primary.dark' } : {},
}}
>
Применить период
</Box>
<Popover <Popover
open={isOpen} open={isOpen}
anchorEl={anchorEl} anchorEl={anchorEl}
@ -212,7 +196,9 @@ export function BrokerDashboardDateFilter({
}, },
}} }}
/> />
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1 }}> <Box
sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 1 }}
>
<Box <Box
component="button" component="button"
type="button" type="button"
@ -230,9 +216,54 @@ export function BrokerDashboardDateFilter({
> >
Сбросить Сбросить
</Box> </Box>
<Box
component="button"
type="button"
onClick={handleApply}
disabled={!canApply}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 1,
bgcolor: canApply ? 'primary.main' : 'grey.400',
color: 'common.white',
border: 'none',
borderRadius: 1,
px: 1.5,
py: 0.75,
fontSize: 12,
fontWeight: 700,
cursor: canApply ? 'pointer' : 'default',
}}
>
Применить период
</Box>
</Box> </Box>
</Box> </Box>
</Popover> </Popover>
<Box
component="button"
type="button"
onClick={onApply}
disabled={!canApply}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 1,
bgcolor: canApply ? 'primary.main' : 'grey.400',
color: 'common.white',
border: 'none',
borderRadius: 1,
px: 1.5,
py: 0.5,
fontSize: 12,
fontWeight: 700,
cursor: canApply ? 'pointer' : 'default',
'&:hover': canApply ? { bgcolor: 'primary.dark' } : {},
}}
>
Обновить
</Box>
</Box> </Box>
</LocalizationProvider> </LocalizationProvider>
) )

View File

@ -79,6 +79,7 @@ type BrokerDashboardEventsCardProps = {
onApplyFilters: () => void onApplyFilters: () => void
onResetFilters: () => void onResetFilters: () => void
hasDraftTypes: boolean hasDraftTypes: boolean
totalCount?: number
page: number page: number
onPreviousPage: () => void onPreviousPage: () => void
onNextPage: () => void onNextPage: () => void
@ -124,6 +125,7 @@ export function BrokerDashboardEventsCard({
onApplyFilters, onApplyFilters,
onResetFilters, onResetFilters,
hasDraftTypes, hasDraftTypes,
totalCount,
page, page,
onPreviousPage, onPreviousPage,
onNextPage, onNextPage,
@ -135,6 +137,7 @@ export function BrokerDashboardEventsCard({
return ( return (
<BrokerDashboardCard <BrokerDashboardCard
title="События" title="События"
badge={events.length > 0 ? `${events.length} из ${totalCount ?? events.length}` : undefined}
action={<Link to={`/broker/${encodeURIComponent(accountId)}/events`}>Все события</Link>} action={<Link to={`/broker/${encodeURIComponent(accountId)}/events`}>Все события</Link>}
filters={ filters={
<BrokerDashboardTableToolbar <BrokerDashboardTableToolbar
@ -260,16 +263,39 @@ export function BrokerDashboardEventsCard({
</Box> </Box>
</Box> </Box>
</Box> </Box>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, alignItems: 'center' }}> <Box
<Button variant="secondary" size="small" onClick={onPreviousPage} disabled={!canGoBack}> sx={{
display: 'flex',
</Button> justifyContent: 'space-between',
<Text variant="body" tone="secondary"> gap: 1,
{page} alignItems: 'center',
flexWrap: 'wrap',
}}
>
<Text variant="caption" tone="secondary">
Показано {events.length} событий за выбранный период
</Text> </Text>
<Button variant="secondary" size="small" onClick={onNextPage} disabled={!canGoForward}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button
</Button> variant="secondary"
size="small"
onClick={onPreviousPage}
disabled={!canGoBack}
>
</Button>
<Text variant="body" tone="secondary">
{page}
</Text>
<Button
variant="secondary"
size="small"
onClick={onNextPage}
disabled={!canGoForward}
>
</Button>
</Box>
</Box> </Box>
</Box> </Box>
)} )}

View File

@ -73,6 +73,7 @@ type BrokerDashboardIncomeCardProps = {
onApplyFilters: () => void onApplyFilters: () => void
onResetFilters: () => void onResetFilters: () => void
hasDraftTypes: boolean hasDraftTypes: boolean
visibleCount: number
pageNumber: number pageNumber: number
canGoBack: boolean canGoBack: boolean
canGoForward: boolean canGoForward: boolean
@ -97,6 +98,7 @@ export function BrokerDashboardIncomeCard({
onApplyFilters, onApplyFilters,
onResetFilters, onResetFilters,
hasDraftTypes, hasDraftTypes,
visibleCount,
pageNumber, pageNumber,
canGoBack, canGoBack,
canGoForward, canGoForward,
@ -109,6 +111,7 @@ export function BrokerDashboardIncomeCard({
return ( return (
<BrokerDashboardCard <BrokerDashboardCard
title="Доходы" title="Доходы"
badge={rows.length > 0 ? `${visibleCount} операции` : undefined}
action={<Link to={`/broker/${encodeURIComponent(accountId)}/operations`}>Все операции</Link>} action={<Link to={`/broker/${encodeURIComponent(accountId)}/operations`}>Все операции</Link>}
filters={ filters={
<BrokerDashboardTableToolbar <BrokerDashboardTableToolbar
@ -223,25 +226,45 @@ export function BrokerDashboardIncomeCard({
</Box> </Box>
</Box> </Box>
</Box> </Box>
<Text variant="caption" tone="secondary"> <Box
Показано: {rows.length} · Итого:{' '} sx={{
{total display: 'flex',
? `${total.value >= 0 ? '+' : ''}${formatDashboardCurrency({ justifyContent: 'space-between',
currency: total.currency, gap: 1,
value: Math.abs(total.value), alignItems: 'center',
})}` flexWrap: 'wrap',
: '—'} }}
</Text> >
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, alignItems: 'center' }}> <Text variant="caption" tone="secondary">
<Button variant="secondary" size="small" onClick={onPreviousPage} disabled={!canGoBack}> Показано {rows.length} · Итого:{' '}
{total
</Button> ? `${total.value >= 0 ? '+' : ''}${formatDashboardCurrency({
<Text variant="body" tone="secondary"> currency: total.currency,
{pageNumber} value: Math.abs(total.value),
})}`
: '—'}
</Text> </Text>
<Button variant="secondary" size="small" onClick={onNextPage} disabled={!canGoForward}> <Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, alignItems: 'center' }}>
<Button
</Button> variant="secondary"
size="small"
onClick={onPreviousPage}
disabled={!canGoBack}
>
</Button>
<Text variant="body" tone="secondary">
{pageNumber}
</Text>
<Button
variant="secondary"
size="small"
onClick={onNextPage}
disabled={!canGoForward}
>
</Button>
</Box>
</Box> </Box>
</Box> </Box>
)} )}

View File

@ -11,7 +11,7 @@ export function BrokerDashboardTableToolbar({ chips, children }: BrokerDashboard
<Box <Box
sx={{ sx={{
display: 'grid', display: 'grid',
gridTemplateColumns: { xs: '1fr', md: 'minmax(0, 1fr) auto' }, gridTemplateColumns: { xs: '1fr', md: 'minmax(0, 1fr) auto auto' },
gap: 1.25, gap: 1.25,
alignItems: 'center', alignItems: 'center',
border: '1px solid', border: '1px solid',
@ -21,7 +21,12 @@ export function BrokerDashboardTableToolbar({ chips, children }: BrokerDashboard
p: 1, p: 1,
}} }}
> >
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, minWidth: 0 }}>{chips}</Box> <Box sx={{ display: 'grid', gap: 0.75, minWidth: 0 }}>
<Box sx={{ color: 'text.secondary', fontSize: 12, fontWeight: 700, lineHeight: 1 }}>
Тип
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, minWidth: 0 }}>{chips}</Box>
</Box>
{children} {children}
</Box> </Box>
) )

View File

@ -65,7 +65,8 @@
### 3. События ### 3. События
- Типы событий переключаются chip-фильтрами с немедленным применением. - Типы событий переключаются chip-фильтрами с немедленным применением.
- Диапазон дат по умолчанию: `сегодня - 7 дней` / `сегодня`. - Диапазон дат по умолчанию: `сегодня - 7 дней` / `сегодня + 7 дней`, чтобы обзор сразу показывал
ближайшие будущие события.
- Диапазон дат редактируется отдельно от применённого состояния: draft state меняется локально, запрос уходит только по действию применения периода. - Диапазон дат редактируется отдельно от применённого состояния: draft state меняется локально, запрос уходит только по действию применения периода.
- В шапке управления периодом не используется слово `Фильтр`; роль управления считывается через иконку календаря, применённый диапазон, раскрытие панели и пресеты. - В шапке управления периодом не используется слово `Фильтр`; роль управления считывается через иконку календаря, применённый диапазон, раскрытие панели и пресеты.
- Native `<input type="date">` не используется; период выбирается через одно визуальное поле и popover с community `DateCalendar` из `@mui/x-date-pickers`. - Native `<input type="date">` не используется; период выбирается через одно визуальное поле и popover с community `DateCalendar` из `@mui/x-date-pickers`.
@ -73,6 +74,7 @@
- При пустом выборе типов запрос отключается, карточка показывает валидационное сообщение. - При пустом выборе типов запрос отключается, карточка показывает валидационное сообщение.
- Пагинация локальная, по 10 событий на страницу, сбрасывается при смене применённых фильтров. - Пагинация локальная, по 10 событий на страницу, сбрасывается при смене применённых фильтров.
- При загрузке карточка показывает skeleton таблицы событий с колонками дата, инструмент, тип, сумма, статус. - При загрузке карточка показывает skeleton таблицы событий с колонками дата, инструмент, тип, сумма, статус.
- В toolbar карточки используются count badge, label `Тип`, кнопка `Обновить` и footer-summary по паттерну HTML-эталона.
### 4. Доходы ### 4. Доходы
@ -118,6 +120,7 @@
используя крупный `Heading size="title"`. используя крупный `Heading size="title"`.
- `BrokerDashboardDateFilter` должен использовать иконку раскрытия вместо текстового символа и сохранять - `BrokerDashboardDateFilter` должен использовать иконку раскрытия вместо текстового символа и сохранять
единый toolbar-паттерн для `События` и `Доходы`. единый toolbar-паттерн для `События` и `Доходы`.
- Для `События` period presets должны поддерживать будущую часть диапазона, а не обрезаться текущим днём.
- Таблицы `События` и `Доходы` должны иметь `thead`, type badges, двухстрочный инструмент при наличии - Таблицы `События` и `Доходы` должны иметь `thead`, type badges, двухстрочный инструмент при наличии
названия и semantic amount colors. названия и semantic amount colors.
- `BrokerDashboardAnalyticsCard` должен окрашивать KPI-карточки по смыслу и показывать RUB через `₽`. - `BrokerDashboardAnalyticsCard` должен окрашивать KPI-карточки по смыслу и показывать RUB через `₽`.
@ -168,7 +171,7 @@
- [ ] Обновить `BrokerDashboardDateFilter`: убрать слово `Фильтр` из пользовательского текста, заменить `Показать` на понятное действие применения периода и визуально собрать chips, диапазон, сброс и применение в аккуратную шапку. - [ ] Обновить `BrokerDashboardDateFilter`: убрать слово `Фильтр` из пользовательского текста, заменить `Показать` на понятное действие применения периода и визуально собрать chips, диапазон, сброс и применение в аккуратную шапку.
- [ ] Заменить native date inputs на одно визуальное поле периода, которое открывает MUI `Popover` с community `DateCalendar`. - [ ] Заменить native date inputs на одно визуальное поле периода, которое открывает MUI `Popover` с community `DateCalendar`.
- [ ] Реализовать локальную логику выбора диапазона: первый клик задаёт начало, второй — конец; если конец раньше начала, диапазон пересобирается от выбранной даты. - [ ] Реализовать локальную логику выбора диапазона: первый клик задаёт начало, второй — конец; если конец раньше начала, диапазон пересобирается от выбранной даты.
- [ ] Настроить default range событий на `сегодня - 7 дней` / `сегодня`. - [ ] Настроить default range событий на `сегодня - 7 дней` / `сегодня + 7 дней`.
- [ ] Подключить для событий draft/applied state: типы применяются сразу, даты только по действию применения периода. - [ ] Подключить для событий draft/applied state: типы применяются сразу, даты только по действию применения периода.
- [ ] Сохранять локальную пагинацию по 10 событий и сбрасывать её при смене применённых фильтров. - [ ] Сохранять локальную пагинацию по 10 событий и сбрасывать её при смене применённых фильтров.
- [ ] Заменить текстовую загрузку событий на skeleton таблицы. - [ ] Заменить текстовую загрузку событий на skeleton таблицы.

View File

@ -105,7 +105,7 @@ Hero показывает:
### 4. Блок `События` ### 4. Блок `События`
- Блок использует существующий источник `useBrokerEvents(accountId, query)`. - Блок использует существующий источник `useBrokerEvents(accountId, query)`.
- По умолчанию применяется период `сегодня - 7 дней` / `сегодня` и типы - По умолчанию применяется период `сегодня - 7 дней` / `сегодня + 7 дней` и типы
`dividend,coupon,maturity,offer`, как в существующей вкладке событий. `dividend,coupon,maturity,offer`, как в существующей вкладке событий.
- Блок содержит кликабельные chip-фильтры типов событий: `Дивиденды`, `Купоны`, `Погашения`, `Оферты`. - Блок содержит кликабельные chip-фильтры типов событий: `Дивиденды`, `Купоны`, `Погашения`, `Оферты`.
- Пользователь может выбрать несколько типов событий. - Пользователь может выбрать несколько типов событий.
@ -114,7 +114,8 @@ Hero показывает:
- Изменение chip-фильтров типов событий применяется сразу и возвращает локальную пагинацию на первую - Изменение chip-фильтров типов событий применяется сразу и возвращает локальную пагинацию на первую
страницу. страницу.
- Блок содержит фильтр периода `from` / `to`. - Блок содержит фильтр периода `from` / `to`.
- По умолчанию применяется недельный период `сегодня - 7 дней` / `сегодня`. - По умолчанию применяется недельный период `сегодня - 7 дней` / `сегодня + 7 дней`, чтобы обзор
сразу показывал ближайшие будущие события.
- Изменение черновых фильтров не запускает запрос до применения периода пользователем. - Изменение черновых фильтров не запускает запрос до применения периода пользователем.
- В пользовательском тексте шапки периода не используется слово `Фильтр`; UI должен считываться как - В пользовательском тексте шапки периода не используется слово `Фильтр`; UI должен считываться как
управление периодом за счёт иконки календаря, применённого диапазона, пресетов и affordance раскрытия. управление периодом за счёт иконки календаря, применённого диапазона, пресетов и affordance раскрытия.
@ -257,7 +258,7 @@ Hero показывает:
- В таблице `События` инструмент отображается двумя строками при наличии названия, тип отображается - В таблице `События` инструмент отображается двумя строками при наличии названия, тип отображается
бейджем, сумма и статус имеют семантические цвета. бейджем, сумма и статус имеют семантические цвета.
- Блок `События` поддерживает multi-select chip-фильтр типов и локальную пагинацию по 10 событий. - Блок `События` поддерживает multi-select chip-фильтр типов и локальную пагинацию по 10 событий.
- Блок `События` по умолчанию запрашивает период `сегодня - 7 дней` / `сегодня` и использует одно поле - Блок `События` по умолчанию запрашивает период `сегодня - 7 дней` / `сегодня + 7 дней` и использует одно поле
периода с popover-календарём на базе community `DateCalendar`. периода с popover-календарём на базе community `DateCalendar`.
- Блок `Доходы` показывает доходные операции дивидендов и купонов и итог по отображаемым строкам. - Блок `Доходы` показывает доходные операции дивидендов и купонов и итог по отображаемым строкам.
- В таблице `Доходы` инструмент отображается двумя строками при наличии названия, тип отображается - В таблице `Доходы` инструмент отображается двумя строками при наличии названия, тип отображается

View File

@ -44,7 +44,8 @@
- [x] Переименовать действие `Показать` в управлении периодом и визуально улучшить шапку фильтров. - [x] Переименовать действие `Показать` в управлении периодом и визуально улучшить шапку фильтров.
- [x] Заменить native date inputs на одно поле периода с popover и community `DateCalendar` из `@mui/x-date-pickers`. - [x] Заменить native date inputs на одно поле периода с popover и community `DateCalendar` из `@mui/x-date-pickers`.
- [x] Не использовать `@mui/x-date-pickers-pro` и `DateRangePicker`. - [x] Не использовать `@mui/x-date-pickers-pro` и `DateRangePicker`.
- [x] Настроить default range событий и доходов на `сегодня - 7 дней` / `сегодня`. - [x] Настроить default range событий на `сегодня - 7 дней` / `сегодня + 7 дней`, доходов — на
`сегодня - 7 дней` / `сегодня`.
- [x] Заменить текстовую загрузку `События` на skeleton таблицы. - [x] Заменить текстовую загрузку `События` на skeleton таблицы.
- [x] Заменить текстовую загрузку `Доходы` на skeleton таблицы. - [x] Заменить текстовую загрузку `Доходы` на skeleton таблицы.
- [x] Обновить component tests под новые тексты, единое поле периода, popover-календарь и skeleton loading states. - [x] Обновить component tests под новые тексты, единое поле периода, popover-календарь и skeleton loading states.
@ -56,18 +57,27 @@
- [x] Согласовать статический визуальный эталон `docs/research/2026-06-27-broker-account-redesign.html`. - [x] Согласовать статический визуальный эталон `docs/research/2026-06-27-broker-account-redesign.html`.
- [x] Обновить `spec.md` под HTML parity: компактные заголовки, единый toolbar, бейджи, цвета, подписи инструментов, `₽`. - [x] Обновить `spec.md` под HTML parity: компактные заголовки, единый toolbar, бейджи, цвета, подписи инструментов, `₽`.
- [x] Обновить `plan.md` под перенос HTML parity в React-компоненты. - [x] Обновить `plan.md` под перенос HTML parity в React-компоненты.
- [ ] Добавить `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts` с helpers для money tone, type tone, currency symbol и instrument display. - [x] Добавить `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts` с helpers для money tone, type tone, currency symbol и instrument display.
- [ ] Добавить `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts`. - [x] Добавить `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts`.
- [ ] Расширить income helpers так, чтобы строки доходов могли отдавать main/subtitle инструмента без дублирования текста. - [ ] Расширить income helpers так, чтобы строки доходов могли отдавать main/subtitle инструмента без дублирования текста.
- [ ] Обновить `BrokerDashboardHero`: semantic colors для доходности, дневного изменения и всего полученных доходов. - [x] Обновить `BrokerDashboardHero`: semantic colors для доходности, дневного изменения и всего полученных доходов.
- [ ] Обновить `BrokerDashboardCard`: компактный card heading вместо крупного page-level heading. - [x] Обновить `BrokerDashboardCard`: компактный card heading вместо крупного page-level heading.
- [ ] Обновить `BrokerDashboardDateFilter`: единый toolbar-паттерн и chevron-иконка вместо текстового `v`. - [x] Обновить `BrokerDashboardDateFilter`: единый toolbar-паттерн и chevron-иконка вместо текстового `v`.
- [ ] Обновить `BrokerDashboardEventsCard`: `thead`, двухстрочный инструмент, type badges, semantic amount colors, status badges. - [x] Обновить `BrokerDashboardEventsCard`: `thead`, двухстрочный инструмент, type badges, semantic amount colors, status badges.
- [ ] Обновить `BrokerDashboardIncomeCard`: `thead`, двухстрочный инструмент, type badges, semantic amount colors. - [x] Обновить `BrokerDashboardIncomeCard`: `thead`, двухстрочный инструмент, type badges, semantic amount colors.
- [ ] Обновить `BrokerDashboardTableSkeleton`: общий skeleton-паттерн для событий и доходов с корректной геометрией колонок. Бейдж `Купон` приведён к `info` (синий) согласно HTML-эталону `.type-badge.coupon` (`incomeTypeTone` в `dashboardVisual.ts`),
`Дивиденд (внешний)` отнесён к `success` (семейство дивидендов).
- [x] Обновить `BrokerDashboardTableSkeleton`: общий skeleton-паттерн для событий и доходов с корректной геометрией колонок.
- [x] Обновить `BrokerDashboardAnalyticsCard`: `₽` для RUB и positive/negative tone карточек. - [x] Обновить `BrokerDashboardAnalyticsCard`: `₽` для RUB и positive/negative tone карточек.
- [x] Обновить component tests dashboard под HTML parity (analytics tones). - [x] Обновить component tests dashboard под HTML parity (analytics tones).
- [ ] Проверить, что блок `Доходы` не расширяет backend/API и остаётся в рамках текущих income-типов. - [x] Проверить, что блок `Доходы` не расширяет backend/API и остаётся в рамках текущих income-типов.
Исправлен пресет «Всё»: `applyDatePreset('all')` теперь отдаёт широкий диапазон `2000-01-01``2099-12-31`
вместо пустых `from`/`to`, которые backend (`BrokerEventsQueryDto @Matches`) отвергал с 400. В DateFilter
кнопка «Применить период» блокируется при инвертированном диапазоне (`to < from`), чтобы избежать
молчаливо пустого ответа (например, будущий `from` при дефолтном `to=сегодня`).
- [x] Исправить default-range `События`, чтобы dashboard по умолчанию и через пресеты мог загружать будущие события.
- [x] Подтянуть card headers, toolbar label `Тип`, count badges и footer summaries ближе к
`docs/research/2026-06-27-broker-account-redesign.html`.
- [ ] Проверить desktop layout `/broker/2084014113` против `docs/research/2026-06-27-broker-account-redesign.html`. - [ ] Проверить desktop layout `/broker/2084014113` против `docs/research/2026-06-27-broker-account-redesign.html`.
- [ ] Проверить mobile layout `/broker/2084014113` на viewport `390x844` против `docs/research/2026-06-27-broker-account-redesign.html`. - [ ] Проверить mobile layout `/broker/2084014113` на viewport `390x844` против `docs/research/2026-06-27-broker-account-redesign.html`.
@ -127,4 +137,3 @@ HTML-эталона (`docs/research/2026-06-27-broker-account-redesign.html:1145
- Реальный viewport на `390x844` для подтверждения отсутствия общего горизонтального overflow. - Реальный viewport на `390x844` для подтверждения отсутствия общего горизонтального overflow.
Геометрия таблиц уже переключена на внутренний `overflowX: 'auto'` (`BrokerDashboardEventsCard.tsx:176`, Геометрия таблиц уже переключена на внутренний `overflowX: 'auto'` (`BrokerDashboardEventsCard.tsx:176`,
`BrokerDashboardIncomeCard.tsx`), но фактическая вёрстка в браузере не сверялась с эталоном. `BrokerDashboardIncomeCard.tsx`), но фактическая вёрстка в браузере не сверялась с эталоном.