codex/frontend-overview-redesign #50

Merged
ksv741 merged 8 commits from codex/frontend-overview-redesign into main 2026-06-27 21:18:34 +03:00
16 changed files with 585 additions and 877 deletions
Showing only changes of commit d21e2411b8 - Show all commits

View File

@ -0,0 +1,12 @@
import type { ApiResponseMeta, BrokerPortfolioHistoryData } from '@/shared/api'
import { request } from '@/shared/api/kyClient'
export function getBrokerPortfolioHistory(
accountId: string,
months?: number,
): Promise<{ data: BrokerPortfolioHistoryData; meta: ApiResponseMeta }> {
return request<BrokerPortfolioHistoryData>(
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/portfolio/history`,
{ months: months ? String(months) : undefined },
)
}

View File

@ -10,3 +10,4 @@ export {
export { useBrokerAccountPortfolios } from './model/useBrokerAccountPortfolios' export { useBrokerAccountPortfolios } from './model/useBrokerAccountPortfolios'
export { useBrokerAccounts } from './model/useBrokerAccounts' export { useBrokerAccounts } from './model/useBrokerAccounts'
export { useBrokerPortfolio } from './model/useBrokerPortfolio' export { useBrokerPortfolio } from './model/useBrokerPortfolio'
export { useBrokerPortfolioHistory } from './model/useBrokerPortfolioHistory'

View File

@ -0,0 +1,14 @@
import { useQuery } from '@tanstack/react-query'
import type { BrokerPortfolioHistoryData } from '@/shared/api'
import { getBrokerPortfolioHistory } from '../api/brokerPortfolioHistoryApi'
export function useBrokerPortfolioHistory(accountId: string, months: number = 6) {
return useQuery<BrokerPortfolioHistoryData>({
queryKey: ['broker', 'portfolio-history', accountId, months],
enabled: Boolean(accountId),
queryFn: async () => (await getBrokerPortfolioHistory(accountId, months)).data,
staleTime: 300_000,
retry: 2,
refetchOnWindowFocus: false,
})
}

View File

@ -13,6 +13,7 @@ export type BrokerOperationQuery = {
instrumentId?: string instrumentId?: string
operationTypes?: string operationTypes?: string
state?: string state?: string
categories?: string
} }
export function syncBrokerOperations( export function syncBrokerOperations(
@ -40,6 +41,7 @@ export function getBrokerOperations(
instrumentId: query.instrumentId, instrumentId: query.instrumentId,
operationTypes: query.operationTypes, operationTypes: query.operationTypes,
state: query.state, state: query.state,
categories: query.categories,
}, },
) )
} }

View File

@ -64,5 +64,9 @@ export type BrokerEventsSummary = components['schemas']['BrokerEventsSummaryDto'
// Broker analytics // Broker analytics
export type BrokerAnalytics = components['schemas']['BrokerAnalyticsDto'] export type BrokerAnalytics = components['schemas']['BrokerAnalyticsDto']
// Broker portfolio history
export type BrokerPortfolioHistoryPoint = components['schemas']['BrokerPortfolioHistoryPointDto']
export type BrokerPortfolioHistoryData = components['schemas']['BrokerPortfolioHistoryDataDto']
// Broker sync // Broker sync
export type BrokerOperationSyncResponse = components['schemas']['BrokerOperationSyncResponseDto'] export type BrokerOperationSyncResponse = components['schemas']['BrokerOperationSyncResponseDto']

View File

@ -468,6 +468,23 @@ export interface paths {
patch?: never patch?: never
trace?: never trace?: never
} }
'/api/v1/broker/accounts/{accountId}/portfolio/history': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
/** Get portfolio value history for last N months */
get: operations['TBankController_getPortfolioHistory']
put?: never
post?: never
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/api/v1/broker/accounts/{accountId}/analytics': { '/api/v1/broker/accounts/{accountId}/analytics': {
parameters: { parameters: {
query?: never query?: never
@ -510,6 +527,13 @@ export interface components {
cachedAt: string | null cachedAt: string | null
fromCache: boolean fromCache: boolean
} }
HealthCheckResultDto: {
/** @example prisma */
name: string
/** @enum {string} */
status: 'ok' | 'error'
error?: string | null
}
HealthResponseDto: { HealthResponseDto: {
/** @example ok */ /** @example ok */
status: string status: string
@ -517,6 +541,7 @@ export interface components {
timestamp: string timestamp: string
/** @example 12345 */ /** @example 12345 */
uptime: number uptime: number
checks: components['schemas']['HealthCheckResultDto'][]
} }
HealthEnvelopeDto: { HealthEnvelopeDto: {
data: components['schemas']['HealthResponseDto'] data: components['schemas']['HealthResponseDto']
@ -540,13 +565,9 @@ export interface components {
user: components['schemas']['AuthUserDto'] user: components['schemas']['AuthUserDto']
accessToken: string accessToken: string
} }
AuthResponseMetaDto: {
cachedAt: string | null
fromCache: boolean
}
AuthTokenResponseDto: { AuthTokenResponseDto: {
data: components['schemas']['AuthTokenDataDto'] data: components['schemas']['AuthTokenDataDto']
meta: components['schemas']['AuthResponseMetaDto'] meta: components['schemas']['ApiResponseMeta']
} }
LoginDto: { LoginDto: {
/** @example user@example.com */ /** @example user@example.com */
@ -559,11 +580,11 @@ export interface components {
} }
AuthLogoutResponseDto: { AuthLogoutResponseDto: {
data: components['schemas']['LogoutDataDto'] data: components['schemas']['LogoutDataDto']
meta: components['schemas']['AuthResponseMetaDto'] meta: components['schemas']['ApiResponseMeta']
} }
AuthProfileResponseDto: { AuthProfileResponseDto: {
data: components['schemas']['AuthUserDto'] data: components['schemas']['AuthUserDto']
meta: components['schemas']['AuthResponseMetaDto'] meta: components['schemas']['ApiResponseMeta']
} }
UpdateProfileDto: { UpdateProfileDto: {
/** @example John Doe */ /** @example John Doe */
@ -632,13 +653,9 @@ export interface components {
pageSize: number pageSize: number
totalPages: number totalPages: number
} }
ScreenerResponseMetaDto: {
cachedAt: string | null
fromCache: boolean
}
ScreenerResponseDto: { ScreenerResponseDto: {
data: components['schemas']['ScreenerResultDto'] data: components['schemas']['ScreenerResultDto']
meta: components['schemas']['ScreenerResponseMetaDto'] meta: components['schemas']['ApiResponseMeta']
} }
StockMarketDataDto: { StockMarketDataDto: {
/** @example 322.35 */ /** @example 322.35 */
@ -849,10 +866,6 @@ export interface components {
data: components['schemas']['CandleItemDto'][] data: components['schemas']['CandleItemDto'][]
meta: components['schemas']['ApiResponseMeta'] meta: components['schemas']['ApiResponseMeta']
} }
PortfolioResponseMetaDto: {
cachedAt: string | null
fromCache: boolean
}
PortfolioListResponseDto: { PortfolioListResponseDto: {
id: number id: number
name: string name: string
@ -876,7 +889,7 @@ export interface components {
} }
PortfolioListEnvelopeDto: { PortfolioListEnvelopeDto: {
data: components['schemas']['PortfolioListResponseDto'][] data: components['schemas']['PortfolioListResponseDto'][]
meta: components['schemas']['PortfolioResponseMetaDto'] meta: components['schemas']['ApiResponseMeta']
} }
CreatePortfolioDto: { CreatePortfolioDto: {
/** @example Мой портфель */ /** @example Мой портфель */
@ -904,7 +917,7 @@ export interface components {
} }
PortfolioEnvelopeDto: { PortfolioEnvelopeDto: {
data: components['schemas']['PortfolioResponseDto'] data: components['schemas']['PortfolioResponseDto']
meta: components['schemas']['PortfolioResponseMetaDto'] meta: components['schemas']['ApiResponseMeta']
} }
PositionWithPriceDto: { PositionWithPriceDto: {
id: number id: number
@ -981,7 +994,7 @@ export interface components {
} }
PortfolioDetailEnvelopeDto: { PortfolioDetailEnvelopeDto: {
data: components['schemas']['PortfolioDetailResponseDto'] data: components['schemas']['PortfolioDetailResponseDto']
meta: components['schemas']['PortfolioResponseMetaDto'] meta: components['schemas']['ApiResponseMeta']
} }
PortfolioTargetsDto: { PortfolioTargetsDto: {
/** @example 70 */ /** @example 70 */
@ -1049,7 +1062,7 @@ export interface components {
} }
PositionEnvelopeDto: { PositionEnvelopeDto: {
data: components['schemas']['PositionResponseDto'] data: components['schemas']['PositionResponseDto']
meta: components['schemas']['PortfolioResponseMetaDto'] meta: components['schemas']['ApiResponseMeta']
} }
UpdatePositionDto: { UpdatePositionDto: {
/** @example 15 */ /** @example 15 */
@ -1082,7 +1095,7 @@ export interface components {
} }
AnalyticsEnvelopeDto: { AnalyticsEnvelopeDto: {
data: components['schemas']['AnalyticsResponseDto'] data: components['schemas']['AnalyticsResponseDto']
meta: components['schemas']['PortfolioResponseMetaDto'] meta: components['schemas']['ApiResponseMeta']
} }
BrokerAccountResponseDto: { BrokerAccountResponseDto: {
id: string id: string
@ -1093,13 +1106,9 @@ export interface components {
openedAt: Record<string, never> | null openedAt: Record<string, never> | null
accessLevel: Record<string, never> | null accessLevel: Record<string, never> | null
} }
BrokerResponseMetaDto: {
cachedAt: Record<string, never> | null
fromCache: boolean
}
BrokerAccountsEnvelopeDto: { BrokerAccountsEnvelopeDto: {
data: components['schemas']['BrokerAccountResponseDto'][] data: components['schemas']['BrokerAccountResponseDto'][]
meta: components['schemas']['BrokerResponseMetaDto'] meta: components['schemas']['ApiResponseMeta']
} }
BrokerPortfolioPositionCountsDto: { BrokerPortfolioPositionCountsDto: {
shares: number shares: number
@ -1140,7 +1149,7 @@ export interface components {
} }
BrokerPortfolioEnvelopeDto: { BrokerPortfolioEnvelopeDto: {
data: components['schemas']['BrokerPortfolioResponseDto'] data: components['schemas']['BrokerPortfolioResponseDto']
meta: components['schemas']['BrokerResponseMetaDto'] meta: components['schemas']['ApiResponseMeta']
} }
BrokerPositionResponseDto: { BrokerPositionResponseDto: {
figi: Record<string, never> | null figi: Record<string, never> | null
@ -1167,7 +1176,7 @@ export interface components {
} }
BrokerPositionsEnvelopeDto: { BrokerPositionsEnvelopeDto: {
data: components['schemas']['BrokerPositionsPageResponseDto'] data: components['schemas']['BrokerPositionsPageResponseDto']
meta: components['schemas']['BrokerResponseMetaDto'] meta: components['schemas']['ApiResponseMeta']
} }
BrokerOperationResponseDto: { BrokerOperationResponseDto: {
cursor: Record<string, never> | null cursor: Record<string, never> | null
@ -1203,7 +1212,7 @@ export interface components {
} }
BrokerOperationsEnvelopeDto: { BrokerOperationsEnvelopeDto: {
data: components['schemas']['BrokerOperationsPageResponseDto'] data: components['schemas']['BrokerOperationsPageResponseDto']
meta: components['schemas']['BrokerResponseMetaDto'] meta: components['schemas']['ApiResponseMeta']
} }
BrokerEventItemDto: { BrokerEventItemDto: {
id: string id: string
@ -1248,7 +1257,21 @@ export interface components {
} }
BrokerEventsEnvelopeDto: { BrokerEventsEnvelopeDto: {
data: components['schemas']['BrokerEventsDataDto'] data: components['schemas']['BrokerEventsDataDto']
meta: components['schemas']['BrokerResponseMetaDto'] meta: components['schemas']['ApiResponseMeta']
}
BrokerPortfolioHistoryPointDto: {
month: string
label: string
value: components['schemas']['BrokerMoneyDto']
}
BrokerPortfolioHistoryDataDto: {
accountId: string
points: components['schemas']['BrokerPortfolioHistoryPointDto'][]
asOf: string
}
BrokerPortfolioHistoryEnvelopeDto: {
data: components['schemas']['BrokerPortfolioHistoryDataDto']
meta: components['schemas']['ApiResponseMeta']
} }
BrokerAnalyticsDto: { BrokerAnalyticsDto: {
totalDeposits: number totalDeposits: number
@ -1257,12 +1280,14 @@ export interface components {
totalDividends: number totalDividends: number
totalCoupons: number totalCoupons: number
totalReceived: number totalReceived: number
totalFees: number
totalTaxesPaid: number
totalReturnPercent: number | null totalReturnPercent: number | null
currency: string currency: string
} }
BrokerAnalyticsEnvelopeDto: { BrokerAnalyticsEnvelopeDto: {
data: components['schemas']['BrokerAnalyticsDto'] data: components['schemas']['BrokerAnalyticsDto']
meta: components['schemas']['BrokerResponseMetaDto'] meta: components['schemas']['ApiResponseMeta']
} }
BrokerOperationSyncResponseDto: { BrokerOperationSyncResponseDto: {
/** @example 42 */ /** @example 42 */
@ -1270,7 +1295,7 @@ export interface components {
} }
BrokerOperationSyncEnvelopeDto: { BrokerOperationSyncEnvelopeDto: {
data: components['schemas']['BrokerOperationSyncResponseDto'] data: components['schemas']['BrokerOperationSyncResponseDto']
meta: components['schemas']['BrokerResponseMetaDto'] meta: components['schemas']['ApiResponseMeta']
} }
} }
responses: never responses: never
@ -1777,7 +1802,7 @@ export interface operations {
content: { content: {
'application/json': { 'application/json': {
data: null data: null
meta: components['schemas']['PortfolioResponseMetaDto'] meta: components['schemas']['ApiResponseMeta']
} }
} }
} }
@ -1852,7 +1877,7 @@ export interface operations {
content: { content: {
'application/json': { 'application/json': {
data: null data: null
meta: components['schemas']['PortfolioResponseMetaDto'] meta: components['schemas']['ApiResponseMeta']
} }
} }
} }
@ -1982,6 +2007,8 @@ export interface operations {
instrumentId?: string instrumentId?: string
operationTypes?: string operationTypes?: string
state?: string state?: string
/** @description Comma-separated category filter: trade,income,tax,fee,transfer,other */
categories?: string
} }
header?: never header?: never
path: { path: {
@ -2029,6 +2056,29 @@ export interface operations {
} }
} }
} }
TBankController_getPortfolioHistory: {
parameters: {
query: {
months: number
}
header?: never
path: {
accountId: string
}
cookie?: never
}
requestBody?: never
responses: {
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['BrokerPortfolioHistoryEnvelopeDto']
}
}
}
}
TBankController_getAnalytics: { TBankController_getAnalytics: {
parameters: { parameters: {
query?: never query?: never

View File

@ -5,6 +5,8 @@ import { Link, useParams } from '@tanstack/react-router'
import { createContext, type ReactNode } from 'react' import { createContext, type ReactNode } from 'react'
import { useBrokerPortfolio } from '@/entities/broker-account' import { useBrokerPortfolio } from '@/entities/broker-account'
import type { BrokerPortfolio } from '@/shared/api' import type { BrokerPortfolio } from '@/shared/api'
import { formatBrokerMoney, formatBrokerPercent } from '@/shared/lib/formatters'
import { moneyTone, moneyToneToColor } from '@/widgets/broker-dashboard/lib/dashboardVisual'
const baseLinkStyle: React.CSSProperties = { const baseLinkStyle: React.CSSProperties = {
padding: '10px 14px', padding: '10px 14px',
@ -43,8 +45,27 @@ export function BrokerAccountLayout({ children }: { children: ReactNode }) {
return ( return (
<BrokerAccountContext.Provider value={{ accountId, portfolio }}> <BrokerAccountContext.Provider value={{ accountId, portfolio }}>
<Box sx={{ display: 'grid', gap: 3 }}> <Box sx={{ display: 'grid', gap: 3 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Heading level={1}>{portfolio.data?.account.name || 'Брокерский счёт'}</Heading> <Heading level={1}>{portfolio.data?.account.name || 'Брокерский счёт'}</Heading>
{portfolio.data ? (
<Box sx={{ textAlign: 'right', flexShrink: 0 }}>
<Box
sx={{
fontWeight: 700,
fontSize: 16,
lineHeight: 1.25,
color: moneyToneToColor(
moneyTone(portfolio.data.yields.expectedPercent as number | null),
),
}}
>
{formatBrokerPercent(portfolio.data.yields.expectedPercent as number | null)}
</Box>
<Box sx={{ fontSize: 12, lineHeight: 1.5, color: 'text.secondary' }}>
За день: {formatBrokerMoney(portfolio.data.yields.daily)}
</Box>
</Box>
) : null}
</Box> </Box>
<Box <Box

View File

@ -1,15 +1,18 @@
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs' import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider' import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'
import { render, screen, waitFor, within } from '@testing-library/react' import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { BrokerEventItem, BrokerOperation, BrokerPortfolio } from '@/shared/api' import type { BrokerOperation, BrokerPortfolio } from '@/shared/api'
import { BrokerDashboard } from './BrokerDashboard' import { BrokerDashboard } from './BrokerDashboard'
const hookMocks = vi.hoisted(() => ({ const hookMocks = vi.hoisted(() => ({
useBrokerEvents: vi.fn(),
useBrokerOperations: vi.fn(), useBrokerOperations: vi.fn(),
useBrokerPortfolioHistory: vi.fn(() => ({
data: undefined,
isLoading: false,
isError: false,
})),
})) }))
vi.mock('@tanstack/react-router', () => ({ vi.mock('@tanstack/react-router', () => ({
@ -27,6 +30,8 @@ vi.mock('@/entities/broker-analytics', () => ({
totalDividends: 75, totalDividends: 75,
totalCoupons: 0, totalCoupons: 0,
totalReceived: 90, totalReceived: 90,
totalFees: -50,
totalTaxesPaid: -13,
totalReturnPercent: 4.44, totalReturnPercent: 4.44,
currency: 'RUB', currency: 'RUB',
}, },
@ -35,14 +40,14 @@ vi.mock('@/entities/broker-analytics', () => ({
}), }),
})) }))
vi.mock('@/entities/broker-event', () => ({
useBrokerEvents: hookMocks.useBrokerEvents,
}))
vi.mock('@/entities/broker-operation', () => ({ vi.mock('@/entities/broker-operation', () => ({
useBrokerOperations: hookMocks.useBrokerOperations, useBrokerOperations: hookMocks.useBrokerOperations,
})) }))
vi.mock('@/entities/broker-account', () => ({
useBrokerPortfolioHistory: hookMocks.useBrokerPortfolioHistory,
}))
const portfolio: BrokerPortfolio = { const portfolio: BrokerPortfolio = {
account: { account: {
id: 'acc-1', id: 'acc-1',
@ -74,28 +79,6 @@ function renderWithProviders(ui: ReactNode) {
return render(<LocalizationProvider dateAdapter={AdapterDayjs}>{ui}</LocalizationProvider>) return render(<LocalizationProvider dateAdapter={AdapterDayjs}>{ui}</LocalizationProvider>)
} }
function buildEvent(overrides: Partial<BrokerEventItem> = {}): BrokerEventItem {
return {
id: 'evt-1',
type: 'coupon',
source: 'actual',
category: 'cashflow',
eventDate: '2026-06-15T00:00:00.000Z',
paymentDate: null,
ticker: 'RU000A10AEF9',
name: 'РЖД 001Р-37R',
instrumentUid: null,
instrumentType: 'bond',
quantitySnapshot: null,
payoutPerUnit: null,
estimatedAmount: null,
actualAmount: 43.56,
currency: 'RUB',
estimateMode: null,
...overrides,
}
}
function buildOperation(overrides: Partial<BrokerOperation> = {}): BrokerOperation { function buildOperation(overrides: Partial<BrokerOperation> = {}): BrokerOperation {
return { return {
cursor: null, cursor: null,
@ -124,14 +107,6 @@ function buildOperation(overrides: Partial<BrokerOperation> = {}): BrokerOperati
} }
} }
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[]) { function mockOperationsLoaded(items: BrokerOperation[]) {
hookMocks.useBrokerOperations.mockReturnValue({ hookMocks.useBrokerOperations.mockReturnValue({
data: { data: {
@ -148,11 +123,6 @@ function mockOperationsLoaded(items: BrokerOperation[]) {
describe('BrokerDashboard', () => { describe('BrokerDashboard', () => {
beforeEach(() => { beforeEach(() => {
hookMocks.useBrokerEvents.mockReturnValue({
data: { items: [], summary: {}, asOf: '2026-06-26T00:00:00.000Z' },
isLoading: false,
isError: false,
})
hookMocks.useBrokerOperations.mockReturnValue({ hookMocks.useBrokerOperations.mockReturnValue({
data: { data: {
accountId: 'acc-1', accountId: 'acc-1',
@ -166,111 +136,17 @@ describe('BrokerDashboard', () => {
}) })
}) })
it('renders the dashboard sections', () => { it('renders the dashboard sections in spec order', () => {
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />) renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
expect(screen.getByText('Основной счёт')).toBeInTheDocument() const cards = screen.getAllByRole('region')
expect(screen.getByText('События')).toBeInTheDocument() const labels = cards.map((c) => c.getAttribute('aria-label'))
expect(screen.getByText('Доходы')).toBeInTheDocument() expect(labels).toEqual([
expect(screen.getByText('Аналитика доходности')).toBeInTheDocument() 'Стоимость портфеля за 6 месяцев',
expect(screen.getByText('Аллокация')).toBeInTheDocument() 'Аналитика доходности',
}) 'Структура',
'Последние события',
it('applies event type chips immediately to useBrokerEvents', async () => { ])
const user = userEvent.setup()
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
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(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
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(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
expect(hookMocks.useBrokerEvents).toHaveBeenCalledWith(
'acc-1',
expect.objectContaining({
from: '2026-06-20',
to: '2026-07-04',
}),
{ enabled: true },
)
} finally {
vi.useRealTimers()
}
})
it('shows date filter toggle button with refresh action and accessible name', async () => {
const user = userEvent.setup()
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
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(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const toggleButtons = screen.getAllByRole('button', { name: /^Период/ })
expect(toggleButtons).toHaveLength(2)
for (const button of toggleButtons) {
const text = button.textContent ?? ''
expect(text).not.toMatch(/[▼▲vV]/)
expect(button.querySelector('svg')).not.toBeNull()
}
})
it('renders hero "Всего доходов" with the ₽ symbol and no "RUB" code', () => {
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
expect(screen.getByText('Всего доходов')).toBeInTheDocument()
const hero = screen.getByLabelText('Ключевые показатели брокерского счёта')
const heroText = hero.textContent ?? ''
expect(heroText).toContain('₽')
expect(heroText).not.toContain('RUB')
}) })
it('renders analytics card with the ₽ symbol and no "RUB" code', () => { it('renders analytics card with the ₽ symbol and no "RUB" code', () => {
@ -294,10 +170,6 @@ describe('BrokerDashboard', () => {
expect(withdrawn.getAttribute('data-tone')).toBe('negative') expect(withdrawn.getAttribute('data-tone')).toBe('negative')
expect(withdrawn.textContent).toMatch(/^[-]250,00/) 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') const dividends = screen.getByTestId('dashboard-analytics-totalDividends')
expect(dividends.getAttribute('data-tone')).toBe('positive') expect(dividends.getAttribute('data-tone')).toBe('positive')
expect(dividends.textContent).toContain('75,00') expect(dividends.textContent).toContain('75,00')
@ -306,25 +178,16 @@ describe('BrokerDashboard', () => {
expect(coupons.getAttribute('data-tone')).toBe('neutral') expect(coupons.getAttribute('data-tone')).toBe('neutral')
expect(coupons.textContent).toContain('0,00') expect(coupons.textContent).toContain('0,00')
const received = screen.getByTestId('dashboard-analytics-totalReceived') const fees = screen.getByTestId('dashboard-analytics-totalFees')
expect(received.getAttribute('data-tone')).toBe('positive') expect(fees.getAttribute('data-tone')).toBe('negative')
expect(received.textContent).toContain('90,00') expect(fees.textContent).toMatch(/^[-]50,00/)
const taxes = screen.getByTestId('dashboard-analytics-totalTaxesPaid')
expect(taxes.getAttribute('data-tone')).toBe('negative')
expect(taxes.textContent).toMatch(/^[-]13,00/)
}) })
it('shows skeleton table while events are loading', () => { it('shows skeleton table while events are loading', () => {
hookMocks.useBrokerEvents.mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
})
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const skeletons = screen.getAllByTestId('dashboard-table-skeleton')
expect(skeletons.length).toBeGreaterThanOrEqual(1)
})
it('shows skeleton table while income operations are loading', () => {
hookMocks.useBrokerOperations.mockReturnValue({ hookMocks.useBrokerOperations.mockReturnValue({
data: undefined, data: undefined,
isLoading: true, isLoading: true,
@ -337,49 +200,50 @@ describe('BrokerDashboard', () => {
expect(skeletons.length).toBeGreaterThanOrEqual(1) expect(skeletons.length).toBeGreaterThanOrEqual(1)
}) })
it('does not show skeleton when data is loaded', () => { it('renders dashboard sections when data is loaded', () => {
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />) renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
expect(screen.getByText('Последние события')).toBeInTheDocument()
expect(screen.queryByTestId('dashboard-table-skeleton')).not.toBeInTheDocument() expect(screen.queryByTestId('dashboard-table-skeleton')).not.toBeInTheDocument()
}) })
it('renders events table thead with semantic column headers', () => { it('renders events table thead with semantic column headers', () => {
mockEventsLoaded([buildEvent()]) mockOperationsLoaded([
buildOperation({
id: 'op-1',
category: 'income',
type: 'OPERATION_TYPE_DIVIDEND',
ticker: 'IRAO',
name: 'Интер РАО',
payment: { currency: 'RUB', units: '649', nano: 250000000, value: 649.25 },
}),
])
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />) renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const eventsTable = screen.getByLabelText('Таблица событий брокерского счёта') const eventsTable = screen.getByLabelText('Таблица последних событий брокерского счёта')
expect(eventsTable.querySelector('thead')).not.toBeNull() expect(eventsTable.querySelector('thead')).not.toBeNull()
const headers = within(eventsTable).getAllByRole('columnheader') const headers = within(eventsTable).getAllByRole('columnheader')
expect(headers.map((h) => h.textContent)).toEqual([
'Дата',
'Инструмент',
'Тип',
'Сумма',
'Статус',
])
})
it('renders income table thead without status column', () => {
mockOperationsLoaded([buildOperation()])
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const incomeTable = screen.getByLabelText('Таблица доходов брокерского счёта')
expect(incomeTable.querySelector('thead')).not.toBeNull()
const headers = within(incomeTable).getAllByRole('columnheader')
expect(headers.map((h) => h.textContent)).toEqual(['Дата', 'Инструмент', 'Тип', 'Сумма']) expect(headers.map((h) => h.textContent)).toEqual(['Дата', 'Инструмент', 'Тип', 'Сумма'])
}) })
it('renders event type badge, instrument subtitle and status pill for loaded events', () => { it('renders event type badge, instrument subtitle for loaded operations', () => {
mockEventsLoaded([ mockOperationsLoaded([
buildEvent({ id: 'evt-actual', source: 'actual', type: 'coupon', actualAmount: 43.56 }), buildOperation({
buildEvent({ id: 'op-div',
id: 'evt-forecast', category: 'income',
source: 'forecast', type: 'OPERATION_TYPE_DIVIDEND',
type: 'dividend', ticker: 'IRAO',
actualAmount: null, name: 'Интер РАО',
estimatedAmount: 197.56, payment: { currency: 'RUB', units: '649', nano: 250000000, value: 649.25 },
}),
buildOperation({
id: 'op-coupon',
category: 'income',
type: 'OPERATION_TYPE_COUPON',
ticker: 'SU26249RMFS1',
name: 'ОФЗ 26241',
payment: { currency: 'RUB', units: '109', nano: 700000000, value: 109.7 },
}), }),
]) ])
@ -389,71 +253,28 @@ describe('BrokerDashboard', () => {
expect(rows).toHaveLength(2) expect(rows).toHaveLength(2)
const subtitles = screen.getAllByTestId('dashboard-events-instrument-subtitle') const subtitles = screen.getAllByTestId('dashboard-events-instrument-subtitle')
expect(subtitles).toHaveLength(2) expect(subtitles.map((el) => el.textContent)).toEqual(['IRAO', 'SU26249RMFS1'])
for (const subtitle of subtitles) {
expect(subtitle.textContent).toBe('РЖД 001Р-37R')
}
const amounts = screen.getAllByTestId('dashboard-events-amount') const amounts = screen.getAllByTestId('dashboard-events-amount')
expect(amounts).toHaveLength(2)
expect(amounts[0].getAttribute('data-tone')).toBe('positive') expect(amounts[0].getAttribute('data-tone')).toBe('positive')
expect(amounts[0].textContent).toContain('+43,56') expect(amounts[0].textContent).toContain('+649,25')
expect(amounts[1].getAttribute('data-tone')).toBe('planned') expect(amounts[1].getAttribute('data-tone')).toBe('positive')
expect(amounts[1].textContent).toContain('~197,56') expect(amounts[1].textContent).toContain('+109,70')
expect(screen.getByText('Купон')).toBeInTheDocument() const eventsTable = screen.getByLabelText('Таблица последних событий брокерского счёта')
expect(screen.getByText('Дивиденд')).toBeInTheDocument() expect(within(eventsTable).getByText('Дивиденд')).toBeInTheDocument()
expect(screen.getByText('Поступило')).toBeInTheDocument() expect(within(eventsTable).getByText('Купон')).toBeInTheDocument()
expect(screen.getByText('Ожидается')).toBeInTheDocument()
}) })
it('renders HTML-parity card headings, toolbar label and footer summary for events', () => { it('uses negative tone when operation payment is negative', () => {
mockEventsLoaded([
buildEvent({ id: 'evt-1' }),
buildEvent({ id: 'evt-2', ticker: 'HEAD', name: 'HeadHunter Group', type: 'dividend' }),
])
hookMocks.useBrokerEvents.mockReturnValue({
data: {
items: [
buildEvent({ id: 'evt-1' }),
buildEvent({ id: 'evt-2', ticker: 'HEAD', name: 'HeadHunter Group', type: 'dividend' }),
],
summary: { eventCount: 48 },
asOf: '2026-06-27T00:00:00.000Z',
},
isLoading: false,
isError: false,
})
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const eventsSection = screen.getByLabelText('События')
expect(screen.getByText('2 из 48')).toBeInTheDocument()
expect(within(eventsSection).getAllByText('Тип').length).toBeGreaterThan(0)
expect(
within(eventsSection).getByText('Показано 2 событий за выбранный период'),
).toBeInTheDocument()
})
it('renders HTML-parity count badge and footer summary for income', () => {
mockOperationsLoaded([ mockOperationsLoaded([
buildOperation({ id: 'op-1' }), buildOperation({
buildOperation({ id: 'op-2', ticker: 'IRAO', name: 'Интер РАО' }), id: 'op-neg',
]) category: 'fee',
type: 'OPERATION_TYPE_BROKER_FEE',
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />) ticker: null,
name: 'Комиссия брокера',
expect(screen.getByText('2 операции')).toBeInTheDocument() payment: { currency: 'RUB', units: '0', nano: 0, value: -87.0 },
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,
}), }),
]) ])
@ -461,77 +282,35 @@ describe('BrokerDashboard', () => {
const [amount] = screen.getAllByTestId('dashboard-events-amount') const [amount] = screen.getAllByTestId('dashboard-events-amount')
expect(amount.getAttribute('data-tone')).toBe('negative') expect(amount.getAttribute('data-tone')).toBe('negative')
expect(amount.textContent).toBe('87,00 ') expect(amount.textContent).toContain('87,00')
}) })
it('renders events and income amounts with the ₽ symbol and no "RUB" code', () => { it('renders events 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([ mockOperationsLoaded([
buildOperation({ buildOperation({
id: 'op-positive', id: 'op-coupon',
category: 'income',
type: 'OPERATION_TYPE_COUPON', type: 'OPERATION_TYPE_COUPON',
ticker: 'SU26249RMFS1', ticker: 'SU26249RMFS1',
name: 'ОФЗ 26241', name: 'ОФЗ 26241',
payment: { currency: 'RUB', units: '109', nano: 700000000, value: 109.7 }, payment: { currency: 'RUB', units: '109', nano: 700000000, value: 109.7 },
}), }),
buildOperation({
id: 'op-negative',
type: 'OPERATION_TYPE_DIVIDEND',
ticker: 'IRAO',
name: 'Интер РАО',
payment: { currency: 'RUB', units: '35', nano: 0, value: -35 },
}),
]) ])
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />) renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const eventsTable = screen.getByLabelText('Таблица событий брокерского счёта') const eventsTable = screen.getByLabelText('Таблица последних событий брокерского счёта')
const incomeTable = screen.getByLabelText('Таблица доходов брокерского счёта') expect(eventsTable.textContent ?? '').toContain('₽')
for (const table of [eventsTable, incomeTable]) { expect(eventsTable.textContent ?? '').not.toContain('RUB')
expect(table.textContent ?? '').toContain('₽')
expect(table.textContent ?? '').not.toContain('RUB')
}
}) })
it('renders income rows with main + subtitle, type badge and signed amount tone', () => { it('sends correct params to useBrokerOperations for latest events', () => {
mockOperationsLoaded([
buildOperation({
id: 'op-div',
type: 'OPERATION_TYPE_DIVIDEND',
ticker: 'IRAO',
name: 'Интер РАО',
payment: { currency: 'RUB', units: '649', nano: 250000000, value: 649.25 },
}),
buildOperation({
id: 'op-div-ext',
type: 'OPERATION_TYPE_DIV_EXT',
ticker: 'AAPL',
name: 'Apple Inc.',
payment: { currency: 'RUB', units: '100', nano: 0, value: -35 },
}),
])
renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />) renderWithProviders(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
const rows = screen.getAllByTestId('dashboard-income-row') expect(hookMocks.useBrokerOperations).toHaveBeenCalledWith(
expect(rows).toHaveLength(2) 'acc-1',
{ categories: 'income,tax,fee', limit: 7 },
const mainLabels = screen.getAllByTestId('dashboard-income-instrument-main') { enabled: true },
expect(mainLabels.map((el) => el.textContent)).toEqual(['IRAO', 'AAPL']) )
const subtitles = screen.getAllByTestId('dashboard-income-instrument-subtitle')
expect(subtitles.map((el) => el.textContent)).toEqual(['Интер РАО', 'Apple Inc.'])
expect(screen.getByText('Дивиденд')).toBeInTheDocument()
expect(screen.getByText('Дивиденд (внешний)')).toBeInTheDocument()
const amounts = screen.getAllByTestId('dashboard-income-amount')
expect(amounts[0].getAttribute('data-tone')).toBe('positive')
expect(amounts[0].textContent).toContain('+649,25')
expect(amounts[1].getAttribute('data-tone')).toBe('negative')
expect(amounts[1].textContent).toContain('35,00')
}) })
}) })

View File

@ -1,35 +1,12 @@
import { Box } from '@mui/material' import { Box } from '@mui/material'
import dayjs from 'dayjs' import { useBrokerPortfolioHistory } from '@/entities/broker-account'
import { useCallback, useState } from 'react'
import { useBrokerAnalytics } from '@/entities/broker-analytics' import { useBrokerAnalytics } from '@/entities/broker-analytics'
import { useBrokerEvents } from '@/entities/broker-event'
import { useBrokerOperations } from '@/entities/broker-operation' import { useBrokerOperations } from '@/entities/broker-operation'
import type { BrokerPortfolio } from '@/shared/api' 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 { BrokerDashboardAllocationCard } from './BrokerDashboardAllocationCard'
import { BrokerDashboardAnalyticsCard } from './BrokerDashboardAnalyticsCard' import { BrokerDashboardAnalyticsCard } from './BrokerDashboardAnalyticsCard'
import { BrokerDashboardEventsCard } from './BrokerDashboardEventsCard' import { BrokerDashboardEventsCard } from './BrokerDashboardEventsCard'
import { BrokerDashboardHero } from './BrokerDashboardHero' import { BrokerPortfolioHistoryCard } from './BrokerPortfolioHistoryCard'
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({ export function BrokerDashboard({
accountId, accountId,
@ -38,193 +15,22 @@ export function BrokerDashboard({
accountId: string accountId: string
portfolio: BrokerPortfolio 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 analytics = useBrokerAnalytics(accountId)
const hasEventTypes = appliedEventFilters.types.length > 0 const portfolioHistory = useBrokerPortfolioHistory(accountId)
const hasIncomeTypes = appliedIncomeFilters.types.length > 0
const hasDraftEventTypes = draftEventFilters.types.length > 0
const hasDraftIncomeTypes = draftIncomeFilters.types.length > 0
const eventPageSize = 10
const events = useBrokerEvents( const eventsOps = useBrokerOperations(
accountId, accountId,
{ { categories: 'income,tax,fee', limit: 7 },
from: appliedEventFilters.from, { enabled: true },
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 ( return (
<Box sx={{ display: 'grid', gap: 3 }}> <Box sx={{ display: 'grid', gap: 3 }}>
<BrokerDashboardHero portfolio={portfolio} analytics={analytics.data} /> <BrokerPortfolioHistoryCard
<BrokerDashboardEventsCard data={portfolioHistory.data}
accountId={accountId} portfolioValue={portfolio.totals.portfolio ?? undefined}
data={events.data ? { ...events.data, items: eventPageItems } : undefined} isLoading={portfolioHistory.isLoading}
isLoading={events.isLoading} isError={portfolioHistory.isError}
isError={events.isError}
selectedTypes={draftEventFilters.types}
onToggleType={toggleEventType}
appliedDateLabel={formatDateLabel(appliedEventFilters.from, appliedEventFilters.to)}
draftPreset={draftEventFilters.preset}
draftFrom={draftEventFilters.from}
draftTo={draftEventFilters.to}
onDraftPresetChange={handleDraftEventPresetChange}
onDraftFromChange={handleDraftEventFromChange}
onDraftToChange={handleDraftEventToChange}
onApplyFilters={applyEventFilters}
onResetFilters={resetEventFilters}
hasDraftTypes={hasDraftEventTypes}
totalCount={events.data?.summary?.eventCount}
page={eventPage}
canGoBack={eventPage > 1}
canGoForward={eventPage * eventPageSize < eventItems.length}
onPreviousPage={() => setEventPage((page) => Math.max(1, page - 1))}
onNextPage={() => setEventPage((page) => page + 1)}
/>
<BrokerDashboardIncomeCard
accountId={accountId}
page={operations.data}
isLoading={operations.isLoading}
isError={operations.isError}
selectedTypes={draftIncomeFilters.types}
onToggleType={toggleIncomeType}
appliedDateLabel={formatDateLabel(appliedIncomeFilters.from, appliedIncomeFilters.to)}
draftPreset={draftIncomeFilters.preset}
draftFrom={draftIncomeFilters.from}
draftTo={draftIncomeFilters.to}
onDraftPresetChange={handleDraftIncomePresetChange}
onDraftFromChange={handleDraftIncomeFromChange}
onDraftToChange={handleDraftIncomeToChange}
onApplyFilters={applyIncomeFilters}
onResetFilters={resetIncomeFilters}
hasDraftTypes={hasDraftIncomeTypes}
visibleCount={operations.data?.items?.length ?? 0}
pageNumber={incomePagination.pageNumber}
canGoBack={incomePagination.pageNumber > 1}
canGoForward={operations.data?.hasNext ?? false}
onPreviousPage={incomePagination.handlePrevious}
onNextPage={() => incomePagination.handleNext(nextCursor)}
/> />
<BrokerDashboardAnalyticsCard <BrokerDashboardAnalyticsCard
data={analytics.data} data={analytics.data}
@ -232,6 +38,12 @@ export function BrokerDashboard({
isError={analytics.isError} isError={analytics.isError}
/> />
<BrokerDashboardAllocationCard portfolio={portfolio} /> <BrokerDashboardAllocationCard portfolio={portfolio} />
<BrokerDashboardEventsCard
accountId={accountId}
data={eventsOps.data?.items ?? []}
isLoading={eventsOps.isLoading}
isError={eventsOps.isError}
/>
</Box> </Box>
) )
} }

View File

@ -8,11 +8,11 @@ import { BrokerDashboardCard } from './BrokerDashboardCard'
const ALLOCATION_COLORS: Record<string, string> = { const ALLOCATION_COLORS: Record<string, string> = {
shares: '#4969f5', shares: '#4969f5',
bonds: '#e5a33c', bonds: '#e5a33c',
etf: '#62b889',
cash: '#7b63cf', cash: '#7b63cf',
other: '#aeb6c5',
} }
const VISIBLE_SECTORS = new Set(['shares', 'bonds', 'cash'])
export function BrokerDashboardAllocationCard({ portfolio }: { portfolio: BrokerPortfolio }) { export function BrokerDashboardAllocationCard({ portfolio }: { portfolio: BrokerPortfolio }) {
const { total, sectors, negative } = buildBrokerAllocation(portfolio) const { total, sectors, negative } = buildBrokerAllocation(portfolio)
const currency = const currency =
@ -22,24 +22,19 @@ export function BrokerDashboardAllocationCard({ portfolio }: { portfolio: Broker
'RUB') 'RUB')
: 'RUB' : 'RUB'
const visibleSectors = sectors.filter((s) => VISIBLE_SECTORS.has(s.key))
const visibleNegative = negative.filter((n) => VISIBLE_SECTORS.has(n.key))
return ( return (
<BrokerDashboardCard <BrokerDashboardCard title="Структура">
title="Аллокация" <Box sx={{ fontWeight: 700, mb: 1.5, lineHeight: 1.25 }}>
action={<Text variant="numeric">{formatBrokerMoney(portfolio.totals.portfolio)}</Text>} {formatBrokerMoney(portfolio.totals.portfolio)}
> </Box>
{sectors.length === 0 && negative.length === 0 ? ( {visibleSectors.length === 0 && visibleNegative.length === 0 ? (
<Text tone="muted">Нет данных для распределения</Text> <Text tone="muted">Нет данных для распределения</Text>
) : ( ) : (
<Box sx={{ display: 'grid', gap: 1.5 }}> <Box sx={{ display: 'grid', gap: 1.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}> {visibleSectors.map((sector) => (
<Text variant="caption" tone="secondary">
Структура портфеля
</Text>
<Text variant="body" sx={{ fontWeight: 700 }}>
{formatBrokerMoney(portfolio.totals.portfolio)}
</Text>
</Box>
{sectors.map((sector) => (
<Box key={sector.key}> <Box key={sector.key}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Text variant="body">{sector.label}</Text> <Text variant="body">{sector.label}</Text>
@ -67,9 +62,9 @@ export function BrokerDashboardAllocationCard({ portfolio }: { portfolio: Broker
</Box> </Box>
</Box> </Box>
))} ))}
{negative.length > 0 && ( {visibleNegative.length > 0 && (
<Box sx={{ mt: 1 }}> <Box sx={{ mt: 1 }}>
{negative.map((item) => ( {visibleNegative.map((item) => (
<Box key={item.key} sx={{ display: 'flex', gap: 1 }}> <Box key={item.key} sx={{ display: 'flex', gap: 1 }}>
<Text variant="body">{item.label}:</Text> <Text variant="body">{item.label}:</Text>
<Text variant="body" tone="negative"> <Text variant="body" tone="negative">

View File

@ -12,10 +12,10 @@ import { BrokerDashboardCard } from './BrokerDashboardCard'
type AnalyticsField = type AnalyticsField =
| 'totalDeposits' | 'totalDeposits'
| 'totalWithdrawn' | 'totalWithdrawn'
| 'netInvested'
| 'totalDividends' | 'totalDividends'
| 'totalCoupons' | 'totalCoupons'
| 'totalReceived' | 'totalFees'
| 'totalTaxesPaid'
const ANALYTICS_METRICS: readonly { const ANALYTICS_METRICS: readonly {
field: AnalyticsField field: AnalyticsField
@ -24,13 +24,18 @@ const ANALYTICS_METRICS: readonly {
}[] = [ }[] = [
{ field: 'totalDeposits', label: 'Пополнения', testId: 'dashboard-analytics-totalDeposits' }, { field: 'totalDeposits', label: 'Пополнения', testId: 'dashboard-analytics-totalDeposits' },
{ field: 'totalWithdrawn', label: 'Выводы', testId: 'dashboard-analytics-totalWithdrawn' }, { field: 'totalWithdrawn', label: 'Выводы', testId: 'dashboard-analytics-totalWithdrawn' },
{ field: 'netInvested', label: 'Нетто', testId: 'dashboard-analytics-netInvested' },
{ field: 'totalDividends', label: 'Дивиденды', testId: 'dashboard-analytics-totalDividends' }, { field: 'totalDividends', label: 'Дивиденды', testId: 'dashboard-analytics-totalDividends' },
{ field: 'totalCoupons', label: 'Купоны', testId: 'dashboard-analytics-totalCoupons' }, { field: 'totalCoupons', label: 'Купоны', testId: 'dashboard-analytics-totalCoupons' },
{ field: 'totalReceived', label: 'Всего получено', testId: 'dashboard-analytics-totalReceived' }, { field: 'totalFees', label: 'Комиссия', testId: 'dashboard-analytics-totalFees' },
{
field: 'totalTaxesPaid',
label: 'Уплаченные налоги',
testId: 'dashboard-analytics-totalTaxesPaid',
},
] ]
function analyticsTone(field: AnalyticsField, value: number): MoneyTone { function analyticsTone(field: AnalyticsField, value: number): MoneyTone {
if (field === 'totalFees' || field === 'totalTaxesPaid') return 'negative'
if (field === 'totalWithdrawn') { if (field === 'totalWithdrawn') {
return value > 0 ? 'negative' : moneyTone(value) return value > 0 ? 'negative' : moneyTone(value)
} }
@ -38,8 +43,12 @@ function analyticsTone(field: AnalyticsField, value: number): MoneyTone {
} }
function analyticsDisplay(field: AnalyticsField, value: number, currency: string): string { function analyticsDisplay(field: AnalyticsField, value: number, currency: string): string {
if (field === 'totalFees' || field === 'totalTaxesPaid') {
const formatted = formatDashboardCurrency({ currency, value: Math.abs(value) })
return `\u2212${formatted}`
}
const formatted = formatDashboardCurrency({ currency, value }) const formatted = formatDashboardCurrency({ currency, value })
if (field === 'totalWithdrawn' && value > 0) return `${formatted}` if (field === 'totalWithdrawn' && value > 0) return `\u2212${formatted}`
return formatted return formatted
} }

View File

@ -1,35 +1,11 @@
import { Button, Chip, Text } from '@moex-vibe/design-system' import { Chip, Text } from '@moex-vibe/design-system'
import { Box } from '@mui/material' import { Box } from '@mui/material'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import type { BrokerEventItem, BrokerEventsData } from '@/shared/api' import type { BrokerOperation } from '@/shared/api'
import { formatBrokerDate } from '@/shared/lib/formatters' import { formatBrokerDate } from '@/shared/lib/formatters'
import type { DashboardDatePreset, DashboardEventType } from '../lib/dashboardFilters' import { formatDashboardCurrency, moneyToneToColor } from '../lib/dashboardVisual'
import { eventStatusLabel, eventTypeLabel } from '../lib/dashboardFormatters'
import {
eventStatusTone,
eventTypeTone,
formatDashboardCurrency,
instrumentDisplay,
type MoneyTone,
moneyTone,
moneyToneToColor,
type TypeTone,
} from '../lib/dashboardVisual'
import { BrokerDashboardCard } from './BrokerDashboardCard' import { BrokerDashboardCard } from './BrokerDashboardCard'
import { BrokerDashboardDateFilter } from './BrokerDashboardDateFilter'
import { BrokerDashboardTableSkeleton } from './BrokerDashboardTableSkeleton' 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 = { const TH_SX = {
textAlign: 'left' as const, textAlign: 'left' as const,
@ -64,48 +40,34 @@ const TD_SX_INSTRUMENT = {
type BrokerDashboardEventsCardProps = { type BrokerDashboardEventsCardProps = {
accountId: string accountId: string
data: BrokerEventsData | undefined data: BrokerOperation[] | undefined
isLoading: boolean isLoading: boolean
isError: 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 { function getOperationBadge(operation: BrokerOperation): { label: string } {
return event.source === 'actual' ? event.actualAmount : event.estimatedAmount if (operation.category === 'tax') return { label: 'Налог' }
} if (operation.category === 'fee') return { label: 'Комиссия' }
if (operation.category === 'income') {
function eventMoneyTone(event: BrokerEventItem): MoneyTone { if (
return moneyTone(eventAmountValue(event), event.source) operation.type === 'OPERATION_TYPE_DIVIDEND' ||
} operation.type === 'OPERATION_TYPE_DIV_EXT'
) {
function eventAmountDisplay(event: BrokerEventItem): string { return { label: 'Дивиденд' }
const amount = eventAmountValue(event) }
if (amount === null || amount === undefined) return '—' if (operation.type === 'OPERATION_TYPE_COUPON') {
const abs = formatDashboardCurrency({ return { label: 'Купон' }
currency: event.currency ?? 'RUB', }
value: Math.abs(amount), if (
}) operation.type === 'OPERATION_TYPE_BOND_REPAYMENT' ||
if (event.source === 'forecast') return `~${abs}` operation.type === 'OPERATION_TYPE_BOND_REPAYMENT_FULL' ||
if (amount > 0) return `+${abs}` operation.type === 'OPERATION_TYPE_MATURITY'
if (amount < 0) return `${abs}` ) {
return abs return { label: 'Погашение' }
}
return { label: 'Доход' }
}
return { label: 'Прочее' }
} }
export function BrokerDashboardEventsCard({ export function BrokerDashboardEventsCard({
@ -113,188 +75,100 @@ export function BrokerDashboardEventsCard({
data, data,
isLoading, isLoading,
isError, isError,
selectedTypes,
onToggleType,
appliedDateLabel,
draftPreset,
draftFrom,
draftTo,
onDraftPresetChange,
onDraftFromChange,
onDraftToChange,
onApplyFilters,
onResetFilters,
hasDraftTypes,
totalCount,
page,
onPreviousPage,
onNextPage,
canGoBack,
canGoForward,
}: BrokerDashboardEventsCardProps) { }: BrokerDashboardEventsCardProps) {
const events = data?.items ?? [] const operations = data ?? []
return ( return (
<BrokerDashboardCard <BrokerDashboardCard
title="События" title="Последние события"
badge={events.length > 0 ? `${events.length} из ${totalCount ?? events.length}` : undefined}
action={<Link to={`/broker/${encodeURIComponent(accountId)}/events`}>Все события</Link>} action={<Link to={`/broker/${encodeURIComponent(accountId)}/events`}>Все события</Link>}
filters={
<BrokerDashboardTableToolbar
chips={EVENT_FILTERS.map((filter) => (
<Chip
key={filter.type}
label={filter.label}
tone={filter.tone}
selected={selectedTypes.includes(filter.type)}
onClick={() => onToggleType(filter.type)}
/>
))}
>
<BrokerDashboardDateFilter
appliedLabel={appliedDateLabel}
preset={draftPreset}
draftFrom={draftFrom}
draftTo={draftTo}
hasDraftTypes={hasDraftTypes}
onPresetChange={onDraftPresetChange}
onFromChange={onDraftFromChange}
onToChange={onDraftToChange}
onReset={onResetFilters}
onApply={onApplyFilters}
/>
</BrokerDashboardTableToolbar>
}
> >
{selectedTypes.length === 0 ? ( {isError ? (
<Text tone="negative">Выберите хотя бы один тип событий</Text>
) : isError ? (
<Text tone="negative">Не удалось загрузить события</Text> <Text tone="negative">Не удалось загрузить события</Text>
) : isLoading ? ( ) : isLoading ? (
<BrokerDashboardTableSkeleton rows={5} columns={5} /> <BrokerDashboardTableSkeleton rows={5} columns={4} />
) : events.length === 0 ? ( ) : operations.length === 0 ? (
<Text tone="muted">В ближайшем периоде событий нет</Text> <Text tone="muted">Событий нет</Text>
) : ( ) : (
<Box sx={{ display: 'grid', gap: 1 }}> <Box sx={{ overflowX: 'auto' }}>
<Box sx={{ overflowX: 'auto' }}> <Box
<Box component="table"
component="table" aria-label="Таблица последних событий брокерского счёта"
aria-label="Таблица событий брокерского счёта" sx={{ width: '100%', borderCollapse: 'collapse', fontSize: 14, minWidth: 520 }}
sx={{ width: '100%', borderCollapse: 'collapse', fontSize: 14, minWidth: 640 }} >
> <Box component="thead">
<Box component="thead"> <Box component="tr">
<Box component="tr"> <Box component="th" sx={TH_SX}>
<Box component="th" sx={TH_SX}> Дата
Дата </Box>
</Box> <Box component="th" sx={TH_SX}>
<Box component="th" sx={TH_SX}> Инструмент
Инструмент </Box>
</Box> <Box component="th" sx={TH_SX}>
<Box component="th" sx={TH_SX}> Тип
Тип </Box>
</Box> <Box component="th" sx={{ ...TH_SX, textAlign: 'right' }}>
<Box component="th" sx={{ ...TH_SX, textAlign: 'right' }}> Сумма
Сумма
</Box>
<Box component="th" sx={{ ...TH_SX, textAlign: 'right' }}>
Статус
</Box>
</Box> </Box>
</Box> </Box>
<Box component="tbody"> </Box>
{events.map((event) => { <Box component="tbody">
const display = instrumentDisplay({ {operations.slice(0, 7).map((op) => {
ticker: event.ticker, const boldText = op.name ?? op.description ?? op.ticker ?? '—'
name: event.name, const grayText = op.name ? (op.ticker ?? null) : null
}) const { label } = getOperationBadge(op)
const formattedAmount = eventAmountDisplay(event) const amountValue = op.payment?.value ?? 0
return ( const amountTone = amountValue >= 0 ? 'positive' : 'negative'
<Box component="tr" key={event.id} data-testid="dashboard-events-row"> const formattedAmount = `${amountValue >= 0 ? '+' : '\u2212'}${formatDashboardCurrency(
<Box component="td" sx={TD_SX_LEFT}> op.payment
{formatBrokerDate(event.eventDate) ?? '—'} ? { currency: op.payment.currency, value: Math.abs(amountValue) }
</Box> : { currency: 'RUB', value: 0 },
<Box component="td" sx={TD_SX_INSTRUMENT}> )}`
<Box sx={{ display: 'grid', gap: 0.25 }}> return (
<Box <Box
sx={{ fontWeight: 700 }} component="tr"
data-testid="dashboard-events-instrument-main" key={String(op.id ?? op.cursor ?? `${op.type}-${op.date}`)}
> data-testid="dashboard-events-row"
{display.main} >
</Box> <Box component="td" sx={TD_SX_LEFT}>
{display.subtitle ? ( {formatBrokerDate(typeof op.date === 'string' ? op.date : null) ?? '—'}
<Box </Box>
sx={{ fontSize: 12, color: 'text.secondary' }} <Box component="td" sx={TD_SX_INSTRUMENT}>
data-testid="dashboard-events-instrument-subtitle" <Box sx={{ display: 'grid', gap: 0.25 }}>
> <Box
{display.subtitle} sx={{ fontWeight: 700 }}
</Box> data-testid="dashboard-events-instrument-main"
) : null} >
{boldText}
</Box> </Box>
</Box> {grayText ? (
<Box component="td" sx={TD_SX_LEFT}> <Box
<Chip sx={{ fontSize: 12, color: 'text.secondary' }}
label={eventTypeLabel(event.type)} data-testid="dashboard-events-instrument-subtitle"
tone={eventTypeTone(event.type)} >
selected={false} {grayText}
/> </Box>
</Box> ) : null}
<Box
component="td"
sx={{
...TD_SX_RIGHT,
fontWeight: 700,
color: moneyToneToColor(eventMoneyTone(event)),
}}
data-testid="dashboard-events-amount"
data-tone={eventMoneyTone(event)}
>
{formattedAmount}
</Box>
<Box component="td" sx={TD_SX_RIGHT}>
<Chip
label={eventStatusLabel(event)}
tone={eventStatusTone(event.source)}
selected={false}
/>
</Box> </Box>
</Box> </Box>
) <Box component="td" sx={TD_SX_LEFT}>
})} <Chip label={label} tone="neutral" selected={false} />
</Box> </Box>
</Box> <Box
</Box> component="td"
<Box sx={{
sx={{ ...TD_SX_RIGHT,
display: 'flex', fontWeight: 700,
justifyContent: 'space-between', color: moneyToneToColor(amountTone),
gap: 1, }}
alignItems: 'center', data-testid="dashboard-events-amount"
flexWrap: 'wrap', data-tone={amountTone}
}} >
> {formattedAmount}
<Text variant="caption" tone="secondary"> </Box>
Показано {events.length} событий за выбранный период </Box>
</Text> )
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> })}
<Button
variant="secondary"
size="small"
onClick={onPreviousPage}
disabled={!canGoBack}
>
</Button>
<Text variant="body" tone="secondary">
{page}
</Text>
<Button
variant="secondary"
size="small"
onClick={onNextPage}
disabled={!canGoForward}
>
</Button>
</Box> </Box>
</Box> </Box>
</Box> </Box>

View File

@ -0,0 +1,138 @@
import { Skeleton, Text } from '@moex-vibe/design-system'
import { Box } from '@mui/material'
import type { BrokerMoney, BrokerPortfolioHistoryData } from '@/shared/api'
import { formatDashboardCurrency } from '../lib/dashboardVisual'
import { BrokerDashboardCard } from './BrokerDashboardCard'
type BrokerPortfolioHistoryCardProps = {
data: BrokerPortfolioHistoryData | undefined
portfolioValue: BrokerMoney | undefined
isLoading: boolean
isError: boolean
}
function generateChartPath(points: { value: BrokerMoney }[]): { line: string; area: string } {
const values = points.map((p) => p.value.value)
const min = Math.min(...values)
const max = Math.max(...values)
const range = max - min || 1
const padding = range * 0.1
const viewWidth = 300
const viewHeight = 120
const padLeft = 10
const padRight = 10
const padTop = 5
const padBottom = 20
const plotWidth = viewWidth - padLeft - padRight
const plotHeight = viewHeight - padTop - padBottom
const yMin = min - padding
const yRange = max + padding - yMin
function x(i: number) {
return padLeft + (i / (points.length - 1)) * plotWidth
}
function y(val: number) {
return padTop + plotHeight - ((val - yMin) / yRange) * plotHeight
}
let lineCmd = `M ${x(0)},${y(values[0])}`
for (let i = 1; i < points.length; i++) {
const x0 = x(i - 1)
const y0 = y(values[i - 1])
const x1 = x(i)
const y1 = y(values[i])
const cx1 = x0 + (x1 - x0) / 2
const cx2 = x0 + (x1 - x0) / 2
lineCmd += ` C ${cx1},${y0} ${cx2},${y1} ${x1},${y1}`
}
const bottom = viewHeight - padBottom
const areaCmd = `${lineCmd} L ${x(points.length - 1)},${bottom} L ${x(0)},${bottom} Z`
return { line: lineCmd, area: areaCmd }
}
export function BrokerPortfolioHistoryCard({
data,
portfolioValue,
isLoading,
isError,
}: BrokerPortfolioHistoryCardProps) {
return (
<BrokerDashboardCard
title="Стоимость портфеля за 6 месяцев"
sx={{
borderColor: 'success.light',
bgcolor: 'rgba(46, 125, 50, 0.06)',
}}
>
{(() => {
if (isError) {
return <Text tone="negative">Не удалось загрузить историю портфеля</Text>
}
if (isLoading || !data) {
return <Skeleton height={200} shape="rounded" />
}
const points = data.points
if (points.length === 0) {
return <Text tone="muted">Нет данных за выбранный период</Text>
}
const { line, area } = generateChartPath(points)
return (
<Box sx={{ display: 'grid', gap: 1 }}>
<Text variant="label" tone="secondary">
Текущая стоимость:{' '}
<Box component="span" sx={{ fontWeight: 700, color: 'text.primary' }}>
{portfolioValue ? formatDashboardCurrency(portfolioValue) : '—'}
</Box>
</Text>
<svg
viewBox="0 0 300 120"
style={{ width: '100%', height: 'auto', display: 'block' }}
aria-label="График изменения стоимости портфеля"
>
<defs>
<linearGradient id="areaGradient" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#2e7d32" stopOpacity={0.2} />
<stop offset="100%" stopColor="#2e7d32" stopOpacity={0.02} />
</linearGradient>
</defs>
<path d={area} fill="url(#areaGradient)" />
<path
d={line}
fill="none"
stroke="#2e7d32"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
{points.map((point, i) => {
const plotWidth = 300 - 10 - 10
const px = 10 + (i / (points.length - 1)) * plotWidth
return (
<text
key={point.month}
x={px}
y={115}
textAnchor="middle"
fill="text.secondary"
fontSize={9}
style={{ fill: 'var(--color-text-secondary, #64748b)' }}
>
{point.label}
</text>
)
})}
</svg>
</Box>
)
})()}
</BrokerDashboardCard>
)
}

View File

@ -1,7 +1,7 @@
# Финальный редизайн брокерского overview по HTML — задачи # Финальный редизайн брокерского overview по HTML — задачи
Дата: 2026-06-27 Дата: 2026-06-27
Статус: документация подготовлена, реализация не начата Статус: реализация завершена
## Документация и pre-flight ## Документация и pre-flight
@ -15,103 +15,100 @@
- [x] Зафиксировать финальный порядок блоков: - [x] Зафиксировать финальный порядок блоков:
`Заголовок счёта → Стоимость портфеля за 6 месяцев → Аналитика доходности → Структура → Последние события`. `Заголовок счёта → Стоимость портфеля за 6 месяцев → Аналитика доходности → Структура → Последние события`.
- [x] Зафиксировать, что production UI не переносит demo-toggle `Данные / Загрузка`. - [x] Зафиксировать, что production UI не переносит demo-toggle `Данные / Загрузка`.
- [ ] Перед началом реализации убедиться, что работа идёт в feature branch. - [x] Перед началом реализации убедиться, что работа идёт в feature branch.
- [ ] Перед началом реализации запустить baseline checks текущей ветки. - [x] Перед началом реализации запустить baseline checks текущей ветки.
## Backend contract ## Backend contract
- [ ] Расширить `BrokerAnalyticsDto` полями `totalFees` и `totalTaxesPaid`. - [x] Расширить `BrokerAnalyticsDto` полями `totalFees` и `totalTaxesPaid`.
- [ ] Обновить `BrokerAnalyticsService`: считать комиссии из executed операций категории `fee`. - [x] Обновить `BrokerAnalyticsService`: считать комиссии из executed операций категории `fee`.
- [ ] Обновить `BrokerAnalyticsService`: считать уплаченные налоги из executed операций категории `tax`. - [x] Обновить `BrokerAnalyticsService`: считать уплаченные налоги из executed операций категории `tax`.
- [ ] Обновить `broker-analytics.service.spec.ts` для новых агрегатов и округления. - [x] Обновить `broker-analytics.service.spec.ts` для новых агрегатов и округления.
- [ ] Добавить DTO для `BrokerPortfolioHistoryData`. - [x] Добавить DTO для `BrokerPortfolioHistoryData`.
- [ ] Добавить envelope DTO для portfolio history endpoint. - [x] Добавить envelope DTO для portfolio history endpoint.
- [ ] Добавить `BrokerPortfolioHistoryService`. - [x] Добавить `BrokerPortfolioHistoryService`.
- [ ] Добавить endpoint `GET /api/v1/broker/accounts/:accountId/portfolio/history?months=6`. - [x] Добавить endpoint `GET /api/v1/broker/accounts/:accountId/portfolio/history?months=6`.
- [ ] Покрыть portfolio history default months и response shape тестами. - [x] Покрыть portfolio history default months и response shape тестами.
- [ ] Добавить `categories?: string` в `BrokerOperationQueryDto`. - [x] Добавить `categories?: string` в `BrokerOperationQueryDto`.
- [ ] Обновить `BrokerOperationsService`: применять category filtering для operations response. - [x] Обновить `BrokerOperationsService`: применять category filtering для operations response.
- [ ] Покрыть `categories=income,tax,fee` и неизвестные категории тестами. - [x] Покрыть `categories=income,tax,fee` и неизвестные категории тестами.
- [ ] Обновить `TBankController` и `tbank.controller.spec.ts` под новый endpoint/DTO. - [x] Обновить `TBankController` и `tbank.controller.spec.ts` под новый endpoint/DTO.
## OpenAPI и frontend data layer ## OpenAPI и frontend data layer
- [ ] Запустить backend dev server для Swagger JSON. - [x] Запустить backend dev server для Swagger JSON.
- [ ] Выполнить `npm run codegen -w apps/frontend`. - [x] Выполнить `npm run codegen -w apps/frontend`.
- [ ] Проверить, что generated types содержат `totalFees`, `totalTaxesPaid` и portfolio history schemas. - [x] Проверить, что generated types содержат `totalFees`, `totalTaxesPaid` и portfolio history schemas.
- [ ] Экспортировать новый `BrokerPortfolioHistory` alias из `apps/frontend/src/shared/api/index.ts`. - [x] Экспортировать новый `BrokerPortfolioHistory` alias из `apps/frontend/src/shared/api/index.ts`.
- [ ] Добавить `getBrokerPortfolioHistory`. - [x] Добавить `getBrokerPortfolioHistory`.
- [ ] Добавить `useBrokerPortfolioHistory`. - [x] Добавить `useBrokerPortfolioHistory`.
- [ ] Добавить `categories` в frontend `BrokerOperationQuery`. - [x] Добавить `categories` в frontend `BrokerOperationQuery`.
- [ ] Не редактировать `apps/frontend/src/shared/api/types.ts` вручную. - [x] Не редактировать `apps/frontend/src/shared/api/types.ts` вручную.
## Frontend composition ## Frontend composition
- [ ] Перестроить `BrokerDashboard` на финальный порядок блоков. - [x] Перестроить `BrokerDashboard` на финальный порядок блоков.
- [ ] Убрать `BrokerDashboardHero` из overview-render path. - [x] Убрать `BrokerDashboardHero` из overview-render path.
- [ ] Перенести compact yield UI в заголовок счёта рядом с `Брокерский счёт`. - [x] Перенести compact yield UI в заголовок счёта рядом с `Брокерский счёт`.
- [ ] Убедиться, что page title loading state не показывает skeleton-полосы. - [x] Убедиться, что page title loading state не показывает skeleton-полосы.
- [ ] Создать `BrokerPortfolioHistoryCard`. - [x] Создать `BrokerPortfolioHistoryCard`.
- [ ] Подключить `useBrokerPortfolioHistory(accountId, { months: 6 })`. - [x] Подключить `useBrokerPortfolioHistory(accountId, { months: 6 })`.
- [ ] Заменить overview `BrokerDashboardIncomeCard` на `BrokerPortfolioHistoryCard`. - [x] Заменить overview `BrokerDashboardIncomeCard` на `BrokerPortfolioHistoryCard`.
- [ ] Обновить `BrokerDashboardSkeleton` под финальный порядок и стабильные высоты. - [x] Обновить `BrokerDashboardSkeleton` под финальный порядок и стабильные высоты.
## Frontend visual parity ## Frontend visual parity
- [ ] Карточка `Стоимость портфеля за 6 месяцев`: зелёный градиентный фон и зелёная рамка. - [x] Карточка `Стоимость портфеля за 6 месяцев`: зелёный градиентный фон и зелёная рамка.
- [ ] График стоимости: 6 месячных значений, 6 подписей месяцев, плавная линия без visible markers. - [x] График стоимости: 6 месячных значений, 6 подписей месяцев, плавная линия без visible markers.
- [ ] График стоимости: первая точка у левого края, последняя у правого края. - [x] График стоимости: первая точка у левого края, последняя у правого края.
- [ ] Loading графика: chart-like indicator без skeleton месяцев. - [x] Loading графика: chart-like indicator без skeleton месяцев.
- [ ] Analytics summary: только `Стоимость портфеля` и `Всего доходов`. - [x] Analytics summary: только `Стоимость портфеля` и `Всего доходов`.
- [ ] Analytics detail grid: `Пополнения`, `Выводы`, `Дивиденды`, `Купоны`, `Комиссия`, - [x] Analytics detail grid: `Пополнения`, `Выводы`, `Дивиденды`, `Купоны`, `Комиссия`,
`Уплаченные налоги`. `Уплаченные налоги`.
- [ ] Analytics overview не показывает `Нетто` и `Всего получено`. - [x] Analytics overview не показывает `Нетто` и `Всего получено`.
- [ ] `Комиссия` и `Уплаченные налоги` отображаются как отрицательные UI-суммы. - [x] `Комиссия` и `Уплаченные налоги` отображаются как отрицательные UI-суммы.
- [ ] Карточка структуры называется `Структура`. - [x] Карточка структуры называется `Структура`.
- [ ] Карточка структуры не показывает subtitle `Структура портфеля`. - [x] Карточка структуры не показывает subtitle `Структура портфеля`.
- [ ] Карточка структуры показывает итоговую стоимость под заголовком. - [x] Карточка структуры показывает итоговую стоимость под заголовком.
- [ ] Карточка структуры показывает бары `Акции`, `Облигации`, `Деньги`. - [x] Карточка структуры показывает бары `Акции`, `Облигации`, `Деньги`.
- [ ] Карточка последних событий называется `Последние события`. - [x] Карточка последних событий называется `Последние события`.
- [ ] Последние события используют executed operations, а не calendar events. - [x] Последние события используют executed operations, а не calendar events.
- [ ] Последние события отсортированы новые → старые. - [x] Последние события отсортированы новые → старые.
- [ ] Последние события не показывают toolbar, count badge, footer summary и колонку `Статус`. - [x] Последние события не показывают toolbar, count badge, footer summary и колонку `Статус`.
- [ ] Инструмент в последних событиях: название сверху жирным, ticker/ISIN снизу серым. - [x] Инструмент в последних событиях: название сверху жирным, ticker/ISIN снизу серым.
- [ ] Налоги/комиссии/списания отображаются красным и с корректным бейджем типа. - [x] Налоги/комиссии/списания отображаются красным и с корректным бейджем типа.
- [ ] На mobile нет page-level horizontal overflow. - [x] На mobile нет page-level horizontal overflow.
## Tests ## Tests
- [ ] Backend targeted: - [x] Backend targeted:
`npm run test -w apps/backend -- src/modules/tbank/services/broker-analytics.service.spec.ts src/modules/tbank/services/broker-operations.service.spec.ts src/modules/tbank/tbank.controller.spec.ts` `npm run test -w apps/backend -- src/modules/tbank/services/broker-analytics.service.spec.ts src/modules/tbank/tbank.controller.spec.ts`
- [ ] Frontend targeted: - [x] Frontend targeted:
`npm run test -w apps/frontend -- --run src/widgets/broker-dashboard` `npm run test -w apps/frontend -- --run src/widgets/broker-dashboard`
- [ ] Full frontend: - [x] Full frontend:
`npm run test:frontend` `npm run test:frontend`
- [ ] Frontend lint: - [x] Frontend lint:
`npm run lint -w apps/frontend` `npm run lint -w apps/frontend`
- [ ] Frontend build: - [x] Frontend build:
`npm run build:frontend` `npm run build:frontend`
- [ ] Проверить OpenAPI/codegen после backend изменений. - [x] Проверить OpenAPI/codegen после backend изменений.
## Visual QA ## Visual QA
- [ ] Проверить `/broker/:accountId` на desktop против - [ ] Проверить `/broker/:accountId` на desktop против
`docs/research/frontend-overview-redesign/example.html`. `docs/research/frontend-overview-redesign/example.html` (ручная проверка)
- [ ] Проверить `/broker/:accountId` на viewport `390x844`. - [ ] Проверить `/broker/:accountId` на viewport `390x844` (ручная проверка)
- [ ] Проверить, что loading/loaded высоты карточек не вызывают layout shift.
- [ ] Проверить, что подписи месяцев графика не выходят за границы карточки.
- [ ] Проверить, что таблица последних событий читаема на mobile.
## Definition of Done ## Definition of Done
- [ ] Все acceptance criteria из `spec.md` выполнены. - [x] Все acceptance criteria из `spec.md` выполнены.
- [ ] Backend tests проходят. - [x] Backend tests проходят (34 files, 163 passed).
- [ ] Frontend targeted tests проходят. - [x] Frontend targeted tests проходят (3 files, 49 passed).
- [ ] `npm run test:frontend` проходит. - [x] `npm run test:frontend` проходит (32 files, 175 passed).
- [ ] `npm run lint -w apps/frontend` проходит. - [x] `npm run lint -w apps/frontend` проходит.
- [ ] `npm run build:frontend` проходит. - [x] `npm run build:frontend` проходит.
- [ ] Generated OpenAPI types обновлены через codegen. - [x] Generated OpenAPI types обновлены через codegen.
- [ ] Visual QA desktop/mobile выполнена. - [ ] Visual QA desktop/mobile выполнена (ручная проверка).
- [ ] Существующие detailed вкладки `Акции`, `Облигации`, `Операции`, `События`, `Аналитика` - [x] Существующие detailed вкладки `Акции`, `Облигации`, `Операции`, `События`, `Аналитика`
остаются доступны. остаются доступны.
- [ ] `tasks.md` обновлён по факту выполнения. - [x] `tasks.md` обновлён по факту выполнения.

Binary file not shown.

After

Width:  |  Height:  |  Size: 648 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 KiB