feat: improove design

This commit is contained in:
Sergey Krylov 2026-06-27 11:11:25 +03:00
parent d804d43616
commit 0116c34a9f
9 changed files with 528 additions and 82 deletions

View File

@ -11,6 +11,7 @@ export type DashboardFilterState<T extends string> = {
from: string from: string
to: string to: string
types: T[] types: T[]
preset: DashboardDatePreset
} }
export function defaultEventsFilters(): DashboardFilterState<DashboardEventType> { export function defaultEventsFilters(): DashboardFilterState<DashboardEventType> {
@ -19,6 +20,7 @@ export function defaultEventsFilters(): DashboardFilterState<DashboardEventType>
from: now.subtract(7, 'day').format('YYYY-MM-DD'), from: now.subtract(7, 'day').format('YYYY-MM-DD'),
to: now.add(7, 'day').format('YYYY-MM-DD'), to: now.add(7, 'day').format('YYYY-MM-DD'),
types: [...DASHBOARD_EVENT_TYPES], types: [...DASHBOARD_EVENT_TYPES],
preset: '7d',
} }
} }
@ -28,6 +30,7 @@ export function defaultIncomeFilters(): DashboardFilterState<DashboardIncomeType
from: now.startOf('year').format('YYYY-MM-DD'), from: now.startOf('year').format('YYYY-MM-DD'),
to: now.format('YYYY-MM-DD'), to: now.format('YYYY-MM-DD'),
types: [...DASHBOARD_INCOME_TYPES], types: [...DASHBOARD_INCOME_TYPES],
preset: '1y',
} }
} }
@ -36,11 +39,12 @@ export function applyDatePreset<T extends string>(
preset: DashboardDatePreset, preset: DashboardDatePreset,
): DashboardFilterState<T> { ): DashboardFilterState<T> {
const now = dayjs() const now = dayjs()
if (preset === 'all') return { ...filters, from: '', to: now.format('YYYY-MM-DD') } const next = { ...filters, preset }
if (preset === 'all') return { ...next, from: '', to: '' }
const amount = preset === '1y' ? 1 : Number.parseInt(preset, 10) const amount = preset === '1y' ? 1 : Number.parseInt(preset, 10)
const unit = preset === '1y' ? 'year' : 'day' const unit = preset === '1y' ? 'year' : 'day'
return { return {
...filters, ...next,
from: now.subtract(amount, unit).format('YYYY-MM-DD'), from: now.subtract(amount, unit).format('YYYY-MM-DD'),
to: now.format('YYYY-MM-DD'), to: now.format('YYYY-MM-DD'),
} }

View File

@ -40,10 +40,6 @@ vi.mock('@/entities/broker-operation', () => ({
useBrokerOperations: hookMocks.useBrokerOperations, useBrokerOperations: hookMocks.useBrokerOperations,
})) }))
vi.mock('@/widgets/broker-allocation-chart', () => ({
BrokerAllocationChart: () => <div>allocation chart</div>,
}))
const portfolio: BrokerPortfolio = { const portfolio: BrokerPortfolio = {
account: { account: {
id: 'acc-1', id: 'acc-1',
@ -99,14 +95,14 @@ describe('BrokerDashboard', () => {
expect(screen.getByText('Доходы')).toBeInTheDocument() expect(screen.getByText('Доходы')).toBeInTheDocument()
expect(screen.getByText('Аналитика доходности')).toBeInTheDocument() expect(screen.getByText('Аналитика доходности')).toBeInTheDocument()
expect(screen.getByText('Аллокация')).toBeInTheDocument() expect(screen.getByText('Аллокация')).toBeInTheDocument()
expect(screen.getByText('allocation chart')).toBeInTheDocument()
}) })
it('uses event chips as request filters', async () => { it('applies event type chips immediately to useBrokerEvents', async () => {
const user = userEvent.setup() const user = userEvent.setup()
render(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />) render(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
await user.click(screen.getAllByRole('button', { name: 'Купоны' })[0]) const couponChips = screen.getAllByRole('button', { name: 'Купоны' })
await user.click(couponChips[0])
await waitFor(() => { await waitFor(() => {
expect(hookMocks.useBrokerEvents).toHaveBeenLastCalledWith( expect(hookMocks.useBrokerEvents).toHaveBeenLastCalledWith(
@ -117,11 +113,12 @@ describe('BrokerDashboard', () => {
}) })
}) })
it('uses income chips as request filters', async () => { it('applies income type chips immediately to useBrokerOperations', async () => {
const user = userEvent.setup() const user = userEvent.setup()
render(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />) render(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
await user.click(screen.getAllByRole('button', { name: 'Купоны' })[1]) const couponChips = screen.getAllByRole('button', { name: 'Купоны' })
await user.click(couponChips[1])
await waitFor(() => { await waitFor(() => {
expect(hookMocks.useBrokerOperations).toHaveBeenLastCalledWith( expect(hookMocks.useBrokerOperations).toHaveBeenLastCalledWith(
@ -133,4 +130,21 @@ describe('BrokerDashboard', () => {
) )
}) })
}) })
it('shows date filter toggle button and expandable panel', async () => {
const user = userEvent.setup()
render(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const filterButtons = screen.getAllByRole('button', { name: /Фильтр дат/ })
expect(filterButtons).toHaveLength(2)
await user.click(filterButtons[0])
expect(screen.getByText('7д')).toBeInTheDocument()
expect(screen.getByText('30д')).toBeInTheDocument()
expect(screen.getByText('90д')).toBeInTheDocument()
expect(screen.getByText('1г')).toBeInTheDocument()
expect(screen.getByText('Всё')).toBeInTheDocument()
expect(screen.getByText('Сбросить')).toBeInTheDocument()
})
}) })

View File

@ -1,11 +1,14 @@
import { Box } from '@mui/material' import { Box } from '@mui/material'
import { useState } from 'react' import dayjs from 'dayjs'
import { useCallback, useState } from 'react'
import { useBrokerAnalytics } from '@/entities/broker-analytics' import { useBrokerAnalytics } from '@/entities/broker-analytics'
import { useBrokerEvents } from '@/entities/broker-event' import { useBrokerEvents } from '@/entities/broker-event'
import { useBrokerOperations } from '@/entities/broker-operation' 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 {
applyDatePreset,
type DashboardDatePreset,
type DashboardEventType, type DashboardEventType,
type DashboardIncomeType, type DashboardIncomeType,
defaultEventsFilters, defaultEventsFilters,
@ -18,6 +21,15 @@ import { BrokerDashboardEventsCard } from './BrokerDashboardEventsCard'
import { BrokerDashboardHero } from './BrokerDashboardHero' import { BrokerDashboardHero } from './BrokerDashboardHero'
import { BrokerDashboardIncomeCard } from './BrokerDashboardIncomeCard' import { BrokerDashboardIncomeCard } from './BrokerDashboardIncomeCard'
function formatDateLabel(from: string, to: string): string | null {
if (!from && !to) return 'За всё время'
const fmt = (d: string) => dayjs(d).format('D MMM')
if (from && to) return `${fmt(from)} ${fmt(to)}`
if (from) return `с ${fmt(from)}`
if (to) return `до ${fmt(to)}`
return null
}
export function BrokerDashboard({ export function BrokerDashboard({
accountId, accountId,
portfolio, portfolio,
@ -25,34 +37,39 @@ export function BrokerDashboard({
accountId: string accountId: string
portfolio: BrokerPortfolio portfolio: BrokerPortfolio
}) { }) {
const [eventFilters, setEventFilters] = useState(defaultEventsFilters) const [appliedEventFilters, setAppliedEventFilters] = useState(defaultEventsFilters)
const [draftEventFilters, setDraftEventFilters] = useState(defaultEventsFilters)
const [eventFilterPanelOpen, setEventFilterPanelOpen] = useState(false)
const [eventPage, setEventPage] = useState(1) const [eventPage, setEventPage] = useState(1)
const [incomeFilters, setIncomeFilters] = useState(defaultIncomeFilters)
const [appliedIncomeFilters, setAppliedIncomeFilters] = useState(defaultIncomeFilters)
const [draftIncomeFilters, setDraftIncomeFilters] = useState(defaultIncomeFilters)
const [incomeFilterPanelOpen, setIncomeFilterPanelOpen] = useState(false)
const incomePagination = useCursorPagination() const incomePagination = useCursorPagination()
const analytics = useBrokerAnalytics(accountId) const analytics = useBrokerAnalytics(accountId)
const hasEventTypes = eventFilters.types.length > 0 const hasEventTypes = appliedEventFilters.types.length > 0
const hasIncomeTypes = incomeFilters.types.length > 0 const hasIncomeTypes = appliedIncomeFilters.types.length > 0
const hasDraftEventTypes = draftEventFilters.types.length > 0
const hasDraftIncomeTypes = draftIncomeFilters.types.length > 0
const eventPageSize = 10
const events = useBrokerEvents( const events = useBrokerEvents(
accountId, accountId,
{ {
from: eventFilters.from, from: appliedEventFilters.from,
to: eventFilters.to, to: appliedEventFilters.to,
types: eventFilters.types.join(','), types: appliedEventFilters.types.join(','),
}, },
{ enabled: hasEventTypes }, { enabled: hasEventTypes },
) )
const eventItems = events.data?.items ?? []
const eventPageSize = 10
const eventPageItems = eventItems.slice(
(eventPage - 1) * eventPageSize,
eventPage * eventPageSize,
)
const operations = useBrokerOperations( const operations = useBrokerOperations(
accountId, accountId,
{ {
from: incomeFilters.from, from: appliedIncomeFilters.from,
to: incomeFilters.to, to: appliedIncomeFilters.to,
operationTypes: incomeTypesToOperationTypes(incomeFilters.types), operationTypes: incomeTypesToOperationTypes(appliedIncomeFilters.types),
cursor: incomePagination.cursor, cursor: incomePagination.cursor,
limit: 10, limit: 10,
}, },
@ -63,26 +80,106 @@ export function BrokerDashboard({
? (operations.data.nextCursor as unknown as string) ? (operations.data.nextCursor as unknown as string)
: undefined : undefined
const eventItems = events.data?.items ?? []
const eventPageItems = eventItems.slice(
(eventPage - 1) * eventPageSize,
eventPage * eventPageSize,
)
function toggleEventType(type: DashboardEventType) { function toggleEventType(type: DashboardEventType) {
setEventFilters((filters) => ({ setAppliedEventFilters((filters) => {
...filters, const nextTypes = filters.types.includes(type)
types: filters.types.includes(type)
? filters.types.filter((item) => item !== type) ? filters.types.filter((item) => item !== type)
: [...filters.types, type], : [...filters.types, type]
})) return { ...filters, types: nextTypes }
})
setDraftEventFilters((filters) => {
const nextTypes = filters.types.includes(type)
? filters.types.filter((item) => item !== type)
: [...filters.types, type]
return { ...filters, types: nextTypes }
})
setEventPage(1) setEventPage(1)
} }
function toggleIncomeType(type: DashboardIncomeType) { function toggleIncomeType(type: DashboardIncomeType) {
setIncomeFilters((filters) => ({ setAppliedIncomeFilters((filters) => {
...filters, const nextTypes = filters.types.includes(type)
types: filters.types.includes(type)
? filters.types.filter((item) => item !== type) ? filters.types.filter((item) => item !== type)
: [...filters.types, type], : [...filters.types, type]
})) return { ...filters, types: nextTypes }
})
setDraftIncomeFilters((filters) => {
const nextTypes = filters.types.includes(type)
? filters.types.filter((item) => item !== type)
: [...filters.types, type]
return { ...filters, types: nextTypes }
})
incomePagination.reset() incomePagination.reset()
} }
const applyEventFilters = useCallback(() => {
setAppliedEventFilters((prev) => ({
...prev,
from: draftEventFilters.from,
to: draftEventFilters.to,
preset: draftEventFilters.preset,
}))
setEventFilterPanelOpen(false)
setEventPage(1)
}, [draftEventFilters.from, draftEventFilters.to, draftEventFilters.preset])
const resetEventFilters = useCallback(() => {
const defaults = defaultEventsFilters()
setAppliedEventFilters(defaults)
setDraftEventFilters(defaults)
setEventFilterPanelOpen(false)
setEventPage(1)
}, [])
const applyIncomeFilters = useCallback(() => {
setAppliedIncomeFilters((prev) => ({
...prev,
from: draftIncomeFilters.from,
to: draftIncomeFilters.to,
preset: draftIncomeFilters.preset,
}))
setIncomeFilterPanelOpen(false)
incomePagination.reset()
}, [draftIncomeFilters.from, draftIncomeFilters.to, draftIncomeFilters.preset, incomePagination])
const resetIncomeFilters = useCallback(() => {
const defaults = defaultIncomeFilters()
setAppliedIncomeFilters(defaults)
setDraftIncomeFilters(defaults)
setIncomeFilterPanelOpen(false)
incomePagination.reset()
}, [incomePagination])
function handleDraftEventPresetChange(preset: DashboardDatePreset) {
setDraftEventFilters((filters) => applyDatePreset(filters, preset))
}
function handleDraftEventFromChange(value: string) {
setDraftEventFilters((filters) => ({ ...filters, from: value }))
}
function handleDraftEventToChange(value: string) {
setDraftEventFilters((filters) => ({ ...filters, to: value }))
}
function handleDraftIncomePresetChange(preset: DashboardDatePreset) {
setDraftIncomeFilters((filters) => applyDatePreset(filters, preset))
}
function handleDraftIncomeFromChange(value: string) {
setDraftIncomeFilters((filters) => ({ ...filters, from: value }))
}
function handleDraftIncomeToChange(value: string) {
setDraftIncomeFilters((filters) => ({ ...filters, to: value }))
}
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} />
@ -91,8 +188,20 @@ export function BrokerDashboard({
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} selectedTypes={draftEventFilters.types}
onToggleType={toggleEventType} onToggleType={toggleEventType}
appliedDateLabel={formatDateLabel(appliedEventFilters.from, appliedEventFilters.to)}
draftPreset={draftEventFilters.preset}
draftFrom={draftEventFilters.from}
draftTo={draftEventFilters.to}
onDraftPresetChange={handleDraftEventPresetChange}
onDraftFromChange={handleDraftEventFromChange}
onDraftToChange={handleDraftEventToChange}
onApplyFilters={applyEventFilters}
onResetFilters={resetEventFilters}
dateFilterOpen={eventFilterPanelOpen}
onToggleDateFilter={() => setEventFilterPanelOpen((v) => !v)}
hasDraftTypes={hasDraftEventTypes}
page={eventPage} page={eventPage}
canGoBack={eventPage > 1} canGoBack={eventPage > 1}
canGoForward={eventPage * eventPageSize < eventItems.length} canGoForward={eventPage * eventPageSize < eventItems.length}
@ -104,8 +213,20 @@ export function BrokerDashboard({
page={operations.data} page={operations.data}
isLoading={operations.isLoading} isLoading={operations.isLoading}
isError={operations.isError} isError={operations.isError}
selectedTypes={incomeFilters.types} selectedTypes={draftIncomeFilters.types}
onToggleType={toggleIncomeType} onToggleType={toggleIncomeType}
appliedDateLabel={formatDateLabel(appliedIncomeFilters.from, appliedIncomeFilters.to)}
draftPreset={draftIncomeFilters.preset}
draftFrom={draftIncomeFilters.from}
draftTo={draftIncomeFilters.to}
onDraftPresetChange={handleDraftIncomePresetChange}
onDraftFromChange={handleDraftIncomeFromChange}
onDraftToChange={handleDraftIncomeToChange}
onApplyFilters={applyIncomeFilters}
onResetFilters={resetIncomeFilters}
dateFilterOpen={incomeFilterPanelOpen}
onToggleDateFilter={() => setIncomeFilterPanelOpen((v) => !v)}
hasDraftTypes={hasDraftIncomeTypes}
pageNumber={incomePagination.pageNumber} pageNumber={incomePagination.pageNumber}
canGoBack={incomePagination.pageNumber > 1} canGoBack={incomePagination.pageNumber > 1}
canGoForward={operations.data?.hasNext ?? false} canGoForward={operations.data?.hasNext ?? false}

View File

@ -1,19 +1,78 @@
import { Text } from '@moex-vibe/design-system' import { Text } from '@moex-vibe/design-system'
import { Box } from '@mui/material' import { Box } from '@mui/material'
import { buildBrokerAllocation } from '@/entities/broker-position'
import type { BrokerPortfolio } from '@/shared/api' import type { BrokerPortfolio } from '@/shared/api'
import { formatBrokerMoney } from '@/shared/lib/formatters' import { formatBrokerCurrencyValue, formatBrokerMoney } from '@/shared/lib/formatters'
import { BrokerAllocationChart } from '@/widgets/broker-allocation-chart'
import { BrokerDashboardCard } from './BrokerDashboardCard' import { BrokerDashboardCard } from './BrokerDashboardCard'
const ALLOCATION_COLORS: Record<string, string> = {
shares: '#4969f5',
bonds: '#e5a33c',
etf: '#62b889',
cash: '#7b63cf',
other: '#aeb6c5',
}
export function BrokerDashboardAllocationCard({ portfolio }: { portfolio: BrokerPortfolio }) { export function BrokerDashboardAllocationCard({ portfolio }: { portfolio: BrokerPortfolio }) {
const { total, sectors, negative } = buildBrokerAllocation(portfolio)
const currency =
total >= 0
? (portfolio.totals.portfolio?.currency ??
Object.values(portfolio.totals).find((t) => t?.currency)?.currency ??
'RUB')
: 'RUB'
return ( return (
<BrokerDashboardCard <BrokerDashboardCard
title="Аллокация" title="Аллокация"
action={<Text variant="numeric">{formatBrokerMoney(portfolio.totals.portfolio)}</Text>} action={<Text variant="numeric">{formatBrokerMoney(portfolio.totals.portfolio)}</Text>}
> >
<Box sx={{ minHeight: 220, display: 'flex', alignItems: 'center' }}> {sectors.length === 0 && negative.length === 0 ? (
<BrokerAllocationChart portfolio={portfolio} /> <Text tone="muted">Нет данных для распределения</Text>
</Box> ) : (
<Box sx={{ display: 'grid', gap: 1.5 }}>
{sectors.map((sector) => (
<Box key={sector.key}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Text variant="body">{sector.label}</Text>
<Text variant="body" tone="secondary">
{formatBrokerCurrencyValue(currency, sector.value)} · {sector.percent.toFixed(1)}%
</Text>
</Box>
<Box
sx={{
height: 10,
borderRadius: 5,
bgcolor: 'grey.200',
overflow: 'hidden',
}}
>
<Box
sx={{
width: `${sector.percent}%`,
height: '100%',
bgcolor: ALLOCATION_COLORS[sector.key] ?? '#aeb6c5',
borderRadius: 5,
transition: 'width 0.3s',
}}
/>
</Box>
</Box>
))}
{negative.length > 0 && (
<Box sx={{ mt: 1 }}>
{negative.map((item) => (
<Box key={item.key} sx={{ display: 'flex', gap: 1 }}>
<Text variant="body">{item.label}:</Text>
<Text variant="body" tone="negative">
{formatBrokerCurrencyValue(currency, item.value)}
</Text>
</Box>
))}
</Box>
)}
</Box>
)}
</BrokerDashboardCard> </BrokerDashboardCard>
) )
} }

View File

@ -0,0 +1,156 @@
import { Button, Chip, Text } from '@moex-vibe/design-system'
import { Box } from '@mui/material'
import type { DashboardDatePreset } from '../lib/dashboardFilters'
const PRESETS: { key: DashboardDatePreset; label: string }[] = [
{ key: '7d', label: '7д' },
{ key: '30d', label: '30д' },
{ key: '90d', label: '90д' },
{ key: '1y', label: '1г' },
{ key: 'all', label: 'Всё' },
]
type BrokerDashboardDateFilterProps = {
appliedLabel: string | null
preset: DashboardDatePreset
draftFrom: string
draftTo: string
hasDraftTypes: boolean
onPresetChange: (preset: DashboardDatePreset) => void
onFromChange: (value: string) => void
onToChange: (value: string) => void
onReset: () => void
onApply: () => void
isOpen: boolean
onToggle: () => void
}
export function BrokerDashboardDateFilter({
appliedLabel,
preset,
draftFrom,
draftTo,
hasDraftTypes,
onPresetChange,
onFromChange,
onToChange,
onReset,
onApply,
isOpen,
onToggle,
}: BrokerDashboardDateFilterProps) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Box
component="button"
type="button"
onClick={onToggle}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 1,
bgcolor: 'grey.800',
color: 'common.white',
border: 'none',
borderRadius: 1.5,
px: 1.5,
py: 0.75,
fontSize: 13,
cursor: 'pointer',
'&:hover': { bgcolor: 'grey.700' },
}}
>
📅 Фильтр дат
{appliedLabel && (
<Box
sx={{
bgcolor: 'grey.600',
borderRadius: 10,
px: 0.75,
py: 0.125,
fontSize: 11,
lineHeight: 1.4,
whiteSpace: 'nowrap',
}}
>
{appliedLabel}
</Box>
)}
</Box>
<Button variant="primary" size="small" onClick={onApply} disabled={!hasDraftTypes}>
Показать
</Button>
{isOpen && (
<Box
sx={{
width: '100%',
mt: 0.5,
p: 1.5,
border: '1px solid',
borderColor: 'divider',
borderRadius: 2,
bgcolor: 'grey.50',
}}
>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 1 }}>
{PRESETS.map((p) => (
<Chip
key={p.key}
label={p.label}
tone={p.key === preset ? 'primary' : 'neutral'}
selected={p.key === preset}
onClick={() => onPresetChange(p.key)}
/>
))}
</Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<input
type="date"
value={draftFrom}
onChange={(e) => onFromChange(e.target.value)}
style={{
border: '1px solid #ccc',
borderRadius: 6,
padding: '4px 8px',
fontSize: 13,
flex: '0 1 auto',
}}
/>
<Text variant="body" tone="muted">
</Text>
<input
type="date"
value={draftTo}
onChange={(e) => onToChange(e.target.value)}
style={{
border: '1px solid #ccc',
borderRadius: 6,
padding: '4px 8px',
fontSize: 13,
flex: '0 1 auto',
}}
/>
<Box
component="button"
type="button"
onClick={onReset}
sx={{
background: 'none',
border: 'none',
color: 'text.secondary',
fontSize: 12,
cursor: 'pointer',
textDecoration: 'underline',
textUnderlineOffset: 2,
p: 0.5,
}}
>
Сбросить
</Box>
</Box>
</Box>
)}
</Box>
)
}

View File

@ -3,9 +3,10 @@ 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 type { DashboardDatePreset, DashboardEventType } from '../lib/dashboardFilters'
import { eventStatusLabel, eventTypeLabel } from '../lib/dashboardFormatters' import { eventStatusLabel, eventTypeLabel } from '../lib/dashboardFormatters'
import { BrokerDashboardCard } from './BrokerDashboardCard' import { BrokerDashboardCard } from './BrokerDashboardCard'
import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter'
const EVENT_FILTERS: Array<{ const EVENT_FILTERS: Array<{
type: DashboardEventType type: DashboardEventType
@ -25,6 +26,18 @@ type BrokerDashboardEventsCardProps = {
isError: boolean isError: boolean
selectedTypes: DashboardEventType[] selectedTypes: DashboardEventType[]
onToggleType: (type: DashboardEventType) => void onToggleType: (type: DashboardEventType) => void
appliedDateLabel: string | null
draftPreset: DashboardDatePreset
draftFrom: string
draftTo: string
onDraftPresetChange: (preset: DashboardDatePreset) => void
onDraftFromChange: (value: string) => void
onDraftToChange: (value: string) => void
onApplyFilters: () => void
onResetFilters: () => void
dateFilterOpen: boolean
onToggleDateFilter: () => void
hasDraftTypes: boolean
page: number page: number
onPreviousPage: () => void onPreviousPage: () => void
onNextPage: () => void onNextPage: () => void
@ -46,6 +59,18 @@ export function BrokerDashboardEventsCard({
isError, isError,
selectedTypes, selectedTypes,
onToggleType, onToggleType,
appliedDateLabel,
draftPreset,
draftFrom,
draftTo,
onDraftPresetChange,
onDraftFromChange,
onDraftToChange,
onApplyFilters,
onResetFilters,
dateFilterOpen,
onToggleDateFilter,
hasDraftTypes,
page, page,
onPreviousPage, onPreviousPage,
onNextPage, onNextPage,
@ -58,18 +83,36 @@ export function BrokerDashboardEventsCard({
<BrokerDashboardCard <BrokerDashboardCard
title="События" title="События"
action={<Link to={`/broker/${encodeURIComponent(accountId)}/events`}>Все события</Link>} action={<Link to={`/broker/${encodeURIComponent(accountId)}/events`}>Все события</Link>}
> filters={
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 1.5 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{EVENT_FILTERS.map((filter) => ( <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
<Chip {EVENT_FILTERS.map((filter) => (
key={filter.type} <Chip
label={filter.label} key={filter.type}
tone={filter.tone} label={filter.label}
selected={selectedTypes.includes(filter.type)} tone={filter.tone}
onClick={() => onToggleType(filter.type)} selected={selectedTypes.includes(filter.type)}
onClick={() => onToggleType(filter.type)}
/>
))}
</Box>
<BrokerDashboardDateFilter
appliedLabel={appliedDateLabel}
preset={draftPreset}
draftFrom={draftFrom}
draftTo={draftTo}
hasDraftTypes={hasDraftTypes}
onPresetChange={onDraftPresetChange}
onFromChange={onDraftFromChange}
onToChange={onDraftToChange}
onReset={onResetFilters}
onApply={onApplyFilters}
isOpen={dateFilterOpen}
onToggle={onToggleDateFilter}
/> />
))} </Box>
</Box> }
>
{selectedTypes.length === 0 ? ( {selectedTypes.length === 0 ? (
<Text tone="negative">Выберите хотя бы один тип событий</Text> <Text tone="negative">Выберите хотя бы один тип событий</Text>
) : isError ? ( ) : isError ? (

View File

@ -32,7 +32,7 @@ export function BrokerDashboardHero({ portfolio, analytics }: BrokerDashboardHer
display: 'grid', display: 'grid',
gap: 2, gap: 2,
gridTemplateColumns: { xs: '1fr', md: 'minmax(240px, 1fr) repeat(3, auto)' }, gridTemplateColumns: { xs: '1fr', md: 'minmax(240px, 1fr) repeat(3, auto)' },
alignItems: 'center', alignItems: 'stretch',
}} }}
> >
<Box> <Box>
@ -43,13 +43,19 @@ export function BrokerDashboardHero({ portfolio, analytics }: BrokerDashboardHer
{accountName} {accountName}
</Box> </Box>
</Box> </Box>
<Metric label="Стоимость портфеля" value={formatBrokerMoney(portfolio.totals.portfolio)} /> <Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Metric <Metric label="Стоимость портфеля" value={formatBrokerMoney(portfolio.totals.portfolio)} />
label="Доходность" </Box>
value={percentValue(returnPercent)} <Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
supportingText={`За день: ${formatBrokerMoney(portfolio.yields.daily)}`} <Metric
/> label="Доходность"
<Metric label="Всего доходов" value={totalReceived} /> value={percentValue(returnPercent)}
supportingText={`За день: ${formatBrokerMoney(portfolio.yields.daily)}`}
/>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Metric label="Всего доходов" value={totalReceived} />
</Box>
</Box> </Box>
) )
} }

View File

@ -3,9 +3,10 @@ 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 type { DashboardDatePreset, DashboardIncomeType } from '../lib/dashboardFilters'
import { getDashboardIncomeRows, sumDashboardIncome } from '../lib/dashboardIncome' import { getDashboardIncomeRows, sumDashboardIncome } from '../lib/dashboardIncome'
import { BrokerDashboardCard } from './BrokerDashboardCard' import { BrokerDashboardCard } from './BrokerDashboardCard'
import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter'
const INCOME_FILTERS: Array<{ const INCOME_FILTERS: Array<{
type: DashboardIncomeType type: DashboardIncomeType
@ -23,6 +24,18 @@ type BrokerDashboardIncomeCardProps = {
isError: boolean isError: boolean
selectedTypes: DashboardIncomeType[] selectedTypes: DashboardIncomeType[]
onToggleType: (type: DashboardIncomeType) => void onToggleType: (type: DashboardIncomeType) => void
appliedDateLabel: string | null
draftPreset: DashboardDatePreset
draftFrom: string
draftTo: string
onDraftPresetChange: (preset: DashboardDatePreset) => void
onDraftFromChange: (value: string) => void
onDraftToChange: (value: string) => void
onApplyFilters: () => void
onResetFilters: () => void
dateFilterOpen: boolean
onToggleDateFilter: () => void
hasDraftTypes: boolean
pageNumber: number pageNumber: number
canGoBack: boolean canGoBack: boolean
canGoForward: boolean canGoForward: boolean
@ -37,6 +50,18 @@ export function BrokerDashboardIncomeCard({
isError, isError,
selectedTypes, selectedTypes,
onToggleType, onToggleType,
appliedDateLabel,
draftPreset,
draftFrom,
draftTo,
onDraftPresetChange,
onDraftFromChange,
onDraftToChange,
onApplyFilters,
onResetFilters,
dateFilterOpen,
onToggleDateFilter,
hasDraftTypes,
pageNumber, pageNumber,
canGoBack, canGoBack,
canGoForward, canGoForward,
@ -50,18 +75,36 @@ export function BrokerDashboardIncomeCard({
<BrokerDashboardCard <BrokerDashboardCard
title="Доходы" title="Доходы"
action={<Link to={`/broker/${encodeURIComponent(accountId)}/operations`}>Все операции</Link>} action={<Link to={`/broker/${encodeURIComponent(accountId)}/operations`}>Все операции</Link>}
> filters={
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 1.5 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{INCOME_FILTERS.map((filter) => ( <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
<Chip {INCOME_FILTERS.map((filter) => (
key={filter.type} <Chip
label={filter.label} key={filter.type}
tone={filter.tone} label={filter.label}
selected={selectedTypes.includes(filter.type)} tone={filter.tone}
onClick={() => onToggleType(filter.type)} selected={selectedTypes.includes(filter.type)}
onClick={() => onToggleType(filter.type)}
/>
))}
</Box>
<BrokerDashboardDateFilter
appliedLabel={appliedDateLabel}
preset={draftPreset}
draftFrom={draftFrom}
draftTo={draftTo}
hasDraftTypes={hasDraftTypes}
onPresetChange={onDraftPresetChange}
onFromChange={onDraftFromChange}
onToChange={onDraftToChange}
onReset={onResetFilters}
onApply={onApplyFilters}
isOpen={dateFilterOpen}
onToggle={onToggleDateFilter}
/> />
))} </Box>
</Box> }
>
{selectedTypes.length === 0 ? ( {selectedTypes.length === 0 ? (
<Text tone="negative">Выберите хотя бы один тип доходов</Text> <Text tone="negative">Выберите хотя бы один тип доходов</Text>
) : isError ? ( ) : isError ? (

View File

@ -26,11 +26,11 @@
- [x] Перенести навигацию счёта из левой колонки в горизонтальные вкладки над контентом. - [x] Перенести навигацию счёта из левой колонки в горизонтальные вкладки над контентом.
- [x] Перестроить dashboard на один блок на строке для `События`, `Доходы`, `Аналитика доходности`, `Аллокация`. - [x] Перестроить dashboard на один блок на строке для `События`, `Доходы`, `Аналитика доходности`, `Аллокация`.
- [x] Добавить кликабельные chip-фильтры типов для `События`. - [x] Добавить кликабельные chip-фильтры типов для `События`.
- [ ] Добавить `BrokerDashboardDateFilter` — переиспользуемый expandable-компонент фильтра дат (пресеты 7д/30д/90д/1г/Всё, from/to поля, Сбросить/Показать). - [x] Добавить `BrokerDashboardDateFilter` — переиспользуемый expandable-компонент фильтра дат (пресеты 7д/30д/90д/1г/Всё, from/to поля, Сбросить/Показать).
- [ ] Подключить `BrokerDashboardDateFilter` в `События` с draft/applied состоянием. - [x] Подключить `BrokerDashboardDateFilter` в `События` с draft/applied состоянием.
- [x] Добавить локальную пагинацию по 10 событий в `События`. - [x] Добавить локальную пагинацию по 10 событий в `События`.
- [x] Добавить кликабельные chip-фильтры типов для `Доходы`. - [x] Добавить кликабельные chip-фильтры типов для `Доходы`.
- [ ] Подключить `BrokerDashboardDateFilter` в `Доходы` с draft/applied состоянием. - [x] Подключить `BrokerDashboardDateFilter` в `Доходы` с draft/applied состоянием.
- [x] Добавить cursor-пагинацию по 10 операций в `Доходы`. - [x] Добавить cursor-пагинацию по 10 операций в `Доходы`.
- [x] Добавить `BrokerDashboardAnalyticsCard` на основе `useBrokerAnalytics`. - [x] Добавить `BrokerDashboardAnalyticsCard` на основе `useBrokerAnalytics`.
- [x] Добавить `BrokerDashboardAllocationCard` на основе существующей аллокации. - [x] Добавить `BrokerDashboardAllocationCard` на основе существующей аллокации.
@ -38,8 +38,8 @@
- [x] Добавить `BrokerDashboard` как top-level composition widget. - [x] Добавить `BrokerDashboard` как top-level composition widget.
- [x] Заменить текущий вертикальный обзор в `BrokerAccountOverviewPage` на `BrokerDashboard`. - [x] Заменить текущий вертикальный обзор в `BrokerAccountOverviewPage` на `BrokerDashboard`.
- [x] Добавить unit/component tests для helpers и базовой dashboard composition. - [x] Добавить unit/component tests для helpers и базовой dashboard composition.
- [ ] Выровнять hero KPI: все Metric одной высоты, supportingText не раздвигает "Доходность" выше соседей. - [x] Выровнять hero KPI: все Metric одной высоты, supportingText не раздвигает "Доходность" выше соседей.
- [ ] Заменить donut-диаграмму аллокации на горизонтальные бары в `BrokerDashboardAllocationCard`. - [x] Заменить donut-диаграмму аллокации на горизонтальные бары в `BrokerDashboardAllocationCard`.
- [ ] Проверить desktop layout `/broker/2084014113`. - [ ] Проверить desktop layout `/broker/2084014113`.
- [ ] Проверить mobile layout `/broker/2084014113`. - [ ] Проверить mobile layout `/broker/2084014113`.