Compare commits

..

10 Commits

Author SHA1 Message Date
fee179d8e9 fix(frontend): align broker dashboard HTML parity
All checks were successful
CI / ci (pull_request) Successful in 15m41s
CI / ci (push) Successful in 13m50s
2026-06-27 15:23:55 +03:00
58b8075583 refactor(frontend): deduplicate analytics metric cells and restore text states 2026-06-27 13:52:37 +03:00
d7b65e256f docs: mark HTML parity analytics task and DoD verified
- Flip BrokerDashboardAnalyticsCard + analytics component tests
  checkboxes for the parity iteration.
- Mark verified DoD items (dashboard tests 49/49, full frontend tests
  175/175, lint, build).
- Add code-to-spec mapping table linking the analytics card to the
  HTML reference and spec §6 for the next agent / reviewer.
- Be explicit that live visual + mobile-overflow checks still need
  manual verification with a running backend.
2026-06-27 13:45:48 +03:00
b12c2741cd feat(frontend): align analytics card with HTML parity
- Render RUB values via formatDashboardCurrency so RUB shows as ₽
  instead of 'RUB', matching the hero, events and income cards.
- Apply semantic color tones per spec §6:
  * Пополнения/Дивиденды/Купоны/Всего получено → positive when > 0,
    neutral when 0.
  * Выводы → prefix value with '−' and render with negative tone
    whenever the underlying amount is positive.
  * Нетто → sign-based tone.
- Add data-testid and data-tone attributes on each analytics value so
  tests and downstream styling can address each metric.
- Update the analytics mock in BrokerDashboard.test.tsx to cover
  positive, negative (net invested + withdrawals) and zero values, and
  add two new tests asserting ₽ rendering and the tone data attributes.
2026-06-27 13:44:31 +03:00
a1377fa5c2 feat(frontend): allow aria-label on BrokerDashboardCard
Support setting an accessible name on the dashboard card section so
consumers can query sections via getByLabelText in tests and assistive
tech, matching the hero pattern in BrokerDashboardHero.
2026-06-27 13:44:25 +03:00
27fb6e77be refactor(frontend): polish dashboard tables per code review
- Drop unused `letterSpacing: 0.2` from TH_SX in both table cards
  (HTML mockup has no letter-spacing on headers)
- Move `eventStatusTone` from EventsCard to dashboardVisual.ts
  for symmetry with `eventTypeTone` (single source of tone semantics)
- Drop unused `columnWidths` prop and `widthsFor` override branch
  in BrokerDashboardTableSkeleton (no caller passes it)
- Drop redundant `role="presentation"` on skeleton wrapper
  (rows already carry `aria-hidden="true"`)
- Drop unused `TypeTone` import from EventsCard after moving
  eventStatusTone out
2026-06-27 13:39:23 +03:00
94f8bb876d fix(frontend): correct events amount sign for actual-negative rows
Spec review of Task 9 caught that the events amount prefix used '+'
unconditionally for any actual source, producing '+-87,00 ₽' on negative
actual amounts. Mirror the income card sign handling: '+' for positive,
Unicode '−' (U+2212) for negative, '~' for forecast (regardless of sign).

Extract a small eventAmountDisplay helper and tighten the corresponding
test assertion to require exact equality instead of substring match, so
this regression class is caught next time.
2026-06-27 13:35:48 +03:00
3bad694dce feat(frontend): align dashboard tables with HTML parity
- Add thead with semantic column headers to events and income tables
- Render instruments as main (ticker/ISIN) + subtitle (name) via instrumentDisplay
- Type column uses compact Chip with eventTypeTone/incomeTypeTone; DIV_EXT
  shows distinct label 'Дивиденд (внешний)'
- Amount column applies moneyTone (positive=green, negative=red, planned=neutral)
  via moneyToneToColor; sign prefixes (+/-/~) preserved per spec
- Status column: 'Поступило' (success) vs 'Ожидается' (neutral)
- Income sum semantics preserved (sum of current page rows)
- Skeleton: unified column-width and variant pattern for both tables
- Drop legacy DashboardIncomeRow.instrument alias (now uses instrumentMain/Subtitle)
- Extract moneyToneToColor helper from hero (single source of MUI color mapping)
- Dashboard tests cover thead, badges, subtitles, signed tones, and that
  tables show ₽ instead of RUB for RUB amounts
2026-06-27 13:33:02 +03:00
21df354df6 fix(frontend): align dashboard card heading hierarchy
BrokerDashboardCard previously rendered <Heading level={3} size="section">,
which produced an <h3> under the <h1> account name in BrokerAccountLayout,
skipping the <h2> level. Use level={2} so the semantic HTML is a proper
<h2> while keeping the compact "section" visual size.

