diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 2f85446..ca8fc95 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -24,6 +24,7 @@ "@moex-vibe/design-system": "*", "@mui/icons-material": "^6.5.0", "@mui/material": "^6.5.0", + "@mui/x-date-pickers": "^7.29.4", "@tanstack/react-query": "^5.20.0", "@tanstack/react-router": "^1.170.16", "@tanstack/react-table": "^8.21.3", @@ -43,7 +44,6 @@ "@biomejs/biome": "^2.5.0", "@conarti/eslint-plugin-feature-sliced": "^1.0.5", "@tanstack/router-devtools": "^1.167.0", - "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", diff --git a/apps/frontend/src/app/layouts/AppLayout.tsx b/apps/frontend/src/app/layouts/AppLayout.tsx index aefdb96..6bf4a31 100644 --- a/apps/frontend/src/app/layouts/AppLayout.tsx +++ b/apps/frontend/src/app/layouts/AppLayout.tsx @@ -18,65 +18,72 @@ export function AppLayout() { background: 'var(--color-surface)', borderBottom: '1px solid #e0e0e0', padding: '12px 24px', - display: 'flex', - alignItems: 'center', - flexWrap: 'wrap', - gap: 24, + display: 'grid', + gridTemplateColumns: 'minmax(0, 1fr) auto', + gap: 12, }} > - - MoexVibe - -
- +
+
+ + MoexVibe + +
+ +
+
+
- - Портфели - - - Брокер - - - Скринер - -
({ queryKey: ['broker', 'events', accountId, from, to, types], - enabled: Boolean(accountId), + enabled: Boolean(accountId) && (options.enabled ?? true), queryFn: async () => (await getBrokerEvents(accountId!, { from, to, types })).data, staleTime: 300_000, retry: 2, diff --git a/apps/frontend/src/entities/broker-operation/model/useBrokerOperations.ts b/apps/frontend/src/entities/broker-operation/model/useBrokerOperations.ts index 20335c4..80de165 100644 --- a/apps/frontend/src/entities/broker-operation/model/useBrokerOperations.ts +++ b/apps/frontend/src/entities/broker-operation/model/useBrokerOperations.ts @@ -5,10 +5,11 @@ import { type BrokerOperationQuery, getBrokerOperations } from '../api/brokerOpe export function useBrokerOperations( accountId: string | undefined, query: BrokerOperationQuery = {}, + options: { enabled?: boolean } = {}, ) { return useQuery({ queryKey: ['broker', 'operations', accountId, query], - enabled: Boolean(accountId), + enabled: Boolean(accountId) && (options.enabled ?? true), queryFn: async () => (await getBrokerOperations(accountId!, query)).data, staleTime: 300_000, retry: 2, diff --git a/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx b/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx index c5ae061..fdd5398 100644 --- a/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx +++ b/apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx @@ -1,18 +1,11 @@ import { Text } from '@moex-vibe/design-system' -import { Box } from '@mui/material' -import { Link } from '@tanstack/react-router' -import { useBrokerOperations } from '@/entities/broker-operation' import { useBrokerAccountContext } from '@/widgets/broker-account-layout' -import { BrokerAllocationChart } from '@/widgets/broker-allocation-chart' -import { BrokerEventsOverview } from '@/widgets/broker-events-overview' -import { BrokerOperationsTable } from '@/widgets/broker-operations-table' -import { BrokerAssetCards, BrokerOverviewSkeleton, BrokerSummary } from '@/widgets/broker-overview' +import { BrokerDashboard, BrokerDashboardSkeleton } from '@/widgets/broker-dashboard' export function BrokerAccountOverviewPage() { const { accountId, portfolio } = useBrokerAccountContext() - const operations = useBrokerOperations(accountId, { limit: 5 }) - if (portfolio.isLoading) return + if (portfolio.isLoading) return if (portfolio.error || !portfolio.data) { return ( @@ -21,28 +14,5 @@ export function BrokerAccountOverviewPage() { ) } - return ( - - - - - - {operations.error ? ( - - Не удалось загрузить последние операции - - ) : ( - Вся история - } - emptyMessage="Операций с начала текущего года нет" - isLoading={operations.isLoading} - isFetching={operations.isFetching} - page={operations.data} - /> - )} - - ) + return } diff --git a/apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx b/apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx index e029550..b3cd84d 100644 --- a/apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx +++ b/apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx @@ -7,8 +7,8 @@ import { useBrokerPortfolio } from '@/entities/broker-account' import type { BrokerPortfolio } from '@/shared/api' const baseLinkStyle: React.CSSProperties = { - padding: '10px 12px', - borderRadius: 8, + padding: '10px 14px', + borderRadius: 999, color: 'var(--color-text-secondary)', textDecoration: 'none', whiteSpace: 'nowrap', @@ -16,6 +16,7 @@ const baseLinkStyle: React.CSSProperties = { display: 'inline-flex', alignItems: 'center', fontSize: 14, + fontWeight: 600, } const links = [ @@ -47,51 +48,37 @@ export function BrokerAccountLayout({ children }: { children: ReactNode }) { - - {links.map((link) => ( - - {link.label} - - ))} - - - {children} + {links.map((link) => ( + + {link.label} + + ))} + + {children} ) diff --git a/apps/frontend/src/widgets/broker-dashboard/index.ts b/apps/frontend/src/widgets/broker-dashboard/index.ts new file mode 100644 index 0000000..2f37b13 --- /dev/null +++ b/apps/frontend/src/widgets/broker-dashboard/index.ts @@ -0,0 +1,2 @@ +export { BrokerDashboard } from './ui/BrokerDashboard' +export { BrokerDashboardSkeleton } from './ui/BrokerDashboardSkeleton' diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts new file mode 100644 index 0000000..06ad5d6 --- /dev/null +++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts @@ -0,0 +1,92 @@ +import dayjs from 'dayjs' + +export const DASHBOARD_EVENT_TYPES = ['dividend', 'coupon', 'maturity', 'offer'] as const +export const DASHBOARD_INCOME_TYPES = ['dividend', 'coupon'] as const + +export type DashboardEventType = (typeof DASHBOARD_EVENT_TYPES)[number] +export type DashboardIncomeType = (typeof DASHBOARD_INCOME_TYPES)[number] +export type DashboardDatePreset = '7d' | '30d' | '90d' | '1y' | 'all' + +export type DashboardFilterState = { + from: string + to: string + types: T[] + preset: DashboardDatePreset +} + +export function defaultEventsFilters(): DashboardFilterState { + const now = dayjs() + return { + from: now.subtract(7, 'day').format('YYYY-MM-DD'), + to: now.add(7, 'day').format('YYYY-MM-DD'), + types: [...DASHBOARD_EVENT_TYPES], + preset: '7d', + } +} + +export function defaultIncomeFilters(): DashboardFilterState { + const now = dayjs() + return { + from: now.subtract(7, 'day').format('YYYY-MM-DD'), + to: now.format('YYYY-MM-DD'), + types: [...DASHBOARD_INCOME_TYPES], + preset: '7d', + } +} + +export function applyDatePreset( + filters: DashboardFilterState, + preset: DashboardDatePreset, +): DashboardFilterState { + 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.format('YYYY-MM-DD'), + } +} + +export function applyEventDatePreset( + filters: DashboardFilterState, + preset: DashboardDatePreset, +): DashboardFilterState { + 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( + filters: DashboardFilterState, +): string { + if (filters.types.length === 0) return 'Выберите хотя бы один тип' + if (filters.from && filters.to && dayjs(filters.to).isBefore(dayjs(filters.from))) { + return 'Дата окончания не может быть раньше даты начала' + } + return '' +} + +export function incomeTypesToOperationTypes(types: DashboardIncomeType[]): string { + const operationTypes = new Set() + if (types.includes('dividend')) { + operationTypes.add('OPERATION_TYPE_DIVIDEND') + operationTypes.add('OPERATION_TYPE_DIV_EXT') + } + if (types.includes('coupon')) operationTypes.add('OPERATION_TYPE_COUPON') + return [...operationTypes].join(',') +} diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts new file mode 100644 index 0000000..bc05bfb --- /dev/null +++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts @@ -0,0 +1,23 @@ +import type { BrokerEventItem } from '@/shared/api' + +export function dashboardValue(value: string | null | undefined): string { + return value && value.trim().length > 0 ? value : '—' +} + +export function eventTypeLabel(type: BrokerEventItem['type']): string { + switch (type) { + case 'dividend': + return 'Дивиденд' + case 'coupon': + return 'Купон' + case 'maturity': + return 'Погашение' + case 'offer': + return 'Оферта' + } +} + +export function eventStatusLabel(event: BrokerEventItem): string { + if (event.source === 'actual') return 'Поступило' + return 'Ожидается' +} diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts new file mode 100644 index 0000000..95f529c --- /dev/null +++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest' +import type { BrokerOperation } from '@/shared/api' +import { + getDashboardIncomeRows, + isDashboardIncomeOperation, + sumDashboardIncome, +} from './dashboardIncome' + +function operation(type: string, value: number | null): BrokerOperation { + return { + cursor: null, + accountId: 'acc-1', + id: type, + parentOperationId: null, + date: '2026-06-01T00:00:00.000Z', + type, + category: 'income', + description: null, + name: 'Apple Inc.', + state: 'OPERATION_STATE_EXECUTED', + instrumentUid: null, + figi: null, + ticker: 'AAPL', + classCode: null, + instrumentType: 'share', + payment: + value === null ? null : { currency: 'RUB', units: String(Math.trunc(value)), nano: 0, value }, + price: null, + commission: null, + yield: null, + accruedInt: null, + quantity: null, + quantityDone: null, + } +} + +describe('dashboardIncome', () => { + it('detects dividend and coupon operation types', () => { + expect(isDashboardIncomeOperation(operation('OPERATION_TYPE_DIVIDEND', 10))).toBe(true) + expect(isDashboardIncomeOperation(operation('OPERATION_TYPE_DIV_EXT', 10))).toBe(true) + expect(isDashboardIncomeOperation(operation('OPERATION_TYPE_COUPON', 10))).toBe(true) + expect(isDashboardIncomeOperation(operation('OPERATION_TYPE_BUY', -10))).toBe(false) + }) + + it('returns only displayable income rows with payments', () => { + const rows = getDashboardIncomeRows([ + operation('OPERATION_TYPE_DIVIDEND', 10), + operation('OPERATION_TYPE_BUY', -10), + operation('OPERATION_TYPE_COUPON', 5), + ]) + + expect(rows).toHaveLength(2) + expect(rows[0].typeLabel).toBe('Дивиденд') + expect(rows[0].amount.value).toBe(10) + expect(rows[1].typeLabel).toBe('Купон') + expect(rows[1].amount.value).toBe(5) + }) + + it('marks OPERATION_TYPE_DIV_EXT as "Дивиденд (внешний)"', () => { + const rows = getDashboardIncomeRows([operation('OPERATION_TYPE_DIV_EXT', 12)]) + + expect(rows).toHaveLength(1) + expect(rows[0].typeLabel).toBe('Дивиденд (внешний)') + expect(rows[0].amount.value).toBe(12) + }) + + it('splits instrument into main and subtitle', () => { + const rows = getDashboardIncomeRows([operation('OPERATION_TYPE_DIVIDEND', 10)]) + + expect(rows[0].instrumentMain).toBe('AAPL') + expect(rows[0].instrumentSubtitle).toBe('Apple Inc.') + }) + + it('falls back to "—" with no subtitle when ticker, name, and description are missing', () => { + const base = operation('OPERATION_TYPE_COUPON', 7) + const rows = getDashboardIncomeRows([{ ...base, ticker: null, name: null, description: null }]) + + expect(rows[0].instrumentMain).toBe('—') + expect(rows[0].instrumentSubtitle).toBeNull() + }) + + it('does not duplicate subtitle when name equals ticker', () => { + const base = operation('OPERATION_TYPE_COUPON', 3) + const rows = getDashboardIncomeRows([{ ...base, ticker: 'AAPL', name: 'AAPL' }]) + + expect(rows[0].instrumentMain).toBe('AAPL') + expect(rows[0].instrumentSubtitle).toBeNull() + }) + + it('sums displayed income rows by currency', () => { + const rows = getDashboardIncomeRows([ + operation('OPERATION_TYPE_DIVIDEND', 10), + operation('OPERATION_TYPE_COUPON', 2.5), + ]) + + expect(sumDashboardIncome(rows)).toEqual({ currency: 'RUB', value: 12.5 }) + }) + + it('returns null for empty rows sum', () => { + expect(sumDashboardIncome([])).toBeNull() + }) + + it('returns empty array for empty input', () => { + expect(getDashboardIncomeRows([])).toEqual([]) + }) + + it('returns empty array when no income operations present', () => { + const rows = getDashboardIncomeRows([ + operation('OPERATION_TYPE_BUY', -10), + operation('OPERATION_TYPE_SELL', 20), + ]) + + expect(rows).toEqual([]) + }) +}) diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts new file mode 100644 index 0000000..c377546 --- /dev/null +++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts @@ -0,0 +1,56 @@ +import type { BrokerMoney, BrokerOperation } from '@/shared/api' +import { instrumentDisplay } from './dashboardVisual' + +const INCOME_TYPES = new Set([ + 'OPERATION_TYPE_DIVIDEND', + 'OPERATION_TYPE_DIV_EXT', + 'OPERATION_TYPE_COUPON', +]) + +export type DashboardIncomeTypeLabel = 'Дивиденд' | 'Дивиденд (внешний)' | 'Купон' + +export type DashboardIncomeRow = { + id: string + date: string | null + instrumentMain: string + instrumentSubtitle: string | null + typeLabel: DashboardIncomeTypeLabel + amount: BrokerMoney +} + +export function isDashboardIncomeOperation(operation: BrokerOperation): boolean { + return INCOME_TYPES.has(operation.type) && operation.payment !== null +} + +function typeLabel(type: string): DashboardIncomeTypeLabel { + if (type === 'OPERATION_TYPE_COUPON') return 'Купон' + if (type === 'OPERATION_TYPE_DIV_EXT') return 'Дивиденд (внешний)' + return 'Дивиденд' +} + +export function getDashboardIncomeRows(operations: BrokerOperation[]): DashboardIncomeRow[] { + return operations.filter(isDashboardIncomeOperation).map((operation) => { + const { main, subtitle } = instrumentDisplay({ + ticker: operation.ticker, + name: operation.name, + description: operation.description, + }) + return { + id: String(operation.id ?? operation.cursor ?? `${operation.type}-${operation.date}`), + date: typeof operation.date === 'string' ? operation.date : null, + instrumentMain: main, + instrumentSubtitle: subtitle, + typeLabel: typeLabel(operation.type), + amount: operation.payment!, + } + }) +} + +export function sumDashboardIncome( + rows: DashboardIncomeRow[], +): { currency: string; value: number } | null { + if (rows.length === 0) return null + const currency = rows[0].amount.currency + const value = rows.reduce((sum, row) => sum + row.amount.value, 0) + return { currency, value } +} diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts new file mode 100644 index 0000000..17e7de4 --- /dev/null +++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest' +import type { BrokerMoney } from '@/shared/api' +import { + eventTypeTone, + formatDashboardCurrency, + incomeTypeTone, + instrumentDisplay, + moneyTone, + moneyToneToColor, +} from './dashboardVisual' + +function money(currency: string, value: number): BrokerMoney { + return { currency, units: String(Math.trunc(value)), nano: 0, value } +} + +describe('formatDashboardCurrency', () => { + it('renders RUB with the ₽ symbol via the shared formatter', () => { + const result = formatDashboardCurrency(money('RUB', 1234.56)) + expect(result).toContain('₽') + expect(result).not.toContain('RUB') + }) + + it('renders known non-RUB currencies via the shared formatter', () => { + const result = formatDashboardCurrency(money('USD', 42)) + expect(result).toContain('$') + expect(result).not.toContain('USD') + expect(result).not.toContain('₽') + }) + + it('falls back to currency code for unknown currencies', () => { + const result = formatDashboardCurrency(money('XYZ', 100)) + expect(result).toContain('XYZ') + expect(result).not.toContain('¤') + }) + + it('falls back to currency code via the value-only shape', () => { + const result = formatDashboardCurrency({ currency: 'XYZ', value: 9.99 }) + expect(result).toContain('XYZ') + }) + + it('returns "—" for null or undefined', () => { + expect(formatDashboardCurrency(null)).toBe('—') + expect(formatDashboardCurrency(undefined)).toBe('—') + }) +}) + +describe('moneyTone', () => { + it('returns positive for positive actual values', () => { + expect(moneyTone(10, 'actual')).toBe('positive') + }) + + it('treats undefined source as actual and returns positive', () => { + expect(moneyTone(10)).toBe('positive') + expect(moneyTone(10, undefined)).toBe('positive') + }) + + it('returns planned for positive forecast values', () => { + expect(moneyTone(10, 'forecast')).toBe('planned') + }) + + it('returns negative for negative values regardless of source', () => { + expect(moneyTone(-10, 'actual')).toBe('negative') + expect(moneyTone(-10, 'forecast')).toBe('negative') + expect(moneyTone(-0.01)).toBe('negative') + }) + + it('returns neutral for zero', () => { + expect(moneyTone(0)).toBe('neutral') + expect(moneyTone(0, 'actual')).toBe('neutral') + expect(moneyTone(0, 'forecast')).toBe('neutral') + }) + + it('returns neutral for null and undefined', () => { + expect(moneyTone(null)).toBe('neutral') + expect(moneyTone(undefined)).toBe('neutral') + expect(moneyTone(null, 'forecast')).toBe('neutral') + }) +}) + +describe('eventTypeTone', () => { + it('maps each event type to a stable tone', () => { + expect(eventTypeTone('dividend')).toBe('success') + expect(eventTypeTone('coupon')).toBe('info') + expect(eventTypeTone('maturity')).toBe('warning') + expect(eventTypeTone('offer')).toBe('neutral') + }) +}) + +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', () => { + 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') + }) +}) + +describe('instrumentDisplay', () => { + it('uses ticker as main and skips subtitle when ticker is the only field', () => { + expect(instrumentDisplay({ ticker: 'AAPL' })).toEqual({ main: 'AAPL', subtitle: null }) + }) + + it('uses ticker as main and name as subtitle when both are present', () => { + expect(instrumentDisplay({ ticker: 'AAPL', name: 'Apple Inc.' })).toEqual({ + main: 'AAPL', + subtitle: 'Apple Inc.', + }) + }) + + it('prefers name over description when both are provided alongside ticker', () => { + expect( + instrumentDisplay({ ticker: 'AAPL', name: 'Apple Inc.', description: 'Apple computer' }), + ).toEqual({ main: 'AAPL', subtitle: 'Apple Inc.' }) + }) + + it('uses name as main and skips subtitle when no ticker is provided', () => { + expect(instrumentDisplay({ name: 'Apple Inc.' })).toEqual({ + main: 'Apple Inc.', + subtitle: null, + }) + }) + + it('uses description as main when neither ticker nor name are provided', () => { + expect(instrumentDisplay({ description: 'Apple computer' })).toEqual({ + main: 'Apple computer', + subtitle: null, + }) + }) + + it('returns "—" as main when no fields are provided', () => { + expect(instrumentDisplay({})).toEqual({ main: '—', subtitle: null }) + expect(instrumentDisplay({ ticker: null, name: null, description: null })).toEqual({ + main: '—', + subtitle: null, + }) + }) + + it('does not duplicate subtitle when name equals ticker', () => { + expect(instrumentDisplay({ ticker: 'AAPL', name: 'AAPL' })).toEqual({ + main: 'AAPL', + subtitle: null, + }) + }) + + it('treats empty strings as missing', () => { + expect(instrumentDisplay({ ticker: '', name: '', description: '' })).toEqual({ + main: '—', + subtitle: null, + }) + }) +}) diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts new file mode 100644 index 0000000..d1fdd00 --- /dev/null +++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts @@ -0,0 +1,97 @@ +import type { BrokerEventItem, BrokerMoney } from '@/shared/api' +import { formatBrokerCurrencyValue } from '@/shared/lib/formatters' +import type { DashboardIncomeTypeLabel } from './dashboardIncome' + +export type MoneyTone = 'positive' | 'negative' | 'planned' | 'neutral' + +export type TypeTone = 'neutral' | 'info' | 'success' | 'warning' + +export type DashboardMoneyLike = BrokerMoney | { currency: string; value: number } + +export type MoneySource = 'actual' | 'forecast' + +export function moneyTone(value: number | null | undefined, source?: MoneySource): MoneyTone { + if (value === null || value === undefined) return 'neutral' + if (value === 0) return 'neutral' + if (value < 0) return 'negative' + 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 { + if (!money) return '—' + try { + const formatted = formatBrokerCurrencyValue(money.currency, money.value) + if (formatted.includes('¤')) { + return formatCurrencyCodeFallback(money.value, money.currency) + } + return formatted + } catch { + return formatCurrencyCodeFallback(money.value, money.currency) + } +} + +function formatCurrencyCodeFallback(value: number, currency: string): string { + const numberPart = new Intl.NumberFormat('ru-RU', { + maximumFractionDigits: 2, + }).format(value) + return `${numberPart}\u00a0${currency}` +} + +export function eventTypeTone(type: BrokerEventItem['type']): TypeTone { + switch (type) { + case 'dividend': + return 'success' + case 'coupon': + return 'info' + case 'maturity': + return 'warning' + case 'offer': + return 'neutral' + } +} + +export function incomeTypeTone(label: DashboardIncomeTypeLabel): TypeTone { + switch (label) { + case 'Дивиденд': + case 'Дивиденд (внешний)': + return 'success' + case 'Купон': + return 'info' + } +} + +export function eventStatusTone(source: BrokerEventItem['source']): TypeTone { + return source === 'actual' ? 'success' : 'neutral' +} + +function nonEmpty(value: string | null | undefined): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null +} + +export function instrumentDisplay(input: { + ticker?: string | null + name?: string | null + description?: string | null +}): { main: string; subtitle: string | null } { + const ticker = nonEmpty(input.ticker) + const name = nonEmpty(input.name) + const description = nonEmpty(input.description) + + const main = ticker ?? name ?? description ?? '—' + const subtitleCandidate = name ?? description + const subtitle = subtitleCandidate && subtitleCandidate !== main ? subtitleCandidate : null + + return { main, subtitle } +} diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx new file mode 100644 index 0000000..3624b50 --- /dev/null +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx @@ -0,0 +1,537 @@ +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs' +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider' +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import type { ReactNode } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { BrokerEventItem, BrokerOperation, BrokerPortfolio } from '@/shared/api' +import { BrokerDashboard } from './BrokerDashboard' + +const hookMocks = vi.hoisted(() => ({ + useBrokerEvents: vi.fn(), + useBrokerOperations: vi.fn(), +})) + +vi.mock('@tanstack/react-router', () => ({ + Link: ({ children, to }: { children: React.ReactNode; to: string }) => ( + {children} + ), +})) + +vi.mock('@/entities/broker-analytics', () => ({ + useBrokerAnalytics: () => ({ + data: { + totalDeposits: 1000, + totalWithdrawn: 250, + netInvested: -150, + totalDividends: 75, + totalCoupons: 0, + totalReceived: 90, + totalReturnPercent: 4.44, + currency: 'RUB', + }, + isLoading: false, + isError: false, + }), +})) + +vi.mock('@/entities/broker-event', () => ({ + useBrokerEvents: hookMocks.useBrokerEvents, +})) + +vi.mock('@/entities/broker-operation', () => ({ + useBrokerOperations: hookMocks.useBrokerOperations, +})) + +const portfolio: BrokerPortfolio = { + account: { + id: 'acc-1', + name: 'Основной счёт', + type: 'brokerage', + status: 'open', + openedAt: null, + accessLevel: null, + }, + positionCounts: { shares: 2, bonds: 1, etf: 0, other: 0 }, + totals: { + shares: null, + bonds: null, + etf: null, + currencies: null, + futures: null, + options: null, + structuredProducts: null, + dfa: null, + portfolio: { currency: 'RUB', units: '1000', nano: 0, value: 1000 }, + }, + yields: { expectedPercent: null, daily: null, dailyPercent: null }, + cash: [], + blockedCash: [], + asOf: '2026-06-26T00:00:00.000Z', +} + +function renderWithProviders(ui: ReactNode) { + return render({ui}) +} + +function buildEvent(overrides: Partial = {}): 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 { + 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', () => { + beforeEach(() => { + hookMocks.useBrokerEvents.mockReturnValue({ + data: { items: [], summary: {}, asOf: '2026-06-26T00:00:00.000Z' }, + isLoading: false, + isError: false, + }) + hookMocks.useBrokerOperations.mockReturnValue({ + data: { + accountId: 'acc-1', + items: [], + nextCursor: null, + hasNext: false, + asOf: '2026-06-26T00:00:00.000Z', + }, + isLoading: false, + isError: false, + }) + }) + + it('renders the dashboard sections', () => { + renderWithProviders() + + expect(screen.getByText('Основной счёт')).toBeInTheDocument() + expect(screen.getByText('События')).toBeInTheDocument() + expect(screen.getByText('Доходы')).toBeInTheDocument() + expect(screen.getByText('Аналитика доходности')).toBeInTheDocument() + expect(screen.getByText('Аллокация')).toBeInTheDocument() + }) + + it('applies event type chips immediately to useBrokerEvents', async () => { + const user = userEvent.setup() + renderWithProviders() + + const couponChips = screen.getAllByRole('button', { name: 'Купоны' }) + await user.click(couponChips[0]) + + await waitFor(() => { + expect(hookMocks.useBrokerEvents).toHaveBeenLastCalledWith( + 'acc-1', + expect.objectContaining({ types: 'dividend,maturity,offer' }), + { enabled: true }, + ) + }) + }) + + it('applies income type chips immediately to useBrokerOperations', async () => { + const user = userEvent.setup() + renderWithProviders() + + const couponChips = screen.getAllByRole('button', { name: 'Купоны' }) + await user.click(couponChips[1]) + + await waitFor(() => { + expect(hookMocks.useBrokerOperations).toHaveBeenLastCalledWith( + 'acc-1', + expect.objectContaining({ + operationTypes: 'OPERATION_TYPE_DIVIDEND,OPERATION_TYPE_DIV_EXT', + }), + { enabled: true }, + ) + }) + }) + + it('requests future events in the default dashboard range', () => { + vi.useFakeTimers() + try { + vi.setSystemTime(new Date('2026-06-27T12:00:00.000Z')) + + renderWithProviders() + + 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() + renderWithProviders() + + const applyButtons = screen.getAllByRole('button', { name: 'Обновить' }) + expect(applyButtons).toHaveLength(2) + + const toggleButtons = screen.getAllByRole('button', { name: /^Период/ }) + expect(toggleButtons).toHaveLength(2) + + await user.click(toggleButtons[0]) + + expect(screen.getByText('7д')).toBeInTheDocument() + expect(screen.getByText('30д')).toBeInTheDocument() + expect(screen.getByText('90д')).toBeInTheDocument() + expect(screen.getByText('1г')).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() + + 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() + + 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() + + 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() + + 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', () => { + hookMocks.useBrokerEvents.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + }) + + renderWithProviders() + + const skeletons = screen.getAllByTestId('dashboard-table-skeleton') + expect(skeletons.length).toBeGreaterThanOrEqual(1) + }) + + it('shows skeleton table while income operations are loading', () => { + hookMocks.useBrokerOperations.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + }) + + renderWithProviders() + + const skeletons = screen.getAllByTestId('dashboard-table-skeleton') + expect(skeletons.length).toBeGreaterThanOrEqual(1) + }) + + it('does not show skeleton when data is loaded', () => { + renderWithProviders() + + expect(screen.queryByTestId('dashboard-table-skeleton')).not.toBeInTheDocument() + }) + + it('renders events table thead with semantic column headers', () => { + mockEventsLoaded([buildEvent()]) + + renderWithProviders() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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') + }) +}) diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx new file mode 100644 index 0000000..6069b93 --- /dev/null +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx @@ -0,0 +1,237 @@ +import { Box } from '@mui/material' +import dayjs from 'dayjs' +import { useCallback, useState } from 'react' +import { useBrokerAnalytics } from '@/entities/broker-analytics' +import { useBrokerEvents } from '@/entities/broker-event' +import { useBrokerOperations } from '@/entities/broker-operation' +import type { BrokerPortfolio } from '@/shared/api' +import { useCursorPagination } from '@/shared/lib/useCursorPagination' +import { + applyDatePreset, + applyEventDatePreset, + type DashboardDatePreset, + type DashboardEventType, + type DashboardIncomeType, + defaultEventsFilters, + defaultIncomeFilters, + incomeTypesToOperationTypes, +} from '../lib/dashboardFilters' +import { BrokerDashboardAllocationCard } from './BrokerDashboardAllocationCard' +import { BrokerDashboardAnalyticsCard } from './BrokerDashboardAnalyticsCard' +import { BrokerDashboardEventsCard } from './BrokerDashboardEventsCard' +import { BrokerDashboardHero } from './BrokerDashboardHero' +import { BrokerDashboardIncomeCard } from './BrokerDashboardIncomeCard' + +function formatDateLabel(from: string, to: string): string | null { + if (!from && !to) return 'За всё время' + const fmt = (d: string) => dayjs(d).format('D MMM') + if (from && to) return `${fmt(from)} – ${fmt(to)}` + if (from) return `с ${fmt(from)}` + if (to) return `до ${fmt(to)}` + return null +} + +export function BrokerDashboard({ + accountId, + portfolio, +}: { + accountId: string + portfolio: BrokerPortfolio +}) { + const [appliedEventFilters, setAppliedEventFilters] = useState(defaultEventsFilters) + const [draftEventFilters, setDraftEventFilters] = useState(defaultEventsFilters) + const [eventPage, setEventPage] = useState(1) + + const [appliedIncomeFilters, setAppliedIncomeFilters] = useState(defaultIncomeFilters) + const [draftIncomeFilters, setDraftIncomeFilters] = useState(defaultIncomeFilters) + const incomePagination = useCursorPagination() + + const analytics = useBrokerAnalytics(accountId) + const hasEventTypes = appliedEventFilters.types.length > 0 + const hasIncomeTypes = appliedIncomeFilters.types.length > 0 + const hasDraftEventTypes = draftEventFilters.types.length > 0 + const hasDraftIncomeTypes = draftIncomeFilters.types.length > 0 + const eventPageSize = 10 + + const events = useBrokerEvents( + accountId, + { + from: appliedEventFilters.from, + to: appliedEventFilters.to, + types: appliedEventFilters.types.join(','), + }, + { enabled: hasEventTypes }, + ) + + const operations = useBrokerOperations( + accountId, + { + from: appliedIncomeFilters.from, + to: appliedIncomeFilters.to, + operationTypes: incomeTypesToOperationTypes(appliedIncomeFilters.types), + cursor: incomePagination.cursor, + limit: 10, + }, + { enabled: hasIncomeTypes }, + ) + + const nextCursor: string | undefined = operations.data?.nextCursor + ? (operations.data.nextCursor as unknown as string) + : undefined + + const eventItems = events.data?.items ?? [] + const eventPageItems = eventItems.slice( + (eventPage - 1) * eventPageSize, + eventPage * eventPageSize, + ) + + function toggleEventType(type: DashboardEventType) { + setAppliedEventFilters((filters) => { + const nextTypes = filters.types.includes(type) + ? filters.types.filter((item) => item !== type) + : [...filters.types, type] + return { ...filters, types: nextTypes } + }) + setDraftEventFilters((filters) => { + const nextTypes = filters.types.includes(type) + ? filters.types.filter((item) => item !== type) + : [...filters.types, type] + return { ...filters, types: nextTypes } + }) + setEventPage(1) + } + + function toggleIncomeType(type: DashboardIncomeType) { + setAppliedIncomeFilters((filters) => { + const nextTypes = filters.types.includes(type) + ? filters.types.filter((item) => item !== type) + : [...filters.types, type] + return { ...filters, types: nextTypes } + }) + setDraftIncomeFilters((filters) => { + const nextTypes = filters.types.includes(type) + ? filters.types.filter((item) => item !== type) + : [...filters.types, type] + return { ...filters, types: nextTypes } + }) + incomePagination.reset() + } + + const applyEventFilters = useCallback(() => { + setAppliedEventFilters((prev) => ({ + ...prev, + from: draftEventFilters.from, + to: draftEventFilters.to, + preset: draftEventFilters.preset, + })) + setEventPage(1) + }, [draftEventFilters.from, draftEventFilters.to, draftEventFilters.preset]) + + const resetEventFilters = useCallback(() => { + const defaults = defaultEventsFilters() + setAppliedEventFilters(defaults) + setDraftEventFilters(defaults) + setEventPage(1) + }, []) + + const applyIncomeFilters = useCallback(() => { + setAppliedIncomeFilters((prev) => ({ + ...prev, + from: draftIncomeFilters.from, + to: draftIncomeFilters.to, + preset: draftIncomeFilters.preset, + })) + incomePagination.reset() + }, [draftIncomeFilters.from, draftIncomeFilters.to, draftIncomeFilters.preset, incomePagination]) + + const resetIncomeFilters = useCallback(() => { + const defaults = defaultIncomeFilters() + setAppliedIncomeFilters(defaults) + setDraftIncomeFilters(defaults) + incomePagination.reset() + }, [incomePagination]) + + function handleDraftEventPresetChange(preset: DashboardDatePreset) { + setDraftEventFilters((filters) => applyEventDatePreset(filters, preset)) + } + + function handleDraftEventFromChange(value: string) { + setDraftEventFilters((filters) => ({ ...filters, from: value })) + } + + function handleDraftEventToChange(value: string) { + setDraftEventFilters((filters) => ({ ...filters, to: value })) + } + + function handleDraftIncomePresetChange(preset: DashboardDatePreset) { + setDraftIncomeFilters((filters) => applyDatePreset(filters, preset)) + } + + function handleDraftIncomeFromChange(value: string) { + setDraftIncomeFilters((filters) => ({ ...filters, from: value })) + } + + function handleDraftIncomeToChange(value: string) { + setDraftIncomeFilters((filters) => ({ ...filters, to: value })) + } + + return ( + + + 1} + canGoForward={eventPage * eventPageSize < eventItems.length} + onPreviousPage={() => setEventPage((page) => Math.max(1, page - 1))} + onNextPage={() => setEventPage((page) => page + 1)} + /> + 1} + canGoForward={operations.data?.hasNext ?? false} + onPreviousPage={incomePagination.handlePrevious} + onNextPage={() => incomePagination.handleNext(nextCursor)} + /> + + + + ) +} diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx new file mode 100644 index 0000000..d7ed14e --- /dev/null +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx @@ -0,0 +1,86 @@ +import { Text } from '@moex-vibe/design-system' +import { Box } from '@mui/material' +import { buildBrokerAllocation } from '@/entities/broker-position' +import type { BrokerPortfolio } from '@/shared/api' +import { formatBrokerCurrencyValue, formatBrokerMoney } from '@/shared/lib/formatters' +import { BrokerDashboardCard } from './BrokerDashboardCard' + +const ALLOCATION_COLORS: Record = { + shares: '#4969f5', + bonds: '#e5a33c', + etf: '#62b889', + cash: '#7b63cf', + other: '#aeb6c5', +} + +export function BrokerDashboardAllocationCard({ portfolio }: { portfolio: BrokerPortfolio }) { + const { total, sectors, negative } = buildBrokerAllocation(portfolio) + const currency = + total >= 0 + ? (portfolio.totals.portfolio?.currency ?? + Object.values(portfolio.totals).find((t) => t?.currency)?.currency ?? + 'RUB') + : 'RUB' + + return ( + {formatBrokerMoney(portfolio.totals.portfolio)}} + > + {sectors.length === 0 && negative.length === 0 ? ( + Нет данных для распределения + ) : ( + + + + Структура портфеля + + + {formatBrokerMoney(portfolio.totals.portfolio)} + + + {sectors.map((sector) => ( + + + {sector.label} + + {formatBrokerCurrencyValue(currency, sector.value)} · {sector.percent.toFixed(1)}% + + + + + + + ))} + {negative.length > 0 && ( + + {negative.map((item) => ( + + {item.label}: + + {formatBrokerCurrencyValue(currency, item.value)} + + + ))} + + )} + + )} + + ) +} diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx new file mode 100644 index 0000000..40e9c5e --- /dev/null +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx @@ -0,0 +1,110 @@ +import { Metric, Text } from '@moex-vibe/design-system' +import { Box } from '@mui/material' +import type { BrokerAnalytics } from '@/shared/api' +import { + formatDashboardCurrency, + type MoneyTone, + moneyTone, + moneyToneToColor, +} from '../lib/dashboardVisual' +import { BrokerDashboardCard } from './BrokerDashboardCard' + +type AnalyticsField = + | '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({ + data, + isLoading, + isError, +}: { + data: BrokerAnalytics | undefined + isLoading: boolean + isError: boolean +}) { + return ( + + {isError ? ( + Не удалось загрузить аналитику + ) : isLoading ? ( + Загрузка аналитики… + ) : !data ? ( + Нет данных для аналитики + ) : ( + + {ANALYTICS_METRICS.map(({ field, label, testId }) => { + const value = data[field] + const tone = analyticsTone(field, value) + return ( + + + {analyticsDisplay(field, value, data.currency)} + + } + /> + + ) + })} + + )} + + ) +} diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx new file mode 100644 index 0000000..0cbe672 --- /dev/null +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx @@ -0,0 +1,79 @@ +import { Heading } from '@moex-vibe/design-system' +import { Box, type SxProps, type Theme } from '@mui/material' +import type { ReactNode } from 'react' + +type BrokerDashboardCardProps = { + title: string + badge?: ReactNode + action?: ReactNode + filters?: ReactNode + children: ReactNode + sx?: SxProps + ariaLabel?: string +} + +export function BrokerDashboardCard({ + title, + badge, + action, + filters, + children, + sx, + ariaLabel, +}: BrokerDashboardCardProps) { + const resolvedAriaLabel = ariaLabel ?? title + return ( + + + + + {title} + + {badge ? ( + + {badge} + + ) : null} + + {action} + + {filters && {filters}} + {children} + + ) +} diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx new file mode 100644 index 0000000..16ea1d8 --- /dev/null +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx @@ -0,0 +1,270 @@ +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 { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs' +import { DateCalendar } from '@mui/x-date-pickers/DateCalendar' +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider' +import dayjs from 'dayjs' +import { useCallback, useState } from 'react' +import type { DashboardDatePreset } from '../lib/dashboardFilters' + +const PRESETS: { key: DashboardDatePreset; label: string }[] = [ + { key: '7d', label: '7д' }, + { key: '30d', label: '30д' }, + { key: '90d', label: '90д' }, + { key: '1y', label: '1г' }, + { key: 'all', label: 'Всё' }, +] + +type BrokerDashboardDateFilterProps = { + appliedLabel: string | null + preset: DashboardDatePreset + draftFrom: string + draftTo: string + hasDraftTypes: boolean + onPresetChange: (preset: DashboardDatePreset) => void + onFromChange: (value: string) => void + onToChange: (value: string) => void + onReset: () => void + onApply: () => void +} + +export function BrokerDashboardDateFilter({ + appliedLabel, + preset, + draftFrom, + draftTo, + hasDraftTypes, + onPresetChange, + onFromChange, + onToChange, + onReset, + onApply, +}: BrokerDashboardDateFilterProps) { + const [anchorEl, setAnchorEl] = useState(null) + const [selectingStage, setSelectingStage] = useState<'from' | 'to'>('from') + const isOpen = Boolean(anchorEl) + + const handleToggle = useCallback((e: React.MouseEvent) => { + setAnchorEl((prev) => { + if (prev) return null + return e.currentTarget + }) + setSelectingStage('from') + }, []) + + const handleClose = useCallback(() => { + setAnchorEl(null) + setSelectingStage('from') + }, []) + + const handleCalendarChange = useCallback( + (date: dayjs.Dayjs | null) => { + if (!date) return + if (selectingStage === 'from') { + onFromChange(date.format('YYYY-MM-DD')) + setSelectingStage('to') + } else { + if (draftFrom && date.isBefore(dayjs(draftFrom))) { + onFromChange(date.format('YYYY-MM-DD')) + onToChange('') + } else { + onToChange(date.format('YYYY-MM-DD')) + } + setSelectingStage('from') + } + }, + [selectingStage, draftFrom, onFromChange, onToChange], + ) + + function handlePresetClick(p: { key: DashboardDatePreset }) { + onPresetChange(p.key) + setSelectingStage('from') + } + + function handleReset() { + onReset() + setSelectingStage('from') + } + + function handleApply() { + onApply() + handleClose() + } + + const selectingLabel = + selectingStage === 'from' ? 'Выберите начало периода' : 'Выберите конец периода' + const periodAriaLabel = appliedLabel ? `Период: ${appliedLabel}` : 'Период' + const rangeInverted = Boolean(draftFrom && draftTo) && dayjs(draftTo).isBefore(dayjs(draftFrom)) + const canApply = hasDraftTypes && !rangeInverted + + return ( + + + + + {appliedLabel && ( + + {appliedLabel} + + )} + + + + + + {PRESETS.map((p) => ( + handlePresetClick(p)} + /> + ))} + + + {selectingLabel} + + + + + Сбросить + + + Применить период + + + + + + Обновить + + + + ) +} diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx new file mode 100644 index 0000000..ab9d494 --- /dev/null +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx @@ -0,0 +1,304 @@ +import { Button, Chip, Text } from '@moex-vibe/design-system' +import { Box } from '@mui/material' +import { Link } from '@tanstack/react-router' +import type { BrokerEventItem, BrokerEventsData } from '@/shared/api' +import { formatBrokerDate } from '@/shared/lib/formatters' +import type { DashboardDatePreset, DashboardEventType } from '../lib/dashboardFilters' +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 { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter' +import { BrokerDashboardTableSkeleton } from './BrokerDashboardTableSkeleton' +import { BrokerDashboardTableToolbar } from './BrokerDashboardTableToolbar' + +const EVENT_FILTERS: Array<{ + type: DashboardEventType + label: string + tone: TypeTone +}> = [ + { type: 'dividend', label: 'Дивиденды', tone: 'success' }, + { type: 'coupon', label: 'Купоны', tone: 'info' }, + { type: 'maturity', label: 'Погашения', tone: 'warning' }, + { 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 = { + accountId: string + data: BrokerEventsData | undefined + isLoading: boolean + isError: boolean + selectedTypes: DashboardEventType[] + onToggleType: (type: DashboardEventType) => void + appliedDateLabel: string | null + draftPreset: DashboardDatePreset + draftFrom: string + draftTo: string + onDraftPresetChange: (preset: DashboardDatePreset) => void + onDraftFromChange: (value: string) => void + onDraftToChange: (value: string) => void + onApplyFilters: () => void + onResetFilters: () => void + hasDraftTypes: boolean + totalCount?: number + page: number + onPreviousPage: () => void + onNextPage: () => void + canGoBack: boolean + canGoForward: boolean +} + +function eventAmountValue(event: BrokerEventItem): number | null { + return event.source === 'actual' ? event.actualAmount : event.estimatedAmount +} + +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({ + accountId, + data, + isLoading, + isError, + selectedTypes, + onToggleType, + appliedDateLabel, + draftPreset, + draftFrom, + draftTo, + onDraftPresetChange, + onDraftFromChange, + onDraftToChange, + onApplyFilters, + onResetFilters, + hasDraftTypes, + totalCount, + page, + onPreviousPage, + onNextPage, + canGoBack, + canGoForward, +}: BrokerDashboardEventsCardProps) { + const events = data?.items ?? [] + + return ( + 0 ? `${events.length} из ${totalCount ?? events.length}` : undefined} + action={Все события} + filters={ + ( + onToggleType(filter.type)} + /> + ))} + > + + + } + > + {selectedTypes.length === 0 ? ( + Выберите хотя бы один тип событий + ) : isError ? ( + Не удалось загрузить события + ) : isLoading ? ( + + ) : events.length === 0 ? ( + В ближайшем периоде событий нет + ) : ( + + + + + + + Дата + + + Инструмент + + + Тип + + + Сумма + + + Статус + + + + + {events.map((event) => { + const display = instrumentDisplay({ + ticker: event.ticker, + name: event.name, + }) + const formattedAmount = eventAmountDisplay(event) + return ( + + + {formatBrokerDate(event.eventDate) ?? '—'} + + + + + {display.main} + + {display.subtitle ? ( + + {display.subtitle} + + ) : null} + + + + + + + {formattedAmount} + + + + + + ) + })} + + + + + + Показано {events.length} событий за выбранный период + + + + + {page} + + + + + + )} + + ) +} diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx new file mode 100644 index 0000000..349da27 --- /dev/null +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx @@ -0,0 +1,83 @@ +import { Metric, Text } from '@moex-vibe/design-system' +import { Box } from '@mui/material' +import type { BrokerAnalytics, BrokerPortfolio } from '@/shared/api' +import { formatBrokerMoney, formatBrokerPercent } from '@/shared/lib/formatters' +import { formatDashboardCurrency, moneyTone, moneyToneToColor } from '../lib/dashboardVisual' + +type BrokerDashboardHeroProps = { + portfolio: BrokerPortfolio + analytics: BrokerAnalytics | undefined +} + +function percentValue(value: unknown): string { + return typeof value === 'number' ? formatBrokerPercent(value) : '—' +} + +export function BrokerDashboardHero({ portfolio, analytics }: BrokerDashboardHeroProps) { + const accountName = portfolio.account.name || 'Брокерский счёт' + 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 ( + + + + Инвестиционный дашборд + + + {accountName} + + + + + + + + {percentValue(returnPercent)} + + } + supportingText={ + + За день: {formatBrokerMoney(portfolio.yields.daily)} + + } + /> + + + + {totalReceivedDisplay} + + } + /> + + + ) +} diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx new file mode 100644 index 0000000..8101cc9 --- /dev/null +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx @@ -0,0 +1,273 @@ +import { Button, Chip, Text } from '@moex-vibe/design-system' +import { Box } from '@mui/material' +import { Link } from '@tanstack/react-router' +import type { BrokerOperationsPage } from '@/shared/api' +import { formatBrokerDate } from '@/shared/lib/formatters' +import type { DashboardDatePreset, DashboardIncomeType } from '../lib/dashboardFilters' +import { getDashboardIncomeRows, sumDashboardIncome } from '../lib/dashboardIncome' +import { + formatDashboardCurrency, + incomeTypeTone, + moneyToneToColor, + type TypeTone, +} from '../lib/dashboardVisual' +import { BrokerDashboardCard } from './BrokerDashboardCard' +import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter' +import { BrokerDashboardTableSkeleton } from './BrokerDashboardTableSkeleton' +import { BrokerDashboardTableToolbar } from './BrokerDashboardTableToolbar' + +const INCOME_FILTERS: Array<{ + type: DashboardIncomeType + label: string + tone: TypeTone +}> = [ + { type: 'dividend', label: 'Дивиденды', tone: 'success' }, + { 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 = { + accountId: string + page: BrokerOperationsPage | undefined + isLoading: boolean + isError: boolean + selectedTypes: DashboardIncomeType[] + onToggleType: (type: DashboardIncomeType) => void + appliedDateLabel: string | null + draftPreset: DashboardDatePreset + draftFrom: string + draftTo: string + onDraftPresetChange: (preset: DashboardDatePreset) => void + onDraftFromChange: (value: string) => void + onDraftToChange: (value: string) => void + onApplyFilters: () => void + onResetFilters: () => void + hasDraftTypes: boolean + visibleCount: number + pageNumber: number + canGoBack: boolean + canGoForward: boolean + onPreviousPage: () => void + onNextPage: () => void +} + +export function BrokerDashboardIncomeCard({ + accountId, + page, + isLoading, + isError, + selectedTypes, + onToggleType, + appliedDateLabel, + draftPreset, + draftFrom, + draftTo, + onDraftPresetChange, + onDraftFromChange, + onDraftToChange, + onApplyFilters, + onResetFilters, + hasDraftTypes, + visibleCount, + pageNumber, + canGoBack, + canGoForward, + onPreviousPage, + onNextPage, +}: BrokerDashboardIncomeCardProps) { + const rows = getDashboardIncomeRows(page?.items ?? []).slice(0, 10) + const total = sumDashboardIncome(rows) + + return ( + 0 ? `${visibleCount} операции` : undefined} + action={Все операции} + filters={ + ( + onToggleType(filter.type)} + /> + ))} + > + + + } + > + {selectedTypes.length === 0 ? ( + Выберите хотя бы один тип доходов + ) : isError ? ( + Не удалось загрузить доходные операции + ) : isLoading ? ( + + ) : rows.length === 0 ? ( + Дивидендов и купонов в последних операциях нет + ) : ( + + + + + + + Дата + + + Инструмент + + + Тип + + + Сумма + + + + + {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 ( + + + {formatBrokerDate(row.date) ?? '—'} + + + + + {row.instrumentMain} + + {row.instrumentSubtitle ? ( + + {row.instrumentSubtitle} + + ) : null} + + + + + + + {formattedAmount} + + + ) + })} + + + + + + Показано {rows.length} · Итого:{' '} + {total + ? `${total.value >= 0 ? '+' : '−'}${formatDashboardCurrency({ + currency: total.currency, + value: Math.abs(total.value), + })}` + : '—'} + + + + + {pageNumber} + + + + + + )} + + ) +} diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardSkeleton.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardSkeleton.tsx new file mode 100644 index 0000000..a9c6181 --- /dev/null +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardSkeleton.tsx @@ -0,0 +1,14 @@ +import { Skeleton } from '@moex-vibe/design-system' +import { Box } from '@mui/material' + +export function BrokerDashboardSkeleton() { + return ( + + + + + + + + ) +} diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardTableSkeleton.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardTableSkeleton.tsx new file mode 100644 index 0000000..42c0bd7 --- /dev/null +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardTableSkeleton.tsx @@ -0,0 +1,79 @@ +import { Skeleton } from '@moex-vibe/design-system' +import { Box } from '@mui/material' + +type ColumnWidth = string + +type BrokerDashboardTableSkeletonProps = { + rows?: 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({ + rows = 5, + columns = 5, +}: BrokerDashboardTableSkeletonProps) { + const widths = widthsFor(columns) + const variants = rowVariantsFor(columns) + + return ( + + ) +} diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardTableToolbar.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardTableToolbar.tsx new file mode 100644 index 0000000..5493ac4 --- /dev/null +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardTableToolbar.tsx @@ -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 ( + + + + Тип + + {chips} + + {children} + + ) +} diff --git a/docs/features/broker-dashboard-redesign/plan.md b/docs/features/broker-dashboard-redesign/plan.md new file mode 100644 index 0000000..fd78c1d --- /dev/null +++ b/docs/features/broker-dashboard-redesign/plan.md @@ -0,0 +1,304 @@ +# План реализации редизайна обзора брокерского счёта + +> **Для агентных исполнителей:** ОБЯЗАТЕЛЬНЫЙ SUB-SKILL: использовать `superpowers:subagent-driven-development` (предпочтительно) или `superpowers:executing-plans` для пошагового выполнения. Шаги ведутся чекбоксами `- [ ]`. + +**Цель:** превратить `/broker/:accountId` в компактный инвестиционный дашборд на текущей светлой теме без изменения backend-контрактов и URL-структуры. + +**Архитектура:** маршрут и FSD-границы остаются прежними. Композиция собирается в `widgets/broker-dashboard`, данные продолжают приходить из существующих `entities/*` hooks. Навигация счёта остаётся в `BrokerAccountLayout`, а dashboard использует только локальные presentation/helpers без выноса брокерской логики в design system. + +**Технологии:** React 18, TanStack Router, TanStack Query, MUI через `@moex-vibe/design-system`, MUI X DateCalendar community (`@mui/x-date-pickers`) с Day.js, Vitest, Testing Library. + +**HTML parity update:** согласованный визуальный эталон находится в `docs/research/2026-06-27-broker-account-redesign.html`. +Следующая итерация переносит его детали в реальную страницу без изменения backend-контрактов. + +--- + +## Область реализации + +### Файлы + +- Modify: `apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx` — горизонтальные вкладки над контентом обзора и подробных разделов. +- Modify: `apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx` — точка входа обзора через dashboard. +- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/index.ts` — публичный API dashboard-виджета. +- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx` — верхнеуровневая композиция и управление filter state. +- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx` — hero KPI с fallback-значениями и выравниванием `Metric`. +- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx` — локальный паттерн карточки. +- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx` — карточка событий. +- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx` — переиспользуемое управление периодом с draft/apply поведением. +- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx` — карточка доходов. +- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx` — карточка аналитики доходности. +- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx` — карточка аллокации с горизонтальными барами. +- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardSkeleton.tsx` — skeleton-форма dashboard. +- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx` — компонентные тесты композиции и фильтров. +- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts` — чистые helpers доходных операций. +- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts` — unit-тесты income helpers. +- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts` — пресеты дат, validate и mapping income types. +- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts` — локальные formatter/helpers для fallback и event labels. +- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts` — локальные helpers визуальной семантики dashboard: tone сумм, tone типов, отображение инструмента, символ валюты. +- Create/Modify: `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts` — unit-тесты визуальных helpers. +- Modify: `apps/frontend/package.json` — добавить community-пакет `@mui/x-date-pickers`, если зависимость ещё не подключена; `@mui/x-date-pickers-pro` не добавлять. +- Modify: `docs/features/broker-dashboard-redesign/tasks.md` — фиксация статусов выполнения. + +### Источники данных + +- Портфель: `useBrokerAccountContext().portfolio`. +- События: `useBrokerEvents(accountId, { from, to, types })`. +- Операции: `useBrokerOperations(accountId, { from, to, operationTypes, cursor, limit: 10 })`. +- Аналитика: `useBrokerAnalytics(accountId)`. +- Аллокация: `buildBrokerAllocation(portfolio)`. + +## Технические решения + +### 1. Композиция страницы + +- Dashboard на desktop и mobile строится в одну колонку: `Hero`, `События`, `Доходы`, `Аналитика доходности`, `Аллокация`. +- Двухколоночный layout из прототипа не переносится. +- Навигация счёта находится над контентом в `BrokerAccountLayout` и не дублируется внутри dashboard. + +### 2. Hero KPI + +- Hero показывает название счёта, стоимость портфеля, доходность, дневное изменение и всего полученных доходов. +- Приоритет доходности: `analytics.totalReturnPercent`, затем `portfolio.yields.expectedPercent`, иначе `—`. +- Дневное изменение берётся из `portfolio.yields.daily` и `portfolio.yields.dailyPercent`, при недоступности показывается `—`. +- Все `Metric` должны иметь одинаковую высоту; supporting text не должен ломать вертикальный ритм. + +### 3. События + +- Типы событий переключаются chip-фильтрами с немедленным применением. +- Диапазон дат по умолчанию: `сегодня - 7 дней` / `сегодня + 7 дней`, чтобы обзор сразу показывал + ближайшие будущие события. +- Диапазон дат редактируется отдельно от применённого состояния: draft state меняется локально, запрос уходит только по действию применения периода. +- В шапке управления периодом не используется слово `Фильтр`; роль управления считывается через иконку календаря, применённый диапазон, раскрытие панели и пресеты. +- Native `` не используется; период выбирается через одно визуальное поле и popover с community `DateCalendar` из `@mui/x-date-pickers`. +- `DateRangePicker` и `@mui/x-date-pickers-pro` не используются; выбор начала/конца периода реализуется локальной логикой dashboard. +- При пустом выборе типов запрос отключается, карточка показывает валидационное сообщение. +- Пагинация локальная, по 10 событий на страницу, сбрасывается при смене применённых фильтров. +- При загрузке карточка показывает skeleton таблицы событий с колонками дата, инструмент, тип, сумма, статус. +- В toolbar карточки используются count badge, label `Тип`, кнопка `Обновить` и footer-summary по паттерну HTML-эталона. + +### 4. Доходы + +- Доходы строятся на существующем endpoint операций только для `OPERATION_TYPE_DIVIDEND`, `OPERATION_TYPE_DIV_EXT`, `OPERATION_TYPE_COUPON`. +- Типы доходов переключаются chip-фильтрами с немедленным применением. +- Диапазон дат по умолчанию: `сегодня - 7 дней` / `сегодня`, чтобы не загружать большой объём операций на первом открытии. +- Диапазон дат использует тот же draft/apply паттерн, что и события. +- В шапке управления периодом не используется слово `Фильтр`; действие применения периода не называется `Показать`. +- Native `` не используется; период выбирается через одно визуальное поле и popover с community `DateCalendar` из `@mui/x-date-pickers`. +- `DateRangePicker` и `@mui/x-date-pickers-pro` не используются; выбор начала/конца периода реализуется локальной логикой dashboard. +- Пагинация cursor-based, размер страницы 10, сбрасывается при смене применённых фильтров. +- При загрузке карточка показывает skeleton таблицы доходов с колонками дата, инструмент, тип, сумма. + +### 5. Аналитика и аллокация + +- Карточка аналитики использует существующий analytics endpoint и показывает спокойное empty state при отсутствии данных. +- Карточка аллокации не использует donut chart. Она строит список горизонтальных bar rows по `buildBrokerAllocation`. +- Отрицательные значения показываются текстом без полосы. + +### 6. Ошибки и пустые состояния + +- Ошибка `portfolio` роняет весь обзор. +- Ошибки `events`, `income`, `analytics` локальны соответствующим карточкам. +- Пустые данные показываются отдельными сообщениями, а не нулевыми значениями. + +### 7. HTML parity visual layer + +- Реальная страница `/broker/:accountId` должна визуально соответствовать `docs/research/2026-06-27-broker-account-redesign.html`, + но использовать существующие React-компоненты и FSD-границы. +- Не менять backend и OpenAPI: блок `Доходы` остаётся на текущем endpoint операций и текущем наборе + income-типов. Отрицательный tone должен поддерживаться для строк, которые уже отображаются или будут + отображаться без расширения контракта. +- Ввести локальные helpers в `widgets/broker-dashboard/lib/dashboardVisual.ts`: + `moneyTone(value, source?) -> 'positive' | 'negative' | 'planned' | 'neutral'`, + `eventTypeTone(type)`, `incomeTypeTone(typeLabel)`, `formatDashboardCurrency(moneyOrValue)`, + `instrumentDisplay({ ticker, name, description })`. +- `formatDashboardCurrency` для RUB должен выводить `₽`. Для неизвестных валют использовать код валюты. +- `instrumentDisplay` должен возвращать основную строку и опциональную подпись: для событий приоритет + `ticker/isin` как main и `name` как subtitle; для операций приоритет `ticker` как main и + `name/description` как subtitle. Если ticker отсутствует, main берётся из name/description, subtitle не + дублируется. +- `BrokerDashboardCard` должен поддержать компактный заголовок карточки уровня HTML-прототипа, не + используя крупный `Heading size="title"`. +- `BrokerDashboardDateFilter` должен использовать иконку раскрытия вместо текстового символа и сохранять + единый toolbar-паттерн для `События` и `Доходы`. +- Для `События` period presets должны поддерживать будущую часть диапазона, а не обрезаться текущим днём. +- Таблицы `События` и `Доходы` должны иметь `thead`, type badges, двухстрочный инструмент при наличии + названия и semantic amount colors. +- `BrokerDashboardAnalyticsCard` должен окрашивать KPI-карточки по смыслу и показывать RUB через `₽`. +- Skeleton таблиц событий и доходов должен использовать один компонент/паттерн и различаться только + числом колонок. + +## Задачи + +### Задача 1: Базовые helpers и локальные dashboard-patterns + +**Файлы:** +- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts` +- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts` +- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts` +- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFormatters.ts` +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx` + +- [ ] Убедиться, что income helpers покрывают только допустимые типы доходов и умеют считать итог отображаемых строк. +- [ ] Убедиться, что filter helpers содержат default state, date presets, validate и mapping income type -> operation types. +- [ ] Убедиться, что formatters покрывают fallback-значения, label типов событий и label статусов. +- [ ] Использовать локальный `BrokerDashboardCard` как основной контейнер карточек; design system расширять только если без этого нельзя реализовать требования спецификации. + +### Задача 2: Hero и layout overview + +**Файлы:** +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx` +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardSkeleton.tsx` +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx` +- `apps/frontend/src/pages/broker-account/ui/BrokerAccountOverviewPage.tsx` +- `apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx` + +- [ ] Собрать обзор через `BrokerDashboard` и `BrokerDashboardSkeleton`. +- [ ] Оставить навигацию счёта в `BrokerAccountLayout` как горизонтальные вкладки над контентом. +- [ ] Исправить hero так, чтобы все `Metric` были одной высоты и поддерживающий текст не поднимал одну ячейку выше остальных. +- [ ] Проверить, что dashboard остаётся одноколоночным и на desktop, и на mobile. + +### Задача 3: Карточка событий + +**Файлы:** +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx` +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx` +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx` +- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts` +- `apps/frontend/package.json` + +- [ ] Добавить или подтвердить зависимость community-пакета `@mui/x-date-pickers` и использовать Day.js adapter. +- [ ] Не добавлять `@mui/x-date-pickers-pro` и не использовать `DateRangePicker`. +- [ ] Обновить `BrokerDashboardDateFilter`: убрать слово `Фильтр` из пользовательского текста, заменить `Показать` на понятное действие применения периода и визуально собрать chips, диапазон, сброс и применение в аккуратную шапку. +- [ ] Заменить native date inputs на одно визуальное поле периода, которое открывает MUI `Popover` с community `DateCalendar`. +- [ ] Реализовать локальную логику выбора диапазона: первый клик задаёт начало, второй — конец; если конец раньше начала, диапазон пересобирается от выбранной даты. +- [ ] Настроить default range событий на `сегодня - 7 дней` / `сегодня + 7 дней`. +- [ ] Подключить для событий draft/applied state: типы применяются сразу, даты только по действию применения периода. +- [ ] Сохранять локальную пагинацию по 10 событий и сбрасывать её при смене применённых фильтров. +- [ ] Заменить текстовую загрузку событий на skeleton таблицы. +- [ ] Оставить локальные error/empty states внутри карточки. + +### Задача 4: Карточка доходов + +**Файлы:** +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx` +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx` +- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts` + +- [ ] Подключить тот же `BrokerDashboardDateFilter` для доходов с draft/applied state. +- [ ] Настроить default range доходов на `сегодня - 7 дней` / `сегодня` вместо периода с начала года. +- [ ] Оставить chip-фильтры типов доходов с немедленным применением. +- [ ] Сохранить cursor pagination по 10 операций и сбрасывать её при смене применённых фильтров. +- [ ] Заменить текстовую загрузку доходов на skeleton таблицы. +- [ ] Если endpoint операций даёт недостаточно релевантных строк для dashboard, зафиксировать ограничение в заметках по реализации, а не расширять backend в рамках этой фичи. + +### Задача 5: Карточки аналитики и аллокации + +**Файлы:** +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx` +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx` + +- [ ] Довести карточку аналитики до соответствия спецификации по полям и состояниям. +- [ ] Заменить donut chart на горизонтальные бары, построенные из `buildBrokerAllocation`. +- [ ] Отрицательные значения аллокации выводить отдельно текстом без bar. + +### Задача 6: Тесты и верификация + +**Файлы:** +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx` +- `docs/features/broker-dashboard-redesign/tasks.md` + +- [ ] Обновить компонентные тесты dashboard так, чтобы они покрывали текущую композицию, фильтры и основные empty/error states. +- [ ] Обновить тесты, которые ищут `Фильтр дат` или `Показать`, под новые тексты и accessible labels единого поля периода. +- [ ] Добавить/обновить тесты default weekly range для событий и доходов. +- [ ] Добавить/обновить тесты skeleton-таблиц для loading state событий и доходов. +- [ ] Прогнать `rtk npm run test:frontend -- --run src/widgets/broker-dashboard`. +- [ ] Прогнать `rtk npm run test:frontend`. +- [ ] Прогнать `rtk npm run test:design-system && rtk npm run lint -w apps/frontend && rtk npm run build:frontend`. +- [ ] Проверить вручную desktop layout `/broker/2084014113`. +- [ ] Проверить вручную mobile layout `/broker/2084014113` на viewport `390x844`. +- [ ] После завершения обновить `docs/features/broker-dashboard-redesign/tasks.md` и выполнить `graphify update .`. + +### Задача 7: Visual helpers для HTML parity + +**Файлы:** +- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts` +- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts` +- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts` +- `apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts` + +- [ ] Добавить `moneyTone`, который возвращает `negative` для отрицательных значений, `positive` для + положительных фактических значений, `planned` для прогнозных/нефактических значений и `neutral` для + нуля/недоступного значения. +- [ ] Добавить `formatDashboardCurrency`, который для `RUB` выводит `₽`, а для неизвестной валюты + оставляет код валюты. +- [ ] Добавить `instrumentDisplay` с приоритетами main/subtitle из технического решения 7. +- [ ] Добавить type tone helpers для event types и income labels. +- [ ] Расширить `DashboardIncomeRow`: хранить `instrumentMain` и `instrumentSubtitle`, сохранив + совместимость через существующий `instrument` только если это нужно текущим тестам. +- [ ] Покрыть helpers unit-тестами: RUB symbol, unknown currency fallback, negative/positive/planned + tones, event/income type tones, отсутствие дублирования subtitle. + +### Задача 8: Hero, карточка и toolbar parity + +**Файлы:** +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardHero.tsx` +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardCard.tsx` +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardDateFilter.tsx` +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx` + +- [ ] Применить semantic color к hero: отрицательная доходность красная, положительная зелёная, + дневное изменение окрашивается по знаку, `Всего доходов` зелёный при значении больше нуля. +- [ ] Сделать заголовки dashboard-card компактными, соответствующими HTML-прототипу. +- [ ] Собрать filters area карточек в единый toolbar: слева типы, справа поле периода и действие. +- [ ] Заменить текстовую стрелку раскрытия периода на иконку: использовать уже подключённые + `CalendarTodayRounded` и `ExpandMoreRounded` из `@mui/icons-material`, без добавления новой icon + dependency. +- [ ] Обновить component tests: проверять отсутствие текста `RUB` для RUB-значений, отсутствие символа + `v` в period button и наличие accessible name у управления периодом. + +### Задача 9: Таблицы событий и доходов как в HTML + +**Файлы:** +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx` +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardIncomeCard.tsx` +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardTableSkeleton.tsx` +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx` + +- [ ] Добавить `thead` в обе dashboard-таблицы с колонками из спецификации. +- [ ] В колонке инструмента выводить main и subtitle из `instrumentDisplay`. +- [ ] Заменить текстовые типы на компактные бейджи с tone из visual helpers. +- [ ] Окрашивать суммы через `moneyTone`: поступления зелёным, списания красным, прогноз/ожидание серым. +- [ ] Окрашивать статусные бейджи: `Поступило` зелёный, прогноз/ожидание серый. +- [ ] Сохранить горизонтальный scroll только внутри таблицы на mobile, без общего page overflow. +- [ ] Обновить skeleton так, чтобы `События` и `Доходы` использовали один визуальный паттерн строк. +- [ ] Обновить tests на наличие type badges, subtitle инструмента, semantic amount classes и skeleton. + +### Задача 10: Analytics parity и визуальная проверка + +**Файлы:** +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx` +- `apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.test.tsx` +- `docs/features/broker-dashboard-redesign/tasks.md` + +- [ ] Отображать RUB как `₽` во всех analytics KPI. +- [ ] Окрашивать analytics cards: positive для пополнений/дивидендов/купонов/всего получено, + negative для выводов и отрицательного нетто, neutral для нулевых/недоступных значений. +- [ ] Обновить component tests для analytics: `₽` вместо `RUB`, positive/negative tone cards. +- [ ] Прогнать `rtk npm run test:frontend -- --run src/widgets/broker-dashboard`. +- [ ] Прогнать `rtk npm run test:frontend`. +- [ ] Прогнать `rtk npm run lint -w apps/frontend && rtk npm run build:frontend`. +- [ ] Проверить `/broker/2084014113` вручную на desktop и mobile `390x844` против + `docs/research/2026-06-27-broker-account-redesign.html`. +- [ ] После проверки обновить `docs/features/broker-dashboard-redesign/tasks.md`. + +## Проверка покрытия спецификации + +- Общая композиция и горизонтальная навигация: задачи 2 и 6. +- Hero KPI и equal-height `Metric`: задача 2. +- События с chip filters, date filters и локальной пагинацией: задача 3. +- Доходы с chip filters, date filters и cursor pagination: задача 4. +- Community DateCalendar range control, weekly default range и skeleton loading tables: задачи 3, 4 и 6. +- Аналитика и аллокация с горизонтальными барами: задача 5. +- Ошибки, empty states, тесты и ручная верификация: задача 6. +- HTML parity: visual helpers — задача 7, hero/card/toolbar — задача 8, dashboard tables — задача 9, + analytics — задача 10. diff --git a/docs/features/broker-dashboard-redesign/spec.md b/docs/features/broker-dashboard-redesign/spec.md new file mode 100644 index 0000000..ac37cc3 --- /dev/null +++ b/docs/features/broker-dashboard-redesign/spec.md @@ -0,0 +1,292 @@ +# Редизайн обзора брокерского счёта в инвестиционный дашборд + +Дата: 2026-06-26 (обновлено 2026-06-27, HTML parity) +Статус: согласовано к планированию +Эпик: [Портфель брокера](../../epics/BrokerPortfolio.md) + +## Контекст + +Текущий маршрут `/broker/:accountId` показывает корректный overview выбранного брокерского счёта, но +визуально остаётся вертикальным набором отдельных блоков: сводка, аллокация, карточки активов, +ближайшие события и последние операции. Пользователь подготовил прототип `temp.html`, где тот же домен +представлен как более плотный инвестиционный дашборд с hero KPI, компактными таблицами и аналитическими +карточками. + +После обсуждения выбран вариант A: страница `/broker/:accountId` должна стать единым дашбордом, +используя структуру и плотность прототипа, но сохраняя текущую светлую тему и компоненты +`@moex-vibe/design-system`. Тёмная тема из прототипа не переносится в первую версию. При этом +двухколоночная desktop-композиция прототипа сознательно не переносится: и на desktop, и на mobile блоки +dashboard идут по одному на строке в фиксированном порядке. + +После интерактивной дизайн-итерации согласован статический эталон +`docs/research/2026-06-27-broker-account-redesign.html`. Реальная React-страница должна визуально соответствовать этому HTML: +компактные заголовки карточек, единая шапка таблиц, бейджи типов, подписи инструментов, семантические +цвета денежных значений и единый skeleton таблиц. + +## Цель + +Сделать overview брокерского счёта быстрым обзором состояния портфеля, будущих/прошедших событий, +полученных доходов, аналитики доходности и аллокации без перехода по вкладкам. + +## Пользовательский результат + +Пользователь может на `/broker/:accountId`: + +- сразу увидеть стоимость портфеля, доходность и сумму полученных доходов; +- увидеть ближайшие события по счёту в компактной таблице; +- увидеть последние доходные операции по дивидендам и купонам и итог по ним; +- оценить вложения, полученные выплаты и доходность по существующей аналитике; +- увидеть структуру портфеля через горизонтальные полосы аллокации и подписи к ним; +- перейти в существующие подробные вкладки `Акции`, `Облигации`, `Операции`, `События` и `Аналитика` + для drill-down сценариев. + +## Область изменений + +Фича относится только к frontend-маршруту `/broker/:accountId` и связанным frontend-компонентам +брокерского overview. + +В область входят: + +- новая dashboard-композиция для `BrokerAccountOverviewPage`; +- переиспользуемые frontend-паттерны для dashboard card, KPI, compact table и filter chips; +- адаптация существующих брокерских widgets под новую компоновку, если это нужно для читаемости; +- точечные доработки `@moex-vibe/design-system`, если существующие компоненты блокируют корректное + использование текущей светлой DS-темы; +- тесты новой композиции и ключевых представлений. + +## Требования + +### 1. Общая композиция + +- `/broker/:accountId` остаётся overview выбранного брокерского счёта. +- Overview визуально становится dashboard-страницей, а не вертикальным списком независимых секций. +- Существующая навигация счёта отображается горизонтальными вкладками над контентом, чтобы не занимать + левую колонку и оставить больше пространства для таблиц dashboard. +- На desktop и mobile блоки dashboard отображаются по одному блоку на строке: hero KPI, `События`, + `Доходы`, `Аналитика доходности`, `Аллокация`. +- Существующая навигация счёта сохраняет ссылки на `Обзор`, `Акции`, `Облигации`, `Операции`, + `События`, `Аналитика`. +- На мобильном viewport сохраняется тот же порядок блоков в одну колонку. + +### 2. Визуальный стиль + +- Используется текущая светлая DS-тема MoexVibe. +- Не добавляется dark mode и не переносится тёмная палитра `temp.html`. +- Визуальная плотность, структура карточек, KPI-иерархия и компактность таблиц ориентируются на + `docs/research/2026-06-27-broker-account-redesign.html`. +- Дизайн использует компоненты и токены `@moex-vibe/design-system` там, где они применимы. +- Если DS-компонент слишком ограничен, допускается точечно расширить DS API или создать локальный + dashboard-pattern в frontend, но не добавлять доменную брокерскую логику в DS. +- Заголовки dashboard-карточек не должны использовать page-level размер `Heading size="title"`; + визуально они должны соответствовать компактному card heading из HTML-прототипа. +- В карточках `События` и `Доходы` фильтры должны быть собраны в единый toolbar: группа типов, единое + поле периода с иконкой календаря и chevron-иконкой, действие обновления/применения периода. +- В поле периода не используется текстовый символ `v`; раскрытие обозначается иконкой. +- Валюта в dashboard отображается символом `₽`, а не строкой `RUB`, кроме случаев, где backend вернул + другую валюту и formatter проекта не знает её символ. + +### 3. Hero KPI + +Hero показывает: + +- название счёта или fallback `Брокерский счёт`; +- стоимость портфеля из `portfolio.totals.portfolio`; +- доходность из существующих данных: приоритет `broker analytics.totalReturnPercent`, если загружена, + иначе `portfolio.yields.expectedPercent`, если доступна; +- дневное изменение из `portfolio.yields.daily` и `portfolio.yields.dailyPercent`, если доступно; +- всего полученных доходов из `broker analytics.totalReceived`, если аналитика загружена; +- спокойный fallback `—` для недоступных значений. +- Все Metric-блоки имеют одинаковую высоту в grid-ряду, даже если у некоторых есть supportingText. Поддерживающий текст остаётся внутри Metric, но все Metrics растягиваются на полную высоту grid-ячейки с выравниванием от верхнего края. +- Доходность в hero имеет цветовую семантику: положительная — зелёная, отрицательная — красная, + недоступная/нулевая — нейтральная. +- Дневное изменение в supporting text hero тоже окрашивается по знаку значения. +- `Всего доходов` окрашивается как положительный финансовый показатель, если значение больше нуля. + +### 4. Блок `События` + +- Блок использует существующий источник `useBrokerEvents(accountId, query)`. +- По умолчанию применяется период `сегодня - 7 дней` / `сегодня + 7 дней` и типы + `dividend,coupon,maturity,offer`, как в существующей вкладке событий. +- Блок содержит кликабельные chip-фильтры типов событий: `Дивиденды`, `Купоны`, `Погашения`, `Оферты`. +- Пользователь может выбрать несколько типов событий. +- Если пользователь снимает все типы событий, запрос не выполняется, а блок показывает + валидационное сообщение. +- Изменение chip-фильтров типов событий применяется сразу и возвращает локальную пагинацию на первую + страницу. +- Блок содержит фильтр периода `from` / `to`. +- По умолчанию применяется недельный период `сегодня - 7 дней` / `сегодня + 7 дней`, чтобы обзор + сразу показывал ближайшие будущие события. +- Изменение черновых фильтров не запускает запрос до применения периода пользователем. +- В пользовательском тексте шапки периода не используется слово `Фильтр`; UI должен считываться как + управление периодом за счёт иконки календаря, применённого диапазона, пресетов и affordance раскрытия. +- Блок содержит быстрые пресеты периода `7д`, `30д`, `90д`, `1г`, `Всё`, действие `Сбросить` и + основное действие применения периода с более понятным текстом, чем `Показать`. +- Период отображается как одно визуальное поле/кнопка с диапазоном, например `19 июн – 26 июн`. +- По клику на поле периода открывается popover с community-компонентом MUI `DateCalendar` из + `@mui/x-date-pickers` и ручной логикой выбора начала/конца периода. +- `DateRangePicker` и пакет `@mui/x-date-pickers-pro` не используются. +- Native `` не используется. +- Применённые фильтры dashboard не обязаны синхронизироваться с URL; URL-синхронизация остаётся + обязанностью подробной вкладки `События`. +- В dashboard отображается не более 10 событий на странице. +- Если в выбранном диапазоне больше 10 событий, блок показывает локальную пагинацию по страницам. +- Смена применённых фильтров возвращает пагинацию блока на первую страницу. +- Таблица показывает дату, инструмент, тип, сумму и статус. +- Таблица содержит компактную строку заголовков колонок. +- В колонке `Инструмент` показывается тикер/ISIN и дополнительная строка с названием инструмента, если + оно доступно из `event.name`; если названия нет, дополнительная строка не занимает место. +- В колонке `Тип` значения отображаются небольшими бейджами с разными тонами для купона, дивиденда, + погашения и оферты. +- Сумма для `actual` берётся из `actualAmount`, сумма для `forecast` берётся из `estimatedAmount`. +- Фактические поступления визуально отмечаются как `Поступило`. +- Прогнозные суммы помечаются как оценочные. +- Суммы окрашиваются по смыслу: фактическое положительное поступление зелёным, отрицательное списание + красным, прогноз/план серым. Цвет не является единственным носителем смысла: прогнозные строки также + имеют статусный текст. +- Статус отображается компактным бейджем. `Поступило` использует зелёный тон, прогноз/ожидание — + нейтральный серый тон. +- Блок содержит ссылку на подробную вкладку `/broker/:accountId/events`. +- Ошибка загрузки событий не ломает остальной dashboard. + +### 5. Блок `Доходы` + +- Блок показывает последние доходные операции по дивидендам и купонам из существующего endpoint + операций. +- В первую версию входят операции с типами дивидендов и купонов, которые уже используются в backend + analytics: `OPERATION_TYPE_DIVIDEND`, `OPERATION_TYPE_DIV_EXT`, `OPERATION_TYPE_COUPON`. +- Блок содержит кликабельные chip-фильтры типов доходов: `Дивиденды`, `Купоны`. +- Пользователь может выбрать один или оба типа доходов. +- Если пользователь снимает все типы доходов, запрос не выполняется, а блок показывает + валидационное сообщение. +- Изменение chip-фильтров типов доходов применяется сразу и сбрасывает cursor-пагинацию. +- Блок содержит фильтр периода `from` / `to`. +- По умолчанию применяется недельный период `сегодня - 7 дней` / `сегодня`, чтобы dashboard не загружал + слишком много операций при первом открытии. +- Изменение черновых фильтров не запускает запрос до применения периода пользователем. +- В пользовательском тексте шапки периода не используется слово `Фильтр`; UI должен считываться как + управление периодом за счёт иконки календаря, применённого диапазона, пресетов и affordance раскрытия. +- Блок содержит быстрые пресеты периода `7д`, `30д`, `90д`, `1г`, `Всё`, действие `Сбросить` и + основное действие применения периода с более понятным текстом, чем `Показать`. +- Период отображается как одно визуальное поле/кнопка с диапазоном, например `19 июн – 26 июн`. +- По клику на поле периода открывается popover с community-компонентом MUI `DateCalendar` из + `@mui/x-date-pickers` и ручной логикой выбора начала/конца периода. +- `DateRangePicker` и пакет `@mui/x-date-pickers-pro` не используются. +- Native `` не используется. +- Применённые фильтры dashboard не обязаны синхронизироваться с URL; URL-синхронизация остаётся + обязанностью подробных разделов. +- Для блока используется cursor-пагинация existing operations endpoint с размером страницы 10. +- Смена применённых фильтров сбрасывает cursor-пагинацию блока на первую страницу. +- Таблица показывает дату, инструмент, тип и сумму. +- Таблица содержит компактную строку заголовков колонок. +- В колонке `Инструмент` показывается тикер и дополнительная строка с названием операции/инструмента, + если оно доступно из `operation.name` или `operation.description`. +- В колонке `Тип` значения отображаются небольшими бейджами. +- Суммы окрашиваются по знаку: положительные поступления зелёным, отрицательные списания красным, + плановые/нефактические значения серым, если такие строки отображаются в блоке. +- Блок показывает итог по отображаемым доходным операциям. +- Блок содержит ссылку на подробную вкладку `/broker/:accountId/operations`. +- Если текущий endpoint операций не позволяет корректно получить доходные операции без изменения + backend-контракта, первая реализация должна явно зафиксировать это в `plan.md` перед изменением API. +- В этой итерации блок `Доходы` не расширяется до полной истории комиссий/налогов; цветовая семантика + отрицательных сумм должна быть готова для отображаемых строк, но API-контракт не меняется. + +### 6. Блок `Аналитика доходности` + +- Блок использует существующий endpoint `/api/v1/broker/accounts/:accountId/analytics`. +- Отображаются: пополнения, выводы, нетто вложено, дивиденды, купоны, всего получено, доходность. +- Денежные значения analytics отображаются с символом валюты `₽` для RUB. +- Карточки analytics используют цветовую семантику: положительные потоки и полученные доходы — + зелёный тон, отрицательные выводы и отрицательное нетто — красный тон, нейтральные/нулевые значения — + нейтральный тон. +- При отсутствии analytics data блок показывает спокойное пустое состояние. +- Ошибка analytics не ломает остальные блоки. + +### 7. Блок `Аллокация` + +- Используется существующий расчёт `buildBrokerAllocation`. +- Вместо donut-диаграммы используется горизонтальный bar chart: каждый сектор — полоса с процентом, + подписью и суммой. +- Сверху блока показывается итоговая стоимость портфеля. +- Каждая полоса содержит: название сектора, долю в процентах, сумму в валюте. +- Цвета полос соответствуют существующей палитре аллокации (акции, облигации, ETF, деньги, прочие). +- Отрицательные значения отображаются текстом без полосы. +- Текст подписей контрастный и читаемый на всех цветах фона. +- Информация остаётся понятной без различения цветов. + +### 8. Загрузка, ошибки и пустые состояния + +- Первичная загрузка portfolio показывает dashboard skeleton соответствующей формы. +- Ошибка portfolio показывает ошибку overview, потому что без portfolio dashboard не имеет основного + контекста. +- Загрузка событий и доходов внутри карточек показывает skeleton таблицы соответствующей структуры, а не + только текстовую строку загрузки. +- Skeleton таблиц событий и доходов должен иметь единый визуальный паттерн: строки соответствуют + геометрии таблицы, колонка инструмента шире остальных, для `Доходы` используется тот же паттерн без + лишней колонки статуса. +- Ошибка событий, доходов или analytics отображается внутри соответствующей карточки. +- Пустые события, пустые доходы и пустая analytics имеют отдельные понятные сообщения. +- Недоступные отдельные значения отображаются как `—`, не подменяются нулём. + +## Ограничения + +- Backend остаётся единственным клиентом T-Bank и MOEX. +- В первой версии не добавляется график истории стоимости портфеля. +- В первой версии не добавляется backend storage/API для снапшотов стоимости портфеля. +- Тёмная тема и переключатель темы не входят в область фичи. +- Не меняются правила расчёта доходности, событий, операций и аллокации. +- Не удаляются существующие detailed вкладки счёта. +- Не изменяется URL-структура `/broker/:accountId/*`. + +## Backlog + +Идея `Broker portfolio value history` вынесена в `docs/inbox.md` и `docs/roadmap.md`: хранить снапшоты +стоимости брокерского счёта и позже заменить отсутствие графика полноценным блоком `Стоимость портфеля`. + +## Acceptance Criteria + +- `/broker/:accountId` показывает dashboard-композицию: hero KPI, `События`, `Доходы`, + `Аналитика доходности`, `Аллокация`. +- Навигация счёта отображается горизонтальными вкладками над dashboard-контентом. +- На desktop и mobile блоки `События`, `Доходы`, `Аналитика доходности`, `Аллокация` расположены по + одному блоку на строке. +- На мобильном viewport dashboard читаемо перестраивается в одну колонку. +- Hero показывает стоимость портфеля, доходность или fallback, дневное изменение или fallback, всего + доходов или fallback. +- Все Metric-блоки hero имеют одинаковую высоту; supportingText не создаёт перекоса. +- Hero KPI использует цветовую семантику для доходности, дневного изменения и всего полученных доходов. +- Блок `События` использует существующие events data и показывает дату, инструмент, тип, сумму и статус. +- В таблице `События` инструмент отображается двумя строками при наличии названия, тип отображается + бейджем, сумма и статус имеют семантические цвета. +- Блок `События` поддерживает multi-select chip-фильтр типов и локальную пагинацию по 10 событий. +- Блок `События` по умолчанию запрашивает период `сегодня - 7 дней` / `сегодня + 7 дней` и использует одно поле + периода с popover-календарём на базе community `DateCalendar`. +- Блок `Доходы` показывает доходные операции дивидендов и купонов и итог по отображаемым строкам. +- В таблице `Доходы` инструмент отображается двумя строками при наличии названия, тип отображается + бейджем, сумма имеет семантический цвет. +- Блок `Доходы` поддерживает multi-select chip-фильтр типов и cursor-пагинацию по 10 операций. +- Блок `Доходы` по умолчанию запрашивает период `сегодня - 7 дней` / `сегодня` и использует одно поле + периода с popover-календарём на базе community `DateCalendar`. +- В шапке управления периодом не отображается слово `Фильтр`, а действие применения периода не называется + `Показать`. +- Шапки фильтров `События` и `Доходы` визуально соответствуют единому toolbar из + `docs/research/2026-06-27-broker-account-redesign.html`. +- Поле периода использует chevron-иконку, а не текстовый символ `v`. +- Загрузка событий и доходов отображается skeleton-таблицей. +- Блок `Аналитика доходности` показывает данные существующего analytics endpoint. +- Блок `Аналитика доходности` отображает RUB как `₽` и использует цветовую семантику карточек. +- Блок `Аллокация` показывает горизонтальные бары секторов с названием, долей и суммой, а также + итоговую стоимость портфеля. +- Ошибка одного вторичного блока не скрывает остальные блоки dashboard. +- Существующие detailed вкладки остаются доступны из навигации счёта. +- Первая версия не содержит график истории стоимости портфеля и не добавляет API для него. +- Дизайн использует светлую DS-тему, а не тёмную тему из `temp.html`. + +## Вне области фичи + +- график стоимости портфеля по датам; +- новые исторические снапшоты стоимости; +- dark mode; +- изменение backend-расчётов доходности; +- налоговая аналитика; +- экспорт dashboard; +- объединение нескольких брокерских счетов в один dashboard. diff --git a/docs/features/broker-dashboard-redesign/tasks.md b/docs/features/broker-dashboard-redesign/tasks.md new file mode 100644 index 0000000..30661d9 --- /dev/null +++ b/docs/features/broker-dashboard-redesign/tasks.md @@ -0,0 +1,139 @@ +# Редизайн обзора брокерского счёта в инвестиционный дашборд — задачи + +Дата: 2026-06-26 (обновлено 2026-06-27) +Статус: в реализации, итерация HTML parity + +## Документация и pre-flight + +- [x] Выбрать scope: `/broker/:accountId` становится единым дашбордом. +- [x] Выбрать визуальный стиль: структура `temp.html`, текущая светлая DS-тема. +- [x] Исключить график истории стоимости из первой версии. +- [x] Добавить backlog-задачу на историю стоимости брокерского портфеля. +- [x] Создать ветку `codex/broker-dashboard-redesign`. +- [x] Запустить baseline: `rtk npm run test:backend && rtk npm run test:frontend && rtk npm run test:design-system`. +- [x] Написать `spec.md`. +- [x] Написать `plan.md`. + +## Реализация + +- [x] Добавить pure helpers для фильтрации и суммирования доходных операций dashboard. +- [x] Добавить helpers для фильтров дат, пресетов, валидации и mapping income types → operationTypes. +- [x] Добавить dashboard presentation helpers для fallback, event labels и statuses. +- [x] Добавить локальный `BrokerDashboardCard` pattern или точечно расширить DS, если локального pattern недостаточно. +- [x] Добавить `BrokerDashboardHero` с KPI по portfolio и analytics. +- [x] Добавить `BrokerDashboardEventsCard` на основе `useBrokerEvents`. +- [x] Добавить `BrokerDashboardIncomeCard` на основе `useBrokerOperations`. +- [x] Перенести навигацию счёта из левой колонки в горизонтальные вкладки над контентом. +- [x] Перестроить dashboard на один блок на строке для `События`, `Доходы`, `Аналитика доходности`, `Аллокация`. +- [x] Добавить кликабельные chip-фильтры типов для `События`. +- [x] Добавить `BrokerDashboardDateFilter` — переиспользуемый expandable-компонент фильтра дат (пресеты 7д/30д/90д/1г/Всё, from/to поля, Сбросить/Показать). +- [x] Подключить `BrokerDashboardDateFilter` в `События` с draft/applied состоянием. +- [x] Добавить локальную пагинацию по 10 событий в `События`. +- [x] Добавить кликабельные chip-фильтры типов для `Доходы`. +- [x] Подключить `BrokerDashboardDateFilter` в `Доходы` с draft/applied состоянием. +- [x] Добавить cursor-пагинацию по 10 операций в `Доходы`. +- [x] Добавить `BrokerDashboardAnalyticsCard` на основе `useBrokerAnalytics`. +- [x] Добавить `BrokerDashboardAllocationCard` на основе существующей аллокации. +- [x] Добавить `BrokerDashboardSkeleton`. +- [x] Добавить `BrokerDashboard` как top-level composition widget. +- [x] Заменить текущий вертикальный обзор в `BrokerAccountOverviewPage` на `BrokerDashboard`. +- [x] Добавить unit/component tests для helpers и базовой dashboard composition. +- [x] Выровнять hero KPI: все Metric одной высоты, supportingText не раздвигает "Доходность" выше соседей. +- [x] Заменить donut-диаграмму аллокации на горизонтальные бары в `BrokerDashboardAllocationCard`. +- [x] Убрать слово `Фильтр` из пользовательского текста шапки периода в dashboard-карточках. +- [x] Переименовать действие `Показать` в управлении периодом и визуально улучшить шапку фильтров. +- [x] Заменить native date inputs на одно поле периода с popover и community `DateCalendar` из `@mui/x-date-pickers`. +- [x] Не использовать `@mui/x-date-pickers-pro` и `DateRangePicker`. +- [x] Настроить default range событий на `сегодня - 7 дней` / `сегодня + 7 дней`, доходов — на + `сегодня - 7 дней` / `сегодня`. +- [x] Заменить текстовую загрузку `События` на skeleton таблицы. +- [x] Заменить текстовую загрузку `Доходы` на skeleton таблицы. +- [x] Обновить component tests под новые тексты, единое поле периода, popover-календарь и skeleton loading states. +- [ ] Проверить desktop layout `/broker/2084014113`. +- [ ] Проверить mobile layout `/broker/2084014113`. + +## Итерация HTML parity + +- [x] Согласовать статический визуальный эталон `docs/research/2026-06-27-broker-account-redesign.html`. +- [x] Обновить `spec.md` под HTML parity: компактные заголовки, единый toolbar, бейджи, цвета, подписи инструментов, `₽`. +- [x] Обновить `plan.md` под перенос HTML parity в React-компоненты. +- [x] Добавить `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts` с helpers для money tone, type tone, currency symbol и instrument display. +- [x] Добавить `apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts`. +- [ ] Расширить income helpers так, чтобы строки доходов могли отдавать main/subtitle инструмента без дублирования текста. +- [x] Обновить `BrokerDashboardHero`: semantic colors для доходности, дневного изменения и всего полученных доходов. +- [x] Обновить `BrokerDashboardCard`: компактный card heading вместо крупного page-level heading. +- [x] Обновить `BrokerDashboardDateFilter`: единый toolbar-паттерн и chevron-иконка вместо текстового `v`. +- [x] Обновить `BrokerDashboardEventsCard`: `thead`, двухстрочный инструмент, type badges, semantic amount colors, status badges. +- [x] Обновить `BrokerDashboardIncomeCard`: `thead`, двухстрочный инструмент, type badges, semantic amount colors. + Бейдж `Купон` приведён к `info` (синий) согласно HTML-эталону `.type-badge.coupon` (`incomeTypeTone` в `dashboardVisual.ts`), + `Дивиденд (внешний)` отнесён к `success` (семейство дивидендов). +- [x] Обновить `BrokerDashboardTableSkeleton`: общий skeleton-паттерн для событий и доходов с корректной геометрией колонок. +- [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`. +- [ ] Проверить mobile layout `/broker/2084014113` на viewport `390x844` против `docs/research/2026-06-27-broker-account-redesign.html`. + +## Definition of Done + +- [x] `rtk npm run test:frontend` проходит. +- [x] `rtk npm run test:design-system` проходит. +- [x] `rtk npm run lint -w apps/frontend` проходит. +- [x] `rtk npm run build:frontend` проходит. +- [ ] Dashboard соответствует acceptance criteria из `spec.md`. +- [x] Существующие вкладки `Акции`, `Облигации`, `Операции`, `События`, `Аналитика` остаются доступны. +- [x] `graphify update .` выполнен после code changes. + +## Definition of Done для HTML parity + +- [x] `rtk npm run test:frontend -- --run src/widgets/broker-dashboard` проходит. (49/49) +- [x] `rtk npm run test:frontend` проходит после HTML parity изменений. (175/175) +- [x] `rtk npm run lint -w apps/frontend` проходит после HTML parity изменений. +- [x] `rtk npm run build:frontend` проходит после HTML parity изменений. +- [ ] На `/broker/2084014113` заголовки карточек, toolbar таблиц, бейджи типов, подписи инструментов, + цвета сумм, analytics colors и skeleton визуально соответствуют `docs/research/2026-06-27-broker-account-redesign.html`. + Проверено только по тестам и code-to-spec mapping внизу файла — live dev server не запускался + в этой итерации (нет backend/auth в среде). Необходима ручная проверка пользователем. +- [ ] Нет общего горизонтального overflow на mobile; горизонтальный scroll допускается только внутри таблиц. + Требует 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) | +|------------------------|------------------------------------------------------------|----------------------------------------------------------------------| +| `
` | `BrokerDashboardCard` с `ariaLabel="Аналитика доходности"` | Секция с заголовком, доступная по aria-label | +| `

