feat: improove design
This commit is contained in:
parent
d804d43616
commit
0116c34a9f
@ -11,6 +11,7 @@ export type DashboardFilterState<T extends string> = {
|
||||
from: string
|
||||
to: string
|
||||
types: T[]
|
||||
preset: DashboardDatePreset
|
||||
}
|
||||
|
||||
export function defaultEventsFilters(): DashboardFilterState<DashboardEventType> {
|
||||
@ -19,6 +20,7 @@ export function defaultEventsFilters(): DashboardFilterState<DashboardEventType>
|
||||
from: now.subtract(7, 'day').format('YYYY-MM-DD'),
|
||||
to: now.add(7, 'day').format('YYYY-MM-DD'),
|
||||
types: [...DASHBOARD_EVENT_TYPES],
|
||||
preset: '7d',
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,6 +30,7 @@ export function defaultIncomeFilters(): DashboardFilterState<DashboardIncomeType
|
||||
from: now.startOf('year').format('YYYY-MM-DD'),
|
||||
to: now.format('YYYY-MM-DD'),
|
||||
types: [...DASHBOARD_INCOME_TYPES],
|
||||
preset: '1y',
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,11 +39,12 @@ export function applyDatePreset<T extends string>(
|
||||
preset: DashboardDatePreset,
|
||||
): DashboardFilterState<T> {
|
||||
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 unit = preset === '1y' ? 'year' : 'day'
|
||||
return {
|
||||
...filters,
|
||||
...next,
|
||||
from: now.subtract(amount, unit).format('YYYY-MM-DD'),
|
||||
to: now.format('YYYY-MM-DD'),
|
||||
}
|
||||
|
||||
@ -40,10 +40,6 @@ vi.mock('@/entities/broker-operation', () => ({
|
||||
useBrokerOperations: hookMocks.useBrokerOperations,
|
||||
}))
|
||||
|
||||
vi.mock('@/widgets/broker-allocation-chart', () => ({
|
||||
BrokerAllocationChart: () => <div>allocation chart</div>,
|
||||
}))
|
||||
|
||||
const portfolio: BrokerPortfolio = {
|
||||
account: {
|
||||
id: 'acc-1',
|
||||
@ -99,14 +95,14 @@ describe('BrokerDashboard', () => {
|
||||
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()
|
||||
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(() => {
|
||||
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()
|
||||
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(() => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
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 { useBrokerEvents } from '@/entities/broker-event'
|
||||
import { useBrokerOperations } from '@/entities/broker-operation'
|
||||
import type { BrokerPortfolio } from '@/shared/api'
|
||||
import { useCursorPagination } from '@/shared/lib/useCursorPagination'
|
||||
import {
|
||||
applyDatePreset,
|
||||
type DashboardDatePreset,
|
||||
type DashboardEventType,
|
||||
type DashboardIncomeType,
|
||||
defaultEventsFilters,
|
||||
@ -18,6 +21,15 @@ import { BrokerDashboardEventsCard } from './BrokerDashboardEventsCard'
|
||||
import { BrokerDashboardHero } from './BrokerDashboardHero'
|
||||
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({
|
||||
accountId,
|
||||
portfolio,
|
||||
@ -25,34 +37,39 @@ export function BrokerDashboard({
|
||||
accountId: string
|
||||
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 [incomeFilters, setIncomeFilters] = useState(defaultIncomeFilters)
|
||||
|
||||
const [appliedIncomeFilters, setAppliedIncomeFilters] = useState(defaultIncomeFilters)
|
||||
const [draftIncomeFilters, setDraftIncomeFilters] = useState(defaultIncomeFilters)
|
||||
const [incomeFilterPanelOpen, setIncomeFilterPanelOpen] = useState(false)
|
||||
const incomePagination = useCursorPagination()
|
||||
|
||||
const analytics = useBrokerAnalytics(accountId)
|
||||
const hasEventTypes = eventFilters.types.length > 0
|
||||
const hasIncomeTypes = incomeFilters.types.length > 0
|
||||
const hasEventTypes = appliedEventFilters.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(
|
||||
accountId,
|
||||
{
|
||||
from: eventFilters.from,
|
||||
to: eventFilters.to,
|
||||
types: eventFilters.types.join(','),
|
||||
from: appliedEventFilters.from,
|
||||
to: appliedEventFilters.to,
|
||||
types: appliedEventFilters.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),
|
||||
from: appliedIncomeFilters.from,
|
||||
to: appliedIncomeFilters.to,
|
||||
operationTypes: incomeTypesToOperationTypes(appliedIncomeFilters.types),
|
||||
cursor: incomePagination.cursor,
|
||||
limit: 10,
|
||||
},
|
||||
@ -63,26 +80,106 @@ export function BrokerDashboard({
|
||||
? (operations.data.nextCursor as unknown as string)
|
||||
: undefined
|
||||
|
||||
const eventItems = events.data?.items ?? []
|
||||
const eventPageItems = eventItems.slice(
|
||||
(eventPage - 1) * eventPageSize,
|
||||
eventPage * eventPageSize,
|
||||
)
|
||||
|
||||
function toggleEventType(type: DashboardEventType) {
|
||||
setEventFilters((filters) => ({
|
||||
...filters,
|
||||
types: filters.types.includes(type)
|
||||
setAppliedEventFilters((filters) => {
|
||||
const nextTypes = filters.types.includes(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)
|
||||
}
|
||||
|
||||
function toggleIncomeType(type: DashboardIncomeType) {
|
||||
setIncomeFilters((filters) => ({
|
||||
...filters,
|
||||
types: filters.types.includes(type)
|
||||
setAppliedIncomeFilters((filters) => {
|
||||
const nextTypes = filters.types.includes(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()
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box sx={{ display: 'grid', gap: 3 }}>
|
||||
<BrokerDashboardHero portfolio={portfolio} analytics={analytics.data} />
|
||||
@ -91,8 +188,20 @@ export function BrokerDashboard({
|
||||
data={events.data ? { ...events.data, items: eventPageItems } : undefined}
|
||||
isLoading={events.isLoading}
|
||||
isError={events.isError}
|
||||
selectedTypes={eventFilters.types}
|
||||
selectedTypes={draftEventFilters.types}
|
||||
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}
|
||||
canGoBack={eventPage > 1}
|
||||
canGoForward={eventPage * eventPageSize < eventItems.length}
|
||||
@ -104,8 +213,20 @@ export function BrokerDashboard({
|
||||
page={operations.data}
|
||||
isLoading={operations.isLoading}
|
||||
isError={operations.isError}
|
||||
selectedTypes={incomeFilters.types}
|
||||
selectedTypes={draftIncomeFilters.types}
|
||||
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}
|
||||
canGoBack={incomePagination.pageNumber > 1}
|
||||
canGoForward={operations.data?.hasNext ?? false}
|
||||
|
||||
@ -1,19 +1,78 @@
|
||||
import { Text } from '@moex-vibe/design-system'
|
||||
import { Box } from '@mui/material'
|
||||
import { buildBrokerAllocation } from '@/entities/broker-position'
|
||||
import type { BrokerPortfolio } from '@/shared/api'
|
||||
import { formatBrokerMoney } from '@/shared/lib/formatters'
|
||||
import { BrokerAllocationChart } from '@/widgets/broker-allocation-chart'
|
||||
import { formatBrokerCurrencyValue, formatBrokerMoney } from '@/shared/lib/formatters'
|
||||
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 }) {
|
||||
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 (
|
||||
<BrokerDashboardCard
|
||||
title="Аллокация"
|
||||
action={<Text variant="numeric">{formatBrokerMoney(portfolio.totals.portfolio)}</Text>}
|
||||
>
|
||||
<Box sx={{ minHeight: 220, display: 'flex', alignItems: 'center' }}>
|
||||
<BrokerAllocationChart portfolio={portfolio} />
|
||||
</Box>
|
||||
{sectors.length === 0 && negative.length === 0 ? (
|
||||
<Text tone="muted">Нет данных для распределения</Text>
|
||||
) : (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -3,9 +3,10 @@ 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 type { DashboardDatePreset, DashboardEventType } from '../lib/dashboardFilters'
|
||||
import { eventStatusLabel, eventTypeLabel } from '../lib/dashboardFormatters'
|
||||
import { BrokerDashboardCard } from './BrokerDashboardCard'
|
||||
import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter'
|
||||
|
||||
const EVENT_FILTERS: Array<{
|
||||
type: DashboardEventType
|
||||
@ -25,6 +26,18 @@ type BrokerDashboardEventsCardProps = {
|
||||
isError: boolean
|
||||
selectedTypes: DashboardEventType[]
|
||||
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
|
||||
onPreviousPage: () => void
|
||||
onNextPage: () => void
|
||||
@ -46,6 +59,18 @@ export function BrokerDashboardEventsCard({
|
||||
isError,
|
||||
selectedTypes,
|
||||
onToggleType,
|
||||
appliedDateLabel,
|
||||
draftPreset,
|
||||
draftFrom,
|
||||
draftTo,
|
||||
onDraftPresetChange,
|
||||
onDraftFromChange,
|
||||
onDraftToChange,
|
||||
onApplyFilters,
|
||||
onResetFilters,
|
||||
dateFilterOpen,
|
||||
onToggleDateFilter,
|
||||
hasDraftTypes,
|
||||
page,
|
||||
onPreviousPage,
|
||||
onNextPage,
|
||||
@ -58,18 +83,36 @@ export function BrokerDashboardEventsCard({
|
||||
<BrokerDashboardCard
|
||||
title="События"
|
||||
action={<Link to={`/broker/${encodeURIComponent(accountId)}/events`}>Все события</Link>}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 1.5 }}>
|
||||
{EVENT_FILTERS.map((filter) => (
|
||||
<Chip
|
||||
key={filter.type}
|
||||
label={filter.label}
|
||||
tone={filter.tone}
|
||||
selected={selectedTypes.includes(filter.type)}
|
||||
onClick={() => onToggleType(filter.type)}
|
||||
filters={
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{EVENT_FILTERS.map((filter) => (
|
||||
<Chip
|
||||
key={filter.type}
|
||||
label={filter.label}
|
||||
tone={filter.tone}
|
||||
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 ? (
|
||||
<Text tone="negative">Выберите хотя бы один тип событий</Text>
|
||||
) : isError ? (
|
||||
|
||||
@ -32,7 +32,7 @@ export function BrokerDashboardHero({ portfolio, analytics }: BrokerDashboardHer
|
||||
display: 'grid',
|
||||
gap: 2,
|
||||
gridTemplateColumns: { xs: '1fr', md: 'minmax(240px, 1fr) repeat(3, auto)' },
|
||||
alignItems: 'center',
|
||||
alignItems: 'stretch',
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
@ -43,13 +43,19 @@ export function BrokerDashboardHero({ portfolio, analytics }: BrokerDashboardHer
|
||||
{accountName}
|
||||
</Box>
|
||||
</Box>
|
||||
<Metric label="Стоимость портфеля" value={formatBrokerMoney(portfolio.totals.portfolio)} />
|
||||
<Metric
|
||||
label="Доходность"
|
||||
value={percentValue(returnPercent)}
|
||||
supportingText={`За день: ${formatBrokerMoney(portfolio.yields.daily)}`}
|
||||
/>
|
||||
<Metric label="Всего доходов" value={totalReceived} />
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Metric label="Стоимость портфеля" value={formatBrokerMoney(portfolio.totals.portfolio)} />
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Metric
|
||||
label="Доходность"
|
||||
value={percentValue(returnPercent)}
|
||||
supportingText={`За день: ${formatBrokerMoney(portfolio.yields.daily)}`}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Metric label="Всего доходов" value={totalReceived} />
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@ -3,9 +3,10 @@ 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 type { DashboardDatePreset, DashboardIncomeType } from '../lib/dashboardFilters'
|
||||
import { getDashboardIncomeRows, sumDashboardIncome } from '../lib/dashboardIncome'
|
||||
import { BrokerDashboardCard } from './BrokerDashboardCard'
|
||||
import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter'
|
||||
|
||||
const INCOME_FILTERS: Array<{
|
||||
type: DashboardIncomeType
|
||||
@ -23,6 +24,18 @@ type BrokerDashboardIncomeCardProps = {
|
||||
isError: boolean
|
||||
selectedTypes: DashboardIncomeType[]
|
||||
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
|
||||
canGoBack: boolean
|
||||
canGoForward: boolean
|
||||
@ -37,6 +50,18 @@ export function BrokerDashboardIncomeCard({
|
||||
isError,
|
||||
selectedTypes,
|
||||
onToggleType,
|
||||
appliedDateLabel,
|
||||
draftPreset,
|
||||
draftFrom,
|
||||
draftTo,
|
||||
onDraftPresetChange,
|
||||
onDraftFromChange,
|
||||
onDraftToChange,
|
||||
onApplyFilters,
|
||||
onResetFilters,
|
||||
dateFilterOpen,
|
||||
onToggleDateFilter,
|
||||
hasDraftTypes,
|
||||
pageNumber,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
@ -50,18 +75,36 @@ export function BrokerDashboardIncomeCard({
|
||||
<BrokerDashboardCard
|
||||
title="Доходы"
|
||||
action={<Link to={`/broker/${encodeURIComponent(accountId)}/operations`}>Все операции</Link>}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 1.5 }}>
|
||||
{INCOME_FILTERS.map((filter) => (
|
||||
<Chip
|
||||
key={filter.type}
|
||||
label={filter.label}
|
||||
tone={filter.tone}
|
||||
selected={selectedTypes.includes(filter.type)}
|
||||
onClick={() => onToggleType(filter.type)}
|
||||
filters={
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{INCOME_FILTERS.map((filter) => (
|
||||
<Chip
|
||||
key={filter.type}
|
||||
label={filter.label}
|
||||
tone={filter.tone}
|
||||
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 ? (
|
||||
<Text tone="negative">Выберите хотя бы один тип доходов</Text>
|
||||
) : isError ? (
|
||||
|
||||
@ -26,11 +26,11 @@
|
||||
- [x] Перенести навигацию счёта из левой колонки в горизонтальные вкладки над контентом.
|
||||
- [x] Перестроить dashboard на один блок на строке для `События`, `Доходы`, `Аналитика доходности`, `Аллокация`.
|
||||
- [x] Добавить кликабельные chip-фильтры типов для `События`.
|
||||
- [ ] Добавить `BrokerDashboardDateFilter` — переиспользуемый expandable-компонент фильтра дат (пресеты 7д/30д/90д/1г/Всё, from/to поля, Сбросить/Показать).
|
||||
- [ ] Подключить `BrokerDashboardDateFilter` в `События` с draft/applied состоянием.
|
||||
- [x] Добавить `BrokerDashboardDateFilter` — переиспользуемый expandable-компонент фильтра дат (пресеты 7д/30д/90д/1г/Всё, from/to поля, Сбросить/Показать).
|
||||
- [x] Подключить `BrokerDashboardDateFilter` в `События` с draft/applied состоянием.
|
||||
- [x] Добавить локальную пагинацию по 10 событий в `События`.
|
||||
- [x] Добавить кликабельные chip-фильтры типов для `Доходы`.
|
||||
- [ ] Подключить `BrokerDashboardDateFilter` в `Доходы` с draft/applied состоянием.
|
||||
- [x] Подключить `BrokerDashboardDateFilter` в `Доходы` с draft/applied состоянием.
|
||||
- [x] Добавить cursor-пагинацию по 10 операций в `Доходы`.
|
||||
- [x] Добавить `BrokerDashboardAnalyticsCard` на основе `useBrokerAnalytics`.
|
||||
- [x] Добавить `BrokerDashboardAllocationCard` на основе существующей аллокации.
|
||||
@ -38,8 +38,8 @@
|
||||
- [x] Добавить `BrokerDashboard` как top-level composition widget.
|
||||
- [x] Заменить текущий вертикальный обзор в `BrokerAccountOverviewPage` на `BrokerDashboard`.
|
||||
- [x] Добавить unit/component tests для helpers и базовой dashboard composition.
|
||||
- [ ] Выровнять hero KPI: все Metric одной высоты, supportingText не раздвигает "Доходность" выше соседей.
|
||||
- [ ] Заменить donut-диаграмму аллокации на горизонтальные бары в `BrokerDashboardAllocationCard`.
|
||||
- [x] Выровнять hero KPI: все Metric одной высоты, supportingText не раздвигает "Доходность" выше соседей.
|
||||
- [x] Заменить donut-диаграмму аллокации на горизонтальные бары в `BrokerDashboardAllocationCard`.
|
||||
- [ ] Проверить desktop layout `/broker/2084014113`.
|
||||
- [ ] Проверить mobile layout `/broker/2084014113`.
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user