Also extract the byte-identical toolbar wrapper (grid + chips + date filter
slot) shared by BrokerDashboardEventsCard and BrokerDashboardIncomeCard
into BrokerDashboardTableToolbar to remove duplicated sx config.
2026-06-27 13:21:15 +03:00
0a1ae0552c feat(frontend): apply hero, card and toolbar HTML parity 2026-06-27 13:07:06 +03:00
22 changed files with 1231 additions and 322 deletions

View File

@ -18,12 +18,13 @@ 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: 'flex', display: 'grid',
alignItems: 'center', gridTemplateColumns: 'minmax(0, 1fr) auto',
flexWrap: 'wrap', gap: 12,
gap: 24,
}} }}
> >
<div style={{ display: 'grid', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap' }}>
<Link <Link
to="/" to="/"
style={{ style={{
@ -38,6 +39,11 @@ export function AppLayout() {
<div style={{ flex: '1 1 280px', minWidth: 220, maxWidth: 420 }}> <div style={{ flex: '1 1 280px', minWidth: 220, maxWidth: 420 }}>
<SearchBar /> <SearchBar />
</div> </div>
</div>
<nav
style={{ display: 'flex', gap: 20, flexWrap: 'wrap' }}
aria-label="Основная навигация"
>
<Link <Link
to="/portfolios" to="/portfolios"
style={{ style={{
@ -71,12 +77,13 @@ export function AppLayout() {
> >
Скринер Скринер
</Link> </Link>
</nav>
</div>
<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 12px', padding: '10px 14px',
borderRadius: 8, borderRadius: 999,
color: 'var(--color-text-secondary)', color: 'var(--color-text-secondary)',
textDecoration: 'none', textDecoration: 'none',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
@ -16,6 +16,7 @@ const baseLinkStyle: React.CSSProperties = {
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
fontSize: 14, fontSize: 14,
fontWeight: 600,
} }
const links = [ const links = [
@ -54,8 +55,6 @@ 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,
}} }}
> >
@ -70,7 +69,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.08)', background: 'rgba(25, 118, 210, 0.1)',
}, },
}} }}
> >

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.format('YYYY-MM-DD'), to: now.add(7, 'day').format('YYYY-MM-DD'),
types: [...DASHBOARD_EVENT_TYPES], types: [...DASHBOARD_EVENT_TYPES],
preset: '7d', preset: '7d',
} }
@ -40,7 +40,9 @@ 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') return { ...next, from: '', to: '' } if (preset === 'all') {
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 {
@ -50,6 +52,25 @@ 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,6 +19,5 @@ 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 'Поступило'
if (event.type === 'offer') return 'Оферта' return 'Ожидается'
return 'Прогноз'
} }

View File

@ -64,12 +64,11 @@ describe('dashboardIncome', () => {
expect(rows[0].amount.value).toBe(12) expect(rows[0].amount.value).toBe(12)
}) })
it('splits instrument into main and subtitle, keeping the legacy alias', () => { it('splits instrument into main and subtitle', () => {
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', () => {
@ -78,7 +77,6 @@ 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,7 +14,6 @@ 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
} }
@ -41,7 +40,6 @@ 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,6 +6,7 @@ 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 {
@ -85,11 +86,20 @@ 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', () => { it('maps each income label to a stable tone matching HTML parity (coupon=info)', () => {
expect(incomeTypeTone('Дивиденд')).toBe('success') expect(incomeTypeTone('Дивиденд')).toBe('success')
expect(incomeTypeTone('Дивиденд (внешний)')).toBe('info') expect(incomeTypeTone('Дивиденд (внешний)')).toBe('success')
expect(incomeTypeTone('Купон')).toBe('warning') expect(incomeTypeTone('Купон')).toBe('info')
}) })
}) })

View File

