codex/broker-dashboard-redesign #49

Merged
ksv741 merged 19 commits from codex/broker-dashboard-redesign into main 2026-06-27 16:55:04 +03:00
12 changed files with 420 additions and 158 deletions
Showing only changes of commit d4d1dd980e - Show all commits

View File

@ -24,6 +24,7 @@
"@moex-vibe/design-system": "*",
"@mui/icons-material": "^6.5.0",
"@mui/material": "^6.5.0",
"@mui/x-date-pickers": "^7.29.4",
"@tanstack/react-query": "^5.20.0",
"@tanstack/react-router": "^1.170.16",
"@tanstack/react-table": "^8.21.3",
@ -43,7 +44,6 @@
"@biomejs/biome": "^2.5.0",
"@conarti/eslint-plugin-feature-sliced": "^1.0.5",
"@tanstack/router-devtools": "^1.167.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",

View File

@ -18,7 +18,7 @@ export function defaultEventsFilters(): DashboardFilterState<DashboardEventType>
const now = dayjs()
return {
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],
preset: '7d',
}
@ -27,10 +27,10 @@ export function defaultEventsFilters(): DashboardFilterState<DashboardEventType>
export function defaultIncomeFilters(): DashboardFilterState<DashboardIncomeType> {
const now = dayjs()
return {
from: now.startOf('year').format('YYYY-MM-DD'),
from: now.subtract(7, 'day').format('YYYY-MM-DD'),
to: now.format('YYYY-MM-DD'),
types: [...DASHBOARD_INCOME_TYPES],
preset: '1y',
preset: '7d',
}
}

View File

