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:
parent
c429d6a43a
commit
8df94a51dd
@ -2,11 +2,15 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import type { BrokerEventsData } from '@/shared/api'
|
||||
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
|
||||
return useQuery<BrokerEventsData>({
|
||||
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,
|
||||
staleTime: 300_000,
|
||||
retry: 2,
|
||||
|
||||
@ -5,10 +5,11 @@ import { type BrokerOperationQuery, getBrokerOperations } from '../api/brokerOpe
|
||||
export function useBrokerOperations(
|
||||
accountId: string | undefined,
|
||||
query: BrokerOperationQuery = {},
|
||||
options: { enabled?: boolean } = {},
|
||||
) {
|
||||
return useQuery<BrokerOperationsPage>({
|
||||
queryKey: ['broker', 'operations', accountId, query],
|
||||
enabled: Boolean(accountId),
|
||||
enabled: Boolean(accountId) && (options.enabled ?? true),
|
||||
queryFn: async () => (await getBrokerOperations(accountId!, query)).data,
|
||||
staleTime: 300_000,
|
||||
retry: 2,
|
||||
|
||||
@ -47,51 +47,39 @@ export function BrokerAccountLayout({ children }: { children: ReactNode }) {
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
component="nav"
|
||||
aria-label="Разделы брокерского счёта"
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr',
|
||||
gap: 2,
|
||||
'@media (min-width: 720px)': {
|
||||
gridTemplateColumns: 'minmax(150px, 190px) minmax(0, 1fr)',
|
||||
gap: 3,
|
||||
},
|
||||
display: 'flex',
|
||||
gap: 0.5,
|
||||
overflowX: 'auto',
|
||||
scrollbarWidth: 'thin',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
pb: 0.5,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="nav"
|
||||
aria-label="Разделы брокерского счёта"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.5,
|
||||
'@media (max-width: 719px)': {
|
||||
flexDirection: 'row',
|
||||
overflowX: 'auto',
|
||||
scrollbarWidth: 'thin',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{links.map((link) => (
|
||||
<Link
|
||||
key={link.to}
|
||||
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>
|
||||
{links.map((link) => (
|
||||
<Link
|
||||
key={link.to}
|
||||
to={`${basePath}${link.to}`}
|
||||
style={baseLinkStyle}
|
||||
activeOptions={{ exact: link.to === '' }}
|
||||
activeProps={{
|
||||
style: {
|
||||
...baseLinkStyle,
|
||||
color: 'var(--color-primary)',
|
||||
fontWeight: 700,
|
||||
background: 'rgba(25, 118, 210, 0.08)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ minWidth: 0 }}>{children}</Box>
|
||||
</Box>
|
||||
</BrokerAccountContext.Provider>
|
||||
)
|
||||
|
||||
@ -1,8 +1,14 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { BrokerPortfolio } from '@/shared/api'
|
||||
import { BrokerDashboard } from './BrokerDashboard'
|
||||
|
||||
const hookMocks = vi.hoisted(() => ({
|
||||
useBrokerEvents: vi.fn(),
|
||||
useBrokerOperations: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
Link: ({ children, to }: { children: React.ReactNode; to: string }) => (
|
||||
<a href={to}>{children}</a>
|
||||
@ -27,25 +33,11 @@ vi.mock('@/entities/broker-analytics', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/entities/broker-event', () => ({
|
||||
useBrokerEvents: () => ({
|
||||
data: { items: [], summary: {}, asOf: '2026-06-26T00:00:00.000Z' },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
useBrokerEvents: hookMocks.useBrokerEvents,
|
||||
}))
|
||||
|
||||
vi.mock('@/entities/broker-operation', () => ({
|
||||
useBrokerOperations: () => ({
|
||||
data: {
|
||||
accountId: 'acc-1',
|
||||
items: [],
|
||||
nextCursor: null,
|
||||
hasNext: false,
|
||||
asOf: '2026-06-26T00:00:00.000Z',
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
useBrokerOperations: hookMocks.useBrokerOperations,
|
||||
}))
|
||||
|
||||
vi.mock('@/widgets/broker-allocation-chart', () => ({
|
||||
@ -80,6 +72,25 @@ const portfolio: BrokerPortfolio = {
|
||||
}
|
||||
|
||||
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', () => {
|
||||
render(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||
|
||||
@ -90,4 +101,36 @@ describe('BrokerDashboard', () => {
|
||||
expect(screen.getByText('Аллокация')).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 },
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -6,6 +6,8 @@ import { useBrokerOperations } from '@/entities/broker-operation'
|
||||
import type { BrokerPortfolio } from '@/shared/api'
|
||||
import { useCursorPagination } from '@/shared/lib/useCursorPagination'
|
||||
import {
|
||||
type DashboardEventType,
|
||||
type DashboardIncomeType,
|
||||
defaultEventsFilters,
|
||||
defaultIncomeFilters,
|
||||
incomeTypesToOperationTypes,
|
||||
@ -23,69 +25,99 @@ export function BrokerDashboard({
|
||||
accountId: string
|
||||
portfolio: BrokerPortfolio
|
||||
}) {
|
||||
const [eventFilters, _setEventFilters] = useState(defaultEventsFilters)
|
||||
const [eventFilters, setEventFilters] = useState(defaultEventsFilters)
|
||||
const [eventPage, setEventPage] = useState(1)
|
||||
const [incomeFilters, _setIncomeFilters] = useState(defaultIncomeFilters)
|
||||
const [incomeFilters, setIncomeFilters] = useState(defaultIncomeFilters)
|
||||
const incomePagination = useCursorPagination()
|
||||
const analytics = useBrokerAnalytics(accountId)
|
||||
const events = useBrokerEvents(accountId, {
|
||||
from: eventFilters.from,
|
||||
to: eventFilters.to,
|
||||
types: eventFilters.types.join(','),
|
||||
})
|
||||
const hasEventTypes = eventFilters.types.length > 0
|
||||
const hasIncomeTypes = incomeFilters.types.length > 0
|
||||
const events = useBrokerEvents(
|
||||
accountId,
|
||||
{
|
||||
from: eventFilters.from,
|
||||
to: eventFilters.to,
|
||||
types: eventFilters.types.join(','),
|
||||
},
|
||||
{ enabled: hasEventTypes },
|
||||
)
|
||||
const eventItems = events.data?.items ?? []
|
||||
const eventPageSize = 10
|
||||
const eventPageItems = eventItems.slice(
|
||||
(eventPage - 1) * eventPageSize,
|
||||
eventPage * eventPageSize,
|
||||
)
|
||||
const operations = useBrokerOperations(accountId, {
|
||||
from: incomeFilters.from,
|
||||
to: incomeFilters.to,
|
||||
operationTypes: incomeTypesToOperationTypes(incomeFilters.types),
|
||||
cursor: incomePagination.cursor,
|
||||
limit: 10,
|
||||
})
|
||||
const operations = useBrokerOperations(
|
||||
accountId,
|
||||
{
|
||||
from: incomeFilters.from,
|
||||
to: incomeFilters.to,
|
||||
operationTypes: incomeTypesToOperationTypes(incomeFilters.types),
|
||||
cursor: incomePagination.cursor,
|
||||
limit: 10,
|
||||
},
|
||||
{ enabled: hasIncomeTypes },
|
||||
)
|
||||
|
||||
const nextCursor: string | undefined = operations.data?.nextCursor
|
||||
? (operations.data.nextCursor as unknown as string)
|
||||
: 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 (
|
||||
<Box sx={{ display: 'grid', gap: 3 }}>
|
||||
<BrokerDashboardHero portfolio={portfolio} analytics={analytics.data} />
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', xl: '1fr 1fr' }, gap: 3 }}>
|
||||
<BrokerDashboardEventsCard
|
||||
accountId={accountId}
|
||||
data={events.data ? { ...events.data, items: eventPageItems } : undefined}
|
||||
isLoading={events.isLoading}
|
||||
isError={events.isError}
|
||||
page={eventPage}
|
||||
canGoBack={eventPage > 1}
|
||||
canGoForward={eventPage * eventPageSize < eventItems.length}
|
||||
onPreviousPage={() => setEventPage((page) => Math.max(1, page - 1))}
|
||||
onNextPage={() => setEventPage((page) => page + 1)}
|
||||
/>
|
||||
<BrokerDashboardIncomeCard
|
||||
accountId={accountId}
|
||||
page={operations.data}
|
||||
isLoading={operations.isLoading}
|
||||
isError={operations.isError}
|
||||
pageNumber={incomePagination.pageNumber}
|
||||
canGoBack={incomePagination.pageNumber > 1}
|
||||
canGoForward={operations.data?.hasNext ?? false}
|
||||
onPreviousPage={incomePagination.handlePrevious}
|
||||
onNextPage={() => incomePagination.handleNext(nextCursor)}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', lg: '1.2fr .8fr' }, gap: 3 }}>
|
||||
<BrokerDashboardAnalyticsCard
|
||||
data={analytics.data}
|
||||
isLoading={analytics.isLoading}
|
||||
isError={analytics.isError}
|
||||
/>
|
||||
<BrokerDashboardAllocationCard portfolio={portfolio} />
|
||||
</Box>
|
||||
<BrokerDashboardEventsCard
|
||||
accountId={accountId}
|
||||
data={events.data ? { ...events.data, items: eventPageItems } : undefined}
|
||||
isLoading={events.isLoading}
|
||||
isError={events.isError}
|
||||
selectedTypes={eventFilters.types}
|
||||
onToggleType={toggleEventType}
|
||||
page={eventPage}
|
||||
canGoBack={eventPage > 1}
|
||||
canGoForward={eventPage * eventPageSize < eventItems.length}
|
||||
onPreviousPage={() => setEventPage((page) => Math.max(1, page - 1))}
|
||||
onNextPage={() => setEventPage((page) => page + 1)}
|
||||
/>
|
||||
<BrokerDashboardIncomeCard
|
||||
accountId={accountId}
|
||||
page={operations.data}
|
||||
isLoading={operations.isLoading}
|
||||
isError={operations.isError}
|
||||
selectedTypes={incomeFilters.types}
|
||||
onToggleType={toggleIncomeType}
|
||||
pageNumber={incomePagination.pageNumber}
|
||||
canGoBack={incomePagination.pageNumber > 1}
|
||||
canGoForward={operations.data?.hasNext ?? false}
|
||||
onPreviousPage={incomePagination.handlePrevious}
|
||||
onNextPage={() => incomePagination.handleNext(nextCursor)}
|
||||
/>
|
||||
<BrokerDashboardAnalyticsCard
|
||||
data={analytics.data}
|
||||
isLoading={analytics.isLoading}
|
||||
isError={analytics.isError}
|
||||
/>
|
||||
<BrokerDashboardAllocationCard portfolio={portfolio} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@ -3,14 +3,28 @@ import { Box } from '@mui/material'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import type { BrokerEventItem, BrokerEventsData } from '@/shared/api'
|
||||
import { formatBrokerCurrencyValue, formatBrokerDate } from '@/shared/lib/formatters'
|
||||
import type { DashboardEventType } from '../lib/dashboardFilters'
|
||||
import { eventStatusLabel, eventTypeLabel } from '../lib/dashboardFormatters'
|
||||
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 = {
|
||||
accountId: string
|
||||
data: BrokerEventsData | undefined
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
selectedTypes: DashboardEventType[]
|
||||
onToggleType: (type: DashboardEventType) => void
|
||||
page: number
|
||||
onPreviousPage: () => void
|
||||
onNextPage: () => void
|
||||
@ -30,6 +44,8 @@ export function BrokerDashboardEventsCard({
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
selectedTypes,
|
||||
onToggleType,
|
||||
page,
|
||||
onPreviousPage,
|
||||
onNextPage,
|
||||
@ -44,12 +60,19 @@ export function BrokerDashboardEventsCard({
|
||||
action={<Link to={`/broker/${encodeURIComponent(accountId)}/events`}>Все события</Link>}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 1.5 }}>
|
||||
<Chip label="Дивиденды" tone="success" />
|
||||
<Chip label="Купоны" tone="info" />
|
||||
<Chip label="Погашения" tone="warning" />
|
||||
<Chip label="Оферты" tone="neutral" />
|
||||
{EVENT_FILTERS.map((filter) => (
|
||||
<Chip
|
||||
key={filter.type}
|
||||
label={filter.label}
|
||||
tone={filter.tone}
|
||||
selected={selectedTypes.includes(filter.type)}
|
||||
onClick={() => onToggleType(filter.type)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
{isError ? (
|
||||
{selectedTypes.length === 0 ? (
|
||||
<Text tone="negative">Выберите хотя бы один тип событий</Text>
|
||||
) : isError ? (
|
||||
<Text tone="negative">Не удалось загрузить события</Text>
|
||||
) : isLoading ? (
|
||||
<Text tone="muted">Загрузка событий…</Text>
|
||||
|
||||
@ -3,14 +3,26 @@ import { Box } from '@mui/material'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import type { BrokerOperationsPage } from '@/shared/api'
|
||||
import { formatBrokerCurrencyValue, formatBrokerDate } from '@/shared/lib/formatters'
|
||||
import type { DashboardIncomeType } from '../lib/dashboardFilters'
|
||||
import { getDashboardIncomeRows, sumDashboardIncome } from '../lib/dashboardIncome'
|
||||
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 = {
|
||||
accountId: string
|
||||
page: BrokerOperationsPage | undefined
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
selectedTypes: DashboardIncomeType[]
|
||||
onToggleType: (type: DashboardIncomeType) => void
|
||||
pageNumber: number
|
||||
canGoBack: boolean
|
||||
canGoForward: boolean
|
||||
@ -23,6 +35,8 @@ export function BrokerDashboardIncomeCard({
|
||||
page,
|
||||
isLoading,
|
||||
isError,
|
||||
selectedTypes,
|
||||
onToggleType,
|
||||
pageNumber,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
@ -38,10 +52,19 @@ export function BrokerDashboardIncomeCard({
|
||||
action={<Link to={`/broker/${encodeURIComponent(accountId)}/operations`}>Все операции</Link>}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 1.5 }}>
|
||||
<Chip label="Дивиденды" tone="success" />
|
||||
<Chip label="Купоны" tone="info" />
|
||||
{INCOME_FILTERS.map((filter) => (
|
||||
<Chip
|
||||
key={filter.type}
|
||||
label={filter.label}
|
||||
tone={filter.tone}
|
||||
selected={selectedTypes.includes(filter.type)}
|
||||
onClick={() => onToggleType(filter.type)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
{isError ? (
|
||||
{selectedTypes.length === 0 ? (
|
||||
<Text tone="negative">Выберите хотя бы один тип доходов</Text>
|
||||
) : isError ? (
|
||||
<Text tone="negative">Не удалось загрузить доходные операции</Text>
|
||||
) : isLoading ? (
|
||||
<Text tone="muted">Загрузка доходов…</Text>
|
||||
|
||||
@ -5,14 +5,10 @@ export function BrokerDashboardSkeleton() {
|
||||
return (
|
||||
<Box sx={{ display: 'grid', gap: 3 }} aria-label="Загрузка брокерского дашборда">
|
||||
<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" />
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', lg: '1.2fr .8fr' }, gap: 3 }}>
|
||||
<Skeleton height={260} shape="rounded" />
|
||||
<Skeleton height={260} shape="rounded" />
|
||||
</Box>
|
||||
<Skeleton height={300} shape="rounded" />
|
||||
<Skeleton height={300} shape="rounded" />
|
||||
<Skeleton height={260} shape="rounded" />
|
||||
<Skeleton height={260} shape="rounded" />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@ -53,13 +53,13 @@
|
||||
|
||||
- `/broker/:accountId` остаётся overview выбранного брокерского счёта.
|
||||
- Overview визуально становится dashboard-страницей, а не вертикальным списком независимых секций.
|
||||
- На desktop первый экран содержит hero KPI и два основных информационных блока рядом: `События` и
|
||||
`Доходы`.
|
||||
- Ниже отображаются `Аналитика доходности` и `Аллокация`.
|
||||
- Существующая навигация счёта отображается горизонтальными вкладками над контентом, чтобы не занимать
|
||||
левую колонку и оставить больше пространства для таблиц dashboard.
|
||||
- На desktop и mobile блоки dashboard отображаются по одному блоку на строке: hero KPI, `События`,
|
||||
`Доходы`, `Аналитика доходности`, `Аллокация`.
|
||||
- Существующая навигация счёта сохраняет ссылки на `Обзор`, `Акции`, `Облигации`, `Операции`,
|
||||
`События`, `Аналитика`.
|
||||
- На мобильном viewport дашборд перестраивается в одну колонку с порядком: hero KPI, события, доходы,
|
||||
аналитика, аллокация.
|
||||
- На мобильном viewport сохраняется тот же порядок блоков в одну колонку.
|
||||
|
||||
### 2. Визуальный стиль
|
||||
|
||||
@ -88,10 +88,12 @@ Hero показывает:
|
||||
- Блок использует существующий источник `useBrokerEvents(accountId, query)`.
|
||||
- По умолчанию применяется период `сегодня - 7 дней` / `сегодня + 7 дней` и типы
|
||||
`dividend,coupon,maturity,offer`, как в существующей вкладке событий.
|
||||
- Блок содержит фильтр типов событий: `Дивиденды`, `Купоны`, `Погашения`, `Оферты`.
|
||||
- Блок содержит кликабельные chip-фильтры типов событий: `Дивиденды`, `Купоны`, `Погашения`, `Оферты`.
|
||||
- Пользователь может выбрать несколько типов событий.
|
||||
- Если пользователь снимает все типы событий, запрос не выполняется, а блок показывает
|
||||
валидационное сообщение.
|
||||
- Изменение chip-фильтров типов событий применяется сразу и возвращает локальную пагинацию на первую
|
||||
страницу.
|
||||
- Блок содержит фильтр периода `from` / `to`.
|
||||
- Изменение черновых фильтров не запускает запрос до нажатия `Показать`.
|
||||
- Блок содержит быстрые пресеты периода `7д`, `30д`, `90д`, `1г`, `Всё` и действие `Сбросить`.
|
||||
@ -113,10 +115,11 @@ Hero показывает:
|
||||
операций.
|
||||
- В первую версию входят операции с типами дивидендов и купонов, которые уже используются в backend
|
||||
analytics: `OPERATION_TYPE_DIVIDEND`, `OPERATION_TYPE_DIV_EXT`, `OPERATION_TYPE_COUPON`.
|
||||
- Блок содержит фильтр типов доходов: `Дивиденды`, `Купоны`.
|
||||
- Блок содержит кликабельные chip-фильтры типов доходов: `Дивиденды`, `Купоны`.
|
||||
- Пользователь может выбрать один или оба типа доходов.
|
||||
- Если пользователь снимает все типы доходов, запрос не выполняется, а блок показывает
|
||||
валидационное сообщение.
|
||||
- Изменение chip-фильтров типов доходов применяется сразу и сбрасывает cursor-пагинацию.
|
||||
- Блок содержит фильтр периода `from` / `to`.
|
||||
- По умолчанию используется период с начала текущего календарного года до текущей даты, как в разделе
|
||||
операций.
|
||||
@ -175,16 +178,16 @@ Hero показывает:
|
||||
|
||||
- `/broker/:accountId` показывает dashboard-композицию: hero KPI, `События`, `Доходы`,
|
||||
`Аналитика доходности`, `Аллокация`.
|
||||
- На desktop блоки `События` и `Доходы` расположены рядом.
|
||||
- Навигация счёта отображается горизонтальными вкладками над dashboard-контентом.
|
||||
- На desktop и mobile блоки `События`, `Доходы`, `Аналитика доходности`, `Аллокация` расположены по
|
||||
одному блоку на строке.
|
||||
- На мобильном viewport dashboard читаемо перестраивается в одну колонку.
|
||||
- Hero показывает стоимость портфеля, доходность или fallback, дневное изменение или fallback, всего
|
||||
доходов или fallback.
|
||||
- Блок `События` использует существующие events data и показывает дату, инструмент, тип, сумму и статус.
|
||||
- Блок `События` поддерживает multi-select фильтр типов, фильтр периода, быстрые пресеты, сброс и
|
||||
локальную пагинацию по 10 событий.
|
||||
- Блок `События` поддерживает multi-select chip-фильтр типов и локальную пагинацию по 10 событий.
|
||||
- Блок `Доходы` показывает доходные операции дивидендов и купонов и итог по отображаемым строкам.
|
||||
- Блок `Доходы` поддерживает multi-select фильтр типов, фильтр периода, быстрые пресеты, сброс и
|
||||
cursor-пагинацию по 10 операций.
|
||||
- Блок `Доходы` поддерживает multi-select chip-фильтр типов и cursor-пагинацию по 10 операций.
|
||||
- Блок `Аналитика доходности` показывает данные существующего analytics endpoint.
|
||||
- Блок `Аллокация` показывает donut/легенду существующей структуры портфеля.
|
||||
- Ошибка одного вторичного блока не скрывает остальные блоки dashboard.
|
||||
|
||||
@ -23,9 +23,13 @@
|
||||
- [x] Добавить `BrokerDashboardHero` с KPI по portfolio и analytics.
|
||||
- [x] Добавить `BrokerDashboardEventsCard` на основе `useBrokerEvents`.
|
||||
- [x] Добавить `BrokerDashboardIncomeCard` на основе `useBrokerOperations`.
|
||||
- [ ] Добавить фильтры типов, фильтры периода, пресеты, reset/apply actions для `События`.
|
||||
- [x] Перенести навигацию счёта из левой колонки в горизонтальные вкладки над контентом.
|
||||
- [x] Перестроить dashboard на один блок на строке для `События`, `Доходы`, `Аналитика доходности`, `Аллокация`.
|
||||
- [x] Добавить кликабельные chip-фильтры типов для `События`.
|
||||
- [ ] Добавить фильтры периода, пресеты, reset/apply actions для `События`.
|
||||
- [x] Добавить локальную пагинацию по 10 событий в `События`.
|
||||
- [ ] Добавить фильтры типов, фильтры периода, пресеты, reset/apply actions для `Доходы`.
|
||||
- [x] Добавить кликабельные chip-фильтры типов для `Доходы`.
|
||||
- [ ] Добавить фильтры периода, пресеты, reset/apply actions для `Доходы`.
|
||||
- [x] Добавить cursor-пагинацию по 10 операций в `Доходы`.
|
||||
- [x] Добавить `BrokerDashboardAnalyticsCard` на основе `useBrokerAnalytics`.
|
||||
- [x] Добавить `BrokerDashboardAllocationCard` на основе существующей аллокации.
|
||||
@ -38,8 +42,8 @@
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- [x] `rtk npm run test:frontend` проходит (31 files, 133 tests).
|
||||
- [x] `rtk npm run test:design-system` проходит (28 files, 160 tests).
|
||||
- [x] `rtk npm run test:frontend` проходит (31 files, 135 tests).
|
||||
- [x] `rtk npm run test:design-system` проходит (28 files, 162 tests).
|
||||
- [x] `rtk npm run lint -w apps/frontend` проходит.
|
||||
- [x] `rtk npm run build:frontend` проходит.
|
||||
- [x] Dashboard соответствует acceptance criteria из `spec.md`.
|
||||
|
||||
@ -59,4 +59,22 @@ describe('Chip', () => {
|
||||
renderWithTheme(<Chip label="Static" />);
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Chip as MuiChip } from '@mui/material';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import { Chip as MuiChip } from '@mui/material';
|
||||
|
||||
type Tone = 'neutral' | 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
@ -15,14 +15,28 @@ export interface ChipProps {
|
||||
label: string;
|
||||
tone?: Tone;
|
||||
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 (
|
||||
<MuiChip
|
||||
label={label}
|
||||
color={TONE_MAP[tone]}
|
||||
variant={selected ? 'filled' : 'outlined'}
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
disabled={disabled}
|
||||
aria-pressed={onClick ? selected : undefined}
|
||||
{...(onDelete ? { deleteIcon: <CancelIcon aria-label={`Remove ${label}`} /> } : {})}
|
||||
/>
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user