@ -17,6 +17,18 @@ 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 {
@ -53,14 +65,17 @@ 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 'Дивиденд':
return 'success'
case 'Дивиденд (внешний)': case 'Дивиденд (внешний)':
return 'info' return 'success'
case 'Купон': case 'Купон':
return 'warning' return 'info'
} }
} }
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 } from '@testing-library/react' import { render, screen, waitFor, within } 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 { BrokerPortfolio } from '@/shared/api' import type { BrokerEventItem, BrokerOperation, 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: 100, totalWithdrawn: 250,
netInvested: 900, netInvested: -150,
totalDividends: 25, totalDividends: 75,
totalCoupons: 15, totalCoupons: 0,
totalReceived: 40, totalReceived: 90,
totalReturnPercent: 4.44, totalReturnPercent: 4.44,
currency: 'RUB', currency: 'RUB',
}, },
@ -74,6 +74,78 @@ 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({
@ -138,14 +210,34 @@ describe('BrokerDashboard', () => {
}) })
}) })
it('shows date filter toggle button with apply action', async () => { it('requests future events in the default dashboard range', () => {
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])
@ -156,6 +248,67 @@ 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', () => {
@ -189,4 +342,196 @@ 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,6 +8,7 @@ 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,
@ -123,7 +124,6 @@ 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,7 +131,6 @@ export function BrokerDashboard({
const defaults = defaultEventsFilters() const defaults = defaultEventsFilters()
setAppliedEventFilters(defaults) setAppliedEventFilters(defaults)
setDraftEventFilters(defaults) setDraftEventFilters(defaults)
setEventFilterPanelOpen(false)
setEventPage(1) setEventPage(1)
}, []) }, [])
@ -142,7 +141,6 @@ 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])
@ -150,12 +148,11 @@ 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) => applyDatePreset(filters, preset)) setDraftEventFilters((filters) => applyEventDatePreset(filters, preset))
} }
function handleDraftEventFromChange(value: string) { function handleDraftEventFromChange(value: string) {
@ -198,6 +195,7 @@ 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}
@ -221,6 +219,7 @@ 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,6 +31,14 @@ 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,10 +1,46 @@
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'
function amount(value: number, currency: string): string { type AnalyticsField =
return `${value.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}` | 'totalDeposits'
| '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({
@ -32,12 +68,41 @@ export function BrokerDashboardAnalyticsCard({
gap: 2, gap: 2,
}} }}
> >
<Metric label="Пополнения" value={amount(data.totalDeposits, data.currency)} /> {ANALYTICS_METRICS.map(({ field, label, testId }) => {
<Metric label="Выводы" value={`${amount(data.totalWithdrawn, data.currency)}`} /> const value = data[field]
<Metric label="Нетто" value={amount(data.netInvested, data.currency)} /> const tone = analyticsTone(field, value)
<Metric label="Дивиденды" value={amount(data.totalDividends, data.currency)} /> return (
<Metric label="Купоны" value={amount(data.totalCoupons, data.currency)} /> <Box
<Metric label="Всего получено" value={amount(data.totalReceived, data.currency)} /> key={field}
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,22 +4,28 @@ 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',
@ -34,13 +40,36 @@ export function BrokerDashboardCard({
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'flex-start',
justifyContent: 'space-between', justifyContent: 'space-between',
gap: 2, gap: 2,
mb: 1.5, mb: 1.5,
}} }}
> >
<Heading level={2}>{title}</Heading> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<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,4 +1,6 @@
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'
@ -86,8 +88,16 @@ 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}>
@ -96,30 +106,38 @@ 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: 'grey.800', bgcolor: 'background.paper',
color: 'common.white', color: 'text.primary',
border: 'none', border: '1px solid',
borderRadius: 1.5, borderColor: 'divider',
px: 1.5, borderRadius: 1,
py: 0.75, px: 1.25,
fontSize: 13, py: 0.5,
fontSize: 12,
fontWeight: 600,
cursor: 'pointer', cursor: 'pointer',
'&:hover': { bgcolor: 'grey.700' }, '&:hover': { borderColor: 'primary.main' },
}} }}
> >
📅 <Box
component="span"
aria-hidden="true"
sx={{ display: 'inline-flex', color: 'text.secondary' }}
>
<CalendarTodayRounded sx={{ fontSize: 14 }} />
</Box>
{appliedLabel && ( {appliedLabel && (
<Box <Box
sx={{ sx={{
bgcolor: 'grey.600', color: 'text.secondary',
borderRadius: 10, fontSize: 12,
px: 0.75, fontWeight: 500,
py: 0.125,
fontSize: 11,
lineHeight: 1.4, lineHeight: 1.4,
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}} }}
@ -127,29 +145,18 @@ export function BrokerDashboardDateFilter({
{appliedLabel} {appliedLabel}
</Box> </Box>
)} )}
<Box sx={{ fontSize: 10, ml: 0.25, color: 'grey.400' }}>{isOpen ? '▲' : '▼'}</Box>
</Box>
<Box <Box
component="button" component="span"
type="button" aria-hidden="true"
onClick={onApply}
disabled={!hasDraftTypes}
sx={{ sx={{
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', color: 'text.secondary',
gap: 1, transform: isOpen ? 'rotate(180deg)' : 'none',
bgcolor: hasDraftTypes ? 'primary.main' : 'grey.400', transition: 'transform 0.15s ease-in-out',
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' } : {},
}} }}
> >
Применить период <ExpandMoreRounded sx={{ fontSize: 16 }} />
</Box>
</Box> </Box>
<Popover <Popover
open={isOpen} open={isOpen}
@ -189,7 +196,9 @@ export function BrokerDashboardDateFilter({
}, },
}} }}
/> />
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1 }}> <Box
sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 1 }}
>
<Box <Box
component="button" component="button"
type="button" type="button"
@ -207,9 +216,54 @@ 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,17 +2,28 @@ 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 { formatBrokerCurrencyValue, formatBrokerDate } from '@/shared/lib/formatters' import { 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: 'success' | 'info' | 'warning' | 'neutral' tone: TypeTone
}> = [ }> = [
{ type: 'dividend', label: 'Дивиденды', tone: 'success' }, { type: 'dividend', label: 'Дивиденды', tone: 'success' },
{ type: 'coupon', label: 'Купоны', tone: 'info' }, { type: 'coupon', label: 'Купоны', tone: 'info' },
@ -20,6 +31,37 @@ 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
@ -37,6 +79,7 @@ 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
@ -44,11 +87,25 @@ type BrokerDashboardEventsCardProps = {
canGoForward: boolean canGoForward: boolean
} }
function eventAmount(event: BrokerEventItem): string { function eventAmountValue(event: BrokerEventItem): number | null {
const amount = event.source === 'actual' ? event.actualAmount : event.estimatedAmount return event.source === 'actual' ? event.actualAmount : event.estimatedAmount
if (amount === null || amount === undefined) return '\u2014' }
const prefix = event.source === 'actual' ? '+' : '~'
return `${prefix}${formatBrokerCurrencyValue(event.currency ?? 'RUB', amount)}` function eventMoneyTone(event: BrokerEventItem): MoneyTone {
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({
@ -68,6 +125,7 @@ export function BrokerDashboardEventsCard({
onApplyFilters, onApplyFilters,
onResetFilters, onResetFilters,
hasDraftTypes, hasDraftTypes,
totalCount,
page, page,
onPreviousPage, onPreviousPage,
onNextPage, onNextPage,
@ -79,11 +137,11 @@ 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={
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}> <BrokerDashboardTableToolbar
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}> chips={EVENT_FILTERS.map((filter) => (
{EVENT_FILTERS.map((filter) => (
<Chip <Chip
key={filter.type} key={filter.type}
label={filter.label} label={filter.label}
@ -92,7 +150,7 @@ export function BrokerDashboardEventsCard({
onClick={() => onToggleType(filter.type)} onClick={() => onToggleType(filter.type)}
/> />
))} ))}
</Box> >
<BrokerDashboardDateFilter <BrokerDashboardDateFilter
appliedLabel={appliedDateLabel} appliedLabel={appliedDateLabel}
preset={draftPreset} preset={draftPreset}
@ -105,7 +163,7 @@ export function BrokerDashboardEventsCard({
onReset={onResetFilters} onReset={onResetFilters}
onApply={onApplyFilters} onApply={onApplyFilters}
/> />
</Box> </BrokerDashboardTableToolbar>
} }
> >
{selectedTypes.length === 0 ? ( {selectedTypes.length === 0 ? (
@ -119,73 +177,127 @@ export function BrokerDashboardEventsCard({
) : ( ) : (
<Box sx={{ display: 'grid', gap: 1 }}> <Box sx={{ display: 'grid', gap: 1 }}>
<Box sx={{ overflowX: 'auto' }}> <Box sx={{ overflowX: 'auto' }}>
<Box component="table" sx={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}> <Box
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) => {
<Box component="tr" key={event.id}> const display = instrumentDisplay({
ticker: event.ticker,
name: event.name,
})
const formattedAmount = eventAmountDisplay(event)
return (
<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 <Box
component="td" sx={{ fontWeight: 700 }}
sx={{ py: 1, borderBottom: '1px solid', borderColor: 'divider' }} data-testid="dashboard-events-instrument-main"
> >
{formatBrokerDate(event.eventDate)} {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>
<Box <Box
component="td" component="td"
sx={{ sx={{
py: 1, ...TD_SX_RIGHT,
borderBottom: '1px solid',
borderColor: 'divider',
fontWeight: 700, fontWeight: 700,
color: moneyToneToColor(eventMoneyTone(event)),
}} }}
data-testid="dashboard-events-amount"
data-tone={eventMoneyTone(event)}
> >
{event.ticker ?? event.name ?? '\u2014'} {formattedAmount}
</Box>
<Box component="td" sx={TD_SX_RIGHT}>
<Chip
label={eventStatusLabel(event)}
tone={eventStatusTone(event.source)}
selected={false}
/>
</Box>
</Box>
)
})}
</Box>
</Box>
</Box> </Box>
<Box <Box
component="td"
sx={{ py: 1, borderBottom: '1px solid', borderColor: 'divider' }}
>
{eventTypeLabel(event.type)}
</Box>
<Box
component="td"
sx={{ sx={{
py: 1, display: 'flex',
borderBottom: '1px solid', justifyContent: 'space-between',
borderColor: 'divider', gap: 1,
textAlign: 'right', alignItems: 'center',
fontWeight: 700, flexWrap: 'wrap',
}} }}
> >
{eventAmount(event)} <Text variant="caption" tone="secondary">
</Box> Показано {events.length} событий за выбранный период
<Box </Text>
component="td" <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
sx={{ <Button
py: 1, variant="secondary"
borderBottom: '1px solid', size="small"
borderColor: 'divider', onClick={onPreviousPage}
textAlign: 'right', disabled={!canGoBack}
}}
> >
{eventStatusLabel(event)}
</Box>
</Box>
))}
</Box>
</Box>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, alignItems: 'center' }}>
<Button variant="secondary" size="small" onClick={onPreviousPage} disabled={!canGoBack}>
</Button> </Button>
<Text variant="body" tone="secondary"> <Text variant="body" tone="secondary">
{page} {page}
</Text> </Text>
<Button variant="secondary" size="small" onClick={onNextPage} disabled={!canGoForward}> <Button
variant="secondary"
size="small"
onClick={onNextPage}
disabled={!canGoForward}
>
</Button> </Button>
</Box> </Box>
</Box> </Box>
</Box>
)} )}
</BrokerDashboardCard> </BrokerDashboardCard>
) )

