Compare commits
10 Commits
1a2937ad58
...
fee179d8e9
| Author | SHA1 | Date | |
|---|---|---|---|
| fee179d8e9 | |||
| 58b8075583 | |||
| d7b65e256f | |||
| b12c2741cd | |||
| a1377fa5c2 | |||
| 27fb6e77be | |||
| 94f8bb876d | |||
| 3bad694dce | |||
| 21df354df6 | |||
| 0a1ae0552c |
@ -18,65 +18,72 @@ 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,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Link
|
<div style={{ display: 'grid', gap: 12 }}>
|
||||||
to="/"
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap' }}>
|
||||||
style={{
|
<Link
|
||||||
fontSize: 20,
|
to="/"
|
||||||
fontWeight: 700,
|
style={{
|
||||||
color: 'var(--color-text)',
|
fontSize: 20,
|
||||||
textDecoration: 'none',
|
fontWeight: 700,
|
||||||
}}
|
color: 'var(--color-text)',
|
||||||
>
|
textDecoration: 'none',
|
||||||
MoexVibe
|
}}
|
||||||
</Link>
|
>
|
||||||
<div style={{ flex: '1 1 280px', minWidth: 220, maxWidth: 420 }}>
|
MoexVibe
|
||||||
<SearchBar />
|
</Link>
|
||||||
|
<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',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -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)',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 'Прогноз'
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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', () => {
|
||||||
|
|||||||
@ -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!,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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 }}>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>}
|
||||||
|
|||||||
@ -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>
|
component="span"
|
||||||
<Box
|
aria-hidden="true"
|
||||||
component="button"
|
sx={{
|
||||||
type="button"
|
display: 'inline-flex',
|
||||||
onClick={onApply}
|
color: 'text.secondary',
|
||||||
disabled={!hasDraftTypes}
|
transform: isOpen ? 'rotate(180deg)' : 'none',
|
||||||
sx={{
|
transition: 'transform 0.15s ease-in-out',
|
||||||
display: 'inline-flex',
|
}}
|
||||||
alignItems: 'center',
|
>
|
||||||
gap: 1,
|
<ExpandMoreRounded sx={{ fontSize: 16 }} />
|
||||||
bgcolor: hasDraftTypes ? 'primary.main' : 'grey.400',
|
</Box>
|
||||||
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}
|
||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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,20 +137,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={
|
||||||
<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}
|
tone={filter.tone}
|
||||||
tone={filter.tone}
|
selected={selectedTypes.includes(filter.type)}
|
||||||
selected={selectedTypes.includes(filter.type)}
|
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,71 +177,125 @@ 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
|
||||||
<Box component="tbody">
|
component="table"
|
||||||
{events.map((event) => (
|
aria-label="Таблица событий брокерского счёта"
|
||||||
<Box component="tr" key={event.id}>
|
sx={{ width: '100%', borderCollapse: 'collapse', fontSize: 14, minWidth: 640 }}
|
||||||
<Box
|
>
|
||||||
component="td"
|
<Box component="thead">
|
||||||
sx={{ py: 1, borderBottom: '1px solid', borderColor: 'divider' }}
|
<Box component="tr">
|
||||||
>
|
<Box component="th" sx={TH_SX}>
|
||||||
{formatBrokerDate(event.eventDate)}
|
Дата
|
||||||
</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 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">
|
||||||
|
{events.map((event) => {
|
||||||
|
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
|
||||||
|
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>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, alignItems: 'center' }}>
|
<Box
|
||||||
<Button variant="secondary" size="small" onClick={onPreviousPage} disabled={!canGoBack}>
|
sx={{
|
||||||
←
|
display: 'flex',
|
||||||
</Button>
|
justifyContent: 'space-between',
|
||||||
<Text variant="body" tone="secondary">
|
gap: 1,
|
||||||
{page}
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text variant="caption" tone="secondary">
|
||||||
|
Показано {events.length} событий за выбранный период
|
||||||
</Text>
|
</Text>
|
||||||
<Button variant="secondary" size="small" onClick={onNextPage} disabled={!canGoForward}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
→
|
<Button
|
||||||
</Button>
|
variant="secondary"
|
||||||
|
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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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,20 +111,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={
|
||||||
<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}
|
tone={filter.tone}
|
||||||
tone={filter.tone}
|
selected={selectedTypes.includes(filter.type)}
|
||||||
selected={selectedTypes.includes(filter.type)}
|
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,65 +151,120 @@ 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
|
||||||
<Box component="tbody">
|
component="table"
|
||||||
{rows.map((row) => (
|
aria-label="Таблица доходов брокерского счёта"
|
||||||
<Box component="tr" key={row.id}>
|
sx={{ width: '100%', borderCollapse: 'collapse', fontSize: 14, minWidth: 520 }}
|
||||||
<Box
|
>
|
||||||
component="td"
|
<Box component="thead">
|
||||||
sx={{ py: 1, borderBottom: '1px solid', borderColor: 'divider' }}
|
<Box component="tr">
|
||||||
>
|
<Box component="th" sx={TH_SX}>
|
||||||
{formatBrokerDate(row.date)}
|
Дата
|
||||||
</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 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">
|
||||||
|
{rows.map((row) => {
|
||||||
|
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
|
||||||
|
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>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Text variant="caption" tone="secondary">
|
<Box
|
||||||
Показано: {rows.length} · Итого:{' '}
|
sx={{
|
||||||
{total ? formatBrokerCurrencyValue(total.currency, total.value) : '—'}
|
display: 'flex',
|
||||||
</Text>
|
justifyContent: 'space-between',
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, alignItems: 'center' }}>
|
gap: 1,
|
||||||
<Button variant="secondary" size="small" onClick={onPreviousPage} disabled={!canGoBack}>
|
alignItems: 'center',
|
||||||
←
|
flexWrap: 'wrap',
|
||||||
</Button>
|
}}
|
||||||
<Text variant="body" tone="secondary">
|
>
|
||||||
{pageNumber}
|
<Text variant="caption" tone="secondary">
|
||||||
|
Показано {rows.length} · Итого:{' '}
|
||||||
|
{total
|
||||||
|
? `${total.value >= 0 ? '+' : '−'}${formatDashboardCurrency({
|
||||||
|
currency: total.currency,
|
||||||
|
value: Math.abs(total.value),
|
||||||
|
})}`
|
||||||
|
: '—'}
|
||||||
</Text>
|
</Text>
|
||||||
<Button variant="secondary" size="small" onClick={onNextPage} disabled={!canGoForward}>
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, alignItems: 'center' }}>
|
||||||
→
|
<Button
|
||||||
</Button>
|
variant="secondary"
|
||||||
|
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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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}
|
||||||
<Skeleton
|
sx={{
|
||||||
key={j}
|
display: 'flex',
|
||||||
height={16}
|
gap: 2,
|
||||||
width={j === 0 ? 80 : j === columns - 1 ? 60 : 100}
|
alignItems: 'center',
|
||||||
shape="rounded"
|
minHeight: 38,
|
||||||
/>
|
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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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 таблицы.
|
||||||
|
|||||||
@ -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`.
|
||||||
- Блок `Доходы` показывает доходные операции дивидендов и купонов и итог по отображаемым строкам.
|
- Блок `Доходы` показывает доходные операции дивидендов и купонов и итог по отображаемым строкам.
|
||||||
- В таблице `Доходы` инструмент отображается двумя строками при наличии названия, тип отображается
|
- В таблице `Доходы` инструмент отображается двумя строками при наличии названия, тип отображается
|
||||||
|
|||||||
@ -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`), но фактическая вёрстка в браузере не сверялась с эталоном.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user