fix: harden broker overview edge cases
This commit is contained in:
parent
a19ad67a89
commit
b82771107d
@ -7,7 +7,7 @@ import { BrokerAllocationChart } from './BrokerAllocationChart';
|
||||
import { BrokerOperationsTable } from './BrokerOperationsTable';
|
||||
|
||||
function formatMoney(value: BrokerMoney | null | undefined) {
|
||||
if (!value) return '-';
|
||||
if (!value) return '—';
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: value.currency || 'RUB',
|
||||
@ -16,7 +16,7 @@ function formatMoney(value: BrokerMoney | null | undefined) {
|
||||
}
|
||||
|
||||
function formatPercent(value: number | null) {
|
||||
if (value === null) return '-';
|
||||
if (value === null) return '—';
|
||||
return `${new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 }).format(value)}%`;
|
||||
}
|
||||
|
||||
@ -47,8 +47,8 @@ function BrokerSummary({ portfolio }: { portfolio: BrokerPortfolio }) {
|
||||
<span>Нет денежных остатков</span>
|
||||
) : (
|
||||
<ul className="broker-overview__cash">
|
||||
{portfolio.cash.map((money) => (
|
||||
<li key={money.currency}>
|
||||
{portfolio.cash.map((money, index) => (
|
||||
<li key={`${money.currency}-${index}`}>
|
||||
<span>{money.currency}</span>
|
||||
<strong>{formatMoney(money)}</strong>
|
||||
</li>
|
||||
@ -61,10 +61,14 @@ function BrokerSummary({ portfolio }: { portfolio: BrokerPortfolio }) {
|
||||
}
|
||||
|
||||
function allocationPercent(value: BrokerMoney | null, total: BrokerMoney | null) {
|
||||
if (!value || !total || total.value <= 0) return 0;
|
||||
if (!value || !total || total.value <= 0) return null;
|
||||
return (value.value / total.value) * 100;
|
||||
}
|
||||
|
||||
function formatAllocationPercent(value: number | null) {
|
||||
return value === null ? '—' : `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function BrokerAssetCards({
|
||||
accountId,
|
||||
portfolio,
|
||||
@ -103,7 +107,9 @@ function BrokerAssetCards({
|
||||
{card.count} {card.countLabel}
|
||||
</span>
|
||||
<span>{formatMoney(card.value)}</span>
|
||||
<span>{allocationPercent(card.value, portfolio.totals.portfolio).toFixed(1)}%</span>
|
||||
<span>
|
||||
{formatAllocationPercent(allocationPercent(card.value, portfolio.totals.portfolio))}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</section>
|
||||
|
||||
@ -4,21 +4,31 @@ import { buildBrokerAllocation } from './brokerAllocation';
|
||||
const RADIUS = 44;
|
||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
||||
|
||||
function formatMoneyValue(value: number) {
|
||||
function formatMoneyValue(value: number, currency: string) {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: 'RUB',
|
||||
currency,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function allocationCurrency(portfolio: BrokerPortfolio) {
|
||||
return (
|
||||
portfolio.totals.portfolio?.currency ||
|
||||
Object.values(portfolio.totals).find((total) => total?.currency)?.currency ||
|
||||
'RUB'
|
||||
);
|
||||
}
|
||||
|
||||
export function BrokerAllocationChart({ portfolio }: { portfolio: BrokerPortfolio }) {
|
||||
const { sectors, negative } = buildBrokerAllocation(portfolio);
|
||||
let consumedPercent = 0;
|
||||
const currency = allocationCurrency(portfolio);
|
||||
let remaining = CIRCUMFERENCE;
|
||||
const arcs = sectors.map((sector) => {
|
||||
const dashOffset = -((consumedPercent / 100) * CIRCUMFERENCE);
|
||||
const dashLength = (sector.percent / 100) * CIRCUMFERENCE;
|
||||
consumedPercent += sector.percent;
|
||||
const dashOffset = -(CIRCUMFERENCE - remaining);
|
||||
const rawDashLength = (sector.percent / 100) * CIRCUMFERENCE;
|
||||
const dashLength = Math.min(Math.max(rawDashLength, 0), remaining);
|
||||
remaining = Math.max(0, remaining - dashLength);
|
||||
return { ...sector, dashOffset, dashLength };
|
||||
});
|
||||
|
||||
@ -54,7 +64,8 @@ export function BrokerAllocationChart({ portfolio }: { portfolio: BrokerPortfoli
|
||||
style={{ background: sector.color }}
|
||||
/>
|
||||
<span>
|
||||
{sector.label}: {formatMoneyValue(sector.value)} · {sector.percent.toFixed(1)}%
|
||||
{sector.label}: {formatMoneyValue(sector.value, currency)} ·{' '}
|
||||
{sector.percent.toFixed(1)}%
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
@ -67,7 +78,7 @@ export function BrokerAllocationChart({ portfolio }: { portfolio: BrokerPortfoli
|
||||
>
|
||||
{negative.map((item) => (
|
||||
<li key={item.key}>
|
||||
{item.label}: отрицательное значение {formatMoneyValue(item.value)}
|
||||
{item.label}: отрицательное значение {formatMoneyValue(item.value, currency)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@ -111,7 +111,7 @@ export function BrokerOperationsTable({
|
||||
const operations = page?.items ?? [];
|
||||
|
||||
return (
|
||||
<section>
|
||||
<section aria-busy={isFetching}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
@ -127,6 +127,7 @@ export function BrokerOperationsTable({
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Предыдущая страница"
|
||||
onClick={onPrevious}
|
||||
disabled={!canGoBack || isFetching}
|
||||
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||
@ -153,6 +154,7 @@ export function BrokerOperationsTable({
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Следующая страница"
|
||||
onClick={onNext}
|
||||
disabled={!canGoForward || isFetching}
|
||||
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||
@ -242,8 +244,8 @@ export function BrokerOperationsTable({
|
||||
</table>
|
||||
</div>
|
||||
{isFetching && (
|
||||
<div className="table-loading-overlay">
|
||||
<div className="loading-spinner" />
|
||||
<div className="table-loading-overlay" role="status">
|
||||
<div className="loading-spinner" aria-hidden="true" />
|
||||
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
||||
{pagination ? `Загрузка страницы ${pageNumber}…` : 'Обновление операций…'}
|
||||
</span>
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<Exclude<BrokerAllocationKey, 'other'>, number> = {
|
||||
shares,
|
||||
bonds,
|
||||
etf,
|
||||
cash,
|
||||
};
|
||||
|
||||
if (total <= 0) {
|
||||
const negative = ALLOCATION_CONFIG.filter(
|
||||
(
|
||||
item,
|
||||
): item is (typeof ALLOCATION_CONFIG)[number] & {
|
||||
key: Exclude<BrokerAllocationKey, 'other'>;
|
||||
} => 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 =
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user