View File

@ -2,6 +2,7 @@ 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
@ -14,10 +15,13 @@ 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 totalReceived = analytics
? `${analytics.totalReceived.toLocaleString('ru-RU', { maximumFractionDigits: 2 })} ${analytics.currency}`
: '—'
const returnPercent = analytics?.totalReturnPercent ?? portfolio.yields.expectedPercent const returnPercent = analytics?.totalReturnPercent ?? portfolio.yields.expectedPercent
const returnTone = moneyTone(typeof returnPercent === 'number' ? returnPercent : null)
const dailyTone = moneyTone(portfolio.yields.daily?.value ?? null)
const totalReceivedTone = moneyTone(analytics?.totalReceived ?? null)
const totalReceivedDisplay = analytics
? formatDashboardCurrency({ currency: analytics.currency, value: analytics.totalReceived })
: '—'
return ( return (
<Box <Box
@ -49,12 +53,30 @@ 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={percentValue(returnPercent)} value={
supportingText={`За день: ${formatBrokerMoney(portfolio.yields.daily)}`} <Box component="span" sx={{ color: moneyToneToColor(returnTone), fontWeight: 700 }}>
{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 label="Всего доходов" value={totalReceived} /> <Metric
label="Всего доходов"
value={
<Box
component="span"
sx={{ color: moneyToneToColor(totalReceivedTone), fontWeight: 700 }}
>
{totalReceivedDisplay}
</Box>
}
/>
</Box> </Box>
</Box> </Box>
) )

View File

@ -2,22 +2,60 @@ 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 { formatBrokerCurrencyValue, formatBrokerDate } from '@/shared/lib/formatters' import { 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: 'success' | 'info' tone: TypeTone
}> = [ }> = [
{ 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
@ -35,6 +73,7 @@ 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
@ -59,6 +98,7 @@ export function BrokerDashboardIncomeCard({
onApplyFilters, onApplyFilters,
onResetFilters, onResetFilters,
hasDraftTypes, hasDraftTypes,
visibleCount,
pageNumber, pageNumber,
canGoBack, canGoBack,
canGoForward, canGoForward,
@ -71,11 +111,11 @@ 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={
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}> <BrokerDashboardTableToolbar
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}> chips={INCOME_FILTERS.map((filter) => (
{INCOME_FILTERS.map((filter) => (
<Chip <Chip
key={filter.type} key={filter.type}
label={filter.label} label={filter.label}
@ -84,7 +124,7 @@ export function BrokerDashboardIncomeCard({
onClick={() => onToggleType(filter.type)} onClick={() => onToggleType(filter.type)}
/> />
))} ))}
</Box> >
<BrokerDashboardDateFilter <BrokerDashboardDateFilter
appliedLabel={appliedDateLabel} appliedLabel={appliedDateLabel}
preset={draftPreset} preset={draftPreset}
@ -97,7 +137,7 @@ export function BrokerDashboardIncomeCard({
onReset={onResetFilters} onReset={onResetFilters}
onApply={onApplyFilters} onApply={onApplyFilters}
/> />
</Box> </BrokerDashboardTableToolbar>
} }
> >
{selectedTypes.length === 0 ? ( {selectedTypes.length === 0 ? (
@ -111,67 +151,122 @@ export function BrokerDashboardIncomeCard({
) : ( ) : (
<Box sx={{ display: 'grid', gap: 1 }}> <Box sx={{ display: 'grid', gap: 1 }}>
<Box sx={{ overflowX: 'auto' }}> <Box sx={{ overflowX: 'auto' }}>
<Box component="table" sx={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}> <Box
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) => {
<Box component="tr" key={row.id}> const formattedAmount = `${row.amount.value >= 0 ? '+' : ''}${formatDashboardCurrency(
{ currency: row.amount.currency, value: Math.abs(row.amount.value) },
)}`
const amountTone = row.amount.value >= 0 ? 'positive' : 'negative'
return (
<Box component="tr" key={row.id} data-testid="dashboard-income-row">
<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 <Box
component="td" sx={{ fontWeight: 700 }}
sx={{ py: 1, borderBottom: '1px solid', borderColor: 'divider' }} data-testid="dashboard-income-instrument-main"
> >
{formatBrokerDate(row.date)} {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>
<Box <Box
component="td" component="td"
sx={{ sx={{
py: 1, ...TD_SX_RIGHT,
borderBottom: '1px solid',
borderColor: 'divider',
fontWeight: 700, fontWeight: 700,
color: moneyToneToColor(amountTone),
}} }}
data-testid="dashboard-income-amount"
data-tone={amountTone}
> >
{row.instrument} {formattedAmount}
</Box>
</Box>
)
})}
</Box>
</Box>
</Box> </Box>
<Box <Box
component="td"
sx={{ py: 1, borderBottom: '1px solid', borderColor: 'divider' }}
>
{row.typeLabel}
</Box>
<Box
component="td"
sx={{ sx={{
py: 1, display: 'flex',
borderBottom: '1px solid', justifyContent: 'space-between',
borderColor: 'divider', gap: 1,
textAlign: 'right', alignItems: 'center',
fontWeight: 700, flexWrap: 'wrap',
color: 'success.main',
}} }}
> >
+{formatBrokerCurrencyValue(row.amount.currency, row.amount.value)}
</Box>
</Box>
))}
</Box>
</Box>
</Box>
<Text variant="caption" tone="secondary"> <Text variant="caption" tone="secondary">
Показано: {rows.length} · Итого:{' '} Показано {rows.length} · Итого:{' '}
{total ? formatBrokerCurrencyValue(total.currency, total.value) : '—'} {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' }}> <Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, alignItems: 'center' }}>
<Button variant="secondary" size="small" onClick={onPreviousPage} disabled={!canGoBack}> <Button
variant="secondary"
size="small"
onClick={onPreviousPage}
disabled={!canGoBack}
>
</Button> </Button>
<Text variant="body" tone="secondary"> <Text variant="body" tone="secondary">
{pageNumber} {pageNumber}
</Text> </Text>
<Button variant="secondary" size="small" onClick={onNextPage} disabled={!canGoForward}> <Button
variant="secondary"
size="small"
onClick={onNextPage}
disabled={!canGoForward}
>
</Button> </Button>
</Box> </Box>
</Box> </Box>
</Box>
)} )}
</BrokerDashboardCard> </BrokerDashboardCard>
) )

View File

@ -1,26 +1,76 @@
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 = 10, rows = 5,
columns = 5, columns = 5,
}: BrokerDashboardTableSkeletonProps) { }: BrokerDashboardTableSkeletonProps) {
const widths = widthsFor(columns)
const variants = rowVariantsFor(columns)
return ( return (
<Box sx={{ display: 'grid', gap: 1.5, py: 1 }} data-testid="dashboard-table-skeleton"> <Box
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 key={i} sx={{ display: 'flex', gap: 2, alignItems: 'center' }}> <Box
{Array.from({ length: columns }, (_, j) => ( key={i}
sx={{
display: 'flex',
gap: 2,
alignItems: 'center',
minHeight: 38,
py: 0.5,
}}
>
{widths.map((width, j) => (
<Box key={j} sx={{ flex: `0 0 ${width}`, minWidth: 0 }}>
<Skeleton <Skeleton
key={j} height={14}
height={16} width={
width={j === 0 ? 80 : j === columns - 1 ? 60 : 100} variants[j] === 'short'
? '45%'
: variants[j] === 'medium'
? '65%'
: variants[j] === 'full'
? '100%'
: '74%'
}
shape="rounded" shape="rounded"
/> />
</Box>
))} ))}
</Box> </Box>
))} ))}