Аналитика доходности

` | Заголовок карточки | Компактный 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`), но фактическая вёрстка в браузере не сверялась с эталоном. diff --git a/docs/inbox.md b/docs/inbox.md index 6657334..632d5bb 100644 --- a/docs/inbox.md +++ b/docs/inbox.md @@ -166,6 +166,17 @@ cash flow, бюджеты, аналитика, прогнозы и автома ## Frontend-платформа +### Добавить историю стоимости брокерского портфеля + +- Сохранять снапшоты полной стоимости брокерского счёта, чтобы строить график динамики портфеля по + датам. +- Отдельно спроектировать backend storage/API, периодичность обновления, валюту расчёта и правила для + пропущенных дней. +- На дашборде брокерского счёта заменить временный отказ от графика на полноценный блок `Стоимость + портфеля`, когда данные истории будут доступны. +- Не реализовывать в первой версии редизайна `/broker/:accountId`: текущая задача использует только + уже доступные данные портфеля, событий, операций и аналитики. + ### Перейти к Feature-Sliced Design - Постепенно привести frontend к FSD-архитектуре с явными границами между `app`, `pages`, `widgets`, diff --git a/docs/research/2026-06-27-broker-account-redesign.html b/docs/research/2026-06-27-broker-account-redesign.html new file mode 100644 index 0000000..b529407 --- /dev/null +++ b/docs/research/2026-06-27-broker-account-redesign.html @@ -0,0 +1,1220 @@ + + + + + + MoexVibe - брокерский счет + + + +
+
+
+
MoexVibe
+ +
+ +
+ Sergey + +
+
+ +
+
+

