Compare commits

..

No commits in common. "fee179d8e988efdbabfa26e4bf641fc703269241" and "1a2937ad58fcdda02c2c443665c7c240cae08a7b" have entirely different histories.

22 changed files with 320 additions and 1229 deletions

View File

@ -18,72 +18,65 @@ export function AppLayout() {
background: 'var(--color-surface)', background: 'var(--color-surface)',
borderBottom: '1px solid #e0e0e0', borderBottom: '1px solid #e0e0e0',
padding: '12px 24px', padding: '12px 24px',
display: 'grid', display: 'flex',
gridTemplateColumns: 'minmax(0, 1fr) auto', alignItems: 'center',
gap: 12, flexWrap: 'wrap',
gap: 24,
}} }}
> >
<div style={{ display: 'grid', gap: 12 }}> <Link
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap' }}> to="/"
<Link style={{
to="/" fontSize: 20,
style={{ fontWeight: 700,
fontSize: 20, color: 'var(--color-text)',
fontWeight: 700, textDecoration: 'none',
color: 'var(--color-text)', }}
textDecoration: 'none', >
}} MoexVibe
> </Link>
MoexVibe <div style={{ flex: '1 1 280px', minWidth: 220, maxWidth: 420 }}>
</Link> <SearchBar />
<div style={{ flex: '1 1 280px', minWidth: 220, maxWidth: 420 }}>
<SearchBar />
</div>
</div>
<nav
style={{ display: 'flex', gap: 20, flexWrap: 'wrap' }}
aria-label="Основная навигация"
>
<Link
to="/portfolios"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Портфели
</Link>
<Link
to="/broker"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Брокер
</Link>
<Link
to="/screener"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Скринер
</Link>
</nav>
</div> </div>
<Link
to="/portfolios"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Портфели
</Link>
<Link
to="/broker"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Брокер
</Link>
<Link
to="/screener"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Скринер
</Link>
<div <div
style={{ style={{
marginLeft: 'auto',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'flex-end',
gap: 12, gap: 12,
flexWrap: 'wrap', flexWrap: 'wrap',
}} }}

View File

@ -7,8 +7,8 @@ import { useBrokerPortfolio } from '@/entities/broker-account'
import type { BrokerPortfolio } from '@/shared/api' import type { BrokerPortfolio } from '@/shared/api'
const baseLinkStyle: React.CSSProperties = { const baseLinkStyle: React.CSSProperties = {
padding: '10px 14px', padding: '10px 12px',
borderRadius: 999, borderRadius: 8,
color: 'var(--color-text-secondary)', color: 'var(--color-text-secondary)',
textDecoration: 'none', textDecoration: 'none',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
@ -16,7 +16,6 @@ const baseLinkStyle: React.CSSProperties = {
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
fontSize: 14, fontSize: 14,
fontWeight: 600,
} }
const links = [ const links = [
@ -55,6 +54,8 @@ export function BrokerAccountLayout({ children }: { children: ReactNode }) {
gap: 0.5, gap: 0.5,
overflowX: 'auto', overflowX: 'auto',
scrollbarWidth: 'thin', scrollbarWidth: 'thin',
borderBottom: '1px solid',
borderColor: 'divider',
pb: 0.5, pb: 0.5,
}} }}
> >
@ -69,7 +70,7 @@ export function BrokerAccountLayout({ children }: { children: ReactNode }) {
...baseLinkStyle, ...baseLinkStyle,
color: 'var(--color-primary)', color: 'var(--color-primary)',
fontWeight: 700, fontWeight: 700,
background: 'rgba(25, 118, 210, 0.1)', background: 'rgba(25, 118, 210, 0.08)',
}, },
}} }}
> >

View File

@ -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',
} }
@ -40,9 +40,7 @@ export function applyDatePreset<T extends string>(
): DashboardFilterState<T> { ): DashboardFilterState<T> {
const now = dayjs() const now = dayjs()
const next = { ...filters, preset } const next = { ...filters, preset }
if (preset === 'all') { if (preset === 'all') return { ...next, from: '', to: '' }
return { ...next, from: '2000-01-01', to: '2099-12-31' }
}
const amount = preset === '1y' ? 1 : Number.parseInt(preset, 10) const amount = preset === '1y' ? 1 : Number.parseInt(preset, 10)
const unit = preset === '1y' ? 'year' : 'day' const unit = preset === '1y' ? 'year' : 'day'
return { return {
@ -52,25 +50,6 @@ export function applyDatePreset<T extends string>(
} }
} }
export function applyEventDatePreset(
filters: DashboardFilterState<DashboardEventType>,
preset: DashboardDatePreset,
): DashboardFilterState<DashboardEventType> {
const now = dayjs()
const next = { ...filters, preset }
if (preset === 'all') {
return { ...next, from: '2000-01-01', to: '2099-12-31' }
}
const amount = preset === '1y' ? 1 : Number.parseInt(preset, 10)
const unit = preset === '1y' ? 'year' : 'day'
return {
...next,
from: now.subtract(amount, unit).format('YYYY-MM-DD'),
to: now.add(amount, unit).format('YYYY-MM-DD'),
}
}
export function validateDashboardFilters<T extends string>( export function validateDashboardFilters<T extends string>(
filters: DashboardFilterState<T>, filters: DashboardFilterState<T>,
): string { ): string {

View File

@ -19,5 +19,6 @@ export function eventTypeLabel(type: BrokerEventItem['type']): string {
export function eventStatusLabel(event: BrokerEventItem): string { export function eventStatusLabel(event: BrokerEventItem): string {
if (event.source === 'actual') return 'Поступило' if (event.source === 'actual') return 'Поступило'
return 'Ожидается' if (event.type === 'offer') return 'Оферта'
return 'Прогноз'
} }

View File

@ -64,11 +64,12 @@ describe('dashboardIncome', () => {
expect(rows[0].amount.value).toBe(12) expect(rows[0].amount.value).toBe(12)
}) })
it('splits instrument into main and subtitle', () => { it('splits instrument into main and subtitle, keeping the legacy alias', () => {
const rows = getDashboardIncomeRows([operation('OPERATION_TYPE_DIVIDEND', 10)]) const rows = getDashboardIncomeRows([operation('OPERATION_TYPE_DIVIDEND', 10)])
expect(rows[0].instrumentMain).toBe('AAPL') expect(rows[0].instrumentMain).toBe('AAPL')
expect(rows[0].instrumentSubtitle).toBe('Apple Inc.') expect(rows[0].instrumentSubtitle).toBe('Apple Inc.')
expect(rows[0].instrument).toBe('AAPL')
}) })
it('falls back to "—" with no subtitle when ticker, name, and description are missing', () => { it('falls back to "—" with no subtitle when ticker, name, and description are missing', () => {
@ -77,6 +78,7 @@ describe('dashboardIncome', () => {
expect(rows[0].instrumentMain).toBe('—') expect(rows[0].instrumentMain).toBe('—')
expect(rows[0].instrumentSubtitle).toBeNull() expect(rows[0].instrumentSubtitle).toBeNull()
expect(rows[0].instrument).toBe('—')
}) })
it('does not duplicate subtitle when name equals ticker', () => { it('does not duplicate subtitle when name equals ticker', () => {

View File

@ -14,6 +14,7 @@ export type DashboardIncomeRow = {
date: string | null date: string | null
instrumentMain: string instrumentMain: string
instrumentSubtitle: string | null instrumentSubtitle: string | null
instrument: string
typeLabel: DashboardIncomeTypeLabel typeLabel: DashboardIncomeTypeLabel
amount: BrokerMoney amount: BrokerMoney
} }
@ -40,6 +41,7 @@ export function getDashboardIncomeRows(operations: BrokerOperation[]): Dashboard
date: typeof operation.date === 'string' ? operation.date : null, date: typeof operation.date === 'string' ? operation.date : null,
instrumentMain: main, instrumentMain: main,
instrumentSubtitle: subtitle, instrumentSubtitle: subtitle,
instrument: main,
typeLabel: typeLabel(operation.type), typeLabel: typeLabel(operation.type),
amount: operation.payment!, amount: operation.payment!,
} }

View File

@ -6,7 +6,6 @@ import {
incomeTypeTone, incomeTypeTone,
instrumentDisplay, instrumentDisplay,
moneyTone, moneyTone,
moneyToneToColor,
} from './dashboardVisual' } from './dashboardVisual'
function money(currency: string, value: number): BrokerMoney { function money(currency: string, value: number): BrokerMoney {
@ -86,20 +85,11 @@ describe('eventTypeTone', () => {
}) })
}) })
describe('moneyToneToColor', () => {
it('maps each MoneyTone to a stable MUI color', () => {
expect(moneyToneToColor('positive')).toBe('success.main')
expect(moneyToneToColor('negative')).toBe('error.main')
expect(moneyToneToColor('planned')).toBe('text.disabled')
expect(moneyToneToColor('neutral')).toBe('text.disabled')
})
})
describe('incomeTypeTone', () => { describe('incomeTypeTone', () => {
it('maps each income label to a stable tone matching HTML parity (coupon=info)', () => { it('maps each income label to a stable tone', () => {
expect(incomeTypeTone('Дивиденд')).toBe('success') expect(incomeTypeTone('Дивиденд')).toBe('success')
expect(incomeTypeTone('Дивиденд (внешний)')).toBe('success') expect(incomeTypeTone('Дивиденд (внешний)')).toBe('info')
expect(incomeTypeTone('Купон')).toBe('info') expect(incomeTypeTone('Купон')).toBe('warning')
}) })
}) })

View File