View File

@ -0,0 +1,33 @@
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,7 +65,8 @@
### 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`.
@ -73,6 +74,7 @@
- При пустом выборе типов запрос отключается, карточка показывает валидационное сообщение. - При пустом выборе типов запрос отключается, карточка показывает валидационное сообщение.
- Пагинация локальная, по 10 событий на страницу, сбрасывается при смене применённых фильтров. - Пагинация локальная, по 10 событий на страницу, сбрасывается при смене применённых фильтров.
- При загрузке карточка показывает skeleton таблицы событий с колонками дата, инструмент, тип, сумма, статус. - При загрузке карточка показывает skeleton таблицы событий с колонками дата, инструмент, тип, сумма, статус.
- В toolbar карточки используются count badge, label `Тип`, кнопка `Обновить` и footer-summary по паттерну HTML-эталона.
### 4. Доходы ### 4. Доходы
@ -118,6 +120,7 @@
используя крупный `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 через `₽`.
@ -168,7 +171,7 @@
- [ ] Обновить `BrokerDashboardDateFilter`: убрать слово `Фильтр` из пользовательского текста, заменить `Показать` на понятное действие применения периода и визуально собрать chips, диапазон, сброс и применение в аккуратную шапку. - [ ] Обновить `BrokerDashboardDateFilter`: убрать слово `Фильтр` из пользовательского текста, заменить `Показать` на понятное действие применения периода и визуально собрать chips, диапазон, сброс и применение в аккуратную шапку.
- [ ] Заменить native date inputs на одно визуальное поле периода, которое открывает MUI `Popover` с community `DateCalendar`. - [ ] Заменить native date inputs на одно визуальное поле периода, которое открывает MUI `Popover` с community `DateCalendar`.
- [ ] Реализовать локальную логику выбора диапазона: первый клик задаёт начало, второй — конец; если конец раньше начала, диапазон пересобирается от выбранной даты. - [ ] Реализовать локальную логику выбора диапазона: первый клик задаёт начало, второй — конец; если конец раньше начала, диапазон пересобирается от выбранной даты.
- [ ] Настроить default range событий на `сегодня - 7 дней` / `сегодня`. - [ ] Настроить default range событий на `сегодня - 7 дней` / `сегодня + 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,7 +114,8 @@ Hero показывает:
- Изменение chip-фильтров типов событий применяется сразу и возвращает локальную пагинацию на первую - Изменение chip-фильтров типов событий применяется сразу и возвращает локальную пагинацию на первую
страницу. страницу.
- Блок содержит фильтр периода `from` / `to`. - Блок содержит фильтр периода `from` / `to`.
- По умолчанию применяется недельный период `сегодня - 7 дней` / `сегодня`. - По умолчанию применяется недельный период `сегодня - 7 дней` / `сегодня + 7 дней`, чтобы обзор
сразу показывал ближайшие будущие события.
- Изменение черновых фильтров не запускает запрос до применения периода пользователем. - Изменение черновых фильтров не запускает запрос до применения периода пользователем.
- В пользовательском тексте шапки периода не используется слово `Фильтр`; UI должен считываться как - В пользовательском тексте шапки периода не используется слово `Фильтр`; UI должен считываться как
управление периодом за счёт иконки календаря, применённого диапазона, пресетов и affordance раскрытия. управление периодом за счёт иконки календаря, применённого диапазона, пресетов и affordance раскрытия.
@ -257,7 +258,7 @@ Hero показывает:
- В таблице `События` инструмент отображается двумя строками при наличии названия, тип отображается - В таблице `События` инструмент отображается двумя строками при наличии названия, тип отображается
бейджем, сумма и статус имеют семантические цвета. бейджем, сумма и статус имеют семантические цвета.
- Блок `События` поддерживает multi-select chip-фильтр типов и локальную пагинацию по 10 событий. - Блок `События` поддерживает multi-select chip-фильтр типов и локальную пагинацию по 10 событий.
- Блок `События` по умолчанию запрашивает период `сегодня - 7 дней` / `сегодня` и использует одно поле - Блок `События` по умолчанию запрашивает период `сегодня - 7 дней` / `сегодня + 7 дней` и использует одно поле
периода с popover-календарём на базе community `DateCalendar`. периода с popover-календарём на базе community `DateCalendar`.
- Блок `Доходы` показывает доходные операции дивидендов и купонов и итог по отображаемым строкам. - Блок `Доходы` показывает доходные операции дивидендов и купонов и итог по отображаемым строкам.
- В таблице `Доходы` инструмент отображается двумя строками при наличии названия, тип отображается - В таблице `Доходы` инструмент отображается двумя строками при наличии названия, тип отображается

