feat: replace native date inputs with single DateCalendar field in broker dashboard
- Replace two MUI DatePicker components with a single visual range field - Click opens Popover with community DateCalendar (no DateRangePicker/pro) - First click sets start, second sets end; if end < start, range resets - Remove isOpen/onToggle props — Popover managed locally - Add BrokerDashboardTableSkeleton for events/income loading - Set default weekly range (today-7d / today) for both events and income - Remove 'Фильтр' label, rename 'Показать' → 'Применить период' - Install @mui/x-date-pickers@7 - Update tests for new UI and skeleton loading states - Align spec.md, plan.md, tasks.md with implementation
This commit is contained in:
parent
0116c34a9f
commit
d4d1dd980e
@ -24,6 +24,7 @@
|
|||||||
"@moex-vibe/design-system": "*",
|
"@moex-vibe/design-system": "*",
|
||||||
"@mui/icons-material": "^6.5.0",
|
"@mui/icons-material": "^6.5.0",
|
||||||
"@mui/material": "^6.5.0",
|
"@mui/material": "^6.5.0",
|
||||||
|
"@mui/x-date-pickers": "^7.29.4",
|
||||||
"@tanstack/react-query": "^5.20.0",
|
"@tanstack/react-query": "^5.20.0",
|
||||||
"@tanstack/react-router": "^1.170.16",
|
"@tanstack/react-router": "^1.170.16",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
@ -43,7 +44,6 @@
|
|||||||
"@biomejs/biome": "^2.5.0",
|
"@biomejs/biome": "^2.5.0",
|
||||||
"@conarti/eslint-plugin-feature-sliced": "^1.0.5",
|
"@conarti/eslint-plugin-feature-sliced": "^1.0.5",
|
||||||
"@tanstack/router-devtools": "^1.167.0",
|
"@tanstack/router-devtools": "^1.167.0",
|
||||||
|
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
|||||||
@ -18,7 +18,7 @@ export function defaultEventsFilters(): DashboardFilterState<DashboardEventType>
|
|||||||
const now = dayjs()
|
const now = dayjs()
|
||||||
return {
|
return {
|
||||||
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.format('YYYY-MM-DD'),
|
||||||
types: [...DASHBOARD_EVENT_TYPES],
|
types: [...DASHBOARD_EVENT_TYPES],
|
||||||
preset: '7d',
|
preset: '7d',
|
||||||
}
|
}
|
||||||
@ -27,10 +27,10 @@ export function defaultEventsFilters(): DashboardFilterState<DashboardEventType>
|
|||||||
export function defaultIncomeFilters(): DashboardFilterState<DashboardIncomeType> {
|
export function defaultIncomeFilters(): DashboardFilterState<DashboardIncomeType> {
|
||||||
const now = dayjs()
|
const now = dayjs()
|
||||||
return {
|
return {
|
||||||
from: now.startOf('year').format('YYYY-MM-DD'),
|
from: now.subtract(7, 'day').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',
|
preset: '7d',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
|
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
|
||||||
|
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'
|
||||||
import { render, screen, waitFor } from '@testing-library/react'
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
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'
|
||||||
@ -67,6 +70,10 @@ const portfolio: BrokerPortfolio = {
|
|||||||
asOf: '2026-06-26T00:00:00.000Z',
|
asOf: '2026-06-26T00:00:00.000Z',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderWithProviders(ui: ReactNode) {
|
||||||
|
return render(<LocalizationProvider dateAdapter={AdapterDayjs}>{ui}</LocalizationProvider>)
|
||||||
|
}
|
||||||
|
|
||||||
describe('BrokerDashboard', () => {
|
describe('BrokerDashboard', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
hookMocks.useBrokerEvents.mockReturnValue({
|
hookMocks.useBrokerEvents.mockReturnValue({
|
||||||
@ -88,7 +95,7 @@ describe('BrokerDashboard', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('renders the dashboard sections', () => {
|
it('renders the dashboard sections', () => {
|
||||||
render(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||||
|
|
||||||
expect(screen.getByText('Основной счёт')).toBeInTheDocument()
|
expect(screen.getByText('Основной счёт')).toBeInTheDocument()
|
||||||
expect(screen.getByText('События')).toBeInTheDocument()
|
expect(screen.getByText('События')).toBeInTheDocument()
|
||||||
@ -99,7 +106,7 @@ describe('BrokerDashboard', () => {
|
|||||||
|
|
||||||
it('applies event type chips immediately to useBrokerEvents', 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} />)
|
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||||
|
|
||||||
const couponChips = screen.getAllByRole('button', { name: 'Купоны' })
|
const couponChips = screen.getAllByRole('button', { name: 'Купоны' })
|
||||||
await user.click(couponChips[0])
|
await user.click(couponChips[0])
|
||||||
@ -115,7 +122,7 @@ describe('BrokerDashboard', () => {
|
|||||||
|
|
||||||
it('applies income type chips immediately to useBrokerOperations', 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} />)
|
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||||
|
|
||||||
const couponChips = screen.getAllByRole('button', { name: 'Купоны' })
|
const couponChips = screen.getAllByRole('button', { name: 'Купоны' })
|
||||||
await user.click(couponChips[1])
|
await user.click(couponChips[1])
|
||||||
@ -131,14 +138,17 @@ describe('BrokerDashboard', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows date filter toggle button and expandable panel', async () => {
|
it('shows date filter toggle button with apply action', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
render(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||||
|
|
||||||
const filterButtons = screen.getAllByRole('button', { name: /Фильтр дат/ })
|
const applyButtons = screen.getAllByRole('button', { name: /Применить период/ })
|
||||||
expect(filterButtons).toHaveLength(2)
|
expect(applyButtons).toHaveLength(2)
|
||||||
|
|
||||||
await user.click(filterButtons[0])
|
const toggleButtons = screen.getAllByRole('button', { name: /📅/ })
|
||||||
|
expect(toggleButtons).toHaveLength(2)
|
||||||
|
|
||||||
|
await user.click(toggleButtons[0])
|
||||||
|
|
||||||
expect(screen.getByText('7д')).toBeInTheDocument()
|
expect(screen.getByText('7д')).toBeInTheDocument()
|
||||||
expect(screen.getByText('30д')).toBeInTheDocument()
|
expect(screen.getByText('30д')).toBeInTheDocument()
|
||||||
@ -147,4 +157,36 @@ describe('BrokerDashboard', () => {
|
|||||||
expect(screen.getByText('Всё')).toBeInTheDocument()
|
expect(screen.getByText('Всё')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Сбросить')).toBeInTheDocument()
|
expect(screen.getByText('Сбросить')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows skeleton table while events are loading', () => {
|
||||||
|
hookMocks.useBrokerEvents.mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: true,
|
||||||
|
isError: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||||
|
|
||||||
|
const skeletons = screen.getAllByTestId('dashboard-table-skeleton')
|
||||||
|
expect(skeletons.length).toBeGreaterThanOrEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows skeleton table while income operations are loading', () => {
|
||||||
|
hookMocks.useBrokerOperations.mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: true,
|
||||||
|
isError: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||||
|
|
||||||
|
const skeletons = screen.getAllByTestId('dashboard-table-skeleton')
|
||||||
|
expect(skeletons.length).toBeGreaterThanOrEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show skeleton when data is loaded', () => {
|
||||||
|
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('dashboard-table-skeleton')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -39,12 +39,10 @@ export function BrokerDashboard({
|
|||||||
}) {
|
}) {
|
||||||
const [appliedEventFilters, setAppliedEventFilters] = useState(defaultEventsFilters)
|
const [appliedEventFilters, setAppliedEventFilters] = useState(defaultEventsFilters)
|
||||||
const [draftEventFilters, setDraftEventFilters] = useState(defaultEventsFilters)
|
const [draftEventFilters, setDraftEventFilters] = useState(defaultEventsFilters)
|
||||||
const [eventFilterPanelOpen, setEventFilterPanelOpen] = useState(false)
|
|
||||||
const [eventPage, setEventPage] = useState(1)
|
const [eventPage, setEventPage] = useState(1)
|
||||||
|
|
||||||
const [appliedIncomeFilters, setAppliedIncomeFilters] = useState(defaultIncomeFilters)
|
const [appliedIncomeFilters, setAppliedIncomeFilters] = useState(defaultIncomeFilters)
|
||||||
const [draftIncomeFilters, setDraftIncomeFilters] = 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)
|
||||||
@ -199,8 +197,6 @@ export function BrokerDashboard({
|
|||||||
onDraftToChange={handleDraftEventToChange}
|
onDraftToChange={handleDraftEventToChange}
|
||||||
onApplyFilters={applyEventFilters}
|
onApplyFilters={applyEventFilters}
|
||||||
onResetFilters={resetEventFilters}
|
onResetFilters={resetEventFilters}
|
||||||
dateFilterOpen={eventFilterPanelOpen}
|
|
||||||
onToggleDateFilter={() => setEventFilterPanelOpen((v) => !v)}
|
|
||||||
hasDraftTypes={hasDraftEventTypes}
|
hasDraftTypes={hasDraftEventTypes}
|
||||||
page={eventPage}
|
page={eventPage}
|
||||||
canGoBack={eventPage > 1}
|
canGoBack={eventPage > 1}
|
||||||
@ -224,8 +220,6 @@ export function BrokerDashboard({
|
|||||||
onDraftToChange={handleDraftIncomeToChange}
|
onDraftToChange={handleDraftIncomeToChange}
|
||||||
onApplyFilters={applyIncomeFilters}
|
onApplyFilters={applyIncomeFilters}
|
||||||
onResetFilters={resetIncomeFilters}
|
onResetFilters={resetIncomeFilters}
|
||||||
dateFilterOpen={incomeFilterPanelOpen}
|
|
||||||
onToggleDateFilter={() => setIncomeFilterPanelOpen((v) => !v)}
|
|
||||||
hasDraftTypes={hasDraftIncomeTypes}
|
hasDraftTypes={hasDraftIncomeTypes}
|
||||||
pageNumber={incomePagination.pageNumber}
|
pageNumber={incomePagination.pageNumber}
|
||||||
canGoBack={incomePagination.pageNumber > 1}
|
canGoBack={incomePagination.pageNumber > 1}
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import { Button, Chip, Text } from '@moex-vibe/design-system'
|
import { Chip, Text } from '@moex-vibe/design-system'
|
||||||
import { Box } from '@mui/material'
|
import { Box, Popover } from '@mui/material'
|
||||||
|
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
|
||||||
|
import { DateCalendar } from '@mui/x-date-pickers/DateCalendar'
|
||||||
|
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { useCallback, useState } from 'react'
|
||||||
import type { DashboardDatePreset } from '../lib/dashboardFilters'
|
import type { DashboardDatePreset } from '../lib/dashboardFilters'
|
||||||
|
|
||||||
const PRESETS: { key: DashboardDatePreset; label: string }[] = [
|
const PRESETS: { key: DashboardDatePreset; label: string }[] = [
|
||||||
@ -21,8 +26,6 @@ type BrokerDashboardDateFilterProps = {
|
|||||||
onToChange: (value: string) => void
|
onToChange: (value: string) => void
|
||||||
onReset: () => void
|
onReset: () => void
|
||||||
onApply: () => void
|
onApply: () => void
|
||||||
isOpen: boolean
|
|
||||||
onToggle: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BrokerDashboardDateFilter({
|
export function BrokerDashboardDateFilter({
|
||||||
@ -36,121 +39,178 @@ export function BrokerDashboardDateFilter({
|
|||||||
onToChange,
|
onToChange,
|
||||||
onReset,
|
onReset,
|
||||||
onApply,
|
onApply,
|
||||||
isOpen,
|
|
||||||
onToggle,
|
|
||||||
}: BrokerDashboardDateFilterProps) {
|
}: BrokerDashboardDateFilterProps) {
|
||||||
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
|
||||||
|
const [selectingStage, setSelectingStage] = useState<'from' | 'to'>('from')
|
||||||
|
const isOpen = Boolean(anchorEl)
|
||||||
|
|
||||||
|
const handleToggle = useCallback((e: React.MouseEvent<HTMLElement>) => {
|
||||||
|
setAnchorEl((prev) => {
|
||||||
|
if (prev) return null
|
||||||
|
return e.currentTarget
|
||||||
|
})
|
||||||
|
setSelectingStage('from')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setAnchorEl(null)
|
||||||
|
setSelectingStage('from')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleCalendarChange = useCallback(
|
||||||
|
(date: dayjs.Dayjs | null) => {
|
||||||
|
if (!date) return
|
||||||
|
if (selectingStage === 'from') {
|
||||||
|
onFromChange(date.format('YYYY-MM-DD'))
|
||||||
|
setSelectingStage('to')
|
||||||
|
} else {
|
||||||
|
if (draftFrom && date.isBefore(dayjs(draftFrom))) {
|
||||||
|
onFromChange(date.format('YYYY-MM-DD'))
|
||||||
|
onToChange('')
|
||||||
|
} else {
|
||||||
|
onToChange(date.format('YYYY-MM-DD'))
|
||||||
|
}
|
||||||
|
setSelectingStage('from')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectingStage, draftFrom, onFromChange, onToChange],
|
||||||
|
)
|
||||||
|
|
||||||
|
function handlePresetClick(p: { key: DashboardDatePreset }) {
|
||||||
|
onPresetChange(p.key)
|
||||||
|
setSelectingStage('from')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
onReset()
|
||||||
|
setSelectingStage('from')
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectingLabel =
|
||||||
|
selectingStage === 'from' ? 'Выберите начало периода' : 'Выберите конец периода'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||||
<Box
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||||
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
|
<Box
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
onClick={handleToggle}
|
||||||
sx={{
|
sx={{
|
||||||
width: '100%',
|
display: 'inline-flex',
|
||||||
mt: 0.5,
|
alignItems: 'center',
|
||||||
p: 1.5,
|
gap: 1,
|
||||||
border: '1px solid',
|
bgcolor: 'grey.800',
|
||||||
borderColor: 'divider',
|
color: 'common.white',
|
||||||
borderRadius: 2,
|
border: 'none',
|
||||||
bgcolor: 'grey.50',
|
borderRadius: 1.5,
|
||||||
|
px: 1.5,
|
||||||
|
py: 0.75,
|
||||||
|
fontSize: 13,
|
||||||
|
cursor: 'pointer',
|
||||||
|
'&:hover': { bgcolor: 'grey.700' },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 1 }}>
|
📅
|
||||||
{PRESETS.map((p) => (
|
{appliedLabel && (
|
||||||
<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
|
<Box
|
||||||
component="button"
|
|
||||||
type="button"
|
|
||||||
onClick={onReset}
|
|
||||||
sx={{
|
sx={{
|
||||||
background: 'none',
|
bgcolor: 'grey.600',
|
||||||
border: 'none',
|
borderRadius: 10,
|
||||||
color: 'text.secondary',
|
px: 0.75,
|
||||||
fontSize: 12,
|
py: 0.125,
|
||||||
cursor: 'pointer',
|
fontSize: 11,
|
||||||
textDecoration: 'underline',
|
lineHeight: 1.4,
|
||||||
textUnderlineOffset: 2,
|
whiteSpace: 'nowrap',
|
||||||
p: 0.5,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Сбросить
|
{appliedLabel}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box sx={{ fontSize: 10, ml: 0.25, color: 'grey.400' }}>{isOpen ? '▲' : '▼'}</Box>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
onClick={onApply}
|
||||||
|
disabled={!hasDraftTypes}
|
||||||
|
sx={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
bgcolor: hasDraftTypes ? 'primary.main' : 'grey.400',
|
||||||
|
color: 'common.white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 1.5,
|
||||||
|
px: 1.5,
|
||||||
|
py: 0.75,
|
||||||
|
fontSize: 13,
|
||||||
|
cursor: hasDraftTypes ? 'pointer' : 'default',
|
||||||
|
'&:hover': hasDraftTypes ? { bgcolor: 'primary.dark' } : {},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Применить период
|
||||||
|
</Box>
|
||||||
|
<Popover
|
||||||
|
open={isOpen}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
onClose={handleClose}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||||
|
sx={{ mt: 0.5 }}
|
||||||
|
>
|
||||||
|
<Box sx={{ p: 2, minWidth: 320 }}>
|
||||||
|
<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={() => handlePresetClick(p)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
<Text variant="caption" tone="secondary" sx={{ mb: 1, display: 'block' }}>
|
||||||
|
{selectingLabel}
|
||||||
|
</Text>
|
||||||
|
<DateCalendar
|
||||||
|
value={draftFrom ? dayjs(draftFrom) : null}
|
||||||
|
onChange={handleCalendarChange}
|
||||||
|
sx={{
|
||||||
|
'& .MuiPickersDay-root': {
|
||||||
|
...(draftFrom && draftTo
|
||||||
|
? {
|
||||||
|
'&.Mui-selected': {
|
||||||
|
bgcolor: 'primary.main',
|
||||||
|
color: 'common.white',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1 }}>
|
||||||
|
<Box
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
onClick={handleReset}
|
||||||
|
sx={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: 'text.secondary',
|
||||||
|
fontSize: 12,
|
||||||
|
cursor: 'pointer',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
textUnderlineOffset: 2,
|
||||||
|
p: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Popover>
|
||||||
)}
|
</Box>
|
||||||
</Box>
|
</LocalizationProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import type { DashboardDatePreset, DashboardEventType } from '../lib/dashboardFi
|
|||||||
import { eventStatusLabel, eventTypeLabel } from '../lib/dashboardFormatters'
|
import { eventStatusLabel, eventTypeLabel } from '../lib/dashboardFormatters'
|
||||||
import { BrokerDashboardCard } from './BrokerDashboardCard'
|
import { BrokerDashboardCard } from './BrokerDashboardCard'
|
||||||
import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter'
|
import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter'
|
||||||
|
import { BrokerDashboardTableSkeleton } from './BrokerDashboardTableSkeleton'
|
||||||
|
|
||||||
const EVENT_FILTERS: Array<{
|
const EVENT_FILTERS: Array<{
|
||||||
type: DashboardEventType
|
type: DashboardEventType
|
||||||
@ -35,8 +36,6 @@ type BrokerDashboardEventsCardProps = {
|
|||||||
onDraftToChange: (value: string) => void
|
onDraftToChange: (value: string) => void
|
||||||
onApplyFilters: () => void
|
onApplyFilters: () => void
|
||||||
onResetFilters: () => void
|
onResetFilters: () => void
|
||||||
dateFilterOpen: boolean
|
|
||||||
onToggleDateFilter: () => void
|
|
||||||
hasDraftTypes: boolean
|
hasDraftTypes: boolean
|
||||||
page: number
|
page: number
|
||||||
onPreviousPage: () => void
|
onPreviousPage: () => void
|
||||||
@ -68,8 +67,6 @@ export function BrokerDashboardEventsCard({
|
|||||||
onDraftToChange,
|
onDraftToChange,
|
||||||
onApplyFilters,
|
onApplyFilters,
|
||||||
onResetFilters,
|
onResetFilters,
|
||||||
dateFilterOpen,
|
|
||||||
onToggleDateFilter,
|
|
||||||
hasDraftTypes,
|
hasDraftTypes,
|
||||||
page,
|
page,
|
||||||
onPreviousPage,
|
onPreviousPage,
|
||||||
@ -107,8 +104,6 @@ export function BrokerDashboardEventsCard({
|
|||||||
onToChange={onDraftToChange}
|
onToChange={onDraftToChange}
|
||||||
onReset={onResetFilters}
|
onReset={onResetFilters}
|
||||||
onApply={onApplyFilters}
|
onApply={onApplyFilters}
|
||||||
isOpen={dateFilterOpen}
|
|
||||||
onToggle={onToggleDateFilter}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
@ -118,7 +113,7 @@ export function BrokerDashboardEventsCard({
|
|||||||
) : isError ? (
|
) : isError ? (
|
||||||
<Text tone="negative">Не удалось загрузить события</Text>
|
<Text tone="negative">Не удалось загрузить события</Text>
|
||||||
) : isLoading ? (
|
) : isLoading ? (
|
||||||
<Text tone="muted">Загрузка событий…</Text>
|
<BrokerDashboardTableSkeleton rows={5} columns={5} />
|
||||||
) : events.length === 0 ? (
|
) : events.length === 0 ? (
|
||||||
<Text tone="muted">В ближайшем периоде событий нет</Text>
|
<Text tone="muted">В ближайшем периоде событий нет</Text>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import type { DashboardDatePreset, DashboardIncomeType } from '../lib/dashboardF
|
|||||||
import { getDashboardIncomeRows, sumDashboardIncome } from '../lib/dashboardIncome'
|
import { getDashboardIncomeRows, sumDashboardIncome } from '../lib/dashboardIncome'
|
||||||
import { BrokerDashboardCard } from './BrokerDashboardCard'
|
import { BrokerDashboardCard } from './BrokerDashboardCard'
|
||||||
import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter'
|
import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter'
|
||||||
|
import { BrokerDashboardTableSkeleton } from './BrokerDashboardTableSkeleton'
|
||||||
|
|
||||||
const INCOME_FILTERS: Array<{
|
const INCOME_FILTERS: Array<{
|
||||||
type: DashboardIncomeType
|
type: DashboardIncomeType
|
||||||
@ -33,8 +34,6 @@ type BrokerDashboardIncomeCardProps = {
|
|||||||
onDraftToChange: (value: string) => void
|
onDraftToChange: (value: string) => void
|
||||||
onApplyFilters: () => void
|
onApplyFilters: () => void
|
||||||
onResetFilters: () => void
|
onResetFilters: () => void
|
||||||
dateFilterOpen: boolean
|
|
||||||
onToggleDateFilter: () => void
|
|
||||||
hasDraftTypes: boolean
|
hasDraftTypes: boolean
|
||||||
pageNumber: number
|
pageNumber: number
|
||||||
canGoBack: boolean
|
canGoBack: boolean
|
||||||
@ -59,8 +58,6 @@ export function BrokerDashboardIncomeCard({
|
|||||||
onDraftToChange,
|
onDraftToChange,
|
||||||
onApplyFilters,
|
onApplyFilters,
|
||||||
onResetFilters,
|
onResetFilters,
|
||||||
dateFilterOpen,
|
|
||||||
onToggleDateFilter,
|
|
||||||
hasDraftTypes,
|
hasDraftTypes,
|
||||||
pageNumber,
|
pageNumber,
|
||||||
canGoBack,
|
canGoBack,
|
||||||
@ -99,8 +96,6 @@ export function BrokerDashboardIncomeCard({
|
|||||||
onToChange={onDraftToChange}
|
onToChange={onDraftToChange}
|
||||||
onReset={onResetFilters}
|
onReset={onResetFilters}
|
||||||
onApply={onApplyFilters}
|
onApply={onApplyFilters}
|
||||||
isOpen={dateFilterOpen}
|
|
||||||
onToggle={onToggleDateFilter}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
@ -110,7 +105,7 @@ export function BrokerDashboardIncomeCard({
|
|||||||
) : isError ? (
|
) : isError ? (
|
||||||
<Text tone="negative">Не удалось загрузить доходные операции</Text>
|
<Text tone="negative">Не удалось загрузить доходные операции</Text>
|
||||||
) : isLoading ? (
|
) : isLoading ? (
|
||||||
<Text tone="muted">Загрузка доходов…</Text>
|
<BrokerDashboardTableSkeleton rows={5} columns={4} />
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<Text tone="muted">Дивидендов и купонов в последних операциях нет</Text>
|
<Text tone="muted">Дивидендов и купонов в последних операциях нет</Text>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -0,0 +1,29 @@
|
|||||||
|
import { Skeleton } from '@moex-vibe/design-system'
|
||||||
|
import { Box } from '@mui/material'
|
||||||
|
|
||||||
|
type BrokerDashboardTableSkeletonProps = {
|
||||||
|
rows?: number
|
||||||
|
columns?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BrokerDashboardTableSkeleton({
|
||||||
|
rows = 10,
|
||||||
|
columns = 5,
|
||||||
|
}: BrokerDashboardTableSkeletonProps) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'grid', gap: 1.5, py: 1 }} data-testid="dashboard-table-skeleton">
|
||||||
|
{Array.from({ length: rows }, (_, i) => (
|
||||||
|
<Box key={i} sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||||
|
{Array.from({ length: columns }, (_, j) => (
|
||||||
|
<Skeleton
|
||||||
|
key={j}
|
||||||
|
height={16}
|
||||||
|
width={j === 0 ? 80 : j === columns - 1 ? 60 : 100}
|
||||||
|
shape="rounded"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
**Архитектура:** маршрут и FSD-границы остаются прежними. Композиция собирается в `widgets/broker-dashboard`, данные продолжают приходить из существующих `entities/*` hooks. Навигация счёта остаётся в `BrokerAccountLayout`, а dashboard использует только локальные presentation/helpers без выноса брокерской логики в design system.
|
**Архитектура:** маршрут и FSD-границы остаются прежними. Композиция собирается в `widgets/broker-dashboard`, данные продолжают приходить из существующих `entities/*` hooks. Навигация счёта остаётся в `BrokerAccountLayout`, а dashboard использует только локальные presentation/helpers без выноса брокерской логики в design system.
|
||||||
|
|
||||||
**Технологии:** React 18, TanStack Router, TanStack Query, MUI через `@moex-vibe/design-system`, Vitest, Testing Library.
|
**Технологии:** React 18, TanStack Router, TanStack Query, MUI через `@moex-vibe/design-system`, MUI X DateCalendar community (`@mui/x-date-pickers`) с Day.js, Vitest, Testing Library.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -21,7 +21,7 @@
|
|||||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx` — hero KPI с fallback-значениями и выравниванием `Metric`.
|
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx` — hero KPI с fallback-значениями и выравниванием `Metric`.
|
||||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx` — локальный паттерн карточки.
|
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx` — локальный паттерн карточки.
|
||||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx` — карточка событий.
|
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx` — карточка событий.
|
||||||
- Create: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx` — переиспользуемый фильтр дат с draft/apply поведением.
|
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx` — переиспользуемое управление периодом с draft/apply поведением.
|
||||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx` — карточка доходов.
|
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx` — карточка доходов.
|
||||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx` — карточка аналитики доходности.
|
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx` — карточка аналитики доходности.
|
||||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx` — карточка аллокации с горизонтальными барами.
|
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx` — карточка аллокации с горизонтальными барами.
|
||||||
@ -31,6 +31,7 @@
|
|||||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts` — unit-тесты income helpers.
|
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts` — unit-тесты income helpers.
|
||||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts` — пресеты дат, validate и mapping income types.
|
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts` — пресеты дат, validate и mapping income types.
|
||||||
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts` — локальные formatter/helpers для fallback и event labels.
|
- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts` — локальные formatter/helpers для fallback и event labels.
|
||||||
|
- Modify: `apps/frontend/package.json` — добавить community-пакет `@mui/x-date-pickers`, если зависимость ещё не подключена; `@mui/x-date-pickers-pro` не добавлять.
|
||||||
- Modify: `docs/features/broker-dashboard-redesign/tasks.md` — фиксация статусов выполнения.
|
- Modify: `docs/features/broker-dashboard-redesign/tasks.md` — фиксация статусов выполнения.
|
||||||
|
|
||||||
### Источники данных
|
### Источники данных
|
||||||
@ -59,16 +60,26 @@
|
|||||||
### 3. События
|
### 3. События
|
||||||
|
|
||||||
- Типы событий переключаются chip-фильтрами с немедленным применением.
|
- Типы событий переключаются chip-фильтрами с немедленным применением.
|
||||||
- Диапазон дат редактируется отдельно от применённого состояния: draft state меняется локально, запрос уходит только по `Показать`.
|
- Диапазон дат по умолчанию: `сегодня - 7 дней` / `сегодня`.
|
||||||
|
- Диапазон дат редактируется отдельно от применённого состояния: draft state меняется локально, запрос уходит только по действию применения периода.
|
||||||
|
- В шапке управления периодом не используется слово `Фильтр`; роль управления считывается через иконку календаря, применённый диапазон, раскрытие панели и пресеты.
|
||||||
|
- Native `<input type="date">` не используется; период выбирается через одно визуальное поле и popover с community `DateCalendar` из `@mui/x-date-pickers`.
|
||||||
|
- `DateRangePicker` и `@mui/x-date-pickers-pro` не используются; выбор начала/конца периода реализуется локальной логикой dashboard.
|
||||||
- При пустом выборе типов запрос отключается, карточка показывает валидационное сообщение.
|
- При пустом выборе типов запрос отключается, карточка показывает валидационное сообщение.
|
||||||
- Пагинация локальная, по 10 событий на страницу, сбрасывается при смене применённых фильтров.
|
- Пагинация локальная, по 10 событий на страницу, сбрасывается при смене применённых фильтров.
|
||||||
|
- При загрузке карточка показывает skeleton таблицы событий с колонками дата, инструмент, тип, сумма, статус.
|
||||||
|
|
||||||
### 4. Доходы
|
### 4. Доходы
|
||||||
|
|
||||||
- Доходы строятся на существующем endpoint операций только для `OPERATION_TYPE_DIVIDEND`, `OPERATION_TYPE_DIV_EXT`, `OPERATION_TYPE_COUPON`.
|
- Доходы строятся на существующем endpoint операций только для `OPERATION_TYPE_DIVIDEND`, `OPERATION_TYPE_DIV_EXT`, `OPERATION_TYPE_COUPON`.
|
||||||
- Типы доходов переключаются chip-фильтрами с немедленным применением.
|
- Типы доходов переключаются chip-фильтрами с немедленным применением.
|
||||||
|
- Диапазон дат по умолчанию: `сегодня - 7 дней` / `сегодня`, чтобы не загружать большой объём операций на первом открытии.
|
||||||
- Диапазон дат использует тот же draft/apply паттерн, что и события.
|
- Диапазон дат использует тот же draft/apply паттерн, что и события.
|
||||||
|
- В шапке управления периодом не используется слово `Фильтр`; действие применения периода не называется `Показать`.
|
||||||
|
- Native `<input type="date">` не используется; период выбирается через одно визуальное поле и popover с community `DateCalendar` из `@mui/x-date-pickers`.
|
||||||
|
- `DateRangePicker` и `@mui/x-date-pickers-pro` не используются; выбор начала/конца периода реализуется локальной логикой dashboard.
|
||||||
- Пагинация cursor-based, размер страницы 10, сбрасывается при смене применённых фильтров.
|
- Пагинация cursor-based, размер страницы 10, сбрасывается при смене применённых фильтров.
|
||||||
|
- При загрузке карточка показывает skeleton таблицы доходов с колонками дата, инструмент, тип, сумма.
|
||||||
|
|
||||||
### 5. Аналитика и аллокация
|
### 5. Аналитика и аллокация
|
||||||
|
|
||||||
@ -118,21 +129,32 @@
|
|||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx`
|
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx`
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx`
|
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx`
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx`
|
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx`
|
||||||
|
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts`
|
||||||
|
- `apps/frontend/package.json`
|
||||||
|
|
||||||
- [ ] Добавить переиспользуемый `BrokerDashboardDateFilter` с preset chips, полями `from/to`, действиями `Сбросить` и `Показать`.
|
- [ ] Добавить или подтвердить зависимость community-пакета `@mui/x-date-pickers` и использовать Day.js adapter.
|
||||||
- [ ] Подключить для событий draft/applied state: типы применяются сразу, даты только по `Показать`.
|
- [ ] Не добавлять `@mui/x-date-pickers-pro` и не использовать `DateRangePicker`.
|
||||||
|
- [ ] Обновить `BrokerDashboardDateFilter`: убрать слово `Фильтр` из пользовательского текста, заменить `Показать` на понятное действие применения периода и визуально собрать chips, диапазон, сброс и применение в аккуратную шапку.
|
||||||
|
- [ ] Заменить native date inputs на одно визуальное поле периода, которое открывает MUI `Popover` с community `DateCalendar`.
|
||||||
|
- [ ] Реализовать локальную логику выбора диапазона: первый клик задаёт начало, второй — конец; если конец раньше начала, диапазон пересобирается от выбранной даты.
|
||||||
|
- [ ] Настроить default range событий на `сегодня - 7 дней` / `сегодня`.
|
||||||
|
- [ ] Подключить для событий draft/applied state: типы применяются сразу, даты только по действию применения периода.
|
||||||
- [ ] Сохранять локальную пагинацию по 10 событий и сбрасывать её при смене применённых фильтров.
|
- [ ] Сохранять локальную пагинацию по 10 событий и сбрасывать её при смене применённых фильтров.
|
||||||
- [ ] Оставить локальные loading/error/empty states внутри карточки.
|
- [ ] Заменить текстовую загрузку событий на skeleton таблицы.
|
||||||
|
- [ ] Оставить локальные error/empty states внутри карточки.
|
||||||
|
|
||||||
### Задача 4: Карточка доходов
|
### Задача 4: Карточка доходов
|
||||||
|
|
||||||
**Файлы:**
|
**Файлы:**
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx`
|
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx`
|
||||||
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx`
|
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx`
|
||||||
|
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts`
|
||||||
|
|
||||||
- [ ] Подключить тот же `BrokerDashboardDateFilter` для доходов с draft/applied state.
|
- [ ] Подключить тот же `BrokerDashboardDateFilter` для доходов с draft/applied state.
|
||||||
|
- [ ] Настроить default range доходов на `сегодня - 7 дней` / `сегодня` вместо периода с начала года.
|
||||||
- [ ] Оставить chip-фильтры типов доходов с немедленным применением.
|
- [ ] Оставить chip-фильтры типов доходов с немедленным применением.
|
||||||
- [ ] Сохранить cursor pagination по 10 операций и сбрасывать её при смене применённых фильтров.
|
- [ ] Сохранить cursor pagination по 10 операций и сбрасывать её при смене применённых фильтров.
|
||||||
|
- [ ] Заменить текстовую загрузку доходов на skeleton таблицы.
|
||||||
- [ ] Если endpoint операций даёт недостаточно релевантных строк для dashboard, зафиксировать ограничение в заметках по реализации, а не расширять backend в рамках этой фичи.
|
- [ ] Если endpoint операций даёт недостаточно релевантных строк для dashboard, зафиксировать ограничение в заметках по реализации, а не расширять backend в рамках этой фичи.
|
||||||
|
|
||||||
### Задача 5: Карточки аналитики и аллокации
|
### Задача 5: Карточки аналитики и аллокации
|
||||||
@ -152,6 +174,9 @@
|
|||||||
- `docs/features/broker-dashboard-redesign/tasks.md`
|
- `docs/features/broker-dashboard-redesign/tasks.md`
|
||||||
|
|
||||||
- [ ] Обновить компонентные тесты dashboard так, чтобы они покрывали текущую композицию, фильтры и основные empty/error states.
|
- [ ] Обновить компонентные тесты dashboard так, чтобы они покрывали текущую композицию, фильтры и основные empty/error states.
|
||||||
|
- [ ] Обновить тесты, которые ищут `Фильтр дат` или `Показать`, под новые тексты и accessible labels единого поля периода.
|
||||||
|
- [ ] Добавить/обновить тесты default weekly range для событий и доходов.
|
||||||
|
- [ ] Добавить/обновить тесты skeleton-таблиц для loading state событий и доходов.
|
||||||
- [ ] Прогнать `rtk npm run test:frontend -- --run src/widgets/broker-dashboard`.
|
- [ ] Прогнать `rtk npm run test:frontend -- --run src/widgets/broker-dashboard`.
|
||||||
- [ ] Прогнать `rtk npm run test:frontend`.
|
- [ ] Прогнать `rtk npm run test:frontend`.
|
||||||
- [ ] Прогнать `rtk npm run test:design-system && rtk npm run lint -w apps/frontend && rtk npm run build:frontend`.
|
- [ ] Прогнать `rtk npm run test:design-system && rtk npm run lint -w apps/frontend && rtk npm run build:frontend`.
|
||||||
@ -165,5 +190,6 @@
|
|||||||
- Hero KPI и equal-height `Metric`: задача 2.
|
- Hero KPI и equal-height `Metric`: задача 2.
|
||||||
- События с chip filters, date filters и локальной пагинацией: задача 3.
|
- События с chip filters, date filters и локальной пагинацией: задача 3.
|
||||||
- Доходы с chip filters, date filters и cursor pagination: задача 4.
|
- Доходы с chip filters, date filters и cursor pagination: задача 4.
|
||||||
|
- Community DateCalendar range control, weekly default range и skeleton loading tables: задачи 3, 4 и 6.
|
||||||
- Аналитика и аллокация с горизонтальными барами: задача 5.
|
- Аналитика и аллокация с горизонтальными барами: задача 5.
|
||||||
- Ошибки, empty states, тесты и ручная верификация: задача 6.
|
- Ошибки, empty states, тесты и ручная верификация: задача 6.
|
||||||
|
|||||||
@ -89,7 +89,7 @@ Hero показывает:
|
|||||||
### 4. Блок `События`
|
### 4. Блок `События`
|
||||||
|
|
||||||
- Блок использует существующий источник `useBrokerEvents(accountId, query)`.
|
- Блок использует существующий источник `useBrokerEvents(accountId, query)`.
|
||||||
- По умолчанию применяется период `сегодня - 7 дней` / `сегодня + 7 дней` и типы
|
- По умолчанию применяется период `сегодня - 7 дней` / `сегодня` и типы
|
||||||
`dividend,coupon,maturity,offer`, как в существующей вкладке событий.
|
`dividend,coupon,maturity,offer`, как в существующей вкладке событий.
|
||||||
- Блок содержит кликабельные chip-фильтры типов событий: `Дивиденды`, `Купоны`, `Погашения`, `Оферты`.
|
- Блок содержит кликабельные chip-фильтры типов событий: `Дивиденды`, `Купоны`, `Погашения`, `Оферты`.
|
||||||
- Пользователь может выбрать несколько типов событий.
|
- Пользователь может выбрать несколько типов событий.
|
||||||
@ -98,8 +98,17 @@ Hero показывает:
|
|||||||
- Изменение chip-фильтров типов событий применяется сразу и возвращает локальную пагинацию на первую
|
- Изменение chip-фильтров типов событий применяется сразу и возвращает локальную пагинацию на первую
|
||||||
страницу.
|
страницу.
|
||||||
- Блок содержит фильтр периода `from` / `to`.
|
- Блок содержит фильтр периода `from` / `to`.
|
||||||
- Изменение черновых фильтров не запускает запрос до нажатия `Показать`.
|
- По умолчанию применяется недельный период `сегодня - 7 дней` / `сегодня`.
|
||||||
- Блок содержит быстрые пресеты периода `7д`, `30д`, `90д`, `1г`, `Всё` и действие `Сбросить`.
|
- Изменение черновых фильтров не запускает запрос до применения периода пользователем.
|
||||||
|
- В пользовательском тексте шапки периода не используется слово `Фильтр`; UI должен считываться как
|
||||||
|
управление периодом за счёт иконки календаря, применённого диапазона, пресетов и affordance раскрытия.
|
||||||
|
- Блок содержит быстрые пресеты периода `7д`, `30д`, `90д`, `1г`, `Всё`, действие `Сбросить` и
|
||||||
|
основное действие применения периода с более понятным текстом, чем `Показать`.
|
||||||
|
- Период отображается как одно визуальное поле/кнопка с диапазоном, например `19 июн – 26 июн`.
|
||||||
|
- По клику на поле периода открывается popover с community-компонентом MUI `DateCalendar` из
|
||||||
|
`@mui/x-date-pickers` и ручной логикой выбора начала/конца периода.
|
||||||
|
- `DateRangePicker` и пакет `@mui/x-date-pickers-pro` не используются.
|
||||||
|
- Native `<input type="date">` не используется.
|
||||||
- Применённые фильтры dashboard не обязаны синхронизироваться с URL; URL-синхронизация остаётся
|
- Применённые фильтры dashboard не обязаны синхронизироваться с URL; URL-синхронизация остаётся
|
||||||
обязанностью подробной вкладки `События`.
|
обязанностью подробной вкладки `События`.
|
||||||
- В dashboard отображается не более 10 событий на странице.
|
- В dashboard отображается не более 10 событий на странице.
|
||||||
@ -124,10 +133,18 @@ Hero показывает:
|
|||||||
валидационное сообщение.
|
валидационное сообщение.
|
||||||
- Изменение chip-фильтров типов доходов применяется сразу и сбрасывает cursor-пагинацию.
|
- Изменение chip-фильтров типов доходов применяется сразу и сбрасывает cursor-пагинацию.
|
||||||
- Блок содержит фильтр периода `from` / `to`.
|
- Блок содержит фильтр периода `from` / `to`.
|
||||||
- По умолчанию используется период с начала текущего календарного года до текущей даты, как в разделе
|
- По умолчанию применяется недельный период `сегодня - 7 дней` / `сегодня`, чтобы dashboard не загружал
|
||||||
операций.
|
слишком много операций при первом открытии.
|
||||||
- Изменение черновых фильтров не запускает запрос до нажатия `Показать`.
|
- Изменение черновых фильтров не запускает запрос до применения периода пользователем.
|
||||||
- Блок содержит быстрые пресеты периода `7д`, `30д`, `90д`, `1г`, `Всё` и действие `Сбросить`.
|
- В пользовательском тексте шапки периода не используется слово `Фильтр`; UI должен считываться как
|
||||||
|
управление периодом за счёт иконки календаря, применённого диапазона, пресетов и affordance раскрытия.
|
||||||
|
- Блок содержит быстрые пресеты периода `7д`, `30д`, `90д`, `1г`, `Всё`, действие `Сбросить` и
|
||||||
|
основное действие применения периода с более понятным текстом, чем `Показать`.
|
||||||
|
- Период отображается как одно визуальное поле/кнопка с диапазоном, например `19 июн – 26 июн`.
|
||||||
|
- По клику на поле периода открывается popover с community-компонентом MUI `DateCalendar` из
|
||||||
|
`@mui/x-date-pickers` и ручной логикой выбора начала/конца периода.
|
||||||
|
- `DateRangePicker` и пакет `@mui/x-date-pickers-pro` не используются.
|
||||||
|
- Native `<input type="date">` не используется.
|
||||||
- Применённые фильтры dashboard не обязаны синхронизироваться с URL; URL-синхронизация остаётся
|
- Применённые фильтры dashboard не обязаны синхронизироваться с URL; URL-синхронизация остаётся
|
||||||
обязанностью подробных разделов.
|
обязанностью подробных разделов.
|
||||||
- Для блока используется cursor-пагинация existing operations endpoint с размером страницы 10.
|
- Для блока используется cursor-пагинация existing operations endpoint с размером страницы 10.
|
||||||
@ -162,6 +179,8 @@ Hero показывает:
|
|||||||
- Первичная загрузка portfolio показывает dashboard skeleton соответствующей формы.
|
- Первичная загрузка portfolio показывает dashboard skeleton соответствующей формы.
|
||||||
- Ошибка portfolio показывает ошибку overview, потому что без portfolio dashboard не имеет основного
|
- Ошибка portfolio показывает ошибку overview, потому что без portfolio dashboard не имеет основного
|
||||||
контекста.
|
контекста.
|
||||||
|
- Загрузка событий и доходов внутри карточек показывает skeleton таблицы соответствующей структуры, а не
|
||||||
|
только текстовую строку загрузки.
|
||||||
- Ошибка событий, доходов или analytics отображается внутри соответствующей карточки.
|
- Ошибка событий, доходов или analytics отображается внутри соответствующей карточки.
|
||||||
- Пустые события, пустые доходы и пустая analytics имеют отдельные понятные сообщения.
|
- Пустые события, пустые доходы и пустая analytics имеют отдельные понятные сообщения.
|
||||||
- Недоступные отдельные значения отображаются как `—`, не подменяются нулём.
|
- Недоступные отдельные значения отображаются как `—`, не подменяются нулём.
|
||||||
@ -194,8 +213,15 @@ Hero показывает:
|
|||||||
- Все Metric-блоки hero имеют одинаковую высоту; supportingText не создаёт перекоса.
|
- Все Metric-блоки hero имеют одинаковую высоту; supportingText не создаёт перекоса.
|
||||||
- Блок `События` использует существующие events data и показывает дату, инструмент, тип, сумму и статус.
|
- Блок `События` использует существующие events data и показывает дату, инструмент, тип, сумму и статус.
|
||||||
- Блок `События` поддерживает multi-select chip-фильтр типов и локальную пагинацию по 10 событий.
|
- Блок `События` поддерживает multi-select chip-фильтр типов и локальную пагинацию по 10 событий.
|
||||||
|
- Блок `События` по умолчанию запрашивает период `сегодня - 7 дней` / `сегодня` и использует одно поле
|
||||||
|
периода с popover-календарём на базе community `DateCalendar`.
|
||||||
- Блок `Доходы` показывает доходные операции дивидендов и купонов и итог по отображаемым строкам.
|
- Блок `Доходы` показывает доходные операции дивидендов и купонов и итог по отображаемым строкам.
|
||||||
- Блок `Доходы` поддерживает multi-select chip-фильтр типов и cursor-пагинацию по 10 операций.
|
- Блок `Доходы` поддерживает multi-select chip-фильтр типов и cursor-пагинацию по 10 операций.
|
||||||
|
- Блок `Доходы` по умолчанию запрашивает период `сегодня - 7 дней` / `сегодня` и использует одно поле
|
||||||
|
периода с popover-календарём на базе community `DateCalendar`.
|
||||||
|
- В шапке управления периодом не отображается слово `Фильтр`, а действие применения периода не называется
|
||||||
|
`Показать`.
|
||||||
|
- Загрузка событий и доходов отображается skeleton-таблицей.
|
||||||
- Блок `Аналитика доходности` показывает данные существующего analytics endpoint.
|
- Блок `Аналитика доходности` показывает данные существующего analytics endpoint.
|
||||||
- Блок `Аллокация` показывает горизонтальные бары секторов с названием, долей и суммой, а также
|
- Блок `Аллокация` показывает горизонтальные бары секторов с названием, долей и суммой, а также
|
||||||
итоговую стоимость портфеля.
|
итоговую стоимость портфеля.
|
||||||
|
|||||||
@ -40,15 +40,23 @@
|
|||||||
- [x] Добавить unit/component tests для helpers и базовой dashboard composition.
|
- [x] Добавить unit/component tests для helpers и базовой dashboard composition.
|
||||||
- [x] Выровнять hero KPI: все Metric одной высоты, supportingText не раздвигает "Доходность" выше соседей.
|
- [x] Выровнять hero KPI: все Metric одной высоты, supportingText не раздвигает "Доходность" выше соседей.
|
||||||
- [x] Заменить donut-диаграмму аллокации на горизонтальные бары в `BrokerDashboardAllocationCard`.
|
- [x] Заменить donut-диаграмму аллокации на горизонтальные бары в `BrokerDashboardAllocationCard`.
|
||||||
|
- [x] Убрать слово `Фильтр` из пользовательского текста шапки периода в dashboard-карточках.
|
||||||
|
- [x] Переименовать действие `Показать` в управлении периодом и визуально улучшить шапку фильтров.
|
||||||
|
- [x] Заменить native date inputs на одно поле периода с popover и community `DateCalendar` из `@mui/x-date-pickers`.
|
||||||
|
- [x] Не использовать `@mui/x-date-pickers-pro` и `DateRangePicker`.
|
||||||
|
- [x] Настроить default range событий и доходов на `сегодня - 7 дней` / `сегодня`.
|
||||||
|
- [x] Заменить текстовую загрузку `События` на skeleton таблицы.
|
||||||
|
- [x] Заменить текстовую загрузку `Доходы` на skeleton таблицы.
|
||||||
|
- [x] Обновить component tests под новые тексты, единое поле периода, popover-календарь и skeleton loading states.
|
||||||
- [ ] Проверить desktop layout `/broker/2084014113`.
|
- [ ] Проверить desktop layout `/broker/2084014113`.
|
||||||
- [ ] Проверить mobile layout `/broker/2084014113`.
|
- [ ] Проверить mobile layout `/broker/2084014113`.
|
||||||
|
|
||||||
## Definition of Done
|
## Definition of Done
|
||||||
|
|
||||||
- [ ] `rtk npm run test:frontend` проходит.
|
- [x] `rtk npm run test:frontend` проходит.
|
||||||
- [ ] `rtk npm run test:design-system` проходит.
|
- [x] `rtk npm run test:design-system` проходит.
|
||||||
- [ ] `rtk npm run lint -w apps/frontend` проходит.
|
- [x] `rtk npm run lint -w apps/frontend` проходит.
|
||||||
- [ ] `rtk npm run build:frontend` проходит.
|
- [x] `rtk npm run build:frontend` проходит.
|
||||||
- [ ] Dashboard соответствует acceptance criteria из `spec.md`.
|
- [ ] Dashboard соответствует acceptance criteria из `spec.md`.
|
||||||
- [x] Существующие вкладки `Акции`, `Облигации`, `Операции`, `События`, `Аналитика` остаются доступны.
|
- [x] Существующие вкладки `Акции`, `Облигации`, `Операции`, `События`, `Аналитика` остаются доступны.
|
||||||
- [ ] `graphify update .` выполнен после code changes.
|
- [x] `graphify update .` выполнен после code changes.
|
||||||
|
|||||||
87
package-lock.json
generated
87
package-lock.json
generated
@ -92,6 +92,7 @@
|
|||||||
"@moex-vibe/design-system": "*",
|
"@moex-vibe/design-system": "*",
|
||||||
"@mui/icons-material": "^6.5.0",
|
"@mui/icons-material": "^6.5.0",
|
||||||
"@mui/material": "^6.5.0",
|
"@mui/material": "^6.5.0",
|
||||||
|
"@mui/x-date-pickers": "^7.29.4",
|
||||||
"@tanstack/react-query": "^5.20.0",
|
"@tanstack/react-query": "^5.20.0",
|
||||||
"@tanstack/react-router": "^1.170.16",
|
"@tanstack/react-router": "^1.170.16",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
@ -7426,6 +7427,92 @@
|
|||||||
"integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
|
"integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@mui/x-date-pickers": {
|
||||||
|
"version": "7.29.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.29.4.tgz",
|
||||||
|
"integrity": "sha512-wJ3tsqk/y6dp+mXGtT9czciAMEO5Zr3IIAHg9x6IL0Eqanqy0N3chbmQQZv3iq0m2qUpQDLvZ4utZBUTJdjNzw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.25.7",
|
||||||
|
"@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0",
|
||||||
|
"@mui/x-internals": "7.29.0",
|
||||||
|
"@types/react-transition-group": "^4.4.11",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"prop-types": "^15.8.1",
|
||||||
|
"react-transition-group": "^4.4.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/mui-org"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@emotion/react": "^11.9.0",
|
||||||
|
"@emotion/styled": "^11.8.1",
|
||||||
|
"@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0",
|
||||||
|
"@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0",
|
||||||
|
"date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0",
|
||||||
|
"date-fns-jalali": "^2.13.0-0 || ^3.2.0-0 || ^4.0.0-0",
|
||||||
|
"dayjs": "^1.10.7",
|
||||||
|
"luxon": "^3.0.2",
|
||||||
|
"moment": "^2.29.4",
|
||||||
|
"moment-hijri": "^2.1.2 || ^3.0.0",
|
||||||
|
"moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0",
|
||||||
|
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@emotion/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@emotion/styled": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"date-fns": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"date-fns-jalali": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"dayjs": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"luxon": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"moment": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"moment-hijri": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"moment-jalaali": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@mui/x-internals": {
|
||||||
|
"version": "7.29.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.29.0.tgz",
|
||||||
|
"integrity": "sha512-+Gk6VTZIFD70XreWvdXBwKd8GZ2FlSCuecQFzm6znwqXg1ZsndavrhG9tkxpxo2fM1Zf7Tk8+HcOO0hCbhTQFA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.25.7",
|
||||||
|
"@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/mui-org"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user