diff --git a/apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx b/apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx index 197def5..39167e8 100644 --- a/apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx +++ b/apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx @@ -121,14 +121,18 @@ export function BrokerAccountDetailPage() { 0} - canGoForward={Boolean(operations.data?.hasNext && operations.data.nextCursor)} - onPrevious={handlePreviousOperationsPage} - onNext={handleNextOperationsPage} + pagination={{ + pageNumber: operationCursorStack.length + 1, + canGoBack: operationCursorStack.length > 0, + canGoForward: Boolean(operations.data?.hasNext && operations.data.nextCursor), + onPrevious: handlePreviousOperationsPage, + onNext: handleNextOperationsPage, + }} /> ); diff --git a/apps/frontend/src/pages/broker/BrokerAccountOverviewPage.tsx b/apps/frontend/src/pages/broker/BrokerAccountOverviewPage.tsx new file mode 100644 index 0000000..fe38f8f --- /dev/null +++ b/apps/frontend/src/pages/broker/BrokerAccountOverviewPage.tsx @@ -0,0 +1,172 @@ +import { Link } from 'react-router-dom'; +import type { BrokerMoney, BrokerPortfolio } from '../../api/responses'; +import { SkeletonBlock } from '../../components/SkeletonBlock'; +import { useBrokerOperations } from '../../hooks/useBrokerOperations'; +import { useBrokerAccountContext } from './BrokerAccountLayout'; +import { BrokerAllocationChart } from './BrokerAllocationChart'; +import { BrokerOperationsTable } from './BrokerOperationsTable'; + +function formatMoney(value: BrokerMoney | null | undefined) { + if (!value) return '-'; + return new Intl.NumberFormat('ru-RU', { + style: 'currency', + currency: value.currency || 'RUB', + maximumFractionDigits: 2, + }).format(value.value); +} + +function formatPercent(value: number | null) { + if (value === null) return '-'; + return `${new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 }).format(value)}%`; +} + +function pluralize(count: number, one: string, few: string, many: string) { + const modulo100 = Math.abs(count) % 100; + const modulo10 = modulo100 % 10; + if (modulo100 > 10 && modulo100 < 20) return many; + if (modulo10 === 1) return one; + if (modulo10 >= 2 && modulo10 <= 4) return few; + return many; +} + +function BrokerSummary({ portfolio }: { portfolio: BrokerPortfolio }) { + return ( +
+
+ Стоимость портфеля + + {formatMoney(portfolio.totals.portfolio)} + + За день: {formatMoney(portfolio.yields.daily)} + Дневная доходность: {formatPercent(portfolio.yields.dailyPercent)} + Ожидаемая доходность: {formatPercent(portfolio.yields.expectedPercent)} +
+
+ Денежный остаток + {portfolio.cash.length === 0 ? ( + Нет денежных остатков + ) : ( +
    + {portfolio.cash.map((money) => ( +
  • + {money.currency} + {formatMoney(money)} +
  • + ))} +
+ )} +
+
+ ); +} + +function allocationPercent(value: BrokerMoney | null, total: BrokerMoney | null) { + if (!value || !total || total.value <= 0) return 0; + return (value.value / total.value) * 100; +} + +function BrokerAssetCards({ + accountId, + portfolio, +}: { + accountId: string; + portfolio: BrokerPortfolio; +}) { + const basePath = `/broker/${encodeURIComponent(accountId)}`; + const cards = [ + { + label: 'Акции', + count: portfolio.positionCounts.shares, + countLabel: pluralize(portfolio.positionCounts.shares, 'позиция', 'позиции', 'позиций'), + value: portfolio.totals.shares, + path: `${basePath}/shares`, + }, + { + label: 'Облигации', + count: portfolio.positionCounts.bonds, + countLabel: pluralize(portfolio.positionCounts.bonds, 'выпуск', 'выпуска', 'выпусков'), + value: portfolio.totals.bonds, + path: `${basePath}/bonds`, + }, + ]; + + return ( +
+ {cards.map((card) => ( + + {card.label} + + {card.count} {card.countLabel} + + {formatMoney(card.value)} + {allocationPercent(card.value, portfolio.totals.portfolio).toFixed(1)}% + + ))} +
+ ); +} + +function BrokerOverviewSkeleton() { + return ( +
+
+ {[1, 2].map((item) => ( +
+ + + +
+ ))} +
+
+ + +
+
+ {[1, 2].map((item) => ( +
+ + + +
+ ))} +
+
+ ); +} + +export function BrokerAccountOverviewPage() { + const { accountId, portfolio } = useBrokerAccountContext(); + const operations = useBrokerOperations(accountId, { limit: 5 }); + + if (portfolio.isLoading) return ; + if (portfolio.error || !portfolio.data) { + return

Не удалось загрузить сводку счёта

; + } + + return ( +
+ + + + {operations.error ? ( +

Не удалось загрузить последние операции

+ ) : ( + Вся история + } + emptyMessage="Операций с начала текущего года нет" + isLoading={operations.isLoading} + isFetching={operations.isFetching} + page={operations.data} + /> + )} +
+ ); +} diff --git a/apps/frontend/src/pages/broker/BrokerAllocationChart.tsx b/apps/frontend/src/pages/broker/BrokerAllocationChart.tsx new file mode 100644 index 0000000..d4274ac --- /dev/null +++ b/apps/frontend/src/pages/broker/BrokerAllocationChart.tsx @@ -0,0 +1,78 @@ +import type { BrokerPortfolio } from '../../api/responses'; +import { buildBrokerAllocation } from './brokerAllocation'; + +const RADIUS = 44; +const CIRCUMFERENCE = 2 * Math.PI * RADIUS; + +function formatMoneyValue(value: number) { + return new Intl.NumberFormat('ru-RU', { + style: 'currency', + currency: 'RUB', + maximumFractionDigits: 2, + }).format(value); +} + +export function BrokerAllocationChart({ portfolio }: { portfolio: BrokerPortfolio }) { + const { sectors, negative } = buildBrokerAllocation(portfolio); + let consumedPercent = 0; + const arcs = sectors.map((sector) => { + const dashOffset = -((consumedPercent / 100) * CIRCUMFERENCE); + const dashLength = (sector.percent / 100) * CIRCUMFERENCE; + consumedPercent += sector.percent; + return { ...sector, dashOffset, dashLength }; + }); + + return ( +
+ + Структура брокерского портфеля + {arcs.map((sector) => ( + + ))} + +
+ {sectors.length === 0 ? ( +

Нет данных для распределения

+ ) : ( +
    + {sectors.map((sector) => ( +
  • +
  • + ))} +
+ )} + {negative.length > 0 && ( +
    + {negative.map((item) => ( +
  • + {item.label}: отрицательное значение {formatMoneyValue(item.value)} +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx b/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx index 62f32b4..baaa12b 100644 --- a/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx +++ b/apps/frontend/src/pages/broker/BrokerOperationsTable.tsx @@ -1,4 +1,5 @@ import { Link } from 'react-router-dom'; +import type { ReactNode } from 'react'; import type { BrokerMoney, BrokerOperation, BrokerOperationsPage } from '../../api/responses'; import { getBrokerInstrumentPath, @@ -94,24 +95,19 @@ const pagButtonDisabledStyle = { } satisfies React.CSSProperties; export function BrokerOperationsTable({ + title, + headerAction, + emptyMessage, isLoading, isFetching, page, - pageNumber, - canGoBack, - canGoForward, - onPrevious, - onNext, -}: { - isLoading: boolean; - isFetching: boolean; - page: BrokerOperationsPage | undefined; - pageNumber: number; - canGoBack: boolean; - canGoForward: boolean; - onPrevious: () => void; - onNext: () => void; -}) { + pagination, +}: BrokerOperationsTableProps) { + const pageNumber = pagination?.pageNumber; + const canGoBack = pagination?.canGoBack ?? false; + const canGoForward = pagination?.canGoForward ?? false; + const onPrevious = pagination?.onPrevious; + const onNext = pagination?.onNext; const operations = page?.items ?? []; return ( @@ -125,50 +121,53 @@ export function BrokerOperationsTable({ marginBottom: 12, }} > -

Операции

-
- - - {pageNumber} - - -
+

{title}

+ {headerAction} + {pagination && ( +
+ + + {pageNumber} + + +
+ )} {isLoading ? ( @@ -197,7 +196,7 @@ export function BrokerOperationsTable({ ) : operations.length === 0 && !isFetching ? ( -

Операций за выбранный период нет

+

{emptyMessage}

) : (
@@ -246,7 +245,7 @@ export function BrokerOperationsTable({
- Загрузка страницы {pageNumber}… + {pagination ? `Загрузка страницы ${pageNumber}…` : 'Обновление операций…'}
)} @@ -255,3 +254,19 @@ export function BrokerOperationsTable({ ); } + +type BrokerOperationsTableProps = { + title: string; + headerAction?: ReactNode; + emptyMessage: string; + isLoading: boolean; + isFetching: boolean; + page: BrokerOperationsPage | undefined; + pagination?: { + pageNumber: number; + canGoBack: boolean; + canGoForward: boolean; + onPrevious: () => void; + onNext: () => void; + }; +}; diff --git a/apps/frontend/src/pages/broker/BrokerPages.test.tsx b/apps/frontend/src/pages/broker/BrokerPages.test.tsx index 919f964..544fcba 100644 --- a/apps/frontend/src/pages/broker/BrokerPages.test.tsx +++ b/apps/frontend/src/pages/broker/BrokerPages.test.tsx @@ -8,9 +8,10 @@ import * as accountHook from '../../hooks/useBrokerAccounts'; import * as operationsHook from '../../hooks/useBrokerOperations'; import * as portfolioHook from '../../hooks/useBrokerPortfolio'; import * as positionsHook from '../../hooks/useBrokerPositions'; -import type { BrokerPosition } from '../../api/responses'; +import type { BrokerPortfolio, BrokerPosition } from '../../api/responses'; import { BrokerAccountLayout, useBrokerAccountContext } from './BrokerAccountLayout'; import { BrokerAccountDetailPage } from './BrokerAccountDetailPage'; +import { BrokerAccountOverviewPage } from './BrokerAccountOverviewPage'; import { BrokerAccountsPage } from './BrokerAccountsPage'; function renderWithClient(ui: ReactElement, initialEntries = ['/broker']) { @@ -43,6 +44,70 @@ function createPosition(input: Partial): BrokerPosition { }; } +function createOverviewPortfolio(overrides: Partial = {}): BrokerPortfolio { + return { + account: { + id: 'acc-1', + type: 'brokerage', + name: 'Основной брокерский счёт', + status: 'ACCOUNT_STATUS_OPEN', + openedAt: null, + accessLevel: null, + }, + positionCounts: { shares: 14, bonds: 8, etf: 2, other: 1 }, + totals: { + shares: { currency: 'RUB', units: '750000', nano: 0, value: 750_000 }, + bonds: { currency: 'RUB', units: '300000', nano: 0, value: 300_000 }, + etf: { currency: 'RUB', units: '50000', nano: 0, value: 50_000 }, + currencies: { currency: 'RUB', units: '100000', nano: 0, value: 100_000 }, + futures: null, + options: null, + structuredProducts: null, + dfa: null, + portfolio: { currency: 'RUB', units: '1250000', nano: 0, value: 1_250_000 }, + }, + yields: { + expectedPercent: 12.4, + daily: { currency: 'RUB', units: '1500', nano: 0, value: 1_500 }, + dailyPercent: 0.12, + }, + cash: [ + { currency: 'RUB', units: '100000', nano: 0, value: 100_000 }, + { currency: 'USD', units: '250', nano: 0, value: 250 }, + ], + blockedCash: [], + asOf: '2026-06-19T00:00:00.000Z', + ...overrides, + }; +} + +function mockOverviewOperations(overrides: Record = {}) { + return vi.spyOn(operationsHook, 'useBrokerOperations').mockReturnValue({ + data: { + accountId: 'acc-1', + items: [], + nextCursor: null, + hasNext: false, + asOf: '2026-06-19T00:00:00.000Z', + }, + isLoading: false, + isFetching: false, + error: null, + ...overrides, + } as any); +} + +function renderOverview() { + return renderWithClient( + + }> + } /> + + , + ['/broker/acc-1'], + ); +} + /** Spy on useBrokerPositions and return only positions matching query.type . */ function mockUseBrokerPositions(...positions: BrokerPosition[]) { return vi.spyOn(positionsHook, 'useBrokerPositions').mockImplementation((_accountId, query) => { @@ -622,4 +687,216 @@ describe('Broker pages', () => { expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined }); expect(withinOperations.getByText('1')).toBeInTheDocument(); }); + + it('renders the broker account overview with allocation, asset links and recent operations', () => { + vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ + data: createOverviewPortfolio(), + isLoading: false, + isFetching: false, + error: null, + } as any); + const operationsSpy = mockOverviewOperations({ + data: { + accountId: 'acc-1', + items: [ + { + cursor: 'recent-1', + accountId: 'acc-1', + id: 'recent-1', + parentOperationId: null, + date: '2026-06-18T10:00:00.000Z', + category: 'income', + type: 'OPERATION_TYPE_COUPON', + description: 'Coupon', + name: 'Купон ОФЗ', + state: 'OPERATION_STATE_EXECUTED', + instrumentUid: 'bond-uid', + figi: null, + ticker: 'SU26238RMFS5', + classCode: 'TQOB', + instrumentType: 'bond', + payment: { currency: 'RUB', units: '120', nano: 0, value: 120 }, + price: null, + commission: null, + yield: null, + accruedInt: null, + quantity: 2, + quantityDone: 2, + }, + ], + nextCursor: null, + hasNext: false, + asOf: '2026-06-19T00:00:00.000Z', + }, + }); + + renderOverview(); + + expect(screen.getByText(/1[\s\u00a0]?250[\s\u00a0]?000/)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Акции.*14 позиций/i })).toHaveAttribute( + 'href', + '/broker/acc-1/shares', + ); + expect(screen.getByRole('link', { name: /Облигации.*8 выпусков/i })).toHaveAttribute( + 'href', + '/broker/acc-1/bonds', + ); + const allocationChart = screen.getByRole('img', { + name: 'Структура брокерского портфеля', + }); + expect(allocationChart).toBeInTheDocument(); + expect(screen.getByTitle('Структура брокерского портфеля')).toBeInTheDocument(); + const circumference = 2 * Math.PI * 44; + const allocationArcs = allocationChart.querySelectorAll('circle'); + expect(allocationArcs[0]).toHaveAttribute( + 'stroke-dasharray', + `${circumference * 0.6} ${circumference - circumference * 0.6}`, + ); + expect(allocationArcs[1]).toHaveAttribute('stroke-dashoffset', `${-circumference * 0.6}`); + expect(screen.getByText(/Акции:.*750[\s\u00a0]?000.*60\.0%/)).toBeInTheDocument(); + expect(document.querySelector('.broker-allocation__swatch')).toHaveAttribute( + 'aria-hidden', + 'true', + ); + expect(screen.getByRole('link', { name: 'Вся история' })).toHaveAttribute( + 'href', + '/broker/acc-1/operations', + ); + expect(operationsSpy).toHaveBeenCalledWith('acc-1', { limit: 5 }); + expect(screen.queryByRole('heading', { name: 'Позиции' })).not.toBeInTheDocument(); + expect(screen.queryByRole('columnheader', { name: 'Количество' })).not.toBeInTheDocument(); + }); + + it.each([ + [1, '1 позиция', '1 выпуск'], + [2, '2 позиции', '2 выпуска'], + [5, '5 позиций', '5 выпусков'], + ])('uses Russian asset count plurals for %i', (count, sharesText, bondsText) => { + vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ + data: createOverviewPortfolio({ + positionCounts: { shares: count, bonds: count, etf: 0, other: 0 }, + }), + isLoading: false, + isFetching: false, + error: null, + } as any); + mockOverviewOperations(); + + renderOverview(); + + expect( + screen.getByRole('link', { name: new RegExp(`Акции.*${sharesText}`) }), + ).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: new RegExp(`Облигации.*${bondsText}`) }), + ).toBeInTheDocument(); + }); + + it('renders an overview skeleton while the portfolio is loading', () => { + vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ + data: undefined, + isLoading: true, + isFetching: true, + error: null, + } as any); + mockOverviewOperations(); + + const { container } = renderOverview(); + + expect(container.querySelector('.broker-overview')).toBeInTheDocument(); + expect(container.querySelectorAll('.skeleton').length).toBeGreaterThan(0); + expect( + screen.getByRole('navigation', { name: 'Разделы брокерского счёта' }), + ).toBeInTheDocument(); + }); + + it('keeps account navigation visible when the overview portfolio fails', () => { + vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ + data: undefined, + isLoading: false, + isFetching: false, + error: new Error('portfolio failed'), + } as any); + mockOverviewOperations(); + + renderOverview(); + + expect(screen.getByRole('alert')).toHaveTextContent('Не удалось загрузить сводку счёта'); + expect( + screen.getByRole('navigation', { name: 'Разделы брокерского счёта' }), + ).toBeInTheDocument(); + }); + + it('keeps the overview summary and navigation when recent operations fail', () => { + vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ + data: createOverviewPortfolio(), + isLoading: false, + isFetching: false, + error: null, + } as any); + mockOverviewOperations({ data: undefined, error: new Error('operations failed') }); + + renderOverview(); + + expect(screen.getByRole('alert')).toHaveTextContent('Не удалось загрузить последние операции'); + expect(screen.getByText(/1[\s\u00a0]?250[\s\u00a0]?000/)).toBeInTheDocument(); + expect( + screen.getByRole('navigation', { name: 'Разделы брокерского счёта' }), + ).toBeInTheDocument(); + }); + + it('renders the overview recent-operations empty state and history link', () => { + vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ + data: createOverviewPortfolio(), + isLoading: false, + isFetching: false, + error: null, + } as any); + mockOverviewOperations(); + + renderOverview(); + + expect(screen.getByText('Операций с начала текущего года нет')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Вся история' })).toHaveAttribute( + 'href', + '/broker/acc-1/operations', + ); + }); + + it('renders negative allocation values as text instead of chart sectors', () => { + const portfolio = createOverviewPortfolio(); + portfolio.totals.bonds = { currency: 'RUB', units: '-10000', nano: 0, value: -10_000 }; + vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ + data: portfolio, + isLoading: false, + isFetching: false, + error: null, + } as any); + mockOverviewOperations(); + + renderOverview(); + + const negativeList = screen.getByRole('list', { + name: 'Отрицательные значения распределения', + }); + expect( + within(negativeList).getByText(/Облигации: отрицательное значение.*10[\s\u00a0]?000/), + ).toBeInTheDocument(); + }); + + it('renders an empty allocation state when the portfolio total has no allocation data', () => { + const portfolio = createOverviewPortfolio(); + portfolio.totals.portfolio = { currency: 'RUB', units: '0', nano: 0, value: 0 }; + vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({ + data: portfolio, + isLoading: false, + isFetching: false, + error: null, + } as any); + mockOverviewOperations(); + + renderOverview(); + + expect(screen.getByText('Нет данных для распределения')).toBeInTheDocument(); + }); }); diff --git a/apps/frontend/src/styles.css b/apps/frontend/src/styles.css index 5e3c383..7f04025 100644 --- a/apps/frontend/src/styles.css +++ b/apps/frontend/src/styles.css @@ -115,6 +115,103 @@ a { min-width: 0; } +.broker-overview { + display: grid; + gap: 24px; +} + +.broker-overview__summary, +.broker-overview__assets { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.broker-overview__card, +.broker-allocation { + padding: 16px; + border: 1px solid #e0e0e0; + border-radius: var(--border-radius); + background: var(--color-surface); +} + +.broker-overview__card { + display: grid; + align-content: start; + gap: 8px; +} + +.broker-overview__label, +.broker-overview__card > span { + color: var(--color-text-secondary); +} + +.broker-overview__total { + font-size: 24px; +} + +.broker-overview__cash, +.broker-allocation ul { + list-style: none; + display: grid; + gap: 8px; +} + +.broker-overview__cash li { + display: flex; + justify-content: space-between; + gap: 12px; +} + +.broker-overview__asset-link { + color: var(--color-text); +} + +.broker-overview__asset-title { + color: var(--color-primary); + font-size: 18px; +} + +.broker-overview__asset-link:focus-visible { + outline: 3px solid color-mix(in srgb, var(--color-primary) 35%, transparent); + outline-offset: 2px; +} + +.broker-allocation { + display: flex; + align-items: center; + gap: 20px; +} + +.broker-allocation svg { + width: 160px; + max-width: 40%; + flex: 0 0 auto; +} + +.broker-allocation figcaption { + display: grid; + gap: 12px; +} + +.broker-allocation li { + display: flex; + align-items: center; + gap: 8px; +} + +.broker-allocation__swatch { + width: 12px; + height: 12px; + border: 1px solid color-mix(in srgb, var(--color-text) 20%, transparent); + border-radius: 2px; + flex: 0 0 auto; +} + +.broker-allocation__negative { + color: var(--color-negative); +} + @media (max-width: 720px) { .broker-account__workspace { gap: 16px; @@ -131,4 +228,20 @@ a { flex: none; white-space: nowrap; } + + .broker-overview__summary, + .broker-overview__assets { + grid-template-columns: 1fr; + } + + .broker-allocation { + align-items: stretch; + flex-direction: column; + } + + .broker-allocation svg { + max-width: 180px; + width: 100%; + align-self: center; + } }