codex/broker-dashboard-redesign #49
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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`.
|
||||||
|
|||||||
@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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}`} /> } : {})}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user