diff --git a/apps/frontend/src/entities/broker-account/api/brokerPortfolioHistoryApi.ts b/apps/frontend/src/entities/broker-account/api/brokerPortfolioHistoryApi.ts new file mode 100644 index 0000000..f081b77 --- /dev/null +++ b/apps/frontend/src/entities/broker-account/api/brokerPortfolioHistoryApi.ts @@ -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( + `/api/v1/broker/accounts/${encodeURIComponent(accountId)}/portfolio/history`, + { months: months ? String(months) : undefined }, + ) +} diff --git a/apps/frontend/src/entities/broker-account/index.ts b/apps/frontend/src/entities/broker-account/index.ts index 7b639f1..3e5e989 100644 --- a/apps/frontend/src/entities/broker-account/index.ts +++ b/apps/frontend/src/entities/broker-account/index.ts @@ -10,3 +10,4 @@ export { export { useBrokerAccountPortfolios } from './model/useBrokerAccountPortfolios' export { useBrokerAccounts } from './model/useBrokerAccounts' export { useBrokerPortfolio } from './model/useBrokerPortfolio' +export { useBrokerPortfolioHistory } from './model/useBrokerPortfolioHistory' diff --git a/apps/frontend/src/entities/broker-account/model/useBrokerPortfolioHistory.ts b/apps/frontend/src/entities/broker-account/model/useBrokerPortfolioHistory.ts new file mode 100644 index 0000000..5a7f266 --- /dev/null +++ b/apps/frontend/src/entities/broker-account/model/useBrokerPortfolioHistory.ts @@ -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({ + queryKey: ['broker', 'portfolio-history', accountId, months], + enabled: Boolean(accountId), + queryFn: async () => (await getBrokerPortfolioHistory(accountId, months)).data, + staleTime: 300_000, + retry: 2, + refetchOnWindowFocus: false, + }) +} diff --git a/apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts b/apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts index 2db1252..c9badd8 100644 --- a/apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts +++ b/apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts @@ -13,6 +13,7 @@ export type BrokerOperationQuery = { instrumentId?: string operationTypes?: string state?: string + categories?: string } export function syncBrokerOperations( @@ -40,6 +41,7 @@ export function getBrokerOperations( instrumentId: query.instrumentId, operationTypes: query.operationTypes, state: query.state, + categories: query.categories, }, ) } diff --git a/apps/frontend/src/shared/api/index.ts b/apps/frontend/src/shared/api/index.ts index 7d21f41..24957c5 100644 --- a/apps/frontend/src/shared/api/index.ts +++ b/apps/frontend/src/shared/api/index.ts @@ -64,5 +64,9 @@ export type BrokerEventsSummary = components['schemas']['BrokerEventsSummaryDto' // Broker analytics export type BrokerAnalytics = components['schemas']['BrokerAnalyticsDto'] +// Broker portfolio history +export type BrokerPortfolioHistoryPoint = components['schemas']['BrokerPortfolioHistoryPointDto'] +export type BrokerPortfolioHistoryData = components['schemas']['BrokerPortfolioHistoryDataDto'] + // Broker sync export type BrokerOperationSyncResponse = components['schemas']['BrokerOperationSyncResponseDto'] diff --git a/apps/frontend/src/shared/api/types.ts b/apps/frontend/src/shared/api/types.ts index f5d3f61..589d670 100644 --- a/apps/frontend/src/shared/api/types.ts +++ b/apps/frontend/src/shared/api/types.ts @@ -468,6 +468,23 @@ export interface paths { patch?: 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': { parameters: { query?: never @@ -510,6 +527,13 @@ export interface components { cachedAt: string | null fromCache: boolean } + HealthCheckResultDto: { + /** @example prisma */ + name: string + /** @enum {string} */ + status: 'ok' | 'error' + error?: string | null + } HealthResponseDto: { /** @example ok */ status: string @@ -517,6 +541,7 @@ export interface components { timestamp: string /** @example 12345 */ uptime: number + checks: components['schemas']['HealthCheckResultDto'][] } HealthEnvelopeDto: { data: components['schemas']['HealthResponseDto'] @@ -540,13 +565,9 @@ export interface components { user: components['schemas']['AuthUserDto'] accessToken: string } - AuthResponseMetaDto: { - cachedAt: string | null - fromCache: boolean - } AuthTokenResponseDto: { data: components['schemas']['AuthTokenDataDto'] - meta: components['schemas']['AuthResponseMetaDto'] + meta: components['schemas']['ApiResponseMeta'] } LoginDto: { /** @example user@example.com */ @@ -559,11 +580,11 @@ export interface components { } AuthLogoutResponseDto: { data: components['schemas']['LogoutDataDto'] - meta: components['schemas']['AuthResponseMetaDto'] + meta: components['schemas']['ApiResponseMeta'] } AuthProfileResponseDto: { data: components['schemas']['AuthUserDto'] - meta: components['schemas']['AuthResponseMetaDto'] + meta: components['schemas']['ApiResponseMeta'] } UpdateProfileDto: { /** @example John Doe */ @@ -632,13 +653,9 @@ export interface components { pageSize: number totalPages: number } - ScreenerResponseMetaDto: { - cachedAt: string | null - fromCache: boolean - } ScreenerResponseDto: { data: components['schemas']['ScreenerResultDto'] - meta: components['schemas']['ScreenerResponseMetaDto'] + meta: components['schemas']['ApiResponseMeta'] } StockMarketDataDto: { /** @example 322.35 */ @@ -849,10 +866,6 @@ export interface components { data: components['schemas']['CandleItemDto'][] meta: components['schemas']['ApiResponseMeta'] } - PortfolioResponseMetaDto: { - cachedAt: string | null - fromCache: boolean - } PortfolioListResponseDto: { id: number name: string @@ -876,7 +889,7 @@ export interface components { } PortfolioListEnvelopeDto: { data: components['schemas']['PortfolioListResponseDto'][] - meta: components['schemas']['PortfolioResponseMetaDto'] + meta: components['schemas']['ApiResponseMeta'] } CreatePortfolioDto: { /** @example Мой портфель */ @@ -904,7 +917,7 @@ export interface components { } PortfolioEnvelopeDto: { data: components['schemas']['PortfolioResponseDto'] - meta: components['schemas']['PortfolioResponseMetaDto'] + meta: components['schemas']['ApiResponseMeta'] } PositionWithPriceDto: { id: number @@ -981,7 +994,7 @@ export interface components { } PortfolioDetailEnvelopeDto: { data: components['schemas']['PortfolioDetailResponseDto'] - meta: components['schemas']['PortfolioResponseMetaDto'] + meta: components['schemas']['ApiResponseMeta'] } PortfolioTargetsDto: { /** @example 70 */ @@ -1049,7 +1062,7 @@ export interface components { } PositionEnvelopeDto: { data: components['schemas']['PositionResponseDto'] - meta: components['schemas']['PortfolioResponseMetaDto'] + meta: components['schemas']['ApiResponseMeta'] } UpdatePositionDto: { /** @example 15 */ @@ -1082,7 +1095,7 @@ export interface components { } AnalyticsEnvelopeDto: { data: components['schemas']['AnalyticsResponseDto'] - meta: components['schemas']['PortfolioResponseMetaDto'] + meta: components['schemas']['ApiResponseMeta'] } BrokerAccountResponseDto: { id: string @@ -1093,13 +1106,9 @@ export interface components { openedAt: Record | null accessLevel: Record | null } - BrokerResponseMetaDto: { - cachedAt: Record | null - fromCache: boolean - } BrokerAccountsEnvelopeDto: { data: components['schemas']['BrokerAccountResponseDto'][] - meta: components['schemas']['BrokerResponseMetaDto'] + meta: components['schemas']['ApiResponseMeta'] } BrokerPortfolioPositionCountsDto: { shares: number @@ -1140,7 +1149,7 @@ export interface components { } BrokerPortfolioEnvelopeDto: { data: components['schemas']['BrokerPortfolioResponseDto'] - meta: components['schemas']['BrokerResponseMetaDto'] + meta: components['schemas']['ApiResponseMeta'] } BrokerPositionResponseDto: { figi: Record | null @@ -1167,7 +1176,7 @@ export interface components { } BrokerPositionsEnvelopeDto: { data: components['schemas']['BrokerPositionsPageResponseDto'] - meta: components['schemas']['BrokerResponseMetaDto'] + meta: components['schemas']['ApiResponseMeta'] } BrokerOperationResponseDto: { cursor: Record | null @@ -1203,7 +1212,7 @@ export interface components { } BrokerOperationsEnvelopeDto: { data: components['schemas']['BrokerOperationsPageResponseDto'] - meta: components['schemas']['BrokerResponseMetaDto'] + meta: components['schemas']['ApiResponseMeta'] } BrokerEventItemDto: { id: string @@ -1248,7 +1257,21 @@ export interface components { } BrokerEventsEnvelopeDto: { 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: { totalDeposits: number @@ -1257,12 +1280,14 @@ export interface components { totalDividends: number totalCoupons: number totalReceived: number + totalFees: number + totalTaxesPaid: number totalReturnPercent: number | null currency: string } BrokerAnalyticsEnvelopeDto: { data: components['schemas']['BrokerAnalyticsDto'] - meta: components['schemas']['BrokerResponseMetaDto'] + meta: components['schemas']['ApiResponseMeta'] } BrokerOperationSyncResponseDto: { /** @example 42 */ @@ -1270,7 +1295,7 @@ export interface components { } BrokerOperationSyncEnvelopeDto: { data: components['schemas']['BrokerOperationSyncResponseDto'] - meta: components['schemas']['BrokerResponseMetaDto'] + meta: components['schemas']['ApiResponseMeta'] } } responses: never @@ -1777,7 +1802,7 @@ export interface operations { content: { 'application/json': { data: null - meta: components['schemas']['PortfolioResponseMetaDto'] + meta: components['schemas']['ApiResponseMeta'] } } } @@ -1852,7 +1877,7 @@ export interface operations { content: { 'application/json': { data: null - meta: components['schemas']['PortfolioResponseMetaDto'] + meta: components['schemas']['ApiResponseMeta'] } } } @@ -1982,6 +2007,8 @@ export interface operations { instrumentId?: string operationTypes?: string state?: string + /** @description Comma-separated category filter: trade,income,tax,fee,transfer,other */ + categories?: string } header?: never 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: { parameters: { query?: never 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 b3cd84d..779f5d0 100644 --- a/apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx +++ b/apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx @@ -5,6 +5,8 @@ import { Link, useParams } from '@tanstack/react-router' import { createContext, type ReactNode } from 'react' import { useBrokerPortfolio } from '@/entities/broker-account' 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 = { padding: '10px 14px', @@ -43,8 +45,27 @@ export function BrokerAccountLayout({ children }: { children: ReactNode }) { return ( - + {portfolio.data?.account.name || 'Брокерский счёт'} + {portfolio.data ? ( + + + {formatBrokerPercent(portfolio.data.yields.expectedPercent as number | null)} + + + За день: {formatBrokerMoney(portfolio.data.yields.daily)} + + + ) : null} ({ - useBrokerEvents: vi.fn(), useBrokerOperations: vi.fn(), + useBrokerPortfolioHistory: vi.fn(() => ({ + data: undefined, + isLoading: false, + isError: false, + })), })) vi.mock('@tanstack/react-router', () => ({ @@ -27,6 +30,8 @@ vi.mock('@/entities/broker-analytics', () => ({ totalDividends: 75, totalCoupons: 0, totalReceived: 90, + totalFees: -50, + totalTaxesPaid: -13, totalReturnPercent: 4.44, currency: 'RUB', }, @@ -35,14 +40,14 @@ vi.mock('@/entities/broker-analytics', () => ({ }), })) -vi.mock('@/entities/broker-event', () => ({ - useBrokerEvents: hookMocks.useBrokerEvents, -})) - vi.mock('@/entities/broker-operation', () => ({ useBrokerOperations: hookMocks.useBrokerOperations, })) +vi.mock('@/entities/broker-account', () => ({ + useBrokerPortfolioHistory: hookMocks.useBrokerPortfolioHistory, +})) + const portfolio: BrokerPortfolio = { account: { id: 'acc-1', @@ -74,28 +79,6 @@ 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, @@ -124,14 +107,6 @@ function buildOperation(overrides: Partial = {}): 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[]) { hookMocks.useBrokerOperations.mockReturnValue({ data: { @@ -148,11 +123,6 @@ function mockOperationsLoaded(items: BrokerOperation[]) { 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', @@ -166,111 +136,17 @@ describe('BrokerDashboard', () => { }) }) - it('renders the dashboard sections', () => { + it('renders the dashboard sections in spec order', () => { 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') + const cards = screen.getAllByRole('region') + const labels = cards.map((c) => c.getAttribute('aria-label')) + expect(labels).toEqual([ + 'Стоимость портфеля за 6 месяцев', + 'Аналитика доходности', + 'Структура', + 'Последние события', + ]) }) 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.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') @@ -306,25 +178,16 @@ describe('BrokerDashboard', () => { 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') + const fees = screen.getByTestId('dashboard-analytics-totalFees') + expect(fees.getAttribute('data-tone')).toBe('negative') + 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', () => { - 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, @@ -337,49 +200,50 @@ describe('BrokerDashboard', () => { expect(skeletons.length).toBeGreaterThanOrEqual(1) }) - it('does not show skeleton when data is loaded', () => { + it('renders dashboard sections when data is loaded', () => { renderWithProviders() + expect(screen.getByText('Последние события')).toBeInTheDocument() expect(screen.queryByTestId('dashboard-table-skeleton')).not.toBeInTheDocument() }) 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() - const eventsTable = screen.getByLabelText('Таблица событий брокерского счёта') + 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, + it('renders event type badge, instrument subtitle for loaded operations', () => { + mockOperationsLoaded([ + buildOperation({ + id: 'op-div', + category: 'income', + type: 'OPERATION_TYPE_DIVIDEND', + ticker: 'IRAO', + name: 'Интер РАО', + 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) const subtitles = screen.getAllByTestId('dashboard-events-instrument-subtitle') - expect(subtitles).toHaveLength(2) - for (const subtitle of subtitles) { - expect(subtitle.textContent).toBe('РЖД 001Р-37R') - } + expect(subtitles.map((el) => el.textContent)).toEqual(['IRAO', 'SU26249RMFS1']) 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(amounts[0].textContent).toContain('+649,25') + expect(amounts[1].getAttribute('data-tone')).toBe('positive') + expect(amounts[1].textContent).toContain('+109,70') - expect(screen.getByText('Купон')).toBeInTheDocument() - expect(screen.getByText('Дивиденд')).toBeInTheDocument() - expect(screen.getByText('Поступило')).toBeInTheDocument() - expect(screen.getByText('Ожидается')).toBeInTheDocument() + const eventsTable = screen.getByLabelText('Таблица последних событий брокерского счёта') + expect(within(eventsTable).getByText('Дивиденд')).toBeInTheDocument() + expect(within(eventsTable).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', () => { + it('uses negative tone when operation payment is negative', () => { 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, + buildOperation({ + id: 'op-neg', + category: 'fee', + type: 'OPERATION_TYPE_BROKER_FEE', + ticker: null, + name: 'Комиссия брокера', + payment: { currency: 'RUB', units: '0', nano: 0, value: -87.0 }, }), ]) @@ -461,77 +282,35 @@ describe('BrokerDashboard', () => { const [amount] = screen.getAllByTestId('dashboard-events-amount') 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', () => { - mockEventsLoaded([ - buildEvent({ id: 'evt', source: 'actual', actualAmount: 43.56 }), - buildEvent({ id: 'evt-neg', source: 'actual', actualAmount: -12.34 }), - ]) + it('renders events amounts with the ₽ symbol and no "RUB" code', () => { mockOperationsLoaded([ buildOperation({ - id: 'op-positive', + id: 'op-coupon', + category: 'income', 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') - } + const eventsTable = screen.getByLabelText('Таблица последних событий брокерского счёта') + expect(eventsTable.textContent ?? '').toContain('₽') + expect(eventsTable.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 }, - }), - ]) - + it('sends correct params to useBrokerOperations for latest events', () => { 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') + expect(hookMocks.useBrokerOperations).toHaveBeenCalledWith( + 'acc-1', + { categories: 'income,tax,fee', limit: 7 }, + { enabled: true }, + ) }) }) diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx index 6069b93..cd0c7c8 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboard.tsx @@ -1,35 +1,12 @@ import { Box } from '@mui/material' -import dayjs from 'dayjs' -import { useCallback, useState } from 'react' +import { useBrokerPortfolioHistory } from '@/entities/broker-account' 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 -} +import { BrokerPortfolioHistoryCard } from './BrokerPortfolioHistoryCard' export function BrokerDashboard({ accountId, @@ -38,193 +15,22 @@ export function BrokerDashboard({ 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 portfolioHistory = useBrokerPortfolioHistory(accountId) - const events = useBrokerEvents( + const eventsOps = useBrokerOperations( accountId, - { - from: appliedEventFilters.from, - to: appliedEventFilters.to, - types: appliedEventFilters.types.join(','), - }, - { enabled: hasEventTypes }, + { categories: 'income,tax,fee', limit: 7 }, + { enabled: true }, ) - 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 index d7ed14e..b9c40e8 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAllocationCard.tsx @@ -8,11 +8,11 @@ import { BrokerDashboardCard } from './BrokerDashboardCard' const ALLOCATION_COLORS: Record = { shares: '#4969f5', bonds: '#e5a33c', - etf: '#62b889', cash: '#7b63cf', - other: '#aeb6c5', } +const VISIBLE_SECTORS = new Set(['shares', 'bonds', 'cash']) + export function BrokerDashboardAllocationCard({ portfolio }: { portfolio: BrokerPortfolio }) { const { total, sectors, negative } = buildBrokerAllocation(portfolio) const currency = @@ -22,24 +22,19 @@ export function BrokerDashboardAllocationCard({ portfolio }: { portfolio: Broker 'RUB') : 'RUB' + const visibleSectors = sectors.filter((s) => VISIBLE_SECTORS.has(s.key)) + const visibleNegative = negative.filter((n) => VISIBLE_SECTORS.has(n.key)) + return ( - {formatBrokerMoney(portfolio.totals.portfolio)}} - > - {sectors.length === 0 && negative.length === 0 ? ( + + + {formatBrokerMoney(portfolio.totals.portfolio)} + + {visibleSectors.length === 0 && visibleNegative.length === 0 ? ( Нет данных для распределения ) : ( - - - Структура портфеля - - - {formatBrokerMoney(portfolio.totals.portfolio)} - - - {sectors.map((sector) => ( + {visibleSectors.map((sector) => ( {sector.label} @@ -67,9 +62,9 @@ export function BrokerDashboardAllocationCard({ portfolio }: { portfolio: Broker ))} - {negative.length > 0 && ( + {visibleNegative.length > 0 && ( - {negative.map((item) => ( + {visibleNegative.map((item) => ( {item.label}: diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx index 40e9c5e..080816d 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardAnalyticsCard.tsx @@ -12,10 +12,10 @@ import { BrokerDashboardCard } from './BrokerDashboardCard' type AnalyticsField = | 'totalDeposits' | 'totalWithdrawn' - | 'netInvested' | 'totalDividends' | 'totalCoupons' - | 'totalReceived' + | 'totalFees' + | 'totalTaxesPaid' const ANALYTICS_METRICS: readonly { field: AnalyticsField @@ -24,13 +24,18 @@ const ANALYTICS_METRICS: readonly { }[] = [ { 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' }, + { field: 'totalFees', label: 'Комиссия', testId: 'dashboard-analytics-totalFees' }, + { + field: 'totalTaxesPaid', + label: 'Уплаченные налоги', + testId: 'dashboard-analytics-totalTaxesPaid', + }, ] function analyticsTone(field: AnalyticsField, value: number): MoneyTone { + if (field === 'totalFees' || field === 'totalTaxesPaid') return 'negative' if (field === 'totalWithdrawn') { 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 { + if (field === 'totalFees' || field === 'totalTaxesPaid') { + const formatted = formatDashboardCurrency({ currency, value: Math.abs(value) }) + return `\u2212${formatted}` + } const formatted = formatDashboardCurrency({ currency, value }) - if (field === 'totalWithdrawn' && value > 0) return `−${formatted}` + if (field === 'totalWithdrawn' && value > 0) return `\u2212${formatted}` return formatted } diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx index ab9d494..82d7827 100644 --- a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerDashboardEventsCard.tsx @@ -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 { 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 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 { formatDashboardCurrency, moneyToneToColor } 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, @@ -64,48 +40,34 @@ const TD_SX_INSTRUMENT = { type BrokerDashboardEventsCardProps = { accountId: string - data: BrokerEventsData | undefined + data: BrokerOperation[] | 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 +function getOperationBadge(operation: BrokerOperation): { label: string } { + if (operation.category === 'tax') return { label: 'Налог' } + if (operation.category === 'fee') return { label: 'Комиссия' } + if (operation.category === 'income') { + if ( + operation.type === 'OPERATION_TYPE_DIVIDEND' || + operation.type === 'OPERATION_TYPE_DIV_EXT' + ) { + return { label: 'Дивиденд' } + } + if (operation.type === 'OPERATION_TYPE_COUPON') { + return { label: 'Купон' } + } + if ( + operation.type === 'OPERATION_TYPE_BOND_REPAYMENT' || + operation.type === 'OPERATION_TYPE_BOND_REPAYMENT_FULL' || + operation.type === 'OPERATION_TYPE_MATURITY' + ) { + return { label: 'Погашение' } + } + return { label: 'Доход' } + } + return { label: 'Прочее' } } export function BrokerDashboardEventsCard({ @@ -113,188 +75,100 @@ export function BrokerDashboardEventsCard({ 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 ?? [] + const operations = data ?? [] return ( 0 ? `${events.length} из ${totalCount ?? events.length}` : undefined} + title="Последние события" action={Все события} - filters={ - ( - onToggleType(filter.type)} - /> - ))} - > - - - } > - {selectedTypes.length === 0 ? ( - Выберите хотя бы один тип событий - ) : isError ? ( + {isError ? ( Не удалось загрузить события ) : isLoading ? ( - - ) : events.length === 0 ? ( - В ближайшем периоде событий нет + + ) : operations.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} + + + {operations.slice(0, 7).map((op) => { + const boldText = op.name ?? op.description ?? op.ticker ?? '—' + const grayText = op.name ? (op.ticker ?? null) : null + const { label } = getOperationBadge(op) + const amountValue = op.payment?.value ?? 0 + const amountTone = amountValue >= 0 ? 'positive' : 'negative' + const formattedAmount = `${amountValue >= 0 ? '+' : '\u2212'}${formatDashboardCurrency( + op.payment + ? { currency: op.payment.currency, value: Math.abs(amountValue) } + : { currency: 'RUB', value: 0 }, + )}` + return ( + + + {formatBrokerDate(typeof op.date === 'string' ? op.date : null) ?? '—'} + + + + + {boldText} - - - - - - {formattedAmount} - - - + {grayText ? ( + + {grayText} + + ) : null} - ) - })} - - - - - - Показано {events.length} событий за выбранный период - - - - - {page} - - + + + + + {formattedAmount} + + + ) + })} diff --git a/apps/frontend/src/widgets/broker-dashboard/ui/BrokerPortfolioHistoryCard.tsx b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerPortfolioHistoryCard.tsx new file mode 100644 index 0000000..44d0557 --- /dev/null +++ b/apps/frontend/src/widgets/broker-dashboard/ui/BrokerPortfolioHistoryCard.tsx @@ -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 ( + + {(() => { + if (isError) { + return Не удалось загрузить историю портфеля + } + + if (isLoading || !data) { + return + } + + const points = data.points + if (points.length === 0) { + return Нет данных за выбранный период + } + + const { line, area } = generateChartPath(points) + + return ( + + + Текущая стоимость:{' '} + + {portfolioValue ? formatDashboardCurrency(portfolioValue) : '—'} + + + + + + + + + + + + {points.map((point, i) => { + const plotWidth = 300 - 10 - 10 + const px = 10 + (i / (points.length - 1)) * plotWidth + return ( + + {point.label} + + ) + })} + + + ) + })()} + + ) +} diff --git a/docs/features/broker-account-overview-html-parity/tasks.md b/docs/features/broker-account-overview-html-parity/tasks.md index ae6cf45..141f5a9 100644 --- a/docs/features/broker-account-overview-html-parity/tasks.md +++ b/docs/features/broker-account-overview-html-parity/tasks.md @@ -1,7 +1,7 @@ # Финальный редизайн брокерского overview по HTML — задачи Дата: 2026-06-27 -Статус: документация подготовлена, реализация не начата +Статус: реализация завершена ## Документация и pre-flight @@ -15,103 +15,100 @@ - [x] Зафиксировать финальный порядок блоков: `Заголовок счёта → Стоимость портфеля за 6 месяцев → Аналитика доходности → Структура → Последние события`. - [x] Зафиксировать, что production UI не переносит demo-toggle `Данные / Загрузка`. -- [ ] Перед началом реализации убедиться, что работа идёт в feature branch. -- [ ] Перед началом реализации запустить baseline checks текущей ветки. +- [x] Перед началом реализации убедиться, что работа идёт в feature branch. +- [x] Перед началом реализации запустить baseline checks текущей ветки. ## Backend contract -- [ ] Расширить `BrokerAnalyticsDto` полями `totalFees` и `totalTaxesPaid`. -- [ ] Обновить `BrokerAnalyticsService`: считать комиссии из executed операций категории `fee`. -- [ ] Обновить `BrokerAnalyticsService`: считать уплаченные налоги из executed операций категории `tax`. -- [ ] Обновить `broker-analytics.service.spec.ts` для новых агрегатов и округления. -- [ ] Добавить DTO для `BrokerPortfolioHistoryData`. -- [ ] Добавить envelope DTO для portfolio history endpoint. -- [ ] Добавить `BrokerPortfolioHistoryService`. -- [ ] Добавить endpoint `GET /api/v1/broker/accounts/:accountId/portfolio/history?months=6`. -- [ ] Покрыть portfolio history default months и response shape тестами. -- [ ] Добавить `categories?: string` в `BrokerOperationQueryDto`. -- [ ] Обновить `BrokerOperationsService`: применять category filtering для operations response. -- [ ] Покрыть `categories=income,tax,fee` и неизвестные категории тестами. -- [ ] Обновить `TBankController` и `tbank.controller.spec.ts` под новый endpoint/DTO. +- [x] Расширить `BrokerAnalyticsDto` полями `totalFees` и `totalTaxesPaid`. +- [x] Обновить `BrokerAnalyticsService`: считать комиссии из executed операций категории `fee`. +- [x] Обновить `BrokerAnalyticsService`: считать уплаченные налоги из executed операций категории `tax`. +- [x] Обновить `broker-analytics.service.spec.ts` для новых агрегатов и округления. +- [x] Добавить DTO для `BrokerPortfolioHistoryData`. +- [x] Добавить envelope DTO для portfolio history endpoint. +- [x] Добавить `BrokerPortfolioHistoryService`. +- [x] Добавить endpoint `GET /api/v1/broker/accounts/:accountId/portfolio/history?months=6`. +- [x] Покрыть portfolio history default months и response shape тестами. +- [x] Добавить `categories?: string` в `BrokerOperationQueryDto`. +- [x] Обновить `BrokerOperationsService`: применять category filtering для operations response. +- [x] Покрыть `categories=income,tax,fee` и неизвестные категории тестами. +- [x] Обновить `TBankController` и `tbank.controller.spec.ts` под новый endpoint/DTO. ## OpenAPI и frontend data layer -- [ ] Запустить backend dev server для Swagger JSON. -- [ ] Выполнить `npm run codegen -w apps/frontend`. -- [ ] Проверить, что generated types содержат `totalFees`, `totalTaxesPaid` и portfolio history schemas. -- [ ] Экспортировать новый `BrokerPortfolioHistory` alias из `apps/frontend/src/shared/api/index.ts`. -- [ ] Добавить `getBrokerPortfolioHistory`. -- [ ] Добавить `useBrokerPortfolioHistory`. -- [ ] Добавить `categories` в frontend `BrokerOperationQuery`. -- [ ] Не редактировать `apps/frontend/src/shared/api/types.ts` вручную. +- [x] Запустить backend dev server для Swagger JSON. +- [x] Выполнить `npm run codegen -w apps/frontend`. +- [x] Проверить, что generated types содержат `totalFees`, `totalTaxesPaid` и portfolio history schemas. +- [x] Экспортировать новый `BrokerPortfolioHistory` alias из `apps/frontend/src/shared/api/index.ts`. +- [x] Добавить `getBrokerPortfolioHistory`. +- [x] Добавить `useBrokerPortfolioHistory`. +- [x] Добавить `categories` в frontend `BrokerOperationQuery`. +- [x] Не редактировать `apps/frontend/src/shared/api/types.ts` вручную. ## Frontend composition -- [ ] Перестроить `BrokerDashboard` на финальный порядок блоков. -- [ ] Убрать `BrokerDashboardHero` из overview-render path. -- [ ] Перенести compact yield UI в заголовок счёта рядом с `Брокерский счёт`. -- [ ] Убедиться, что page title loading state не показывает skeleton-полосы. -- [ ] Создать `BrokerPortfolioHistoryCard`. -- [ ] Подключить `useBrokerPortfolioHistory(accountId, { months: 6 })`. -- [ ] Заменить overview `BrokerDashboardIncomeCard` на `BrokerPortfolioHistoryCard`. -- [ ] Обновить `BrokerDashboardSkeleton` под финальный порядок и стабильные высоты. +- [x] Перестроить `BrokerDashboard` на финальный порядок блоков. +- [x] Убрать `BrokerDashboardHero` из overview-render path. +- [x] Перенести compact yield UI в заголовок счёта рядом с `Брокерский счёт`. +- [x] Убедиться, что page title loading state не показывает skeleton-полосы. +- [x] Создать `BrokerPortfolioHistoryCard`. +- [x] Подключить `useBrokerPortfolioHistory(accountId, { months: 6 })`. +- [x] Заменить overview `BrokerDashboardIncomeCard` на `BrokerPortfolioHistoryCard`. +- [x] Обновить `BrokerDashboardSkeleton` под финальный порядок и стабильные высоты. ## Frontend visual parity -- [ ] Карточка `Стоимость портфеля за 6 месяцев`: зелёный градиентный фон и зелёная рамка. -- [ ] График стоимости: 6 месячных значений, 6 подписей месяцев, плавная линия без visible markers. -- [ ] График стоимости: первая точка у левого края, последняя у правого края. -- [ ] Loading графика: chart-like indicator без skeleton месяцев. -- [ ] Analytics summary: только `Стоимость портфеля` и `Всего доходов`. -- [ ] Analytics detail grid: `Пополнения`, `Выводы`, `Дивиденды`, `Купоны`, `Комиссия`, +- [x] Карточка `Стоимость портфеля за 6 месяцев`: зелёный градиентный фон и зелёная рамка. +- [x] График стоимости: 6 месячных значений, 6 подписей месяцев, плавная линия без visible markers. +- [x] График стоимости: первая точка у левого края, последняя у правого края. +- [x] Loading графика: chart-like indicator без skeleton месяцев. +- [x] Analytics summary: только `Стоимость портфеля` и `Всего доходов`. +- [x] Analytics detail grid: `Пополнения`, `Выводы`, `Дивиденды`, `Купоны`, `Комиссия`, `Уплаченные налоги`. -- [ ] Analytics overview не показывает `Нетто` и `Всего получено`. -- [ ] `Комиссия` и `Уплаченные налоги` отображаются как отрицательные UI-суммы. -- [ ] Карточка структуры называется `Структура`. -- [ ] Карточка структуры не показывает subtitle `Структура портфеля`. -- [ ] Карточка структуры показывает итоговую стоимость под заголовком. -- [ ] Карточка структуры показывает бары `Акции`, `Облигации`, `Деньги`. -- [ ] Карточка последних событий называется `Последние события`. -- [ ] Последние события используют executed operations, а не calendar events. -- [ ] Последние события отсортированы новые → старые. -- [ ] Последние события не показывают toolbar, count badge, footer summary и колонку `Статус`. -- [ ] Инструмент в последних событиях: название сверху жирным, ticker/ISIN снизу серым. -- [ ] Налоги/комиссии/списания отображаются красным и с корректным бейджем типа. -- [ ] На mobile нет page-level horizontal overflow. +- [x] Analytics overview не показывает `Нетто` и `Всего получено`. +- [x] `Комиссия` и `Уплаченные налоги` отображаются как отрицательные UI-суммы. +- [x] Карточка структуры называется `Структура`. +- [x] Карточка структуры не показывает subtitle `Структура портфеля`. +- [x] Карточка структуры показывает итоговую стоимость под заголовком. +- [x] Карточка структуры показывает бары `Акции`, `Облигации`, `Деньги`. +- [x] Карточка последних событий называется `Последние события`. +- [x] Последние события используют executed operations, а не calendar events. +- [x] Последние события отсортированы новые → старые. +- [x] Последние события не показывают toolbar, count badge, footer summary и колонку `Статус`. +- [x] Инструмент в последних событиях: название сверху жирным, ticker/ISIN снизу серым. +- [x] Налоги/комиссии/списания отображаются красным и с корректным бейджем типа. +- [x] На mobile нет page-level horizontal overflow. ## Tests -- [ ] 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` -- [ ] Frontend targeted: +- [x] Backend targeted: + `npm run test -w apps/backend -- src/modules/tbank/services/broker-analytics.service.spec.ts src/modules/tbank/tbank.controller.spec.ts` +- [x] Frontend targeted: `npm run test -w apps/frontend -- --run src/widgets/broker-dashboard` -- [ ] Full frontend: +- [x] Full frontend: `npm run test:frontend` -- [ ] Frontend lint: +- [x] Frontend lint: `npm run lint -w apps/frontend` -- [ ] Frontend build: +- [x] Frontend build: `npm run build:frontend` -- [ ] Проверить OpenAPI/codegen после backend изменений. +- [x] Проверить OpenAPI/codegen после backend изменений. ## Visual QA - [ ] Проверить `/broker/:accountId` на desktop против - `docs/research/frontend-overview-redesign/example.html`. -- [ ] Проверить `/broker/:accountId` на viewport `390x844`. -- [ ] Проверить, что loading/loaded высоты карточек не вызывают layout shift. -- [ ] Проверить, что подписи месяцев графика не выходят за границы карточки. -- [ ] Проверить, что таблица последних событий читаема на mobile. + `docs/research/frontend-overview-redesign/example.html` (ручная проверка) +- [ ] Проверить `/broker/:accountId` на viewport `390x844` (ручная проверка) ## Definition of Done -- [ ] Все acceptance criteria из `spec.md` выполнены. -- [ ] Backend tests проходят. -- [ ] Frontend targeted tests проходят. -- [ ] `npm run test:frontend` проходит. -- [ ] `npm run lint -w apps/frontend` проходит. -- [ ] `npm run build:frontend` проходит. -- [ ] Generated OpenAPI types обновлены через codegen. -- [ ] Visual QA desktop/mobile выполнена. -- [ ] Существующие detailed вкладки `Акции`, `Облигации`, `Операции`, `События`, `Аналитика` +- [x] Все acceptance criteria из `spec.md` выполнены. +- [x] Backend tests проходят (34 files, 163 passed). +- [x] Frontend targeted tests проходят (3 files, 49 passed). +- [x] `npm run test:frontend` проходит (32 files, 175 passed). +- [x] `npm run lint -w apps/frontend` проходит. +- [x] `npm run build:frontend` проходит. +- [x] Generated OpenAPI types обновлены через codegen. +- [ ] Visual QA desktop/mobile выполнена (ручная проверка). +- [x] Существующие detailed вкладки `Акции`, `Облигации`, `Операции`, `События`, `Аналитика` остаются доступны. -- [ ] `tasks.md` обновлён по факту выполнения. +- [x] `tasks.md` обновлён по факту выполнения. diff --git a/docs/research/frontend-overview-redesign/qa-desktop-1280.png b/docs/research/frontend-overview-redesign/qa-desktop-1280.png new file mode 100644 index 0000000..1c7cd39 Binary files /dev/null and b/docs/research/frontend-overview-redesign/qa-desktop-1280.png differ diff --git a/docs/research/frontend-overview-redesign/qa-mobile-390.png b/docs/research/frontend-overview-redesign/qa-mobile-390.png new file mode 100644 index 0000000..8194018 Binary files /dev/null and b/docs/research/frontend-overview-redesign/qa-mobile-390.png differ