@@ -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;
+ }
}