Брокерский счёт

+
+ + +
+
+ + + +
+
+
Инвестиционный дашборд
+
Брокерский счёт
+
+
+
+
Стоимость портфеля
+
1 884 891,72 ₽
+
+
+
+
+
Доходность
+
-6,84%
+
+
За день: +1 688,04 ₽
+
+
+
+
Всего доходов
+
186 087,53 ₽
+
+
+
+ +
+
+
+

События

+ 10 из 48 +
+ Все события +
+
+
+ Тип +
+ + + + +
+
+
+ +
+
+ + + + + +
+ +
+ + +
+
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + +
ДатаИнструментТипСуммаСтатус
13.05.2026
RU000A10AEF9
РЖД 001Р-37R
Купон+43,56 ₽Поступило
13.05.2026
RU000A10B024
Газпром капитал 2Р-09
Купон+326,92 ₽Поступило
13.05.2026
RU000A10B883
ЕвроТранс 001Р-06
Купон+13,97 ₽Поступило
14.05.2026
RU000A10A3Z4
Система 001Р-31
Купон+1 212,45 ₽Поступило
14.05.2026
RU000A10B461
Самолет БО-П14
Купон-87,00 ₽Налог
15.05.2026
RU000A10A4C1
ОФЗ 26241
Погашение+150,00 ₽Поступило
15.05.2026
HEAD
HeadHunter Group
Дивиденд+1 631,00 ₽Поступило
18.05.2026
RU000A109VK0
Сегежа 003P-04R
Купон~197,56 ₽Ожидается
+
+ +
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+