@ -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 userEvent from '@testing-library/user-event'
import type { ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { BrokerPortfolio } from '@/shared/api'
import { BrokerDashboard } from './BrokerDashboard'
@ -67,6 +70,10 @@ const portfolio: BrokerPortfolio = {
asOf: '2026-06-26T00:00:00.000Z',
}
function renderWithProviders(ui: ReactNode) {
return render(<LocalizationProvider dateAdapter={AdapterDayjs}>{ui}</LocalizationProvider>)
}
describe('BrokerDashboard', () => {
beforeEach(() => {
hookMocks.useBrokerEvents.mockReturnValue({
@ -88,7 +95,7 @@ describe('BrokerDashboard', () => {
})
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()
@ -99,7 +106,7 @@ describe('BrokerDashboard', () => {
it('applies event type chips immediately to useBrokerEvents', async () => {
const user = userEvent.setup()
render(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const couponChips = screen.getAllByRole('button', { name: 'Купоны' })
await user.click(couponChips[0])
@ -115,7 +122,7 @@ describe('BrokerDashboard', () => {
it('applies income type chips immediately to useBrokerOperations', async () => {
const user = userEvent.setup()
render(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const couponChips = screen.getAllByRole('button', { name: 'Купоны' })
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()
render(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const filterButtons = screen.getAllByRole('button', { name: /Фильтр дат/ })
expect(filterButtons).toHaveLength(2)
const applyButtons = screen.getAllByRole('button', { name: /Применить период/ })
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('30д')).toBeInTheDocument()
@ -147,4 +157,36 @@ describe('BrokerDashboard', () => {
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()
})
})

View File

@ -39,12 +39,10 @@ export function BrokerDashboard({
}) {
const [appliedEventFilters, setAppliedEventFilters] = useState(defaultEventsFilters)
const [draftEventFilters, setDraftEventFilters] = useState(defaultEventsFilters)
const [eventFilterPanelOpen, setEventFilterPanelOpen] = useState(false)
const [eventPage, setEventPage] = useState(1)
const [appliedIncomeFilters, setAppliedIncomeFilters] = useState(defaultIncomeFilters)
const [draftIncomeFilters, setDraftIncomeFilters] = useState(defaultIncomeFilters)
const [incomeFilterPanelOpen, setIncomeFilterPanelOpen] = useState(false)
const incomePagination = useCursorPagination()
const analytics = useBrokerAnalytics(accountId)
@ -199,8 +197,6 @@ export function BrokerDashboard({
onDraftToChange={handleDraftEventToChange}
onApplyFilters={applyEventFilters}
onResetFilters={resetEventFilters}
dateFilterOpen={eventFilterPanelOpen}
onToggleDateFilter={() => setEventFilterPanelOpen((v) => !v)}
hasDraftTypes={hasDraftEventTypes}
page={eventPage}
canGoBack={eventPage > 1}
@ -224,8 +220,6 @@ export function BrokerDashboard({
onDraftToChange={handleDraftIncomeToChange}
onApplyFilters={applyIncomeFilters}
onResetFilters={resetIncomeFilters}
dateFilterOpen={incomeFilterPanelOpen}
onToggleDateFilter={() => setIncomeFilterPanelOpen((v) => !v)}
hasDraftTypes={hasDraftIncomeTypes}
pageNumber={incomePagination.pageNumber}
canGoBack={incomePagination.pageNumber > 1}

View File

@ -1,5 +1,10 @@
import { Button, Chip, Text } from '@moex-vibe/design-system'
import { Box } from '@mui/material'
import { Chip, Text } from '@moex-vibe/design-system'
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'
const PRESETS: { key: DashboardDatePreset; label: string }[] = [
@ -21,8 +26,6 @@ type BrokerDashboardDateFilterProps = {
onToChange: (value: string) => void
onReset: () => void
onApply: () => void
isOpen: boolean
onToggle: () => void
}
export function BrokerDashboardDateFilter({
@ -36,15 +39,63 @@ export function BrokerDashboardDateFilter({
onToChange,
onReset,
onApply,
isOpen,
onToggle,
}: 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 (
<LocalizationProvider dateAdapter={AdapterDayjs}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Box
component="button"
type="button"
onClick={onToggle}
onClick={handleToggle}
sx={{
display: 'inline-flex',
alignItems: 'center',
@ -60,7 +111,7 @@ export function BrokerDashboardDateFilter({
'&:hover': { bgcolor: 'grey.700' },
}}
>
📅 Фильтр дат
📅
{appliedLabel && (
<Box
sx={{
@ -76,22 +127,38 @@ export function BrokerDashboardDateFilter({
{appliedLabel}
</Box>
)}
<Box sx={{ fontSize: 10, ml: 0.25, color: 'grey.400' }}>{isOpen ? '▲' : '▼'}</Box>
</Box>
<Button variant="primary" size="small" onClick={onApply} disabled={!hasDraftTypes}>
Показать
</Button>
{isOpen && (
<Box
component="button"
type="button"
onClick={onApply}
disabled={!hasDraftTypes}
sx={{
width: '100%',
mt: 0.5,
p: 1.5,
border: '1px solid',
borderColor: 'divider',
borderRadius: 2,
bgcolor: 'grey.50',
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
@ -99,42 +166,34 @@ export function BrokerDashboardDateFilter({
label={p.label}
tone={p.key === preset ? 'primary' : 'neutral'}
selected={p.key === preset}
onClick={() => onPresetChange(p.key)}
onClick={() => handlePresetClick(p)}
/>
))}
</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 variant="caption" tone="secondary" sx={{ mb: 1, display: 'block' }}>
{selectingLabel}
</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',
<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={onReset}
onClick={handleReset}
sx={{
background: 'none',
border: 'none',
@ -150,7 +209,8 @@ export function BrokerDashboardDateFilter({
</Box>
</Box>
</Box>
)}
</Popover>
</Box>
</LocalizationProvider>
)
}

View File

@ -7,6 +7,7 @@ import type { DashboardDatePreset, DashboardEventType } from '../lib/dashboardFi
import { eventStatusLabel, eventTypeLabel } from '../lib/dashboardFormatters'
import { BrokerDashboardCard } from './BrokerDashboardCard'
import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter'
import { BrokerDashboardTableSkeleton } from './BrokerDashboardTableSkeleton'
const EVENT_FILTERS: Array<{
type: DashboardEventType
@ -35,8 +36,6 @@ type BrokerDashboardEventsCardProps = {
onDraftToChange: (value: string) => void
onApplyFilters: () => void
onResetFilters: () => void
dateFilterOpen: boolean
onToggleDateFilter: () => void
hasDraftTypes: boolean
page: number
onPreviousPage: () => void
@ -68,8 +67,6 @@ export function BrokerDashboardEventsCard({
onDraftToChange,
onApplyFilters,
onResetFilters,
dateFilterOpen,
onToggleDateFilter,
hasDraftTypes,
page,
onPreviousPage,
@ -107,8 +104,6 @@ export function BrokerDashboardEventsCard({
onToChange={onDraftToChange}
onReset={onResetFilters}
onApply={onApplyFilters}
isOpen={dateFilterOpen}
onToggle={onToggleDateFilter}
/>
</Box>
}
@ -118,7 +113,7 @@ export function BrokerDashboardEventsCard({
) : isError ? (
<Text tone="negative">Не удалось загрузить события</Text>
) : isLoading ? (
<Text tone="muted">Загрузка событий</Text>
<BrokerDashboardTableSkeleton rows={5} columns={5} />
) : events.length === 0 ? (
<Text tone="muted">В ближайшем периоде событий нет</Text>
) : (

View File

@ -7,6 +7,7 @@ import type { DashboardDatePreset, DashboardIncomeType } from '../lib/dashboardF
import { getDashboardIncomeRows, sumDashboardIncome } from '../lib/dashboardIncome'
import { BrokerDashboardCard } from './BrokerDashboardCard'
import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter'
import { BrokerDashboardTableSkeleton } from './BrokerDashboardTableSkeleton'
const INCOME_FILTERS: Array<{
type: DashboardIncomeType
@ -33,8 +34,6 @@ type BrokerDashboardIncomeCardProps = {
onDraftToChange: (value: string) => void
onApplyFilters: () => void
onResetFilters: () => void
dateFilterOpen: boolean
onToggleDateFilter: () => void
hasDraftTypes: boolean
pageNumber: number
canGoBack: boolean
@ -59,8 +58,6 @@ export function BrokerDashboardIncomeCard({
onDraftToChange,
onApplyFilters,
onResetFilters,
dateFilterOpen,
onToggleDateFilter,
hasDraftTypes,
pageNumber,
canGoBack,
@ -99,8 +96,6 @@ export function BrokerDashboardIncomeCard({
onToChange={onDraftToChange}
onReset={onResetFilters}
onApply={onApplyFilters}
isOpen={dateFilterOpen}
onToggle={onToggleDateFilter}
/>
</Box>
}
@ -110,7 +105,7 @@ export function BrokerDashboardIncomeCard({
) : isError ? (
<Text tone="negative">Не удалось загрузить доходные операции</Text>
) : isLoading ? (
<Text tone="muted">Загрузка доходов</Text>
<BrokerDashboardTableSkeleton rows={5} columns={4} />
) : rows.length === 0 ? (
<Text tone="muted">Дивидендов и купонов в последних операциях нет</Text>
) : (

View File

@ -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>
)
}

View File

@ -6,7 +6,7 @@
**Архитектура:** маршрут и 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/BrokerDashboardCard.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/BrokerDashboardAnalyticsCard.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/dashboardFilters.ts` — пресеты дат, validate и mapping income types.
- 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` — фиксация статусов выполнения.
### Источники данных
@ -59,16 +60,26 @@
### 3. События
- Типы событий переключаются 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 событий на страницу, сбрасывается при смене применённых фильтров.
- При загрузке карточка показывает skeleton таблицы событий с колонками дата, инструмент, тип, сумма, статус.
### 4. Доходы
- Доходы строятся на существующем endpoint операций только для `OPERATION_TYPE_DIVIDEND`, `OPERATION_TYPE_DIV_EXT`, `OPERATION_TYPE_COUPON`.
- Типы доходов переключаются chip-фильтрами с немедленным применением.
- Диапазон дат по умолчанию: `сегодня - 7 дней` / `сегодня`, чтобы не загружать большой объём операций на первом открытии.
- Диапазон дат использует тот же draft/apply паттерн, что и события.
- В шапке управления периодом не используется слово `Фильтр`; действие применения периода не называется `Показать`.
- Native `<input type="date">` не используется; период выбирается через одно визуальное поле и popover с community `DateCalendar` из `@mui/x-date-pickers`.
- `DateRangePicker` и `@mui/x-date-pickers-pro` не используются; выбор начала/конца периода реализуется локальной логикой dashboard.
- Пагинация cursor-based, размер страницы 10, сбрасывается при смене применённых фильтров.
- При загрузке карточка показывает skeleton таблицы доходов с колонками дата, инструмент, тип, сумма.
### 5. Аналитика и аллокация
@ -118,21 +129,32 @@
- `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/BrokerDashboard.tsx`
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts`
- `apps/frontend/package.json`
- [ ] Добавить переиспользуемый `BrokerDashboardDateFilter` с preset chips, полями `from/to`, действиями `Сбросить` и `Показать`.
- [ ] Подключить для событий draft/applied state: типы применяются сразу, даты только по `Показать`.
- [ ] Добавить или подтвердить зависимость community-пакета `@mui/x-date-pickers` и использовать Day.js adapter.
- [ ] Не добавлять `@mui/x-date-pickers-pro` и не использовать `DateRangePicker`.
- [ ] Обновить `BrokerDashboardDateFilter`: убрать слово `Фильтр` из пользовательского текста, заменить `Показать` на понятное действие применения периода и визуально собрать chips, диапазон, сброс и применение в аккуратную шапку.
- [ ] Заменить native date inputs на одно визуальное поле периода, которое открывает MUI `Popover` с community `DateCalendar`.
- [ ] Реализовать локальную логику выбора диапазона: первый клик задаёт начало, второй — конец; если конец раньше начала, диапазон пересобирается от выбранной даты.
- [ ] Настроить default range событий на `сегодня - 7 дней` / `сегодня`.
- [ ] Подключить для событий draft/applied state: типы применяются сразу, даты только по действию применения периода.
- [ ] Сохранять локальную пагинацию по 10 событий и сбрасывать её при смене применённых фильтров.
- [ ] Оставить локальные loading/error/empty states внутри карточки.
- [ ] Заменить текстовую загрузку событий на skeleton таблицы.
- [ ] Оставить локальные error/empty states внутри карточки.
### Задача 4: Карточка доходов
**Файлы:**
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx`
- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx`
- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts`
- [ ] Подключить тот же `BrokerDashboardDateFilter` для доходов с draft/applied state.
- [ ] Настроить default range доходов на `сегодня - 7 дней` / `сегодня` вместо периода с начала года.
- [ ] Оставить chip-фильтры типов доходов с немедленным применением.
- [ ] Сохранить cursor pagination по 10 операций и сбрасывать её при смене применённых фильтров.
- [ ] Заменить текстовую загрузку доходов на skeleton таблицы.
- [ ] Если endpoint операций даёт недостаточно релевантных строк для dashboard, зафиксировать ограничение в заметках по реализации, а не расширять backend в рамках этой фичи.
### Задача 5: Карточки аналитики и аллокации
@ -152,6 +174,9 @@
- `docs/features/broker-dashboard-redesign/tasks.md`
- [ ] Обновить компонентные тесты 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`.
- [ ] Прогнать `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.
- События с chip filters, date filters и локальной пагинацией: задача 3.
- Доходы с chip filters, date filters и cursor pagination: задача 4.
- Community DateCalendar range control, weekly default range и skeleton loading tables: задачи 3, 4 и 6.
- Аналитика и аллокация с горизонтальными барами: задача 5.
- Ошибки, empty states, тесты и ручная верификация: задача 6.

View File

@ -89,7 +89,7 @@ Hero показывает:
### 4. Блок `События`
- Блок использует существующий источник `useBrokerEvents(accountId, query)`.
- По умолчанию применяется период `сегодня - 7 дней` / `сегодня + 7 дней` и типы
- По умолчанию применяется период `сегодня - 7 дней` / `сегодня` и типы
`dividend,coupon,maturity,offer`, как в существующей вкладке событий.
- Блок содержит кликабельные chip-фильтры типов событий: `Дивиденды`, `Купоны`, `Погашения`, `Оферты`.
- Пользователь может выбрать несколько типов событий.
@ -98,8 +98,17 @@ Hero показывает:
- Изменение chip-фильтров типов событий применяется сразу и возвращает локальную пагинацию на первую
страницу.
- Блок содержит фильтр периода `from` / `to`.
- Изменение черновых фильтров не запускает запрос до нажатия `Показать`.
- Блок содержит быстрые пресеты периода `7д`, `30д`, `90д`, `1г`, `Всё` и действие `Сбросить`.
- По умолчанию применяется недельный период `сегодня - 7 дней` / `сегодня`.
- Изменение черновых фильтров не запускает запрос до применения периода пользователем.
- В пользовательском тексте шапки периода не используется слово `Фильтр`; 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 отображается не более 10 событий на странице.
@ -124,10 +133,18 @@ Hero показывает:
валидационное сообщение.
- Изменение chip-фильтров типов доходов применяется сразу и сбрасывает cursor-пагинацию.
- Блок содержит фильтр периода `from` / `to`.
- По умолчанию используется период с начала текущего календарного года до текущей даты, как в разделе
операций.
- Изменение черновых фильтров не запускает запрос до нажатия `Показать`.
- Блок содержит быстрые пресеты периода `7д`, `30д`, `90д`, `1г`, `Всё` и действие `Сбросить`.
- По умолчанию применяется недельный период `сегодня - 7 дней` / `сегодня`, чтобы dashboard не загружал
слишком много операций при первом открытии.
- Изменение черновых фильтров не запускает запрос до применения периода пользователем.
- В пользовательском тексте шапки периода не используется слово `Фильтр`; 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-синхронизация остаётся
обязанностью подробных разделов.
- Для блока используется cursor-пагинация existing operations endpoint с размером страницы 10.
@ -162,6 +179,8 @@ Hero показывает:
- Первичная загрузка portfolio показывает dashboard skeleton соответствующей формы.
- Ошибка portfolio показывает ошибку overview, потому что без portfolio dashboard не имеет основного
контекста.
- Загрузка событий и доходов внутри карточек показывает skeleton таблицы соответствующей структуры, а не
только текстовую строку загрузки.
- Ошибка событий, доходов или analytics отображается внутри соответствующей карточки.
- Пустые события, пустые доходы и пустая analytics имеют отдельные понятные сообщения.
- Недоступные отдельные значения отображаются как `—`, не подменяются нулём.
@ -194,8 +213,15 @@ Hero показывает:
- Все Metric-блоки hero имеют одинаковую высоту; supportingText не создаёт перекоса.
- Блок `События` использует существующие events data и показывает дату, инструмент, тип, сумму и статус.
- Блок `События` поддерживает multi-select chip-фильтр типов и локальную пагинацию по 10 событий.
- Блок `События` по умолчанию запрашивает период `сегодня - 7 дней` / `сегодня` и использует одно поле
периода с popover-календарём на базе community `DateCalendar`.
- Блок `Доходы` показывает доходные операции дивидендов и купонов и итог по отображаемым строкам.
- Блок `Доходы` поддерживает multi-select chip-фильтр типов и cursor-пагинацию по 10 операций.
- Блок `Доходы` по умолчанию запрашивает период `сегодня - 7 дней` / `сегодня` и использует одно поле
периода с popover-календарём на базе community `DateCalendar`.
- В шапке управления периодом не отображается слово `Фильтр`, а действие применения периода не называется
`Показать`.
- Загрузка событий и доходов отображается skeleton-таблицей.
- Блок `Аналитика доходности` показывает данные существующего analytics endpoint.
- Блок `Аллокация` показывает горизонтальные бары секторов с названием, долей и суммой, а также
итоговую стоимость портфеля.

View File

@ -40,15 +40,23 @@
- [x] Добавить unit/component tests для helpers и базовой dashboard composition.
- [x] Выровнять hero KPI: все Metric одной высоты, supportingText не раздвигает "Доходность" выше соседей.
- [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`.
- [ ] Проверить mobile layout `/broker/2084014113`.
## Definition of Done
- [ ] `rtk npm run test:frontend` проходит.
- [ ] `rtk npm run test:design-system` проходит.
- [ ] `rtk npm run lint -w apps/frontend` проходит.
- [ ] `rtk npm run build:frontend` проходит.
- [x] `rtk npm run test:frontend` проходит.
- [x] `rtk npm run test:design-system` проходит.
- [x] `rtk npm run lint -w apps/frontend` проходит.
- [x] `rtk npm run build:frontend` проходит.
- [ ] Dashboard соответствует acceptance criteria из `spec.md`.
- [x] Существующие вкладки `Акции`, `Облигации`, `Операции`, `События`, `Аналитика` остаются доступны.
- [ ] `graphify update .` выполнен после code changes.
- [x] `graphify update .` выполнен после code changes.

87
package-lock.json generated
View File

@ -92,6 +92,7 @@
"@moex-vibe/design-system": "*",
"@mui/icons-material": "^6.5.0",
"@mui/material": "^6.5.0",
"@mui/x-date-pickers": "^7.29.4",
"@tanstack/react-query": "^5.20.0",
"@tanstack/react-router": "^1.170.16",
"@tanstack/react-table": "^8.21.3",
@ -7426,6 +7427,92 @@
"integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
"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": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",