+
{pagination ? `Загрузка страницы ${pageNumber}…` : 'Обновление операций…'}
diff --git a/apps/frontend/src/pages/broker/BrokerPages.test.tsx b/apps/frontend/src/pages/broker/BrokerPages.test.tsx
index 544fcba..4d7dba3 100644
--- a/apps/frontend/src/pages/broker/BrokerPages.test.tsx
+++ b/apps/frontend/src/pages/broker/BrokerPages.test.tsx
@@ -668,8 +668,8 @@ describe('Broker pages', () => {
const operationsSection = screen.getByRole('heading', { name: 'Операции' }).closest('section')!;
const withinOperations = within(operationsSection);
- const nextButton = withinOperations.getByRole('button', { name: '→' });
- const prevButton = withinOperations.getByRole('button', { name: '←' });
+ const nextButton = withinOperations.getByRole('button', { name: 'Следующая страница' });
+ const prevButton = withinOperations.getByRole('button', { name: 'Предыдущая страница' });
expect(prevButton).toBeDisabled();
expect(nextButton).not.toBeDisabled();
@@ -899,4 +899,198 @@ describe('Broker pages', () => {
expect(screen.getByText('Нет данных для распределения')).toBeInTheDocument();
});
+
+ it('bounds visual allocation arcs when textual percentages exceed 100%', () => {
+ const portfolio = createOverviewPortfolio();
+ portfolio.totals.portfolio = { currency: 'RUB', units: '100', nano: 0, value: 100 };
+ portfolio.totals.shares = { currency: 'RUB', units: '120', nano: 0, value: 120 };
+ portfolio.totals.bonds = { currency: 'RUB', units: '-20', nano: 0, value: -20 };
+ portfolio.totals.etf = null;
+ portfolio.totals.currencies = null;
+ vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
+ data: portfolio,
+ isLoading: false,
+ isFetching: false,
+ error: null,
+ } as any);
+ mockOverviewOperations();
+
+ renderOverview();
+
+ const chart = screen.getByRole('img', { name: 'Структура брокерского портфеля' });
+ const circumference = 2 * Math.PI * 44;
+ const dashLengths = Array.from(chart.querySelectorAll('circle')).map((circle) => {
+ const parts = circle
+ .getAttribute('stroke-dasharray')!
+ .split(' ')
+ .map((part) => Number(part));
+ expect(parts.every((part) => Number.isFinite(part) && part >= 0)).toBe(true);
+ expect(Math.abs(Number(circle.getAttribute('stroke-dashoffset')))).toBeLessThanOrEqual(
+ circumference,
+ );
+ return parts[0];
+ });
+ expect(dashLengths.reduce((sum, value) => sum + value, 0)).toBeLessThanOrEqual(circumference);
+ expect(screen.getByText(/Акции:.*120.*120\.0%/)).toBeInTheDocument();
+ });
+
+ it('uses the portfolio currency in the allocation legend', () => {
+ const portfolio = createOverviewPortfolio();
+ portfolio.totals.portfolio!.currency = 'USD';
+ vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
+ data: portfolio,
+ isLoading: false,
+ isFetching: false,
+ error: null,
+ } as any);
+ mockOverviewOperations();
+
+ renderOverview();
+
+ const sharesLegend = screen.getByText(/Акции:.*60\.0%/);
+ expect(sharesLegend).toHaveTextContent('$');
+ expect(sharesLegend).not.toHaveTextContent('₽');
+ });
+
+ it('falls back to an available asset currency when the portfolio currency is absent', () => {
+ const portfolio = createOverviewPortfolio();
+ for (const total of Object.values(portfolio.totals)) {
+ if (total) total.currency = 'USD';
+ }
+ portfolio.totals.portfolio!.currency = '';
+ vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
+ data: portfolio,
+ isLoading: false,
+ isFetching: false,
+ error: null,
+ } as any);
+ mockOverviewOperations();
+
+ renderOverview();
+
+ const sharesLegend = screen.getByText(/Акции:.*60\.0%/);
+ expect(sharesLegend).toHaveTextContent('$');
+ expect(sharesLegend).not.toHaveTextContent('₽');
+ });
+
+ it('distinguishes unavailable allocation percentages from a genuine zero', () => {
+ const unavailable = createOverviewPortfolio();
+ unavailable.totals.shares = null;
+ unavailable.totals.bonds = { currency: 'RUB', units: '0', nano: 0, value: 0 };
+ vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
+ data: unavailable,
+ isLoading: false,
+ isFetching: false,
+ error: null,
+ } as any);
+ mockOverviewOperations();
+
+ renderOverview();
+
+ const shares = screen.getByRole('link', { name: /Акции.*14 позиций/ });
+ const bonds = screen.getByRole('link', { name: /Облигации.*8 выпусков/ });
+ expect(within(shares).getAllByText('—')).toHaveLength(2);
+ expect(within(bonds).getByText('0.0%')).toBeInTheDocument();
+ });
+
+ it.each([null, 0, -10])(
+ 'shows an unavailable card percentage for portfolio total %s',
+ (portfolioTotal) => {
+ const portfolio = createOverviewPortfolio();
+ portfolio.totals.portfolio =
+ portfolioTotal === null
+ ? null
+ : { currency: 'RUB', units: String(portfolioTotal), nano: 0, value: portfolioTotal };
+ vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
+ data: portfolio,
+ isLoading: false,
+ isFetching: false,
+ error: null,
+ } as any);
+ mockOverviewOperations();
+
+ renderOverview();
+
+ const shares = screen.getByRole('link', { name: /Акции.*14 позиций/ });
+ expect(within(shares).getByText('—')).toBeInTheDocument();
+ expect(within(shares).queryByText('0.0%')).not.toBeInTheDocument();
+ },
+ );
+
+ it('renders duplicate cash currencies without duplicate React keys', () => {
+ const portfolio = createOverviewPortfolio();
+ portfolio.cash = [
+ { currency: 'RUB', units: '100', nano: 0, value: 100 },
+ { currency: 'RUB', units: '200', nano: 0, value: 200 },
+ ];
+ vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
+ data: portfolio,
+ isLoading: false,
+ isFetching: false,
+ error: null,
+ } as any);
+ mockOverviewOperations();
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
+
+ renderOverview();
+
+ expect(screen.getAllByText('RUB')).toHaveLength(2);
+ expect(consoleError.mock.calls.flat().join(' ')).not.toContain(
+ 'Encountered two children with the same key',
+ );
+ consoleError.mockRestore();
+ });
+
+ it('marks the recent operations table busy while retaining its rows', () => {
+ vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
+ data: createOverviewPortfolio(),
+ isLoading: false,
+ isFetching: false,
+ error: null,
+ } as any);
+ mockOverviewOperations({
+ data: {
+ accountId: 'acc-1',
+ items: [
+ {
+ cursor: 'recent-busy',
+ accountId: 'acc-1',
+ id: 'recent-busy',
+ 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',
+ },
+ isFetching: true,
+ });
+
+ renderOverview();
+
+ const operations = screen
+ .getByRole('heading', { name: 'Последние операции' })
+ .closest('section')!;
+ expect(operations).toHaveAttribute('aria-busy', 'true');
+ expect(screen.getByRole('status')).toHaveTextContent('Обновление операций…');
+ expect(screen.getByText('SU26238RMFS5')).toBeInTheDocument();
+ });
});
diff --git a/apps/frontend/src/pages/broker/brokerAllocation.test.ts b/apps/frontend/src/pages/broker/brokerAllocation.test.ts
index 6fb9165..ce66737 100644
--- a/apps/frontend/src/pages/broker/brokerAllocation.test.ts
+++ b/apps/frontend/src/pages/broker/brokerAllocation.test.ts
@@ -128,4 +128,17 @@ describe('buildBrokerAllocation', () => {
negative: [],
});
});
+
+ it('preserves named negative components when the portfolio total is nonpositive', () => {
+ expect(buildBrokerAllocation(portfolio({ shares: 100, bonds: -20, portfolio: 0 }))).toEqual({
+ total: 0,
+ sectors: [],
+ negative: [{ key: 'bonds', label: 'Облигации', value: -20, color: '#e5a33c' }],
+ });
+ expect(buildBrokerAllocation(portfolio({ currencies: -30, etf: 5, portfolio: -10 }))).toEqual({
+ total: -10,
+ sectors: [],
+ negative: [{ key: 'cash', label: 'Деньги', value: -30, color: '#7b63cf' }],
+ });
+ });
});
diff --git a/apps/frontend/src/pages/broker/brokerAllocation.ts b/apps/frontend/src/pages/broker/brokerAllocation.ts
index 20c29f6..9455080 100644
--- a/apps/frontend/src/pages/broker/brokerAllocation.ts
+++ b/apps/frontend/src/pages/broker/brokerAllocation.ts
@@ -26,12 +26,30 @@ export function buildBrokerAllocation(portfolio: BrokerPortfolio): {
negative: BrokerNegativeAllocationItem[];
} {
const total = portfolio.totals.portfolio?.value ?? 0;
- if (total <= 0) return { total, sectors: [], negative: [] };
-
const shares = portfolio.totals.shares?.value ?? 0;
const bonds = portfolio.totals.bonds?.value ?? 0;
const etf = portfolio.totals.etf?.value ?? 0;
const cash = portfolio.totals.currencies?.value ?? 0;
+ const namedValues: Record
, number> = {
+ shares,
+ bonds,
+ etf,
+ cash,
+ };
+
+ if (total <= 0) {
+ const negative = ALLOCATION_CONFIG.filter(
+ (
+ item,
+ ): item is (typeof ALLOCATION_CONFIG)[number] & {
+ key: Exclude;
+ } => item.key !== 'other',
+ )
+ .filter((item) => namedValues[item.key] < 0)
+ .map((item) => ({ ...item, value: namedValues[item.key] }));
+ return { total, sectors: [], negative };
+ }
+
const mappedTotal = shares + bonds + etf + cash;
const residual = total - mappedTotal;
const residualTolerance =