@ -17,18 +17,6 @@ export function moneyTone(value: number | null | undefined, source?: MoneySource
return source === 'forecast' ? 'planned' : 'positive' return source === 'forecast' ? 'planned' : 'positive'
} }
export function moneyToneToColor(tone: MoneyTone): string {
switch (tone) {
case 'positive':
return 'success.main'
case 'negative':
return 'error.main'
case 'planned':
case 'neutral':
return 'text.disabled'
}
}
export function formatDashboardCurrency(money: DashboardMoneyLike | null | undefined): string { export function formatDashboardCurrency(money: DashboardMoneyLike | null | undefined): string {
if (!money) return '—' if (!money) return '—'
try { try {
@ -65,17 +53,14 @@ export function eventTypeTone(type: BrokerEventItem['type']): TypeTone {
export function incomeTypeTone(label: DashboardIncomeTypeLabel): TypeTone { export function incomeTypeTone(label: DashboardIncomeTypeLabel): TypeTone {
switch (label) { switch (label) {
case 'Дивиденд': case 'Дивиденд':
case 'Дивиденд (внешний)':
return 'success' return 'success'
case 'Купон': case 'Дивиденд (внешний)':
return 'info' return 'info'
case 'Купон':
return 'warning'
} }
} }
export function eventStatusTone(source: BrokerEventItem['source']): TypeTone {
return source === 'actual' ? 'success' : 'neutral'
}
function nonEmpty(value: string | null | undefined): string | null { function nonEmpty(value: string | null | undefined): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
} }

View File

@ -1,10 +1,10 @@
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs' import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider' import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'
import { render, screen, waitFor, within } 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 type { ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { BrokerEventItem, BrokerOperation, BrokerPortfolio } from '@/shared/api' import type { BrokerPortfolio } from '@/shared/api'
import { BrokerDashboard } from './BrokerDashboard' import { BrokerDashboard } from './BrokerDashboard'
const hookMocks = vi.hoisted(() => ({ const hookMocks = vi.hoisted(() => ({
@ -22,11 +22,11 @@ vi.mock('@/entities/broker-analytics', () => ({
useBrokerAnalytics: () => ({ useBrokerAnalytics: () => ({
data: { data: {
totalDeposits: 1000, totalDeposits: 1000,
totalWithdrawn: 250, totalWithdrawn: 100,
netInvested: -150, netInvested: 900,
totalDividends: 75, totalDividends: 25,
totalCoupons: 0, totalCoupons: 15,
totalReceived: 90, totalReceived: 40,
totalReturnPercent: 4.44, totalReturnPercent: 4.44,
currency: 'RUB', currency: 'RUB',
}, },
@ -74,78 +74,6 @@ function renderWithProviders(ui: ReactNode) {
return render(<LocalizationProvider dateAdapter={AdapterDayjs}>{ui}</LocalizationProvider>) return render(<LocalizationProvider dateAdapter={AdapterDayjs}>{ui}</LocalizationProvider>)
} }
function buildEvent(overrides: Partial<BrokerEventItem> = {}): BrokerEventItem {
return {
id: 'evt-1',
type: 'coupon',
source: 'actual',
category: 'cashflow',
eventDate: '2026-06-15T00:00:00.000Z',
paymentDate: null,
ticker: 'RU000A10AEF9',
name: 'РЖД 001Р-37R',
instrumentUid: null,
instrumentType: 'bond',
quantitySnapshot: null,
payoutPerUnit: null,
estimatedAmount: null,
actualAmount: 43.56,
currency: 'RUB',
estimateMode: null,
...overrides,
}
}
function buildOperation(overrides: Partial<BrokerOperation> = {}): BrokerOperation {
return {
cursor: null,
accountId: 'acc-1',
id: 'op-1',
parentOperationId: null,
date: '2026-06-15T00:00:00.000Z',
type: 'OPERATION_TYPE_COUPON',
category: 'income',
description: null,
name: 'ОФЗ 26241',
state: 'OPERATION_STATE_EXECUTED',
instrumentUid: null,
figi: null,
ticker: 'SU26249RMFS1',
classCode: null,
instrumentType: 'bond',
payment: { currency: 'RUB', units: '109', nano: 700000000, value: 109.7 },
price: null,
commission: null,
yield: null,
accruedInt: null,
quantity: null,
quantityDone: null,
...overrides,
}
}
function mockEventsLoaded(items: BrokerEventItem[]) {
hookMocks.useBrokerEvents.mockReturnValue({
data: { items, summary: {}, asOf: '2026-06-26T00:00:00.000Z' },
isLoading: false,
isError: false,
})
}
function mockOperationsLoaded(items: BrokerOperation[]) {
hookMocks.useBrokerOperations.mockReturnValue({
data: {
accountId: 'acc-1',
items,
nextCursor: null,
hasNext: false,
asOf: '2026-06-26T00:00:00.000Z',
},
isLoading: false,
isError: false,
})
}
describe('BrokerDashboard', () => { describe('BrokerDashboard', () => {
beforeEach(() => { beforeEach(() => {
hookMocks.useBrokerEvents.mockReturnValue({ hookMocks.useBrokerEvents.mockReturnValue({
@ -210,34 +138,14 @@ describe('BrokerDashboard', () => {
}) })
}) })
it('requests future events in the default dashboard range', () => { it('shows date filter toggle button with apply action', async () => {
vi.useFakeTimers()
try {
vi.setSystemTime(new Date('2026-06-27T12:00:00.000Z'))
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
expect(hookMocks.useBrokerEvents).toHaveBeenCalledWith(
'acc-1',
expect.objectContaining({
from: '2026-06-20',
to: '2026-07-04',
}),
{ enabled: true },
)
} finally {
vi.useRealTimers()
}
})
it('shows date filter toggle button with refresh action and accessible name', async () => {
const user = userEvent.setup() const user = userEvent.setup()
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />) renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const applyButtons = screen.getAllByRole('button', { name: 'Обновить' }) const applyButtons = screen.getAllByRole('button', { name: /Применить период/ })
expect(applyButtons).toHaveLength(2) expect(applyButtons).toHaveLength(2)
const toggleButtons = screen.getAllByRole('button', { name: /^Период/ }) const toggleButtons = screen.getAllByRole('button', { name: /📅/ })
expect(toggleButtons).toHaveLength(2) expect(toggleButtons).toHaveLength(2)
await user.click(toggleButtons[0]) await user.click(toggleButtons[0])
@ -248,67 +156,6 @@ describe('BrokerDashboard', () => {
expect(screen.getByText('1г')).toBeInTheDocument() expect(screen.getByText('1г')).toBeInTheDocument()
expect(screen.getByText('Всё')).toBeInTheDocument() expect(screen.getByText('Всё')).toBeInTheDocument()
expect(screen.getByText('Сбросить')).toBeInTheDocument() expect(screen.getByText('Сбросить')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Применить период' })).toBeInTheDocument()
})
it('does not render a text chevron glyph inside the period toggle button', () => {
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const toggleButtons = screen.getAllByRole('button', { name: /^Период/ })
expect(toggleButtons).toHaveLength(2)
for (const button of toggleButtons) {
const text = button.textContent ?? ''
expect(text).not.toMatch(/[▼▲vV]/)
expect(button.querySelector('svg')).not.toBeNull()
}
})
it('renders hero "Всего доходов" with the ₽ symbol and no "RUB" code', () => {
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
expect(screen.getByText('Всего доходов')).toBeInTheDocument()
const hero = screen.getByLabelText('Ключевые показатели брокерского счёта')
const heroText = hero.textContent ?? ''
expect(heroText).toContain('₽')
expect(heroText).not.toContain('RUB')
})
it('renders analytics card with the ₽ symbol and no "RUB" code', () => {
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const analytics = screen.getByLabelText('Аналитика доходности')
const analyticsText = analytics.textContent ?? ''
expect(analyticsText).toContain('₽')
expect(analyticsText).not.toContain('RUB')
})
it('applies positive, negative and neutral tones to analytics values', () => {
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const deposits = screen.getByTestId('dashboard-analytics-totalDeposits')
expect(deposits.getAttribute('data-tone')).toBe('positive')
expect(deposits.textContent).toContain('1\u00a0000,00')
expect(deposits.textContent).toContain('₽')
const withdrawn = screen.getByTestId('dashboard-analytics-totalWithdrawn')
expect(withdrawn.getAttribute('data-tone')).toBe('negative')
expect(withdrawn.textContent).toMatch(/^[-]250,00/)
const net = screen.getByTestId('dashboard-analytics-netInvested')
expect(net.getAttribute('data-tone')).toBe('negative')
expect(net.textContent).toMatch(/^[-]150,00/)
const dividends = screen.getByTestId('dashboard-analytics-totalDividends')
expect(dividends.getAttribute('data-tone')).toBe('positive')
expect(dividends.textContent).toContain('75,00')
const coupons = screen.getByTestId('dashboard-analytics-totalCoupons')
expect(coupons.getAttribute('data-tone')).toBe('neutral')
expect(coupons.textContent).toContain('0,00')
const received = screen.getByTestId('dashboard-analytics-totalReceived')
expect(received.getAttribute('data-tone')).toBe('positive')
expect(received.textContent).toContain('90,00')
}) })
it('shows skeleton table while events are loading', () => { it('shows skeleton table while events are loading', () => {
@ -342,196 +189,4 @@ describe('BrokerDashboard', () => {
expect(screen.queryByTestId('dashboard-table-skeleton')).not.toBeInTheDocument() expect(screen.queryByTestId('dashboard-table-skeleton')).not.toBeInTheDocument()
}) })
it('renders events table thead with semantic column headers', () => {
mockEventsLoaded([buildEvent()])
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const eventsTable = screen.getByLabelText('Таблица событий брокерского счёта')
expect(eventsTable.querySelector('thead')).not.toBeNull()
const headers = within(eventsTable).getAllByRole('columnheader')
expect(headers.map((h) => h.textContent)).toEqual([
'Дата',
'Инструмент',
'Тип',
'Сумма',
'Статус',
])
})
it('renders income table thead without status column', () => {
mockOperationsLoaded([buildOperation()])
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const incomeTable = screen.getByLabelText('Таблица доходов брокерского счёта')
expect(incomeTable.querySelector('thead')).not.toBeNull()
const headers = within(incomeTable).getAllByRole('columnheader')
expect(headers.map((h) => h.textContent)).toEqual(['Дата', 'Инструмент', 'Тип', 'Сумма'])
})
it('renders event type badge, instrument subtitle and status pill for loaded events', () => {
mockEventsLoaded([
buildEvent({ id: 'evt-actual', source: 'actual', type: 'coupon', actualAmount: 43.56 }),
buildEvent({
id: 'evt-forecast',
source: 'forecast',
type: 'dividend',
actualAmount: null,
estimatedAmount: 197.56,
}),
])
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const rows = screen.getAllByTestId('dashboard-events-row')
expect(rows).toHaveLength(2)
const subtitles = screen.getAllByTestId('dashboard-events-instrument-subtitle')
expect(subtitles).toHaveLength(2)
for (const subtitle of subtitles) {
expect(subtitle.textContent).toBe('РЖД 001Р-37R')
}
const amounts = screen.getAllByTestId('dashboard-events-amount')
expect(amounts).toHaveLength(2)
expect(amounts[0].getAttribute('data-tone')).toBe('positive')
expect(amounts[0].textContent).toContain('+43,56')
expect(amounts[1].getAttribute('data-tone')).toBe('planned')
expect(amounts[1].textContent).toContain('~197,56')
expect(screen.getByText('Купон')).toBeInTheDocument()
expect(screen.getByText('Дивиденд')).toBeInTheDocument()
expect(screen.getByText('Поступило')).toBeInTheDocument()
expect(screen.getByText('Ожидается')).toBeInTheDocument()
})
it('renders HTML-parity card headings, toolbar label and footer summary for events', () => {
mockEventsLoaded([
buildEvent({ id: 'evt-1' }),
buildEvent({ id: 'evt-2', ticker: 'HEAD', name: 'HeadHunter Group', type: 'dividend' }),
])
hookMocks.useBrokerEvents.mockReturnValue({
data: {
items: [
buildEvent({ id: 'evt-1' }),
buildEvent({ id: 'evt-2', ticker: 'HEAD', name: 'HeadHunter Group', type: 'dividend' }),
],
summary: { eventCount: 48 },
asOf: '2026-06-27T00:00:00.000Z',
},
isLoading: false,
isError: false,
})
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const eventsSection = screen.getByLabelText('События')
expect(screen.getByText('2 из 48')).toBeInTheDocument()
expect(within(eventsSection).getAllByText('Тип').length).toBeGreaterThan(0)
expect(
within(eventsSection).getByText('Показано 2 событий за выбранный период'),
).toBeInTheDocument()
})
it('renders HTML-parity count badge and footer summary for income', () => {
mockOperationsLoaded([
buildOperation({ id: 'op-1' }),
buildOperation({ id: 'op-2', ticker: 'IRAO', name: 'Интер РАО' }),
])
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
expect(screen.getByText('2 операции')).toBeInTheDocument()
expect(screen.getByText(/Показано 2 · Итого:/)).toBeInTheDocument()
})
it('uses negative tone when event actual amount is negative', () => {
mockEventsLoaded([
buildEvent({
id: 'evt-tax',
type: 'coupon',
source: 'actual',
actualAmount: -87.0,
}),
])
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const [amount] = screen.getAllByTestId('dashboard-events-amount')
expect(amount.getAttribute('data-tone')).toBe('negative')
expect(amount.textContent).toBe('87,00 ₽')
})
it('renders events and income amounts with the ₽ symbol and no "RUB" code', () => {
mockEventsLoaded([
buildEvent({ id: 'evt', source: 'actual', actualAmount: 43.56 }),
buildEvent({ id: 'evt-neg', source: 'actual', actualAmount: -12.34 }),
])
mockOperationsLoaded([
buildOperation({
id: 'op-positive',
type: 'OPERATION_TYPE_COUPON',
ticker: 'SU26249RMFS1',
name: 'ОФЗ 26241',
payment: { currency: 'RUB', units: '109', nano: 700000000, value: 109.7 },
}),
buildOperation({
id: 'op-negative',
type: 'OPERATION_TYPE_DIVIDEND',
ticker: 'IRAO',
name: 'Интер РАО',
payment: { currency: 'RUB', units: '35', nano: 0, value: -35 },
}),
])
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const eventsTable = screen.getByLabelText('Таблица событий брокерского счёта')
const incomeTable = screen.getByLabelText('Таблица доходов брокерского счёта')
for (const table of [eventsTable, incomeTable]) {
expect(table.textContent ?? '').toContain('₽')
expect(table.textContent ?? '').not.toContain('RUB')
}
})
it('renders income rows with main + subtitle, type badge and signed amount tone', () => {
mockOperationsLoaded([
buildOperation({
id: 'op-div',
type: 'OPERATION_TYPE_DIVIDEND',
ticker: 'IRAO',
name: 'Интер РАО',
payment: { currency: 'RUB', units: '649', nano: 250000000, value: 649.25 },
}),
buildOperation({
id: 'op-div-ext',
type: 'OPERATION_TYPE_DIV_EXT',
ticker: 'AAPL',
name: 'Apple Inc.',
payment: { currency: 'RUB', units: '100', nano: 0, value: -35 },
}),
])
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const rows = screen.getAllByTestId('dashboard-income-row')
expect(rows).toHaveLength(2)
const mainLabels = screen.getAllByTestId('dashboard-income-instrument-main')
expect(mainLabels.map((el) => el.textContent)).toEqual(['IRAO', 'AAPL'])
const subtitles = screen.getAllByTestId('dashboard-income-instrument-subtitle')
expect(subtitles.map((el) => el.textContent)).toEqual(['Интер РАО', 'Apple Inc.'])
expect(screen.getByText('Дивиденд')).toBeInTheDocument()
expect(screen.getByText('Дивиденд (внешний)')).toBeInTheDocument()
const amounts = screen.getAllByTestId('dashboard-income-amount')
expect(amounts[0].getAttribute('data-tone')).toBe('positive')
expect(amounts[0].textContent).toContain('+649,25')
expect(amounts[1].getAttribute('data-tone')).toBe('negative')
expect(amounts[1].textContent).toContain('35,00')
})
}) })

View File

@ -8,7 +8,6 @@ import type { BrokerPortfolio } from '@/shared/api'
import { useCursorPagination } from '@/shared/lib/useCursorPagination' import { useCursorPagination } from '@/shared/lib/useCursorPagination'
import { import {
applyDatePreset, applyDatePreset,
applyEventDatePreset,
type DashboardDatePreset, type DashboardDatePreset,
type DashboardEventType, type DashboardEventType,
type DashboardIncomeType, type DashboardIncomeType,
@ -124,6 +123,7 @@ export function BrokerDashboard({
to: draftEventFilters.to, to: draftEventFilters.to,
preset: draftEventFilters.preset, preset: draftEventFilters.preset,
})) }))
setEventFilterPanelOpen(false)
setEventPage(1) setEventPage(1)
}, [draftEventFilters.from, draftEventFilters.to, draftEventFilters.preset]) }, [draftEventFilters.from, draftEventFilters.to, draftEventFilters.preset])
@ -131,6 +131,7 @@ export function BrokerDashboard({
const defaults = defaultEventsFilters() const defaults = defaultEventsFilters()
setAppliedEventFilters(defaults) setAppliedEventFilters(defaults)
setDraftEventFilters(defaults) setDraftEventFilters(defaults)
setEventFilterPanelOpen(false)
setEventPage(1) setEventPage(1)
}, []) }, [])
@ -141,6 +142,7 @@ export function BrokerDashboard({
to: draftIncomeFilters.to, to: draftIncomeFilters.to,
preset: draftIncomeFilters.preset, preset: draftIncomeFilters.preset,
})) }))
setIncomeFilterPanelOpen(false)
incomePagination.reset() incomePagination.reset()
}, [draftIncomeFilters.from, draftIncomeFilters.to, draftIncomeFilters.preset, incomePagination]) }, [draftIncomeFilters.from, draftIncomeFilters.to, draftIncomeFilters.preset, incomePagination])
@ -148,11 +150,12 @@ export function BrokerDashboard({
const defaults = defaultIncomeFilters() const defaults = defaultIncomeFilters()
setAppliedIncomeFilters(defaults) setAppliedIncomeFilters(defaults)
setDraftIncomeFilters(defaults) setDraftIncomeFilters(defaults)
setIncomeFilterPanelOpen(false)
incomePagination.reset() incomePagination.reset()
}, [incomePagination]) }, [incomePagination])
function handleDraftEventPresetChange(preset: DashboardDatePreset) { function handleDraftEventPresetChange(preset: DashboardDatePreset) {
setDraftEventFilters((filters) => applyEventDatePreset(filters, preset)) setDraftEventFilters((filters) => applyDatePreset(filters, preset))
} }
function handleDraftEventFromChange(value: string) { function handleDraftEventFromChange(value: string) {
@ -195,7 +198,6 @@ export function BrokerDashboard({
onApplyFilters={applyEventFilters} onApplyFilters={applyEventFilters}
onResetFilters={resetEventFilters} onResetFilters={resetEventFilters}
hasDraftTypes={hasDraftEventTypes} hasDraftTypes={hasDraftEventTypes}
totalCount={events.data?.summary?.eventCount}
page={eventPage} page={eventPage}
canGoBack={eventPage > 1} canGoBack={eventPage > 1}
canGoForward={eventPage * eventPageSize < eventItems.length} canGoForward={eventPage * eventPageSize < eventItems.length}
@ -219,7 +221,6 @@ export function BrokerDashboard({
onApplyFilters={applyIncomeFilters} onApplyFilters={applyIncomeFilters}
onResetFilters={resetIncomeFilters} onResetFilters={resetIncomeFilters}
hasDraftTypes={hasDraftIncomeTypes} hasDraftTypes={hasDraftIncomeTypes}
visibleCount={operations.data?.items?.length ?? 0}
pageNumber={incomePagination.pageNumber} pageNumber={incomePagination.pageNumber}
canGoBack={incomePagination.pageNumber > 1} canGoBack={incomePagination.pageNumber > 1}
canGoForward={operations.data?.hasNext ?? false} canGoForward={operations.data?.hasNext ?? false}

View File

@ -31,14 +31,6 @@ export function BrokerDashboardAllocationCard({ portfolio }: { portfolio: Broker
<Text tone="muted">Нет данных для распределения</Text> <Text tone="muted">Нет данных для распределения</Text>
) : ( ) : (
<Box sx={{ display: 'grid', gap: 1.5 }}> <Box sx={{ display: 'grid', gap: 1.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
<Text variant="caption" tone="secondary">
Структура портфеля
</Text>
<Text variant="body" sx={{ fontWeight: 700 }}>
{formatBrokerMoney(portfolio.totals.portfolio)}
</Text>
</Box>
{sectors.map((sector) => ( {sectors.map((sector) => (
<Box key={sector.key}> <Box key={sector.key}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>

View File

@ -1,46 +1,10 @@
import { Metric, Text } from '@moex-vibe/design-system' import { Metric, Text } from '@moex-vibe/design-system'
import { Box } from '@mui/material' import { Box } from '@mui/material'
import type { BrokerAnalytics } from '@/shared/api' import type { BrokerAnalytics } from '@/shared/api'
import {
formatDashboardCurrency,
type MoneyTone,
moneyTone,
moneyToneToColor,
} from '../lib/dashboardVisual'
import { BrokerDashboardCard } from './BrokerDashboardCard' import { BrokerDashboardCard } from './BrokerDashboardCard'
type AnalyticsField = function amount(value: number, currency: string): string {
| 'totalDeposits' return `${value.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}`
| 'totalWithdrawn'
| 'netInvested'
| 'totalDividends'
| 'totalCoupons'
| 'totalReceived'
const ANALYTICS_METRICS: readonly {
field: AnalyticsField
label: string
testId: string
}[] = [
{ field: 'totalDeposits', label: 'Пополнения', testId: 'dashboard-analytics-totalDeposits' },
{ field: 'totalWithdrawn', label: 'Выводы', testId: 'dashboard-analytics-totalWithdrawn' },
{ field: 'netInvested', label: 'Нетто', testId: 'dashboard-analytics-netInvested' },
{ field: 'totalDividends', label: 'Дивиденды', testId: 'dashboard-analytics-totalDividends' },
{ field: 'totalCoupons', label: 'Купоны', testId: 'dashboard-analytics-totalCoupons' },
{ field: 'totalReceived', label: 'Всего получено', testId: 'dashboard-analytics-totalReceived' },
]
function analyticsTone(field: AnalyticsField, value: number): MoneyTone {
if (field === 'totalWithdrawn') {
return value > 0 ? 'negative' : moneyTone(value)
}
return moneyTone(value)
}
function analyticsDisplay(field: AnalyticsField, value: number, currency: string): string {
const formatted = formatDashboardCurrency({ currency, value })
if (field === 'totalWithdrawn' && value > 0) return `${formatted}`
return formatted
} }
export function BrokerDashboardAnalyticsCard({ export function BrokerDashboardAnalyticsCard({
@ -68,41 +32,12 @@ export function BrokerDashboardAnalyticsCard({
gap: 2, gap: 2,
}} }}
> >
{ANALYTICS_METRICS.map(({ field, label, testId }) => { <Metric label="Пополнения" value={amount(data.totalDeposits, data.currency)} />
const value = data[field] <Metric label="Выводы" value={`${amount(data.totalWithdrawn, data.currency)}`} />
const tone = analyticsTone(field, value) <Metric label="Нетто" value={amount(data.netInvested, data.currency)} />
return ( <Metric label="Дивиденды" value={amount(data.totalDividends, data.currency)} />
<Box <Metric label="Купоны" value={amount(data.totalCoupons, data.currency)} />
key={field} <Metric label="Всего получено" value={amount(data.totalReceived, data.currency)} />
sx={{
borderRadius: 2,
border: '1px solid',
borderColor: tone === 'negative' ? 'error.light' : 'divider',
bgcolor:
tone === 'positive'
? 'rgba(46, 125, 50, 0.06)'
: tone === 'negative'
? 'rgba(211, 47, 47, 0.06)'
: 'grey.50',
p: 1.5,
}}
>
<Metric
label={label}
value={
<Box
component="span"
data-testid={testId}
data-tone={tone}
sx={{ color: moneyToneToColor(tone), fontWeight: 700 }}
>
{analyticsDisplay(field, value, data.currency)}
</Box>
}
/>
</Box>
)
})}
</Box> </Box>
)} )}
</BrokerDashboardCard> </BrokerDashboardCard>

View File

@ -4,28 +4,22 @@ import type { ReactNode } from 'react'
type BrokerDashboardCardProps = { type BrokerDashboardCardProps = {
title: string title: string
badge?: ReactNode
action?: ReactNode action?: ReactNode
filters?: ReactNode filters?: ReactNode
children: ReactNode children: ReactNode
sx?: SxProps<Theme> sx?: SxProps<Theme>
ariaLabel?: string
} }
export function BrokerDashboardCard({ export function BrokerDashboardCard({
title, title,
badge,
action, action,
filters, filters,
children, children,
sx, sx,
ariaLabel,
}: BrokerDashboardCardProps) { }: BrokerDashboardCardProps) {
const resolvedAriaLabel = ariaLabel ?? title
return ( return (
<Box <Box
component="section" component="section"
aria-label={resolvedAriaLabel}
sx={{ sx={{
border: '1px solid', border: '1px solid',
borderColor: 'divider', borderColor: 'divider',
@ -40,36 +34,13 @@ export function BrokerDashboardCard({
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
alignItems: 'flex-start', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
gap: 2, gap: 2,
mb: 1.5, mb: 1.5,
}} }}
> >
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}> <Heading level={2}>{title}</Heading>
<Heading level={2} size="section">
{title}
</Heading>
{badge ? (
<Box
component="span"
sx={{
display: 'inline-flex',
alignItems: 'center',
minHeight: 24,
px: 1,
borderRadius: 999,
bgcolor: 'grey.100',
color: 'text.secondary',
fontSize: 12,
fontWeight: 700,
lineHeight: 1,
}}
>
{badge}
</Box>
) : null}
</Box>
{action} {action}
</Box> </Box>
{filters && <Box sx={{ mb: 1.5 }}>{filters}</Box>} {filters && <Box sx={{ mb: 1.5 }}>{filters}</Box>}

View File

@ -1,6 +1,4 @@
import { Chip, Text } from '@moex-vibe/design-system' import { Chip, Text } from '@moex-vibe/design-system'
import CalendarTodayRounded from '@mui/icons-material/CalendarTodayRounded'
import ExpandMoreRounded from '@mui/icons-material/ExpandMoreRounded'
import { Box, Popover } from '@mui/material' import { Box, Popover } from '@mui/material'
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs' import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
import { DateCalendar } from '@mui/x-date-pickers/DateCalendar' import { DateCalendar } from '@mui/x-date-pickers/DateCalendar'
@ -88,16 +86,8 @@ export function BrokerDashboardDateFilter({
setSelectingStage('from') setSelectingStage('from')
} }
function handleApply() {
onApply()
handleClose()
}
const selectingLabel = const selectingLabel =
selectingStage === 'from' ? 'Выберите начало периода' : 'Выберите конец периода' selectingStage === 'from' ? 'Выберите начало периода' : 'Выберите конец периода'
const periodAriaLabel = appliedLabel ? `Период: ${appliedLabel}` : 'Период'
const rangeInverted = Boolean(draftFrom && draftTo) && dayjs(draftTo).isBefore(dayjs(draftFrom))
const canApply = hasDraftTypes && !rangeInverted
return ( return (
<LocalizationProvider dateAdapter={AdapterDayjs}> <LocalizationProvider dateAdapter={AdapterDayjs}>
@ -106,38 +96,30 @@ export function BrokerDashboardDateFilter({
component="button" component="button"
type="button" type="button"
onClick={handleToggle} onClick={handleToggle}
aria-label={periodAriaLabel}
aria-expanded={isOpen}
sx={{ sx={{
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
gap: 1, gap: 1,
bgcolor: 'background.paper', bgcolor: 'grey.800',
color: 'text.primary', color: 'common.white',
border: '1px solid', border: 'none',
borderColor: 'divider', borderRadius: 1.5,
borderRadius: 1, px: 1.5,
px: 1.25, py: 0.75,
py: 0.5, fontSize: 13,
fontSize: 12,
fontWeight: 600,
cursor: 'pointer', cursor: 'pointer',
'&:hover': { borderColor: 'primary.main' }, '&:hover': { bgcolor: 'grey.700' },
}} }}
> >
<Box 📅
component="span"
aria-hidden="true"
sx={{ display: 'inline-flex', color: 'text.secondary' }}
>
<CalendarTodayRounded sx={{ fontSize: 14 }} />
</Box>
{appliedLabel && ( {appliedLabel && (
<Box <Box
sx={{ sx={{
color: 'text.secondary', bgcolor: 'grey.600',
fontSize: 12, borderRadius: 10,
fontWeight: 500, px: 0.75,
py: 0.125,
fontSize: 11,
lineHeight: 1.4, lineHeight: 1.4,
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}} }}
@ -145,18 +127,29 @@ export function BrokerDashboardDateFilter({
{appliedLabel} {appliedLabel}
</Box> </Box>
)} )}
<Box <Box sx={{ fontSize: 10, ml: 0.25, color: 'grey.400' }}>{isOpen ? '▲' : '▼'}</Box>
component="span" </Box>
aria-hidden="true" <Box
sx={{ component="button"
display: 'inline-flex', type="button"
color: 'text.secondary', onClick={onApply}
transform: isOpen ? 'rotate(180deg)' : 'none', disabled={!hasDraftTypes}
transition: 'transform 0.15s ease-in-out', sx={{
}} display: 'inline-flex',
> alignItems: 'center',
<ExpandMoreRounded sx={{ fontSize: 16 }} /> gap: 1,
</Box> 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> </Box>
<Popover <Popover
open={isOpen} open={isOpen}
@ -196,9 +189,7 @@ export function BrokerDashboardDateFilter({
}, },
}} }}
/> />
<Box <Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1 }}>
sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 1 }}
>
<Box <Box
component="button" component="button"
type="button" type="button"
@ -216,54 +207,9 @@ export function BrokerDashboardDateFilter({
> >
Сбросить Сбросить
</Box> </Box>
<Box
component="button"
type="button"
onClick={handleApply}
disabled={!canApply}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 1,
bgcolor: canApply ? 'primary.main' : 'grey.400',
color: 'common.white',
border: 'none',
borderRadius: 1,
px: 1.5,
py: 0.75,
fontSize: 12,
fontWeight: 700,
cursor: canApply ? 'pointer' : 'default',
}}
>
Применить период
</Box>
</Box> </Box>
</Box> </Box>
</Popover> </Popover>
<Box
component="button"
type="button"
onClick={onApply}
disabled={!canApply}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 1,
bgcolor: canApply ? 'primary.main' : 'grey.400',
color: 'common.white',
border: 'none',
borderRadius: 1,
px: 1.5,
py: 0.5,
fontSize: 12,
fontWeight: 700,
cursor: canApply ? 'pointer' : 'default',
'&:hover': canApply ? { bgcolor: 'primary.dark' } : {},
}}
>
Обновить
</Box>
</Box> </Box>
</LocalizationProvider> </LocalizationProvider>
) )

View File

@ -2,28 +2,17 @@ import { Button, Chip, Text } from '@moex-vibe/design-system'
import { Box } from '@mui/material' import { Box } from '@mui/material'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import type { BrokerEventItem, BrokerEventsData } from '@/shared/api' import type { BrokerEventItem, BrokerEventsData } from '@/shared/api'
import { formatBrokerDate } from '@/shared/lib/formatters' import { formatBrokerCurrencyValue, formatBrokerDate } from '@/shared/lib/formatters'
import type { DashboardDatePreset, DashboardEventType } from '../lib/dashboardFilters' import type { DashboardDatePreset, DashboardEventType } from '../lib/dashboardFilters'
import { eventStatusLabel, eventTypeLabel } from '../lib/dashboardFormatters' import { eventStatusLabel, eventTypeLabel } from '../lib/dashboardFormatters'
import {
eventStatusTone,
eventTypeTone,
formatDashboardCurrency,
instrumentDisplay,
type MoneyTone,
moneyTone,
moneyToneToColor,
type TypeTone,
} from '../lib/dashboardVisual'
import { BrokerDashboardCard } from './BrokerDashboardCard' import { BrokerDashboardCard } from './BrokerDashboardCard'
import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter' import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter'
import { BrokerDashboardTableSkeleton } from './BrokerDashboardTableSkeleton' import { BrokerDashboardTableSkeleton } from './BrokerDashboardTableSkeleton'
import { BrokerDashboardTableToolbar } from './BrokerDashboardTableToolbar'
const EVENT_FILTERS: Array<{ const EVENT_FILTERS: Array<{
type: DashboardEventType type: DashboardEventType
label: string label: string
tone: TypeTone tone: 'success' | 'info' | 'warning' | 'neutral'
}> = [ }> = [
{ type: 'dividend', label: 'Дивиденды', tone: 'success' }, { type: 'dividend', label: 'Дивиденды', tone: 'success' },
{ type: 'coupon', label: 'Купоны', tone: 'info' }, { type: 'coupon', label: 'Купоны', tone: 'info' },
@ -31,37 +20,6 @@ const EVENT_FILTERS: Array<{
{ type: 'offer', label: 'Оферты', tone: 'neutral' }, { type: 'offer', label: 'Оферты', tone: 'neutral' },
] ]
const TH_SX = {
textAlign: 'left' as const,
borderBottom: '1px solid',
borderColor: 'divider',
px: 1.25,
py: 1,
color: 'text.secondary',
fontWeight: 600,
fontSize: 12,
textTransform: 'uppercase' as const,
whiteSpace: 'nowrap' as const,
}
const TD_SX_LEFT = {
px: 1.25,
py: 1,
borderBottom: '1px solid',
borderColor: 'divider',
verticalAlign: 'middle' as const,
}
const TD_SX_RIGHT = {
...TD_SX_LEFT,
textAlign: 'right' as const,
}
const TD_SX_INSTRUMENT = {
...TD_SX_LEFT,
minWidth: 180,
}
type BrokerDashboardEventsCardProps = { type BrokerDashboardEventsCardProps = {
accountId: string accountId: string
data: BrokerEventsData | undefined data: BrokerEventsData | undefined
@ -79,7 +37,6 @@ type BrokerDashboardEventsCardProps = {
onApplyFilters: () => void onApplyFilters: () => void
onResetFilters: () => void onResetFilters: () => void
hasDraftTypes: boolean hasDraftTypes: boolean
totalCount?: number
page: number page: number
onPreviousPage: () => void onPreviousPage: () => void
onNextPage: () => void onNextPage: () => void
@ -87,25 +44,11 @@ type BrokerDashboardEventsCardProps = {
canGoForward: boolean canGoForward: boolean
} }
function eventAmountValue(event: BrokerEventItem): number | null { function eventAmount(event: BrokerEventItem): string {
return event.source === 'actual' ? event.actualAmount : event.estimatedAmount const amount = event.source === 'actual' ? event.actualAmount : event.estimatedAmount
} if (amount === null || amount === undefined) return '\u2014'
const prefix = event.source === 'actual' ? '+' : '~'
function eventMoneyTone(event: BrokerEventItem): MoneyTone { return `${prefix}${formatBrokerCurrencyValue(event.currency ?? 'RUB', amount)}`
return moneyTone(eventAmountValue(event), event.source)
}
function eventAmountDisplay(event: BrokerEventItem): string {
const amount = eventAmountValue(event)
if (amount === null || amount === undefined) return '—'
const abs = formatDashboardCurrency({
currency: event.currency ?? 'RUB',
value: Math.abs(amount),
})
if (event.source === 'forecast') return `~${abs}`
if (amount > 0) return `+${abs}`
if (amount < 0) return `${abs}`
return abs
} }
export function BrokerDashboardEventsCard({ export function BrokerDashboardEventsCard({
@ -125,7 +68,6 @@ export function BrokerDashboardEventsCard({
onApplyFilters, onApplyFilters,
onResetFilters, onResetFilters,
hasDraftTypes, hasDraftTypes,
totalCount,
page, page,
onPreviousPage, onPreviousPage,
onNextPage, onNextPage,
@ -137,20 +79,20 @@ export function BrokerDashboardEventsCard({
return ( return (
<BrokerDashboardCard <BrokerDashboardCard
title="События" title="События"
badge={events.length > 0 ? `${events.length} из ${totalCount ?? events.length}` : undefined}
action={<Link to={`/broker/${encodeURIComponent(accountId)}/events`}>Все события</Link>} action={<Link to={`/broker/${encodeURIComponent(accountId)}/events`}>Все события</Link>}
filters={ filters={
<BrokerDashboardTableToolbar <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
chips={EVENT_FILTERS.map((filter) => ( <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
<Chip {EVENT_FILTERS.map((filter) => (
key={filter.type} <Chip
label={filter.label} key={filter.type}
tone={filter.tone} label={filter.label}
selected={selectedTypes.includes(filter.type)} tone={filter.tone}
onClick={() => onToggleType(filter.type)} selected={selectedTypes.includes(filter.type)}
/> onClick={() => onToggleType(filter.type)}
))} />
> ))}
</Box>
<BrokerDashboardDateFilter <BrokerDashboardDateFilter
appliedLabel={appliedDateLabel} appliedLabel={appliedDateLabel}
preset={draftPreset} preset={draftPreset}
@ -163,7 +105,7 @@ export function BrokerDashboardEventsCard({
onReset={onResetFilters} onReset={onResetFilters}
onApply={onApplyFilters} onApply={onApplyFilters}
/> />
</BrokerDashboardTableToolbar> </Box>
} }
> >
{selectedTypes.length === 0 ? ( {selectedTypes.length === 0 ? (
@ -177,125 +119,71 @@ export function BrokerDashboardEventsCard({
) : ( ) : (
<Box sx={{ display: 'grid', gap: 1 }}> <Box sx={{ display: 'grid', gap: 1 }}>
<Box sx={{ overflowX: 'auto' }}> <Box sx={{ overflowX: 'auto' }}>
<Box <Box component="table" sx={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
component="table"
aria-label="Таблица событий брокерского счёта"
sx={{ width: '100%', borderCollapse: 'collapse', fontSize: 14, minWidth: 640 }}
>
<Box component="thead">
<Box component="tr">
<Box component="th" sx={TH_SX}>
Дата
</Box>
<Box component="th" sx={TH_SX}>
Инструмент
</Box>
<Box component="th" sx={TH_SX}>
Тип
</Box>
<Box component="th" sx={{ ...TH_SX, textAlign: 'right' }}>
Сумма
</Box>
<Box component="th" sx={{ ...TH_SX, textAlign: 'right' }}>
Статус
</Box>
</Box>
</Box>
<Box component="tbody"> <Box component="tbody">
{events.map((event) => { {events.map((event) => (
const display = instrumentDisplay({ <Box component="tr" key={event.id}>
ticker: event.ticker, <Box
name: event.name, component="td"
}) sx={{ py: 1, borderBottom: '1px solid', borderColor: 'divider' }}
const formattedAmount = eventAmountDisplay(event) >
return ( {formatBrokerDate(event.eventDate)}
<Box component="tr" key={event.id} data-testid="dashboard-events-row">
<Box component="td" sx={TD_SX_LEFT}>
{formatBrokerDate(event.eventDate) ?? '—'}
</Box>
<Box component="td" sx={TD_SX_INSTRUMENT}>
<Box sx={{ display: 'grid', gap: 0.25 }}>
<Box
sx={{ fontWeight: 700 }}
data-testid="dashboard-events-instrument-main"
>
{display.main}
</Box>
{display.subtitle ? (
<Box
sx={{ fontSize: 12, color: 'text.secondary' }}
data-testid="dashboard-events-instrument-subtitle"
>
{display.subtitle}
</Box>
) : null}
</Box>
</Box>
<Box component="td" sx={TD_SX_LEFT}>
<Chip
label={eventTypeLabel(event.type)}
tone={eventTypeTone(event.type)}
selected={false}
/>
</Box>
<Box
component="td"
sx={{
...TD_SX_RIGHT,
fontWeight: 700,
color: moneyToneToColor(eventMoneyTone(event)),
}}
data-testid="dashboard-events-amount"
data-tone={eventMoneyTone(event)}
>
{formattedAmount}
</Box>
<Box component="td" sx={TD_SX_RIGHT}>
<Chip
label={eventStatusLabel(event)}
tone={eventStatusTone(event.source)}
selected={false}
/>
</Box>
</Box> </Box>
) <Box
})} component="td"
sx={{
py: 1,
borderBottom: '1px solid',
borderColor: 'divider',
fontWeight: 700,
}}
>
{event.ticker ?? event.name ?? '\u2014'}
</Box>
<Box
component="td"
sx={{ py: 1, borderBottom: '1px solid', borderColor: 'divider' }}
>
{eventTypeLabel(event.type)}
</Box>
<Box
component="td"
sx={{
py: 1,
borderBottom: '1px solid',
borderColor: 'divider',
textAlign: 'right',
fontWeight: 700,
}}
>
{eventAmount(event)}
</Box>
<Box
component="td"
sx={{
py: 1,
borderBottom: '1px solid',
borderColor: 'divider',
textAlign: 'right',
}}
>
{eventStatusLabel(event)}
</Box>
</Box>
))}
</Box> </Box>
</Box> </Box>
</Box> </Box>
<Box <Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, alignItems: 'center' }}>
sx={{ <Button variant="secondary" size="small" onClick={onPreviousPage} disabled={!canGoBack}>
display: 'flex',
justifyContent: 'space-between', </Button>
gap: 1, <Text variant="body" tone="secondary">
alignItems: 'center', {page}
flexWrap: 'wrap',
}}
>
<Text variant="caption" tone="secondary">
Показано {events.length} событий за выбранный период
</Text> </Text>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Button variant="secondary" size="small" onClick={onNextPage} disabled={!canGoForward}>
<Button
variant="secondary" </Button>
size="small"
onClick={onPreviousPage}
disabled={!canGoBack}
>
</Button>
<Text variant="body" tone="secondary">
{page}
</Text>
<Button
variant="secondary"
size="small"
onClick={onNextPage}
disabled={!canGoForward}
>
</Button>
</Box>
</Box> </Box>
</Box> </Box>
)} )}

View File

@ -2,7 +2,6 @@ import { Metric, Text } from '@moex-vibe/design-system'
import { Box } from '@mui/material' import { Box } from '@mui/material'
import type { BrokerAnalytics, BrokerPortfolio } from '@/shared/api' import type { BrokerAnalytics, BrokerPortfolio } from '@/shared/api'
import { formatBrokerMoney, formatBrokerPercent } from '@/shared/lib/formatters' import { formatBrokerMoney, formatBrokerPercent } from '@/shared/lib/formatters'
import { formatDashboardCurrency, moneyTone, moneyToneToColor } from '../lib/dashboardVisual'
type BrokerDashboardHeroProps = { type BrokerDashboardHeroProps = {
portfolio: BrokerPortfolio portfolio: BrokerPortfolio
@ -15,13 +14,10 @@ function percentValue(value: unknown): string {
export function BrokerDashboardHero({ portfolio, analytics }: BrokerDashboardHeroProps) { export function BrokerDashboardHero({ portfolio, analytics }: BrokerDashboardHeroProps) {
const accountName = portfolio.account.name || 'Брокерский счёт' const accountName = portfolio.account.name || 'Брокерский счёт'
const returnPercent = analytics?.totalReturnPercent ?? portfolio.yields.expectedPercent const totalReceived = analytics
const returnTone = moneyTone(typeof returnPercent === 'number' ? returnPercent : null) ? `${analytics.totalReceived.toLocaleString('ru-RU', { maximumFractionDigits: 2 })} ${analytics.currency}`
const dailyTone = moneyTone(portfolio.yields.daily?.value ?? null)
const totalReceivedTone = moneyTone(analytics?.totalReceived ?? null)
const totalReceivedDisplay = analytics
? formatDashboardCurrency({ currency: analytics.currency, value: analytics.totalReceived })
: '—' : '—'
const returnPercent = analytics?.totalReturnPercent ?? portfolio.yields.expectedPercent
return ( return (
<Box <Box
@ -53,30 +49,12 @@ export function BrokerDashboardHero({ portfolio, analytics }: BrokerDashboardHer
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}> <Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Metric <Metric
label="Доходность" label="Доходность"
value={ value={percentValue(returnPercent)}
<Box component="span" sx={{ color: moneyToneToColor(returnTone), fontWeight: 700 }}> supportingText={`За день: ${formatBrokerMoney(portfolio.yields.daily)}`}
{percentValue(returnPercent)}
</Box>
}
supportingText={
<Box component="span" sx={{ color: moneyToneToColor(dailyTone) }}>
За день: {formatBrokerMoney(portfolio.yields.daily)}
</Box>
}
/> />
</Box> </Box>
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}> <Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Metric <Metric label="Всего доходов" value={totalReceived} />
label="Всего доходов"
value={
<Box
component="span"
sx={{ color: moneyToneToColor(totalReceivedTone), fontWeight: 700 }}
>
{totalReceivedDisplay}
</Box>
}
/>
</Box> </Box>
</Box> </Box>
) )

View File

@ -2,60 +2,22 @@ import { Button, Chip, Text } from '@moex-vibe/design-system'
import { Box } from '@mui/material' import { Box } from '@mui/material'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import type { BrokerOperationsPage } from '@/shared/api' import type { BrokerOperationsPage } from '@/shared/api'
import { formatBrokerDate } from '@/shared/lib/formatters' import { formatBrokerCurrencyValue, formatBrokerDate } from '@/shared/lib/formatters'
import type { DashboardDatePreset, DashboardIncomeType } from '../lib/dashboardFilters' import type { DashboardDatePreset, DashboardIncomeType } from '../lib/dashboardFilters'
import { getDashboardIncomeRows, sumDashboardIncome } from '../lib/dashboardIncome' import { getDashboardIncomeRows, sumDashboardIncome } from '../lib/dashboardIncome'
import {
formatDashboardCurrency,
incomeTypeTone,
moneyToneToColor,
type TypeTone,
} from '../lib/dashboardVisual'
import { BrokerDashboardCard } from './BrokerDashboardCard' import { BrokerDashboardCard } from './BrokerDashboardCard'
import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter' import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter'
import { BrokerDashboardTableSkeleton } from './BrokerDashboardTableSkeleton' import { BrokerDashboardTableSkeleton } from './BrokerDashboardTableSkeleton'
import { BrokerDashboardTableToolbar } from './BrokerDashboardTableToolbar'
const INCOME_FILTERS: Array<{ const INCOME_FILTERS: Array<{
type: DashboardIncomeType type: DashboardIncomeType
label: string label: string
tone: TypeTone tone: 'success' | 'info'
}> = [ }> = [
{ type: 'dividend', label: 'Дивиденды', tone: 'success' }, { type: 'dividend', label: 'Дивиденды', tone: 'success' },
{ type: 'coupon', label: 'Купоны', tone: 'info' }, { type: 'coupon', label: 'Купоны', tone: 'info' },
] ]
const TH_SX = {
textAlign: 'left' as const,
borderBottom: '1px solid',
borderColor: 'divider',
px: 1.25,
py: 1,
color: 'text.secondary',
fontWeight: 600,
fontSize: 12,
textTransform: 'uppercase' as const,
whiteSpace: 'nowrap' as const,
}
const TD_SX_LEFT = {
px: 1.25,
py: 1,
borderBottom: '1px solid',
borderColor: 'divider',
verticalAlign: 'middle' as const,
}
const TD_SX_RIGHT = {
...TD_SX_LEFT,
textAlign: 'right' as const,
}
const TD_SX_INSTRUMENT = {
...TD_SX_LEFT,
minWidth: 200,
}
type BrokerDashboardIncomeCardProps = { type BrokerDashboardIncomeCardProps = {
accountId: string accountId: string
page: BrokerOperationsPage | undefined page: BrokerOperationsPage | undefined
@ -73,7 +35,6 @@ type BrokerDashboardIncomeCardProps = {
onApplyFilters: () => void onApplyFilters: () => void
onResetFilters: () => void onResetFilters: () => void
hasDraftTypes: boolean hasDraftTypes: boolean
visibleCount: number
pageNumber: number pageNumber: number
canGoBack: boolean canGoBack: boolean
canGoForward: boolean canGoForward: boolean
@ -98,7 +59,6 @@ export function BrokerDashboardIncomeCard({
onApplyFilters, onApplyFilters,
onResetFilters, onResetFilters,
hasDraftTypes, hasDraftTypes,
visibleCount,
pageNumber, pageNumber,
canGoBack, canGoBack,
canGoForward, canGoForward,
@ -111,20 +71,20 @@ export function BrokerDashboardIncomeCard({
return ( return (
<BrokerDashboardCard <BrokerDashboardCard
title="Доходы" title="Доходы"
badge={rows.length > 0 ? `${visibleCount} операции` : undefined}
action={<Link to={`/broker/${encodeURIComponent(accountId)}/operations`}>Все операции</Link>} action={<Link to={`/broker/${encodeURIComponent(accountId)}/operations`}>Все операции</Link>}
filters={ filters={
<BrokerDashboardTableToolbar <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
chips={INCOME_FILTERS.map((filter) => ( <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
<Chip {INCOME_FILTERS.map((filter) => (
key={filter.type} <Chip
label={filter.label} key={filter.type}
tone={filter.tone} label={filter.label}
selected={selectedTypes.includes(filter.type)} tone={filter.tone}
onClick={() => onToggleType(filter.type)} selected={selectedTypes.includes(filter.type)}
/> onClick={() => onToggleType(filter.type)}
))} />
> ))}
</Box>
<BrokerDashboardDateFilter <BrokerDashboardDateFilter
appliedLabel={appliedDateLabel} appliedLabel={appliedDateLabel}
preset={draftPreset} preset={draftPreset}
@ -137,7 +97,7 @@ export function BrokerDashboardIncomeCard({
onReset={onResetFilters} onReset={onResetFilters}
onApply={onApplyFilters} onApply={onApplyFilters}
/> />
</BrokerDashboardTableToolbar> </Box>
} }
> >
{selectedTypes.length === 0 ? ( {selectedTypes.length === 0 ? (
@ -151,120 +111,65 @@ export function BrokerDashboardIncomeCard({
) : ( ) : (
<Box sx={{ display: 'grid', gap: 1 }}> <Box sx={{ display: 'grid', gap: 1 }}>
<Box sx={{ overflowX: 'auto' }}> <Box sx={{ overflowX: 'auto' }}>
<Box <Box component="table" sx={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
component="table"
aria-label="Таблица доходов брокерского счёта"
sx={{ width: '100%', borderCollapse: 'collapse', fontSize: 14, minWidth: 520 }}
>
<Box component="thead">
<Box component="tr">
<Box component="th" sx={TH_SX}>
Дата
</Box>
<Box component="th" sx={TH_SX}>
Инструмент
</Box>
<Box component="th" sx={TH_SX}>
Тип
</Box>
<Box component="th" sx={{ ...TH_SX, textAlign: 'right' }}>
Сумма
</Box>
</Box>
</Box>
<Box component="tbody"> <Box component="tbody">
{rows.map((row) => { {rows.map((row) => (
const formattedAmount = `${row.amount.value >= 0 ? '+' : ''}${formatDashboardCurrency( <Box component="tr" key={row.id}>
{ currency: row.amount.currency, value: Math.abs(row.amount.value) }, <Box
)}` component="td"
const amountTone = row.amount.value >= 0 ? 'positive' : 'negative' sx={{ py: 1, borderBottom: '1px solid', borderColor: 'divider' }}
return ( >
<Box component="tr" key={row.id} data-testid="dashboard-income-row"> {formatBrokerDate(row.date)}
<Box component="td" sx={TD_SX_LEFT}>
{formatBrokerDate(row.date) ?? '—'}
</Box>
<Box component="td" sx={TD_SX_INSTRUMENT}>
<Box sx={{ display: 'grid', gap: 0.25 }}>
<Box
sx={{ fontWeight: 700 }}
data-testid="dashboard-income-instrument-main"
>
{row.instrumentMain}
</Box>
{row.instrumentSubtitle ? (
<Box
sx={{ fontSize: 12, color: 'text.secondary' }}
data-testid="dashboard-income-instrument-subtitle"
>
{row.instrumentSubtitle}
</Box>
) : null}
</Box>
</Box>
<Box component="td" sx={TD_SX_LEFT}>
<Chip
label={row.typeLabel}
tone={incomeTypeTone(row.typeLabel)}
selected={false}
/>
</Box>
<Box
component="td"
sx={{
...TD_SX_RIGHT,
fontWeight: 700,
color: moneyToneToColor(amountTone),
}}
data-testid="dashboard-income-amount"
data-tone={amountTone}
>
{formattedAmount}
</Box>
</Box> </Box>
) <Box
})} component="td"
sx={{
py: 1,
borderBottom: '1px solid',
borderColor: 'divider',
fontWeight: 700,
}}
>
{row.instrument}
</Box>
<Box
component="td"
sx={{ py: 1, borderBottom: '1px solid', borderColor: 'divider' }}
>
{row.typeLabel}
</Box>
<Box
component="td"
sx={{
py: 1,
borderBottom: '1px solid',
borderColor: 'divider',
textAlign: 'right',
fontWeight: 700,
color: 'success.main',
}}
>
+{formatBrokerCurrencyValue(row.amount.currency, row.amount.value)}
</Box>
</Box>
))}
</Box> </Box>
</Box> </Box>
</Box> </Box>
<Box <Text variant="caption" tone="secondary">
sx={{ Показано: {rows.length} · Итого:{' '}
display: 'flex', {total ? formatBrokerCurrencyValue(total.currency, total.value) : '—'}
justifyContent: 'space-between', </Text>
gap: 1, <Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, alignItems: 'center' }}>
alignItems: 'center', <Button variant="secondary" size="small" onClick={onPreviousPage} disabled={!canGoBack}>
flexWrap: 'wrap',
}} </Button>
> <Text variant="body" tone="secondary">
<Text variant="caption" tone="secondary"> {pageNumber}
Показано {rows.length} · Итого:{' '}
{total
? `${total.value >= 0 ? '+' : ''}${formatDashboardCurrency({
currency: total.currency,
value: Math.abs(total.value),
})}`
: '—'}
</Text> </Text>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, alignItems: 'center' }}> <Button variant="secondary" size="small" onClick={onNextPage} disabled={!canGoForward}>
<Button
variant="secondary" </Button>
size="small"
onClick={onPreviousPage}
disabled={!canGoBack}
>
</Button>
<Text variant="body" tone="secondary">
{pageNumber}
</Text>
<Button
variant="secondary"
size="small"
onClick={onNextPage}
disabled={!canGoForward}
>
</Button>
</Box>
</Box> </Box>
</Box> </Box>
)} )}

View File

@ -1,76 +1,26 @@
import { Skeleton } from '@moex-vibe/design-system' import { Skeleton } from '@moex-vibe/design-system'
import { Box } from '@mui/material' import { Box } from '@mui/material'
type ColumnWidth = string
type BrokerDashboardTableSkeletonProps = { type BrokerDashboardTableSkeletonProps = {
rows?: number rows?: number
columns?: number columns?: number
} }
const DEFAULT_EVENT_WIDTHS: ColumnWidth[] = ['14%', '32%', '18%', '18%', '18%']
const DEFAULT_INCOME_WIDTHS: ColumnWidth[] = ['18%', '40%', '22%', '20%']
const EVENT_ROW_VARIANTS: Array<'short' | 'long' | 'full'> = [
'short',
'long',
'medium',
'short',
'medium',
]
const INCOME_ROW_VARIANTS: Array<'short' | 'long' | 'full'> = ['short', 'long', 'medium', 'short']
function widthsFor(columns: number): ColumnWidth[] {
if (columns === 4) return DEFAULT_INCOME_WIDTHS
if (columns === 5) return DEFAULT_EVENT_WIDTHS
return Array.from({ length: columns }, () => '100%')
}
function rowVariantsFor(columns: number): Array<'short' | 'long' | 'full' | 'medium'> {
if (columns === 4) return INCOME_ROW_VARIANTS
return EVENT_ROW_VARIANTS.slice(0, columns)
}
export function BrokerDashboardTableSkeleton({ export function BrokerDashboardTableSkeleton({
rows = 5, rows = 10,
columns = 5, columns = 5,
}: BrokerDashboardTableSkeletonProps) { }: BrokerDashboardTableSkeletonProps) {
const widths = widthsFor(columns)
const variants = rowVariantsFor(columns)
return ( return (
<Box <Box sx={{ display: 'grid', gap: 1.5, py: 1 }} data-testid="dashboard-table-skeleton">
sx={{ display: 'grid', gap: 1, py: 1 }}
data-testid="dashboard-table-skeleton"
aria-hidden="true"
>
{Array.from({ length: rows }, (_, i) => ( {Array.from({ length: rows }, (_, i) => (
<Box <Box key={i} sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
key={i} {Array.from({ length: columns }, (_, j) => (
sx={{ <Skeleton
display: 'flex', key={j}
gap: 2, height={16}
alignItems: 'center', width={j === 0 ? 80 : j === columns - 1 ? 60 : 100}
minHeight: 38, shape="rounded"
py: 0.5, />
}}
>
{widths.map((width, j) => (
<Box key={j} sx={{ flex: `0 0 ${width}`, minWidth: 0 }}>
<Skeleton
height={14}
width={
variants[j] === 'short'
? '45%'
: variants[j] === 'medium'
? '65%'
: variants[j] === 'full'
? '100%'
: '74%'
}
shape="rounded"
/>
</Box>
))} ))}
</Box> </Box>
))} ))}

View File

@ -1,33 +0,0 @@
import { Box } from '@mui/material'
import type { ReactNode } from 'react'
type BrokerDashboardTableToolbarProps = {
chips: ReactNode
children: ReactNode
}
export function BrokerDashboardTableToolbar({ chips, children }: BrokerDashboardTableToolbarProps) {
return (
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', md: 'minmax(0, 1fr) auto auto' },
gap: 1.25,
alignItems: 'center',
border: '1px solid',
borderColor: 'divider',
bgcolor: 'grey.50',
borderRadius: 2,
p: 1,
}}
>
<Box sx={{ display: 'grid', gap: 0.75, minWidth: 0 }}>
<Box sx={{ color: 'text.secondary', fontSize: 12, fontWeight: 700, lineHeight: 1 }}>
Тип
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, minWidth: 0 }}>{chips}</Box>
</Box>
{children}
</Box>
)
}

View File

@ -65,8 +65,7 @@
### 3. События ### 3. События
- Типы событий переключаются chip-фильтрами с немедленным применением. - Типы событий переключаются chip-фильтрами с немедленным применением.
- Диапазон дат по умолчанию: `сегодня - 7 дней` / `сегодня + 7 дней`, чтобы обзор сразу показывал - Диапазон дат по умолчанию: `сегодня - 7 дней` / `сегодня`.
ближайшие будущие события.
- Диапазон дат редактируется отдельно от применённого состояния: draft state меняется локально, запрос уходит только по действию применения периода. - Диапазон дат редактируется отдельно от применённого состояния: draft state меняется локально, запрос уходит только по действию применения периода.
- В шапке управления периодом не используется слово `Фильтр`; роль управления считывается через иконку календаря, применённый диапазон, раскрытие панели и пресеты. - В шапке управления периодом не используется слово `Фильтр`; роль управления считывается через иконку календаря, применённый диапазон, раскрытие панели и пресеты.
- Native `<input type="date">` не используется; период выбирается через одно визуальное поле и popover с community `DateCalendar` из `@mui/x-date-pickers`. - Native `<input type="date">` не используется; период выбирается через одно визуальное поле и popover с community `DateCalendar` из `@mui/x-date-pickers`.
@ -74,7 +73,6 @@
- При пустом выборе типов запрос отключается, карточка показывает валидационное сообщение. - При пустом выборе типов запрос отключается, карточка показывает валидационное сообщение.
- Пагинация локальная, по 10 событий на страницу, сбрасывается при смене применённых фильтров. - Пагинация локальная, по 10 событий на страницу, сбрасывается при смене применённых фильтров.
- При загрузке карточка показывает skeleton таблицы событий с колонками дата, инструмент, тип, сумма, статус. - При загрузке карточка показывает skeleton таблицы событий с колонками дата, инструмент, тип, сумма, статус.
- В toolbar карточки используются count badge, label `Тип`, кнопка `Обновить` и footer-summary по паттерну HTML-эталона.
### 4. Доходы ### 4. Доходы
@ -120,7 +118,6 @@
используя крупный `Heading size="title"`. используя крупный `Heading size="title"`.
- `BrokerDashboardDateFilter` должен использовать иконку раскрытия вместо текстового символа и сохранять - `BrokerDashboardDateFilter` должен использовать иконку раскрытия вместо текстового символа и сохранять
единый toolbar-паттерн для `События` и `Доходы`. единый toolbar-паттерн для `События` и `Доходы`.
- Для `События` period presets должны поддерживать будущую часть диапазона, а не обрезаться текущим днём.
- Таблицы `События` и `Доходы` должны иметь `thead`, type badges, двухстрочный инструмент при наличии - Таблицы `События` и `Доходы` должны иметь `thead`, type badges, двухстрочный инструмент при наличии
названия и semantic amount colors. названия и semantic amount colors.
- `BrokerDashboardAnalyticsCard` должен окрашивать KPI-карточки по смыслу и показывать RUB через `₽`. - `BrokerDashboardAnalyticsCard` должен окрашивать KPI-карточки по смыслу и показывать RUB через `₽`.
@ -171,7 +168,7 @@
- [ ] Обновить `BrokerDashboardDateFilter`: убрать слово `Фильтр` из пользовательского текста, заменить `Показать` на понятное действие применения периода и визуально собрать chips, диапазон, сброс и применение в аккуратную шапку. - [ ] Обновить `BrokerDashboardDateFilter`: убрать слово `Фильтр` из пользовательского текста, заменить `Показать` на понятное действие применения периода и визуально собрать chips, диапазон, сброс и применение в аккуратную шапку.
- [ ] Заменить native date inputs на одно визуальное поле периода, которое открывает MUI `Popover` с community `DateCalendar`. - [ ] Заменить native date inputs на одно визуальное поле периода, которое открывает MUI `Popover` с community `DateCalendar`.
- [ ] Реализовать локальную логику выбора диапазона: первый клик задаёт начало, второй — конец; если конец раньше начала, диапазон пересобирается от выбранной даты. - [ ] Реализовать локальную логику выбора диапазона: первый клик задаёт начало, второй — конец; если конец раньше начала, диапазон пересобирается от выбранной даты.
- [ ] Настроить default range событий на `сегодня - 7 дней` / `сегодня + 7 дней`. - [ ] Настроить default range событий на `сегодня - 7 дней` / `сегодня`.
- [ ] Подключить для событий draft/applied state: типы применяются сразу, даты только по действию применения периода. - [ ] Подключить для событий draft/applied state: типы применяются сразу, даты только по действию применения периода.
- [ ] Сохранять локальную пагинацию по 10 событий и сбрасывать её при смене применённых фильтров. - [ ] Сохранять локальную пагинацию по 10 событий и сбрасывать её при смене применённых фильтров.
- [ ] Заменить текстовую загрузку событий на skeleton таблицы. - [ ] Заменить текстовую загрузку событий на skeleton таблицы.

View File

@ -105,7 +105,7 @@ Hero показывает:
### 4. Блок `События` ### 4. Блок `События`
- Блок использует существующий источник `useBrokerEvents(accountId, query)`. - Блок использует существующий источник `useBrokerEvents(accountId, query)`.
- По умолчанию применяется период `сегодня - 7 дней` / `сегодня + 7 дней` и типы - По умолчанию применяется период `сегодня - 7 дней` / `сегодня` и типы
`dividend,coupon,maturity,offer`, как в существующей вкладке событий. `dividend,coupon,maturity,offer`, как в существующей вкладке событий.
- Блок содержит кликабельные chip-фильтры типов событий: `Дивиденды`, `Купоны`, `Погашения`, `Оферты`. - Блок содержит кликабельные chip-фильтры типов событий: `Дивиденды`, `Купоны`, `Погашения`, `Оферты`.
- Пользователь может выбрать несколько типов событий. - Пользователь может выбрать несколько типов событий.
@ -114,8 +114,7 @@ Hero показывает:
- Изменение chip-фильтров типов событий применяется сразу и возвращает локальную пагинацию на первую - Изменение chip-фильтров типов событий применяется сразу и возвращает локальную пагинацию на первую
страницу. страницу.
- Блок содержит фильтр периода `from` / `to`. - Блок содержит фильтр периода `from` / `to`.
- По умолчанию применяется недельный период `сегодня - 7 дней` / `сегодня + 7 дней`, чтобы обзор - По умолчанию применяется недельный период `сегодня - 7 дней` / `сегодня`.
сразу показывал ближайшие будущие события.
- Изменение черновых фильтров не запускает запрос до применения периода пользователем. - Изменение черновых фильтров не запускает запрос до применения периода пользователем.
- В пользовательском тексте шапки периода не используется слово `Фильтр`; UI должен считываться как - В пользовательском тексте шапки периода не используется слово `Фильтр`; UI должен считываться как
управление периодом за счёт иконки календаря, применённого диапазона, пресетов и affordance раскрытия. управление периодом за счёт иконки календаря, применённого диапазона, пресетов и affordance раскрытия.
@ -258,7 +257,7 @@ Hero показывает:
- В таблице `События` инструмент отображается двумя строками при наличии названия, тип отображается - В таблице `События` инструмент отображается двумя строками при наличии названия, тип отображается
бейджем, сумма и статус имеют семантические цвета. бейджем, сумма и статус имеют семантические цвета.
- Блок `События` поддерживает multi-select chip-фильтр типов и локальную пагинацию по 10 событий. - Блок `События` поддерживает multi-select chip-фильтр типов и локальную пагинацию по 10 событий.
- Блок `События` по умолчанию запрашивает период `сегодня - 7 дней` / `сегодня + 7 дней` и использует одно поле - Блок `События` по умолчанию запрашивает период `сегодня - 7 дней` / `сегодня` и использует одно поле
периода с popover-календарём на базе community `DateCalendar`. периода с popover-календарём на базе community `DateCalendar`.
- Блок `Доходы` показывает доходные операции дивидендов и купонов и итог по отображаемым строкам. - Блок `Доходы` показывает доходные операции дивидендов и купонов и итог по отображаемым строкам.
- В таблице `Доходы` инструмент отображается двумя строками при наличии названия, тип отображается - В таблице `Доходы` инструмент отображается двумя строками при наличии названия, тип отображается

View File

@ -44,8 +44,7 @@
- [x] Переименовать действие `Показать` в управлении периодом и визуально улучшить шапку фильтров. - [x] Переименовать действие `Показать` в управлении периодом и визуально улучшить шапку фильтров.
- [x] Заменить native date inputs на одно поле периода с popover и community `DateCalendar` из `@mui/x-date-pickers`. - [x] Заменить native date inputs на одно поле периода с popover и community `DateCalendar` из `@mui/x-date-pickers`.
- [x] Не использовать `@mui/x-date-pickers-pro` и `DateRangePicker`. - [x] Не использовать `@mui/x-date-pickers-pro` и `DateRangePicker`.
- [x] Настроить default range событий на `сегодня - 7 дней` / `сегодня + 7 дней`, доходов — на - [x] Настроить default range событий и доходов на `сегодня - 7 дней` / `сегодня`.
`сегодня - 7 дней` / `сегодня`.
- [x] Заменить текстовую загрузку `События` на skeleton таблицы. - [x] Заменить текстовую загрузку `События` на skeleton таблицы.
- [x] Заменить текстовую загрузку `Доходы` на skeleton таблицы. - [x] Заменить текстовую загрузку `Доходы` на skeleton таблицы.
- [x] Обновить component tests под новые тексты, единое поле периода, popover-календарь и skeleton loading states. - [x] Обновить component tests под новые тексты, единое поле периода, popover-календарь и skeleton loading states.
@ -57,27 +56,18 @@
- [x] Согласовать статический визуальный эталон `docs/research/2026-06-27-broker-account-redesign.html`. - [x] Согласовать статический визуальный эталон `docs/research/2026-06-27-broker-account-redesign.html`.
- [x] Обновить `spec.md` под HTML parity: компактные заголовки, единый toolbar, бейджи, цвета, подписи инструментов, `₽`. - [x] Обновить `spec.md` под HTML parity: компактные заголовки, единый toolbar, бейджи, цвета, подписи инструментов, `₽`.
- [x] Обновить `plan.md` под перенос HTML parity в React-компоненты. - [x] Обновить `plan.md` под перенос HTML parity в React-компоненты.
- [x] Добавить `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts` с helpers для money tone, type tone, currency symbol и instrument display. - [ ] Добавить `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts` с helpers для money tone, type tone, currency symbol и instrument display.
- [x] Добавить `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts`. - [ ] Добавить `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts`.
- [ ] Расширить income helpers так, чтобы строки доходов могли отдавать main/subtitle инструмента без дублирования текста. - [ ] Расширить income helpers так, чтобы строки доходов могли отдавать main/subtitle инструмента без дублирования текста.
- [x] Обновить `BrokerDashboardHero`: semantic colors для доходности, дневного изменения и всего полученных доходов. - [ ] Обновить `BrokerDashboardHero`: semantic colors для доходности, дневного изменения и всего полученных доходов.
- [x] Обновить `BrokerDashboardCard`: компактный card heading вместо крупного page-level heading. - [ ] Обновить `BrokerDashboardCard`: компактный card heading вместо крупного page-level heading.
- [x] Обновить `BrokerDashboardDateFilter`: единый toolbar-паттерн и chevron-иконка вместо текстового `v`. - [ ] Обновить `BrokerDashboardDateFilter`: единый toolbar-паттерн и chevron-иконка вместо текстового `v`.
- [x] Обновить `BrokerDashboardEventsCard`: `thead`, двухстрочный инструмент, type badges, semantic amount colors, status badges. - [ ] Обновить `BrokerDashboardEventsCard`: `thead`, двухстрочный инструмент, type badges, semantic amount colors, status badges.
- [x] Обновить `BrokerDashboardIncomeCard`: `thead`, двухстрочный инструмент, type badges, semantic amount colors. - [ ] Обновить `BrokerDashboardIncomeCard`: `thead`, двухстрочный инструмент, type badges, semantic amount colors.
Бейдж `Купон` приведён к `info` (синий) согласно HTML-эталону `.type-badge.coupon` (`incomeTypeTone` в `dashboardVisual.ts`), - [ ] Обновить `BrokerDashboardTableSkeleton`: общий skeleton-паттерн для событий и доходов с корректной геометрией колонок.
`Дивиденд (внешний)` отнесён к `success` (семейство дивидендов). - [ ] Обновить `BrokerDashboardAnalyticsCard`: `₽` для RUB и positive/negative tone карточек.
- [x] Обновить `BrokerDashboardTableSkeleton`: общий skeleton-паттерн для событий и доходов с корректной геометрией колонок. - [ ] Обновить component tests dashboard под HTML parity.
- [x] Обновить `BrokerDashboardAnalyticsCard`: `₽` для RUB и positive/negative tone карточек. - [ ] Проверить, что блок `Доходы` не расширяет backend/API и остаётся в рамках текущих income-типов.
- [x] Обновить component tests dashboard под HTML parity (analytics tones).
- [x] Проверить, что блок `Доходы` не расширяет backend/API и остаётся в рамках текущих income-типов.
Исправлен пресет «Всё»: `applyDatePreset('all')` теперь отдаёт широкий диапазон `2000-01-01``2099-12-31`
вместо пустых `from`/`to`, которые backend (`BrokerEventsQueryDto @Matches`) отвергал с 400. В DateFilter
кнопка «Применить период» блокируется при инвертированном диапазоне (`to < from`), чтобы избежать
молчаливо пустого ответа (например, будущий `from` при дефолтном `to=сегодня`).
- [x] Исправить default-range `События`, чтобы dashboard по умолчанию и через пресеты мог загружать будущие события.
- [x] Подтянуть card headers, toolbar label `Тип`, count badges и footer summaries ближе к
`docs/research/2026-06-27-broker-account-redesign.html`.
- [ ] Проверить desktop layout `/broker/2084014113` против `docs/research/2026-06-27-broker-account-redesign.html`. - [ ] Проверить desktop layout `/broker/2084014113` против `docs/research/2026-06-27-broker-account-redesign.html`.
- [ ] Проверить mobile layout `/broker/2084014113` на viewport `390x844` против `docs/research/2026-06-27-broker-account-redesign.html`. - [ ] Проверить mobile layout `/broker/2084014113` на viewport `390x844` против `docs/research/2026-06-27-broker-account-redesign.html`.
@ -93,47 +83,11 @@
## Definition of Done для HTML parity ## Definition of Done для HTML parity
- [x] `rtk npm run test:frontend -- --run src/widgets/broker-dashboard` проходит. (49/49) - [ ] `rtk npm run test:frontend -- --run src/widgets/broker-dashboard` проходит.
- [x] `rtk npm run test:frontend` проходит после HTML parity изменений. (175/175) - [ ] `rtk npm run test:frontend` проходит после HTML parity изменений.
- [x] `rtk npm run lint -w apps/frontend` проходит после HTML parity изменений. - [ ] `rtk npm run lint -w apps/frontend` проходит после HTML parity изменений.
- [x] `rtk npm run build:frontend` проходит после HTML parity изменений. - [ ] `rtk npm run build:frontend` проходит после HTML parity изменений.
- [ ] На `/broker/2084014113` заголовки карточек, toolbar таблиц, бейджи типов, подписи инструментов, - [ ] На `/broker/2084014113` заголовки карточек, toolbar таблиц, бейджи типов, подписи инструментов,
цвета сумм, analytics colors и skeleton визуально соответствуют `docs/research/2026-06-27-broker-account-redesign.html`. цвета сумм, analytics colors и skeleton визуально соответствуют `docs/research/2026-06-27-broker-account-redesign.html`.
Проверено только по тестам и code-to-spec mapping внизу файла — live dev server не запускался
в этой итерации (нет backend/auth в среде). Необходима ручная проверка пользователем.
- [ ] Нет общего горизонтального overflow на mobile; горизонтальный scroll допускается только внутри таблиц. - [ ] Нет общего горизонтального overflow на mobile; горизонтальный scroll допускается только внутри таблиц.
Требует live dev server + ручной проверки на viewport `390x844`. - [ ] `docs/features/broker-dashboard-redesign/tasks.md` обновлён по факту выполнения.
- [x] `docs/features/broker-dashboard-redesign/tasks.md` обновлён по факту выполнения.
### Code-to-spec mapping для analytics (HTML parity)
`BrokerDashboardAnalyticsCard.tsx` (`apps/frontend/src/widgets/broker-dashboard/ui/`) против
HTML-эталона (`docs/research/2026-06-27-broker-account-redesign.html:1145-1158`):
| HTML reference element | React-компонент / data-testid | Спецификация §6 (spec.md:165-180) |
|------------------------|------------------------------------------------------------|----------------------------------------------------------------------|
| `<section aria-labelledby="analytics-title">` | `BrokerDashboardCard` с `ariaLabel="Аналитика доходности"` | Секция с заголовком, доступная по aria-label |
| `<h2>Аналитика доходности</h2>` | Заголовок карточки | Компактный card heading (§2) |
| `.analytics-item.positive .analytics-value` | `data-testid="dashboard-analytics-totalDeposits"` (tone `positive`) | Пополнения — positive (§6, AC `Блок Аналитика доходности`) |
| `.analytics-item.negative .analytics-value` для Выводы | `data-testid="dashboard-analytics-totalWithdrawn"` (tone `negative`, префикс ``) | Выводы — negative, всегда со знаком `` (§6) |
| `.analytics-item.negative .analytics-value` для Нетто (если нетто<0) | `data-testid="dashboard-analytics-netInvested"` (tone `negative` при `value<0`) | Нетто sign-based 6) |
| `.analytics-item.positive .analytics-value` для Дивиденды/Купоны/Всего получено | `data-testid="dashboard-analytics-totalDividends"`, `…-totalCoupons`, `…-totalReceived` | Positive если `value > 0`, neutral если `value === 0` (§6) |
| `113 773,03 ₽` (валюта) | `formatDashboardCurrency` через `shared/lib/formatters` | RUB отображается как `₽` (AC, §2) |
Тесты в `BrokerDashboard.test.tsx`:
- `renders analytics card with the ₽ symbol and no "RUB" code` — подтверждает замену `RUB` на `₽`.
- `applies positive, negative and neutral tones to analytics values` — подтверждает tone-атрибуты
для каждого поля с разнообразными значениями (`totalDeposits: 1000`, `totalWithdrawn: 250`,
`netInvested: -150`, `totalDividends: 75`, `totalCoupons: 0`, `totalReceived: 90`).
### Что НЕ было проверено в этой итерации
- Реальный визуальный рендеринг `/broker/2084014113` на desktop и `390x844` mobile.
Требуется ручная проверка пользователем с поднятым backend (нужны реальные auth и T-Bank/MOEX
прокси). Dev server не запускался.
- Поведение отсутствующего/ошибочного analytics под live-нагрузкой. Логика в карточке покрыта
тестами, но проверка UX-сообщений и skeleton-states в браузере не делалась.
- Реальный viewport на `390x844` для подтверждения отсутствия общего горизонтального overflow.
Геометрия таблиц уже переключена на внутренний `overflowX: 'auto'` (`BrokerDashboardEventsCard.tsx:176`,
`BrokerDashboardIncomeCard.tsx`), но фактическая вёрстка в браузере не сверялась с эталоном.