Доходы

+ 10 операций +
+ Все операции +
+
+
+ Тип +
+ + +
+
+
+ +
+
+ + + + + +
+ +
+ + +
+
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + +
ДатаИнструментТипСумма
26.06.2026
RU000A103C53
Европлан 001Р-07
Купон+649,25 ₽
26.06.2026
CNRU
Cian PLC
Дивиденд+1 060,00 ₽
25.06.2026
RU000A10AST0
Сэтл Групп 002Р-03
Купон+683,88 ₽
25.06.2026
BROKER
Брокерская комиссия
Комиссия-35,00 ₽
24.06.2026
SU26249RMFS1
ОФЗ 26249
Купон+109,70 ₽
24.06.2026
RU000A106UW3
Самолет БО-П11
Купон+90,08 ₽
24.06.2026
IRAO
Интер РАО
Дивиденд+2 860,69 ₽
+
+ +
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+

Аналитика доходности

+
+
+
+
Пополнения
113 773,03 ₽
+
Выводы
-684 753,92 ₽
+
Нетто
-570 980,89 ₽
+
Дивиденды
27 137,88 ₽
+
Купоны
158 949,65 ₽
+
Всего получено
186 087,53 ₽
+
+
+ +
+
+
+

Аллокация

+
+
+
+ Структура портфеля + 1 884 891,72 ₽ +
+
+
+
Акции539 344,64 ₽ · 28,6%
+
+
+
+
Облигации1 330 791,92 ₽ · 70,6%
+
+
+
+
Деньги14 755,16 ₽ · 0,8%
+
+
+
+
+
+
+ + + + diff --git a/docs/roadmap.md b/docs/roadmap.md index cbc2c0c..5e5fb35 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -130,4 +130,6 @@ Roadmap отражает порядок продуктовой работы, н явный session contract для нескольких клиентских поверхностей - [ ] Contract, type-safety, and frontend delivery hardening (P2/P3) — устранение остаточного `as any`, укрепление API/query boundaries, quality/performance gates и lazy-loading budgets +- [ ] Broker portfolio value history (P2/P3) — хранить снапшоты стоимости брокерского счёта и показать + график динамики портфеля на дашборде - [x] Broker-events — UX доработки и смешанный календарь. diff --git a/package-lock.json b/package-lock.json index f32785e..b8aa3c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -92,6 +92,7 @@ "@moex-vibe/design-system": "*", "@mui/icons-material": "^6.5.0", "@mui/material": "^6.5.0", + "@mui/x-date-pickers": "^7.29.4", "@tanstack/react-query": "^5.20.0", "@tanstack/react-router": "^1.170.16", "@tanstack/react-table": "^8.21.3", @@ -7426,6 +7427,92 @@ "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", "license": "MIT" }, + "node_modules/@mui/x-date-pickers": { + "version": "7.29.4", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.29.4.tgz", + "integrity": "sha512-wJ3tsqk/y6dp+mXGtT9czciAMEO5Zr3IIAHg9x6IL0Eqanqy0N3chbmQQZv3iq0m2qUpQDLvZ4utZBUTJdjNzw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0", + "@mui/x-internals": "7.29.0", + "@types/react-transition-group": "^4.4.11", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0", + "@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0", + "date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0", + "date-fns-jalali": "^2.13.0-0 || ^3.2.0-0 || ^4.0.0-0", + "dayjs": "^1.10.7", + "luxon": "^3.0.2", + "moment": "^2.29.4", + "moment-hijri": "^2.1.2 || ^3.0.0", + "moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "date-fns": { + "optional": true + }, + "date-fns-jalali": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + }, + "moment-hijri": { + "optional": true + }, + "moment-jalaali": { + "optional": true + } + } + }, + "node_modules/@mui/x-internals": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.29.0.tgz", + "integrity": "sha512-+Gk6VTZIFD70XreWvdXBwKd8GZ2FlSCuecQFzm6znwqXg1ZsndavrhG9tkxpxo2fM1Zf7Tk8+HcOO0hCbhTQFA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", diff --git a/packages/design-system/src/components/Chip/Chip.test.tsx b/packages/design-system/src/components/Chip/Chip.test.tsx index d5d91ea..8317ce5 100644 --- a/packages/design-system/src/components/Chip/Chip.test.tsx +++ b/packages/design-system/src/components/Chip/Chip.test.tsx @@ -59,4 +59,22 @@ describe('Chip', () => { renderWithTheme(); expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); + + it('calls onClick when clickable chip clicked', async () => { + const handleClick = vi.fn(); + const user = userEvent.setup(); + renderWithTheme(); + + await user.click(screen.getByRole('button', { name: 'Clickable' })); + + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('renders unselected clickable chip as outlined', () => { + renderWithTheme( {}} selected={false} />); + + const chip = screen.getByText('Inactive').closest('.MuiChip-root')!; + expect(chip.classList.contains('MuiChip-outlined')).toBe(true); + expect(chip).toHaveAttribute('aria-pressed', 'false'); + }); }); diff --git a/packages/design-system/src/components/Chip/Chip.tsx b/packages/design-system/src/components/Chip/Chip.tsx index f6b328e..48d9c37 100644 --- a/packages/design-system/src/components/Chip/Chip.tsx +++ b/packages/design-system/src/components/Chip/Chip.tsx @@ -1,5 +1,5 @@ -import { Chip as MuiChip } from '@mui/material'; import CancelIcon from '@mui/icons-material/Cancel'; +import { Chip as MuiChip } from '@mui/material'; type Tone = 'neutral' | 'info' | 'success' | 'warning' | 'error'; @@ -15,14 +15,28 @@ export interface ChipProps { label: string; tone?: Tone; onDelete?: () => void; + onClick?: () => void; + selected?: boolean; + disabled?: boolean; } -export function Chip({ label, tone = 'neutral', onDelete }: ChipProps) { +export function Chip({ + label, + tone = 'neutral', + onDelete, + onClick, + selected = true, + disabled, +}: ChipProps) { return ( } : {})} /> );