feat: add broker account overview
This commit is contained in:
parent
1c26d2a3eb
commit
a19ad67a89
@ -121,14 +121,18 @@ export function BrokerAccountDetailPage() {
|
||||
<BrokerPositionsSection accountId={accountId!} />
|
||||
|
||||
<BrokerOperationsTable
|
||||
title="Операции"
|
||||
emptyMessage="Операций за выбранный период нет"
|
||||
isLoading={operations.isLoading}
|
||||
isFetching={operations.isFetching}
|
||||
page={operations.data}
|
||||
pageNumber={operationCursorStack.length + 1}
|
||||
canGoBack={operationCursorStack.length > 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,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
172
apps/frontend/src/pages/broker/BrokerAccountOverviewPage.tsx
Normal file
172
apps/frontend/src/pages/broker/BrokerAccountOverviewPage.tsx
Normal file
@ -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 (
|
||||
<section className="broker-overview__summary" aria-label="Сводка счёта">
|
||||
<div className="broker-overview__card">
|
||||
<span className="broker-overview__label">Стоимость портфеля</span>
|
||||
<strong className="broker-overview__total">
|
||||
{formatMoney(portfolio.totals.portfolio)}
|
||||
</strong>
|
||||
<span>За день: {formatMoney(portfolio.yields.daily)}</span>
|
||||
<span>Дневная доходность: {formatPercent(portfolio.yields.dailyPercent)}</span>
|
||||
<span>Ожидаемая доходность: {formatPercent(portfolio.yields.expectedPercent)}</span>
|
||||
</div>
|
||||
<div className="broker-overview__card">
|
||||
<span className="broker-overview__label">Денежный остаток</span>
|
||||
{portfolio.cash.length === 0 ? (
|
||||
<span>Нет денежных остатков</span>
|
||||
) : (
|
||||
<ul className="broker-overview__cash">
|
||||
{portfolio.cash.map((money) => (
|
||||
<li key={money.currency}>
|
||||
<span>{money.currency}</span>
|
||||
<strong>{formatMoney(money)}</strong>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="broker-overview__assets" aria-label="Основные классы активов">
|
||||
{cards.map((card) => (
|
||||
<Link
|
||||
className="broker-overview__card broker-overview__asset-link"
|
||||
key={card.label}
|
||||
to={card.path}
|
||||
>
|
||||
<strong className="broker-overview__asset-title">{card.label}</strong>
|
||||
<span>
|
||||
{card.count} {card.countLabel}
|
||||
</span>
|
||||
<span>{formatMoney(card.value)}</span>
|
||||
<span>{allocationPercent(card.value, portfolio.totals.portfolio).toFixed(1)}%</span>
|
||||
</Link>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function BrokerOverviewSkeleton() {
|
||||
return (
|
||||
<div className="broker-overview" aria-label="Загрузка сводки счёта">
|
||||
<div className="broker-overview__summary">
|
||||
{[1, 2].map((item) => (
|
||||
<div className="broker-overview__card" key={item}>
|
||||
<SkeletonBlock height={16} width="45%" />
|
||||
<SkeletonBlock height={28} width="70%" />
|
||||
<SkeletonBlock height={16} width="55%" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="broker-allocation">
|
||||
<SkeletonBlock height={160} width={160} borderRadius={80} />
|
||||
<SkeletonBlock height={80} width="60%" />
|
||||
</div>
|
||||
<div className="broker-overview__assets">
|
||||
{[1, 2].map((item) => (
|
||||
<div className="broker-overview__card" key={item}>
|
||||
<SkeletonBlock height={20} width="35%" />
|
||||
<SkeletonBlock height={16} width="55%" />
|
||||
<SkeletonBlock height={16} width="70%" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BrokerAccountOverviewPage() {
|
||||
const { accountId, portfolio } = useBrokerAccountContext();
|
||||
const operations = useBrokerOperations(accountId, { limit: 5 });
|
||||
|
||||
if (portfolio.isLoading) return <BrokerOverviewSkeleton />;
|
||||
if (portfolio.error || !portfolio.data) {
|
||||
return <p role="alert">Не удалось загрузить сводку счёта</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="broker-overview">
|
||||
<BrokerSummary portfolio={portfolio.data} />
|
||||
<BrokerAllocationChart portfolio={portfolio.data} />
|
||||
<BrokerAssetCards accountId={accountId} portfolio={portfolio.data} />
|
||||
{operations.error ? (
|
||||
<p role="alert">Не удалось загрузить последние операции</p>
|
||||
) : (
|
||||
<BrokerOperationsTable
|
||||
title="Последние операции"
|
||||
headerAction={
|
||||
<Link to={`/broker/${encodeURIComponent(accountId)}/operations`}>Вся история</Link>
|
||||
}
|
||||
emptyMessage="Операций с начала текущего года нет"
|
||||
isLoading={operations.isLoading}
|
||||
isFetching={operations.isFetching}
|
||||
page={operations.data}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
apps/frontend/src/pages/broker/BrokerAllocationChart.tsx
Normal file
78
apps/frontend/src/pages/broker/BrokerAllocationChart.tsx
Normal file
@ -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 (
|
||||
<figure className="broker-allocation">
|
||||
<svg role="img" aria-label="Структура брокерского портфеля" viewBox="0 0 120 120">
|
||||
<title>Структура брокерского портфеля</title>
|
||||
{arcs.map((sector) => (
|
||||
<circle
|
||||
key={sector.key}
|
||||
cx="60"
|
||||
cy="60"
|
||||
r={RADIUS}
|
||||
fill="none"
|
||||
stroke={sector.color}
|
||||
strokeWidth="14"
|
||||
strokeDasharray={`${sector.dashLength} ${CIRCUMFERENCE - sector.dashLength}`}
|
||||
strokeDashoffset={sector.dashOffset}
|
||||
transform="rotate(-90 60 60)"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
<figcaption>
|
||||
{sectors.length === 0 ? (
|
||||
<p>Нет данных для распределения</p>
|
||||
) : (
|
||||
<ul>
|
||||
{sectors.map((sector) => (
|
||||
<li key={sector.key}>
|
||||
<span
|
||||
className="broker-allocation__swatch"
|
||||
aria-hidden="true"
|
||||
style={{ background: sector.color }}
|
||||
/>
|
||||
<span>
|
||||
{sector.label}: {formatMoneyValue(sector.value)} · {sector.percent.toFixed(1)}%
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{negative.length > 0 && (
|
||||
<ul
|
||||
className="broker-allocation__negative"
|
||||
aria-label="Отрицательные значения распределения"
|
||||
>
|
||||
{negative.map((item) => (
|
||||
<li key={item.key}>
|
||||
{item.label}: отрицательное значение {formatMoneyValue(item.value)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
}}
|
||||
>
|
||||
<h2 style={{ fontSize: 20, margin: 0 }}>Операции</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPrevious}
|
||||
disabled={!canGoBack || isFetching}
|
||||
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||
>
|
||||
{isFetching ? (
|
||||
<span
|
||||
className="loading-spinner"
|
||||
style={{ width: 14, height: 14, display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
'←'
|
||||
)}
|
||||
</button>
|
||||
<span
|
||||
style={{
|
||||
minWidth: 20,
|
||||
textAlign: 'center',
|
||||
color: 'var(--color-text-secondary)',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{pageNumber}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
disabled={!canGoForward || isFetching}
|
||||
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||
>
|
||||
{isFetching ? (
|
||||
<span
|
||||
className="loading-spinner"
|
||||
style={{ width: 14, height: 14, display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
'→'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<h2 style={{ fontSize: 20, margin: 0 }}>{title}</h2>
|
||||
{headerAction}
|
||||
{pagination && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPrevious}
|
||||
disabled={!canGoBack || isFetching}
|
||||
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||
>
|
||||
{isFetching ? (
|
||||
<span
|
||||
className="loading-spinner"
|
||||
style={{ width: 14, height: 14, display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
'←'
|
||||
)}
|
||||
</button>
|
||||
<span
|
||||
style={{
|
||||
minWidth: 20,
|
||||
textAlign: 'center',
|
||||
color: 'var(--color-text-secondary)',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{pageNumber}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
disabled={!canGoForward || isFetching}
|
||||
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||
>
|
||||
{isFetching ? (
|
||||
<span
|
||||
className="loading-spinner"
|
||||
style={{ width: 14, height: 14, display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
'→'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
@ -197,7 +196,7 @@ export function BrokerOperationsTable({
|
||||
</table>
|
||||
</div>
|
||||
) : operations.length === 0 && !isFetching ? (
|
||||
<p style={{ color: 'var(--color-text-secondary)' }}>Операций за выбранный период нет</p>
|
||||
<p style={{ color: 'var(--color-text-secondary)' }}>{emptyMessage}</p>
|
||||
) : (
|
||||
<div className="table-container">
|
||||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||
@ -246,7 +245,7 @@ export function BrokerOperationsTable({
|
||||
<div className="table-loading-overlay">
|
||||
<div className="loading-spinner" />
|
||||
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
||||
Загрузка страницы {pageNumber}…
|
||||
{pagination ? `Загрузка страницы ${pageNumber}…` : 'Обновление операций…'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@ -255,3 +254,19 @@ export function BrokerOperationsTable({
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
};
|
||||
|
||||
@ -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>): BrokerPosition {
|
||||
};
|
||||
}
|
||||
|
||||
function createOverviewPortfolio(overrides: Partial<BrokerPortfolio> = {}): 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<string, unknown> = {}) {
|
||||
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(
|
||||
<Routes>
|
||||
<Route path="/broker/:accountId" element={<BrokerAccountLayout />}>
|
||||
<Route index element={<BrokerAccountOverviewPage />} />
|
||||
</Route>
|
||||
</Routes>,
|
||||
['/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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user