View File

@ -44,7 +44,8 @@
- [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 дней` / `сегодня`. - [x] Настроить default range событий на `сегодня - 7 дней` / `сегодня + 7 дней`, доходов — на
`сегодня - 7 дней` / `сегодня`.
- [x] Заменить текстовую загрузку `События` на skeleton таблицы. - [x] Заменить текстовую загрузку `События` на skeleton таблицы.
- [x] Заменить текстовую загрузку `Доходы` на skeleton таблицы. - [x] Заменить текстовую загрузку `Доходы` на skeleton таблицы.
- [x] Обновить component tests под новые тексты, единое поле периода, popover-календарь и skeleton loading states. - [x] Обновить component tests под новые тексты, единое поле периода, popover-календарь и skeleton loading states.
@ -56,18 +57,27 @@
- [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-компоненты.
- [ ] Добавить `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.ts` с helpers для money tone, type tone, currency symbol и instrument display.
- [ ] Добавить `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts`. - [x] Добавить `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts`.
- [ ] Расширить income helpers так, чтобы строки доходов могли отдавать main/subtitle инструмента без дублирования текста. - [ ] Расширить income helpers так, чтобы строки доходов могли отдавать main/subtitle инструмента без дублирования текста.
- [ ] Обновить `BrokerDashboardHero`: semantic colors для доходности, дневного изменения и всего полученных доходов. - [x] Обновить `BrokerDashboardHero`: semantic colors для доходности, дневного изменения и всего полученных доходов.
- [ ] Обновить `BrokerDashboardCard`: компактный card heading вместо крупного page-level heading. - [x] Обновить `BrokerDashboardCard`: компактный card heading вместо крупного page-level heading.
- [ ] Обновить `BrokerDashboardDateFilter`: единый toolbar-паттерн и chevron-иконка вместо текстового `v`. - [x] Обновить `BrokerDashboardDateFilter`: единый toolbar-паттерн и chevron-иконка вместо текстового `v`.
- [ ] Обновить `BrokerDashboardEventsCard`: `thead`, двухстрочный инструмент, type badges, semantic amount colors, status badges. - [x] Обновить `BrokerDashboardEventsCard`: `thead`, двухстрочный инструмент, type badges, semantic amount colors, status badges.
- [ ] Обновить `BrokerDashboardIncomeCard`: `thead`, двухстрочный инструмент, type badges, semantic amount colors. - [x] Обновить `BrokerDashboardIncomeCard`: `thead`, двухстрочный инструмент, type badges, semantic amount colors.
- [ ] Обновить `BrokerDashboardTableSkeleton`: общий skeleton-паттерн для событий и доходов с корректной геометрией колонок. Бейдж `Купон` приведён к `info` (синий) согласно HTML-эталону `.type-badge.coupon` (`incomeTypeTone` в `dashboardVisual.ts`),
- [ ] Обновить `BrokerDashboardAnalyticsCard`: `₽` для RUB и positive/negative tone карточек. `Дивиденд (внешний)` отнесён к `success` (семейство дивидендов).
- [ ] Обновить component tests dashboard под HTML parity. - [x] Обновить `BrokerDashboardTableSkeleton`: общий skeleton-паттерн для событий и доходов с корректной геометрией колонок.
- [ ] Проверить, что блок `Доходы` не расширяет backend/API и остаётся в рамках текущих income-типов. - [x] Обновить `BrokerDashboardAnalyticsCard`: `₽` для RUB и positive/negative tone карточек.
- [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`.
@ -83,11 +93,47 @@
## Definition of Done для HTML parity ## Definition of Done для HTML parity
- [ ] `rtk npm run test:frontend -- --run src/widgets/broker-dashboard` проходит. - [x] `rtk npm run test:frontend -- --run src/widgets/broker-dashboard` проходит. (49/49)
- [ ] `rtk npm run test:frontend` проходит после HTML parity изменений. - [x] `rtk npm run test:frontend` проходит после HTML parity изменений. (175/175)
- [ ] `rtk npm run lint -w apps/frontend` проходит после HTML parity изменений. - [x] `rtk npm run lint -w apps/frontend` проходит после HTML parity изменений.
- [ ] `rtk npm run build:frontend` проходит после HTML parity изменений. - [x] `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 допускается только внутри таблиц.
- [ ] `docs/features/broker-dashboard-redesign/tasks.md` обновлён по факту выполнения. Требует live dev server + ручной проверки на viewport `390x844`.
- [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`), но фактическая вёрстка в браузере не сверялась с эталоном.