feat: broker dashboard redesign with top tabs, single-column layout, and clickable chip filters

- Move navigation from left sidebar to horizontal top tabs in BrokerAccountLayout
- Change dashboard blocks to single-column layout (Events, Income, Analytics, Allocation)
- Extend DS Chip with onClick/selected/disabled/aria-pressed
- Add clickable chip filters to Events and Income cards with immediate filtering
- Disable backend query when all types deselected (show validation message)
- Add useBrokerEvents/useBrokerOperations options.enabled param
- Update dashboard skeleton to single-column shape
- Add userEvent tests for chip filter interactions
This commit is contained in:
Sergey Krylov 2026-06-26 10:47:08 +03:00
parent c429d6a43a
commit 8df94a51dd
12 changed files with 291 additions and 142 deletions

View File

@ -2,11 +2,15 @@ import { useQuery } from '@tanstack/react-query'
import type { BrokerEventsData } from '@/shared/api' import type { BrokerEventsData } from '@/shared/api'
import { type BrokerEventsQuery, getBrokerEvents } from '../api/brokerEventApi' import { type BrokerEventsQuery, getBrokerEvents } from '../api/brokerEventApi'
export function useBrokerEvents(accountId: string | undefined, query: BrokerEventsQuery) { export function useBrokerEvents(
accountId: string | undefined,
query: BrokerEventsQuery,
options: { enabled?: boolean } = {},
) {
const { from, to, types } = query const { from, to, types } = query
return useQuery<BrokerEventsData>({ return useQuery<BrokerEventsData>({
queryKey: ['broker', 'events', accountId, from, to, types], queryKey: ['broker', 'events', accountId, from, to, types],
enabled: Boolean(accountId), enabled: Boolean(accountId) && (options.enabled ?? true),
queryFn: async () => (await getBrokerEvents(accountId!, { from, to, types })).data, queryFn: async () => (await getBrokerEvents(accountId!, { from, to, types })).data,
staleTime: 300_000, staleTime: 300_000,
retry: 2, retry: 2,

View File

@ -5,10 +5,11 @@ import { type BrokerOperationQuery, getBrokerOperations } from '../api/brokerOpe
export function useBrokerOperations( export function useBrokerOperations(
accountId: string | undefined, accountId: string | undefined,
query: BrokerOperationQuery = {}, query: BrokerOperationQuery = {},
options: { enabled?: boolean } = {},
) { ) {
return useQuery<BrokerOperationsPage>({ return useQuery<BrokerOperationsPage>({
queryKey: ['broker', 'operations', accountId, query], queryKey: ['broker', 'operations', accountId, query],
enabled: Boolean(accountId), enabled: Boolean(accountId) && (options.enabled ?? true),
queryFn: async () => (await getBrokerOperations(accountId!, query)).data, queryFn: async () => (await getBrokerOperations(accountId!, query)).data,
staleTime: 300_000, staleTime: 300_000,
retry: 2, retry: 2,

View File

@ -47,51 +47,39 @@ export function BrokerAccountLayout({ children }: { children: ReactNode }) {
</Box> </Box>
<Box <Box
component="nav"
aria-label="Разделы брокерского счёта"
sx={{ sx={{
display: 'grid', display: 'flex',
gridTemplateColumns: '1fr', gap: 0.5,
gap: 2, overflowX: 'auto',
'@media (min-width: 720px)': { scrollbarWidth: 'thin',
gridTemplateColumns: 'minmax(150px, 190px) minmax(0, 1fr)', borderBottom: '1px solid',
gap: 3, borderColor: 'divider',
}, pb: 0.5,
}} }}
> >
<Box {links.map((link) => (
component="nav" <Link
aria-label="Разделы брокерского счёта" key={link.to}
sx={{ to={`${basePath}${link.to}`}
display: 'flex', style={baseLinkStyle}
flexDirection: 'column', activeOptions={{ exact: link.to === '' }}
gap: 0.5, activeProps={{
'@media (max-width: 719px)': { style: {
flexDirection: 'row', ...baseLinkStyle,
overflowX: 'auto', color: 'var(--color-primary)',
scrollbarWidth: 'thin', fontWeight: 700,
}, background: 'rgba(25, 118, 210, 0.08)',
}} },
> }}
{links.map((link) => ( >
<Link {link.label}
key={link.to} </Link>
to={`${basePath}${link.to}`} ))}
style={baseLinkStyle}
activeOptions={{ exact: link.to === '' }}
activeProps={{
style: {
...baseLinkStyle,
color: 'var(--color-primary)',
fontWeight: 700,
},
}}
>
{link.label}
</Link>
))}
</Box>
<Box sx={{ minWidth: 0 }}>{children}</Box>
</Box> </Box>
<Box sx={{ minWidth: 0 }}>{children}</Box>
</Box> </Box>
</BrokerAccountContext.Provider> </BrokerAccountContext.Provider>
) )

View File

@ -1,8 +1,14 @@
import { render, screen } from '@testing-library/react' import { render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest' import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { BrokerPortfolio } from '@/shared/api' import type { BrokerPortfolio } from '@/shared/api'
import { BrokerDashboard } from './BrokerDashboard' import { BrokerDashboard } from './BrokerDashboard'
const hookMocks = vi.hoisted(() => ({
useBrokerEvents: vi.fn(),
useBrokerOperations: vi.fn(),
}))
vi.mock('@tanstack/react-router', () => ({ vi.mock('@tanstack/react-router', () => ({
Link: ({ children, to }: { children: React.ReactNode; to: string }) => ( Link: ({ children, to }: { children: React.ReactNode; to: string }) => (
<a href={to}>{children}</a> <a href={to}>{children}</a>
@ -27,25 +33,11 @@ vi.mock('@/entities/broker-analytics', () => ({
})) }))
vi.mock('@/entities/broker-event', () => ({ vi.mock('@/entities/broker-event', () => ({
useBrokerEvents: () => ({ useBrokerEvents: hookMocks.useBrokerEvents,
data: { items: [], summary: {}, asOf: '2026-06-26T00:00:00.000Z' },
isLoading: false,
isError: false,
}),
})) }))
vi.mock('@/entities/broker-operation', () => ({ vi.mock('@/entities/broker-operation', () => ({
useBrokerOperations: () => ({ useBrokerOperations: hookMocks.useBrokerOperations,
data: {
accountId: 'acc-1',
items: [],
nextCursor: null,
hasNext: false,
asOf: '2026-06-26T00:00:00.000Z',
},
isLoading: false,
isError: false,
}),
})) }))
vi.mock('@/widgets/broker-allocation-chart', () => ({ vi.mock('@/widgets/broker-allocation-chart', () => ({
@ -80,6 +72,25 @@ const portfolio: BrokerPortfolio = {
} }
describe('BrokerDashboard', () => { describe('BrokerDashboard', () => {
beforeEach(() => {
hookMocks.useBrokerEvents.mockReturnValue({
data: { items: [], summary: {}, asOf: '2026-06-26T00:00:00.000Z' },
isLoading: false,
isError: false,
})
hookMocks.useBrokerOperations.mockReturnValue({
data: {
accountId: 'acc-1',
items: [],
nextCursor: null,
hasNext: false,
asOf: '2026-06-26T00:00:00.000Z',
},
isLoading: false,
isError: false,
})
})
it('renders the dashboard sections', () => { it('renders the dashboard sections', () => {
render(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />) render(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
@ -90,4 +101,36 @@ describe('BrokerDashboard', () => {
expect(screen.getByText('Аллокация')).toBeInTheDocument() expect(screen.getByText('Аллокация')).toBeInTheDocument()
expect(screen.getByText('allocation chart')).toBeInTheDocument() expect(screen.getByText('allocation chart')).toBeInTheDocument()
}) })
it('uses event chips as request filters', async () => {
const user = userEvent.setup()
render(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
await user.click(screen.getAllByRole('button', { name: 'Купоны' })[0])
await waitFor(() => {
expect(hookMocks.useBrokerEvents).toHaveBeenLastCalledWith(
'acc-1',
expect.objectContaining({ types: 'dividend,maturity,offer' }),
{ enabled: true },
)
})
})
it('uses income chips as request filters', async () => {
const user = userEvent.setup()
render(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
await user.click(screen.getAllByRole('button', { name: 'Купоны' })[1])
await waitFor(() => {
expect(hookMocks.useBrokerOperations).toHaveBeenLastCalledWith(
'acc-1',
expect.objectContaining({
operationTypes: 'OPERATION_TYPE_DIVIDEND,OPERATION_TYPE_DIV_EXT',
}),
{ enabled: true },
)
})
})
}) })

View File

@ -6,6 +6,8 @@ import { useBrokerOperations } from '@/entities/broker-operation'
import type { BrokerPortfolio } from '@/shared/api' import type { BrokerPortfolio } from '@/shared/api'
import { useCursorPagination } from '@/shared/lib/useCursorPagination' import { useCursorPagination } from '@/shared/lib/useCursorPagination'
import { import {
type DashboardEventType,
type DashboardIncomeType,
defaultEventsFilters, defaultEventsFilters,
defaultIncomeFilters, defaultIncomeFilters,
incomeTypesToOperationTypes, incomeTypesToOperationTypes,
@ -23,69 +25,99 @@ export function BrokerDashboard({
accountId: string accountId: string
portfolio: BrokerPortfolio portfolio: BrokerPortfolio
}) { }) {
const [eventFilters, _setEventFilters] = useState(defaultEventsFilters) const [eventFilters, setEventFilters] = useState(defaultEventsFilters)
const [eventPage, setEventPage] = useState(1) const [eventPage, setEventPage] = useState(1)
const [incomeFilters, _setIncomeFilters] = useState(defaultIncomeFilters) const [incomeFilters, setIncomeFilters] = useState(defaultIncomeFilters)
const incomePagination = useCursorPagination() const incomePagination = useCursorPagination()
const analytics = useBrokerAnalytics(accountId) const analytics = useBrokerAnalytics(accountId)
const events = useBrokerEvents(accountId, { const hasEventTypes = eventFilters.types.length > 0
from: eventFilters.from, const hasIncomeTypes = incomeFilters.types.length > 0
to: eventFilters.to, const events = useBrokerEvents(
types: eventFilters.types.join(','), accountId,
}) {
from: eventFilters.from,
to: eventFilters.to,
types: eventFilters.types.join(','),
},
{ enabled: hasEventTypes },
)
const eventItems = events.data?.items ?? [] const eventItems = events.data?.items ?? []
const eventPageSize = 10 const eventPageSize = 10
const eventPageItems = eventItems.slice( const eventPageItems = eventItems.slice(
(eventPage - 1) * eventPageSize, (eventPage - 1) * eventPageSize,
eventPage * eventPageSize, eventPage * eventPageSize,
) )
const operations = useBrokerOperations(accountId, { const operations = useBrokerOperations(
from: incomeFilters.from, accountId,
to: incomeFilters.to, {
operationTypes: incomeTypesToOperationTypes(incomeFilters.types), from: incomeFilters.from,
cursor: incomePagination.cursor, to: incomeFilters.to,
limit: 10, operationTypes: incomeTypesToOperationTypes(incomeFilters.types),
}) cursor: incomePagination.cursor,
limit: 10,
},
{ enabled: hasIncomeTypes },
)
const nextCursor: string | undefined = operations.data?.nextCursor const nextCursor: string | undefined = operations.data?.nextCursor
? (operations.data.nextCursor as unknown as string) ? (operations.data.nextCursor as unknown as string)
: undefined : undefined
function toggleEventType(type: DashboardEventType) {
setEventFilters((filters) => ({
...filters,
types: filters.types.includes(type)
? filters.types.filter((item) => item !== type)
: [...filters.types, type],
}))
setEventPage(1)
}
function toggleIncomeType(type: DashboardIncomeType) {
setIncomeFilters((filters) => ({
...filters,
types: filters.types.includes(type)
? filters.types.filter((item) => item !== type)
: [...filters.types, type],
}))
incomePagination.reset()
}
return ( return (
<Box sx={{ display: 'grid', gap: 3 }}> <Box sx={{ display: 'grid', gap: 3 }}>
<BrokerDashboardHero portfolio={portfolio} analytics={analytics.data} /> <BrokerDashboardHero portfolio={portfolio} analytics={analytics.data} />
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', xl: '1fr 1fr' }, gap: 3 }}> <BrokerDashboardEventsCard
<BrokerDashboardEventsCard accountId={accountId}
accountId={accountId} data={events.data ? { ...events.data, items: eventPageItems } : undefined}
data={events.data ? { ...events.data, items: eventPageItems } : undefined} isLoading={events.isLoading}
isLoading={events.isLoading} isError={events.isError}
isError={events.isError} selectedTypes={eventFilters.types}
page={eventPage} onToggleType={toggleEventType}
canGoBack={eventPage > 1} page={eventPage}
canGoForward={eventPage * eventPageSize < eventItems.length} canGoBack={eventPage > 1}
onPreviousPage={() => setEventPage((page) => Math.max(1, page - 1))} canGoForward={eventPage * eventPageSize < eventItems.length}
onNextPage={() => setEventPage((page) => page + 1)} onPreviousPage={() => setEventPage((page) => Math.max(1, page - 1))}
/> onNextPage={() => setEventPage((page) => page + 1)}
<BrokerDashboardIncomeCard />
accountId={accountId} <BrokerDashboardIncomeCard
page={operations.data} accountId={accountId}
isLoading={operations.isLoading} page={operations.data}
isError={operations.isError} isLoading={operations.isLoading}
pageNumber={incomePagination.pageNumber} isError={operations.isError}
canGoBack={incomePagination.pageNumber > 1} selectedTypes={incomeFilters.types}
canGoForward={operations.data?.hasNext ?? false} onToggleType={toggleIncomeType}
onPreviousPage={incomePagination.handlePrevious} pageNumber={incomePagination.pageNumber}
onNextPage={() => incomePagination.handleNext(nextCursor)} canGoBack={incomePagination.pageNumber > 1}
/> canGoForward={operations.data?.hasNext ?? false}
</Box> onPreviousPage={incomePagination.handlePrevious}
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', lg: '1.2fr .8fr' }, gap: 3 }}> onNextPage={() => incomePagination.handleNext(nextCursor)}
<BrokerDashboardAnalyticsCard />
data={analytics.data} <BrokerDashboardAnalyticsCard
isLoading={analytics.isLoading} data={analytics.data}
isError={analytics.isError} isLoading={analytics.isLoading}
/> isError={analytics.isError}
<BrokerDashboardAllocationCard portfolio={portfolio} /> />
</Box> <BrokerDashboardAllocationCard portfolio={portfolio} />
</Box> </Box>
) )
} }

View File

@ -3,14 +3,28 @@ import { Box } from '@mui/material'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import type { BrokerEventItem, BrokerEventsData } from '@/shared/api' import type { BrokerEventItem, BrokerEventsData } from '@/shared/api'
import { formatBrokerCurrencyValue, formatBrokerDate } from '@/shared/lib/formatters' import { formatBrokerCurrencyValue, formatBrokerDate } from '@/shared/lib/formatters'
import type { DashboardEventType } from '../lib/dashboardFilters'
import { eventStatusLabel, eventTypeLabel } from '../lib/dashboardFormatters' import { eventStatusLabel, eventTypeLabel } from '../lib/dashboardFormatters'
import { BrokerDashboardCard } from './BrokerDashboardCard' import { BrokerDashboardCard } from './BrokerDashboardCard'
const EVENT_FILTERS: Array<{
type: DashboardEventType
label: string
tone: 'success' | 'info' | 'warning' | 'neutral'
}> = [
{ type: 'dividend', label: 'Дивиденды', tone: 'success' },
{ type: 'coupon', label: 'Купоны', tone: 'info' },
{ type: 'maturity', label: 'Погашения', tone: 'warning' },
{ type: 'offer', label: 'Оферты', tone: 'neutral' },
]
type BrokerDashboardEventsCardProps = { type BrokerDashboardEventsCardProps = {
accountId: string accountId: string
data: BrokerEventsData | undefined data: BrokerEventsData | undefined
isLoading: boolean isLoading: boolean
isError: boolean isError: boolean
selectedTypes: DashboardEventType[]
onToggleType: (type: DashboardEventType) => void
page: number page: number
onPreviousPage: () => void onPreviousPage: () => void
onNextPage: () => void onNextPage: () => void
@ -30,6 +44,8 @@ export function BrokerDashboardEventsCard({
data, data,
isLoading, isLoading,
isError, isError,
selectedTypes,
onToggleType,
page, page,
onPreviousPage, onPreviousPage,
onNextPage, onNextPage,
@ -44,12 +60,19 @@ export function BrokerDashboardEventsCard({
action={<Link to={`/broker/${encodeURIComponent(accountId)}/events`}>Все события</Link>} action={<Link to={`/broker/${encodeURIComponent(accountId)}/events`}>Все события</Link>}
> >
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 1.5 }}> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 1.5 }}>
<Chip label="Дивиденды" tone="success" /> {EVENT_FILTERS.map((filter) => (
<Chip label="Купоны" tone="info" /> <Chip
<Chip label="Погашения" tone="warning" /> key={filter.type}
<Chip label="Оферты" tone="neutral" /> label={filter.label}
tone={filter.tone}
selected={selectedTypes.includes(filter.type)}
onClick={() => onToggleType(filter.type)}
/>
))}
</Box> </Box>
{isError ? ( {selectedTypes.length === 0 ? (
<Text tone="negative">Выберите хотя бы один тип событий</Text>
) : isError ? (
<Text tone="negative">Не удалось загрузить события</Text> <Text tone="negative">Не удалось загрузить события</Text>
) : isLoading ? ( ) : isLoading ? (
<Text tone="muted">Загрузка событий</Text> <Text tone="muted">Загрузка событий</Text>

View File

@ -3,14 +3,26 @@ import { Box } from '@mui/material'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import type { BrokerOperationsPage } from '@/shared/api' import type { BrokerOperationsPage } from '@/shared/api'
import { formatBrokerCurrencyValue, formatBrokerDate } from '@/shared/lib/formatters' import { formatBrokerCurrencyValue, formatBrokerDate } from '@/shared/lib/formatters'
import type { DashboardIncomeType } from '../lib/dashboardFilters'
import { getDashboardIncomeRows, sumDashboardIncome } from '../lib/dashboardIncome' import { getDashboardIncomeRows, sumDashboardIncome } from '../lib/dashboardIncome'
import { BrokerDashboardCard } from './BrokerDashboardCard' import { BrokerDashboardCard } from './BrokerDashboardCard'
const INCOME_FILTERS: Array<{
type: DashboardIncomeType
label: string
tone: 'success' | 'info'
}> = [
{ type: 'dividend', label: 'Дивиденды', tone: 'success' },
{ type: 'coupon', label: 'Купоны', tone: 'info' },
]
type BrokerDashboardIncomeCardProps = { type BrokerDashboardIncomeCardProps = {
accountId: string accountId: string
page: BrokerOperationsPage | undefined page: BrokerOperationsPage | undefined
isLoading: boolean isLoading: boolean
isError: boolean isError: boolean
selectedTypes: DashboardIncomeType[]
onToggleType: (type: DashboardIncomeType) => void
pageNumber: number pageNumber: number
canGoBack: boolean canGoBack: boolean
canGoForward: boolean canGoForward: boolean
@ -23,6 +35,8 @@ export function BrokerDashboardIncomeCard({
page, page,
isLoading, isLoading,
isError, isError,
selectedTypes,
onToggleType,
pageNumber, pageNumber,
canGoBack, canGoBack,
canGoForward, canGoForward,
@ -38,10 +52,19 @@ export function BrokerDashboardIncomeCard({
action={<Link to={`/broker/${encodeURIComponent(accountId)}/operations`}>Все операции</Link>} action={<Link to={`/broker/${encodeURIComponent(accountId)}/operations`}>Все операции</Link>}
> >
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 1.5 }}> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 1.5 }}>
<Chip label="Дивиденды" tone="success" /> {INCOME_FILTERS.map((filter) => (
<Chip label="Купоны" tone="info" /> <Chip
key={filter.type}
label={filter.label}
tone={filter.tone}
selected={selectedTypes.includes(filter.type)}
onClick={() => onToggleType(filter.type)}
/>
))}
</Box> </Box>
{isError ? ( {selectedTypes.length === 0 ? (
<Text tone="negative">Выберите хотя бы один тип доходов</Text>
) : isError ? (
<Text tone="negative">Не удалось загрузить доходные операции</Text> <Text tone="negative">Не удалось загрузить доходные операции</Text>
) : isLoading ? ( ) : isLoading ? (
<Text tone="muted">Загрузка доходов</Text> <Text tone="muted">Загрузка доходов</Text>

View File

@ -5,14 +5,10 @@ export function BrokerDashboardSkeleton() {
return ( return (
<Box sx={{ display: 'grid', gap: 3 }} aria-label="Загрузка брокерского дашборда"> <Box sx={{ display: 'grid', gap: 3 }} aria-label="Загрузка брокерского дашборда">
<Skeleton height={120} shape="rounded" /> <Skeleton height={120} shape="rounded" />
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', xl: '1fr 1fr' }, gap: 3 }}> <Skeleton height={300} shape="rounded" />
<Skeleton height={300} shape="rounded" /> <Skeleton height={300} shape="rounded" />
<Skeleton height={300} shape="rounded" /> <Skeleton height={260} shape="rounded" />
</Box> <Skeleton height={260} shape="rounded" />
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', lg: '1.2fr .8fr' }, gap: 3 }}>
<Skeleton height={260} shape="rounded" />
<Skeleton height={260} shape="rounded" />
</Box>
</Box> </Box>
) )
} }

View File

@ -53,13 +53,13 @@
- `/broker/:accountId` остаётся overview выбранного брокерского счёта. - `/broker/:accountId` остаётся overview выбранного брокерского счёта.
- Overview визуально становится dashboard-страницей, а не вертикальным списком независимых секций. - Overview визуально становится dashboard-страницей, а не вертикальным списком независимых секций.
- На desktop первый экран содержит hero KPI и два основных информационных блока рядом: `События` и - Существующая навигация счёта отображается горизонтальными вкладками над контентом, чтобы не занимать
`Доходы`. левую колонку и оставить больше пространства для таблиц dashboard.
- Ниже отображаются `Аналитика доходности` и `Аллокация`. - На desktop и mobile блоки dashboard отображаются по одному блоку на строке: hero KPI, `События`,
`Доходы`, `Аналитика доходности`, `Аллокация`.
- Существующая навигация счёта сохраняет ссылки на `Обзор`, `Акции`, `Облигации`, `Операции`, - Существующая навигация счёта сохраняет ссылки на `Обзор`, `Акции`, `Облигации`, `Операции`,
`События`, `Аналитика`. `События`, `Аналитика`.
- На мобильном viewport дашборд перестраивается в одну колонку с порядком: hero KPI, события, доходы, - На мобильном viewport сохраняется тот же порядок блоков в одну колонку.
аналитика, аллокация.
### 2. Визуальный стиль ### 2. Визуальный стиль
@ -88,10 +88,12 @@ Hero показывает:
- Блок использует существующий источник `useBrokerEvents(accountId, query)`. - Блок использует существующий источник `useBrokerEvents(accountId, query)`.
- По умолчанию применяется период `сегодня - 7 дней` / `сегодня + 7 дней` и типы - По умолчанию применяется период `сегодня - 7 дней` / `сегодня + 7 дней` и типы
`dividend,coupon,maturity,offer`, как в существующей вкладке событий. `dividend,coupon,maturity,offer`, как в существующей вкладке событий.
- Блок содержит фильтр типов событий: `Дивиденды`, `Купоны`, `Погашения`, `Оферты`. - Блок содержит кликабельные chip-фильтры типов событий: `Дивиденды`, `Купоны`, `Погашения`, `Оферты`.
- Пользователь может выбрать несколько типов событий. - Пользователь может выбрать несколько типов событий.
- Если пользователь снимает все типы событий, запрос не выполняется, а блок показывает - Если пользователь снимает все типы событий, запрос не выполняется, а блок показывает
валидационное сообщение. валидационное сообщение.
- Изменение chip-фильтров типов событий применяется сразу и возвращает локальную пагинацию на первую
страницу.
- Блок содержит фильтр периода `from` / `to`. - Блок содержит фильтр периода `from` / `to`.
- Изменение черновых фильтров не запускает запрос до нажатия `Показать`. - Изменение черновых фильтров не запускает запрос до нажатия `Показать`.
- Блок содержит быстрые пресеты периода `7д`, `30д`, `90д`, `1г`, `Всё` и действие `Сбросить`. - Блок содержит быстрые пресеты периода `7д`, `30д`, `90д`, `1г`, `Всё` и действие `Сбросить`.
@ -113,10 +115,11 @@ Hero показывает:
операций. операций.
- В первую версию входят операции с типами дивидендов и купонов, которые уже используются в backend - В первую версию входят операции с типами дивидендов и купонов, которые уже используются в backend
analytics: `OPERATION_TYPE_DIVIDEND`, `OPERATION_TYPE_DIV_EXT`, `OPERATION_TYPE_COUPON`. analytics: `OPERATION_TYPE_DIVIDEND`, `OPERATION_TYPE_DIV_EXT`, `OPERATION_TYPE_COUPON`.
- Блок содержит фильтр типов доходов: `Дивиденды`, `Купоны`. - Блок содержит кликабельные chip-фильтры типов доходов: `Дивиденды`, `Купоны`.
- Пользователь может выбрать один или оба типа доходов. - Пользователь может выбрать один или оба типа доходов.
- Если пользователь снимает все типы доходов, запрос не выполняется, а блок показывает - Если пользователь снимает все типы доходов, запрос не выполняется, а блок показывает
валидационное сообщение. валидационное сообщение.
- Изменение chip-фильтров типов доходов применяется сразу и сбрасывает cursor-пагинацию.
- Блок содержит фильтр периода `from` / `to`. - Блок содержит фильтр периода `from` / `to`.
- По умолчанию используется период с начала текущего календарного года до текущей даты, как в разделе - По умолчанию используется период с начала текущего календарного года до текущей даты, как в разделе
операций. операций.
@ -175,16 +178,16 @@ Hero показывает:
- `/broker/:accountId` показывает dashboard-композицию: hero KPI, `События`, `Доходы`, - `/broker/:accountId` показывает dashboard-композицию: hero KPI, `События`, `Доходы`,
`Аналитика доходности`, `Аллокация`. `Аналитика доходности`, `Аллокация`.
- На desktop блоки `События` и `Доходы` расположены рядом. - Навигация счёта отображается горизонтальными вкладками над dashboard-контентом.
- На desktop и mobile блоки `События`, `Доходы`, `Аналитика доходности`, `Аллокация` расположены по
одному блоку на строке.
- На мобильном viewport dashboard читаемо перестраивается в одну колонку. - На мобильном viewport dashboard читаемо перестраивается в одну колонку.
- Hero показывает стоимость портфеля, доходность или fallback, дневное изменение или fallback, всего - Hero показывает стоимость портфеля, доходность или fallback, дневное изменение или fallback, всего
доходов или fallback. доходов или fallback.
- Блок `События` использует существующие events data и показывает дату, инструмент, тип, сумму и статус. - Блок `События` использует существующие events data и показывает дату, инструмент, тип, сумму и статус.
- Блок `События` поддерживает multi-select фильтр типов, фильтр периода, быстрые пресеты, сброс и - Блок `События` поддерживает multi-select chip-фильтр типов и локальную пагинацию по 10 событий.
локальную пагинацию по 10 событий.
- Блок `Доходы` показывает доходные операции дивидендов и купонов и итог по отображаемым строкам. - Блок `Доходы` показывает доходные операции дивидендов и купонов и итог по отображаемым строкам.
- Блок `Доходы` поддерживает multi-select фильтр типов, фильтр периода, быстрые пресеты, сброс и - Блок `Доходы` поддерживает multi-select chip-фильтр типов и cursor-пагинацию по 10 операций.
cursor-пагинацию по 10 операций.
- Блок `Аналитика доходности` показывает данные существующего analytics endpoint. - Блок `Аналитика доходности` показывает данные существующего analytics endpoint.
- Блок `Аллокация` показывает donut/легенду существующей структуры портфеля. - Блок `Аллокация` показывает donut/легенду существующей структуры портфеля.
- Ошибка одного вторичного блока не скрывает остальные блоки dashboard. - Ошибка одного вторичного блока не скрывает остальные блоки dashboard.

View File

@ -23,9 +23,13 @@
- [x] Добавить `BrokerDashboardHero` с KPI по portfolio и analytics. - [x] Добавить `BrokerDashboardHero` с KPI по portfolio и analytics.
- [x] Добавить `BrokerDashboardEventsCard` на основе `useBrokerEvents`. - [x] Добавить `BrokerDashboardEventsCard` на основе `useBrokerEvents`.
- [x] Добавить `BrokerDashboardIncomeCard` на основе `useBrokerOperations`. - [x] Добавить `BrokerDashboardIncomeCard` на основе `useBrokerOperations`.
- [ ] Добавить фильтры типов, фильтры периода, пресеты, reset/apply actions для `События`. - [x] Перенести навигацию счёта из левой колонки в горизонтальные вкладки над контентом.
- [x] Перестроить dashboard на один блок на строке для `События`, `Доходы`, `Аналитика доходности`, `Аллокация`.
- [x] Добавить кликабельные chip-фильтры типов для `События`.
- [ ] Добавить фильтры периода, пресеты, reset/apply actions для `События`.
- [x] Добавить локальную пагинацию по 10 событий в `События`. - [x] Добавить локальную пагинацию по 10 событий в `События`.
- [ ] Добавить фильтры типов, фильтры периода, пресеты, reset/apply actions для `Доходы`. - [x] Добавить кликабельные chip-фильтры типов для `Доходы`.
- [ ] Добавить фильтры периода, пресеты, reset/apply actions для `Доходы`.
- [x] Добавить cursor-пагинацию по 10 операций в `Доходы`. - [x] Добавить cursor-пагинацию по 10 операций в `Доходы`.
- [x] Добавить `BrokerDashboardAnalyticsCard` на основе `useBrokerAnalytics`. - [x] Добавить `BrokerDashboardAnalyticsCard` на основе `useBrokerAnalytics`.
- [x] Добавить `BrokerDashboardAllocationCard` на основе существующей аллокации. - [x] Добавить `BrokerDashboardAllocationCard` на основе существующей аллокации.
@ -38,8 +42,8 @@
## Definition of Done ## Definition of Done
- [x] `rtk npm run test:frontend` проходит (31 files, 133 tests). - [x] `rtk npm run test:frontend` проходит (31 files, 135 tests).
- [x] `rtk npm run test:design-system` проходит (28 files, 160 tests). - [x] `rtk npm run test:design-system` проходит (28 files, 162 tests).
- [x] `rtk npm run lint -w apps/frontend` проходит. - [x] `rtk npm run lint -w apps/frontend` проходит.
- [x] `rtk npm run build:frontend` проходит. - [x] `rtk npm run build:frontend` проходит.
- [x] Dashboard соответствует acceptance criteria из `spec.md`. - [x] Dashboard соответствует acceptance criteria из `spec.md`.

View File

@ -59,4 +59,22 @@ describe('Chip', () => {
renderWithTheme(<Chip label="Static" />); renderWithTheme(<Chip label="Static" />);
expect(screen.queryByRole('button')).not.toBeInTheDocument(); expect(screen.queryByRole('button')).not.toBeInTheDocument();
}); });
it('calls onClick when clickable chip clicked', async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
renderWithTheme(<Chip label="Clickable" onClick={handleClick} />);
await user.click(screen.getByRole('button', { name: 'Clickable' }));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('renders unselected clickable chip as outlined', () => {
renderWithTheme(<Chip label="Inactive" onClick={() => {}} selected={false} />);
const chip = screen.getByText('Inactive').closest('.MuiChip-root')!;
expect(chip.classList.contains('MuiChip-outlined')).toBe(true);
expect(chip).toHaveAttribute('aria-pressed', 'false');
});
}); });

View File

@ -1,5 +1,5 @@
import { Chip as MuiChip } from '@mui/material';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import { Chip as MuiChip } from '@mui/material';
type Tone = 'neutral' | 'info' | 'success' | 'warning' | 'error'; type Tone = 'neutral' | 'info' | 'success' | 'warning' | 'error';
@ -15,14 +15,28 @@ export interface ChipProps {
label: string; label: string;
tone?: Tone; tone?: Tone;
onDelete?: () => void; onDelete?: () => void;
onClick?: () => void;
selected?: boolean;
disabled?: boolean;
} }
export function Chip({ label, tone = 'neutral', onDelete }: ChipProps) { export function Chip({
label,
tone = 'neutral',
onDelete,
onClick,
selected = true,
disabled,
}: ChipProps) {
return ( return (
<MuiChip <MuiChip
label={label} label={label}
color={TONE_MAP[tone]} color={TONE_MAP[tone]}
variant={selected ? 'filled' : 'outlined'}
onClick={onClick}
onDelete={onDelete} onDelete={onDelete}
disabled={disabled}
aria-pressed={onClick ? selected : undefined}
{...(onDelete ? { deleteIcon: <CancelIcon aria-label={`Remove ${label}`} /> } : {})} {...(onDelete ? { deleteIcon: <CancelIcon aria-label={`Remove ${label}`} /> } : {})}
/> />
); );