feat: add broker account overview
This commit is contained in:
parent
1c26d2a3eb
commit
a19ad67a89
@ -121,14 +121,18 @@ export function BrokerAccountDetailPage() {
|
|||||||
<BrokerPositionsSection accountId={accountId!} />
|
<BrokerPositionsSection accountId={accountId!} />
|
||||||
|
|
||||||
<BrokerOperationsTable
|
<BrokerOperationsTable
|
||||||
|
title="Операции"
|
||||||
|
emptyMessage="Операций за выбранный период нет"
|
||||||
isLoading={operations.isLoading}
|
isLoading={operations.isLoading}
|
||||||
isFetching={operations.isFetching}
|
isFetching={operations.isFetching}
|
||||||
page={operations.data}
|
page={operations.data}
|
||||||
pageNumber={operationCursorStack.length + 1}
|
pagination={{
|
||||||
canGoBack={operationCursorStack.length > 0}
|
pageNumber: operationCursorStack.length + 1,
|
||||||
canGoForward={Boolean(operations.data?.hasNext && operations.data.nextCursor)}
|
canGoBack: operationCursorStack.length > 0,
|
||||||
onPrevious={handlePreviousOperationsPage}
|
canGoForward: Boolean(operations.data?.hasNext && operations.data.nextCursor),
|
||||||
onNext={handleNextOperationsPage}
|
onPrevious: handlePreviousOperationsPage,
|
||||||
|
onNext: handleNextOperationsPage,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 { Link } from 'react-router-dom';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
import type { BrokerMoney, BrokerOperation, BrokerOperationsPage } from '../../api/responses';
|
import type { BrokerMoney, BrokerOperation, BrokerOperationsPage } from '../../api/responses';
|
||||||
import {
|
import {
|
||||||
getBrokerInstrumentPath,
|
getBrokerInstrumentPath,
|
||||||
@ -94,24 +95,19 @@ const pagButtonDisabledStyle = {
|
|||||||
} satisfies React.CSSProperties;
|
} satisfies React.CSSProperties;
|
||||||
|
|
||||||
export function BrokerOperationsTable({
|
export function BrokerOperationsTable({
|
||||||
|
title,
|
||||||
|
headerAction,
|
||||||
|
emptyMessage,
|
||||||
isLoading,
|
isLoading,
|
||||||
isFetching,
|
isFetching,
|
||||||
page,
|
page,
|
||||||
pageNumber,
|
pagination,
|
||||||
canGoBack,
|
}: BrokerOperationsTableProps) {
|
||||||
canGoForward,
|
const pageNumber = pagination?.pageNumber;
|
||||||
onPrevious,
|
const canGoBack = pagination?.canGoBack ?? false;
|
||||||
onNext,
|
const canGoForward = pagination?.canGoForward ?? false;
|
||||||
}: {
|
const onPrevious = pagination?.onPrevious;
|
||||||
isLoading: boolean;
|
const onNext = pagination?.onNext;
|
||||||
isFetching: boolean;
|
|
||||||
page: BrokerOperationsPage | undefined;
|
|
||||||
pageNumber: number;
|
|
||||||
canGoBack: boolean;
|
|
||||||
canGoForward: boolean;
|
|
||||||
onPrevious: () => void;
|
|
||||||
onNext: () => void;
|
|
||||||
}) {
|
|
||||||
const operations = page?.items ?? [];
|
const operations = page?.items ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -125,50 +121,53 @@ export function BrokerOperationsTable({
|
|||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h2 style={{ fontSize: 20, margin: 0 }}>Операции</h2>
|
<h2 style={{ fontSize: 20, margin: 0 }}>{title}</h2>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
{headerAction}
|
||||||
<button
|
{pagination && (
|
||||||
type="button"
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
onClick={onPrevious}
|
<button
|
||||||
disabled={!canGoBack || isFetching}
|
type="button"
|
||||||
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
onClick={onPrevious}
|
||||||
>
|
disabled={!canGoBack || isFetching}
|
||||||
{isFetching ? (
|
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||||
<span
|
>
|
||||||
className="loading-spinner"
|
{isFetching ? (
|
||||||
style={{ width: 14, height: 14, display: 'block' }}
|
<span
|
||||||
/>
|
className="loading-spinner"
|
||||||
) : (
|
style={{ width: 14, height: 14, display: 'block' }}
|
||||||
'←'
|
/>
|
||||||
)}
|
) : (
|
||||||
</button>
|
'←'
|
||||||
<span
|
)}
|
||||||
style={{
|
</button>
|
||||||
minWidth: 20,
|
<span
|
||||||
textAlign: 'center',
|
style={{
|
||||||
color: 'var(--color-text-secondary)',
|
minWidth: 20,
|
||||||
fontSize: 14,
|
textAlign: 'center',
|
||||||
fontWeight: 600,
|
color: 'var(--color-text-secondary)',
|
||||||
}}
|
fontSize: 14,
|
||||||
>
|
fontWeight: 600,
|
||||||
{pageNumber}
|
}}
|
||||||
</span>
|
>
|
||||||
<button
|
{pageNumber}
|
||||||
type="button"
|
</span>
|
||||||
onClick={onNext}
|
<button
|
||||||
disabled={!canGoForward || isFetching}
|
type="button"
|
||||||
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
onClick={onNext}
|
||||||
>
|
disabled={!canGoForward || isFetching}
|
||||||
{isFetching ? (
|
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||||
<span
|
>
|
||||||
className="loading-spinner"
|
{isFetching ? (
|
||||||
style={{ width: 14, height: 14, display: 'block' }}
|
<span
|
||||||
/>
|
className="loading-spinner"
|
||||||
) : (
|
style={{ width: 14, height: 14, display: 'block' }}
|
||||||
'→'
|
/>
|
||||||
)}
|
) : (
|
||||||
</button>
|
'→'
|
||||||
</div>
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@ -197,7 +196,7 @@ export function BrokerOperationsTable({
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
) : operations.length === 0 && !isFetching ? (
|
) : 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 className="table-container">
|
||||||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||||
@ -246,7 +245,7 @@ export function BrokerOperationsTable({
|
|||||||
<div className="table-loading-overlay">
|
<div className="table-loading-overlay">
|
||||||
<div className="loading-spinner" />
|
<div className="loading-spinner" />
|
||||||
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
||||||
Загрузка страницы {pageNumber}…
|
{pagination ? `Загрузка страницы ${pageNumber}…` : 'Обновление операций…'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -255,3 +254,19 @@ export function BrokerOperationsTable({
|
|||||||
</section>
|
</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 operationsHook from '../../hooks/useBrokerOperations';
|
||||||
import * as portfolioHook from '../../hooks/useBrokerPortfolio';
|
import * as portfolioHook from '../../hooks/useBrokerPortfolio';
|
||||||
import * as positionsHook from '../../hooks/useBrokerPositions';
|
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 { BrokerAccountLayout, useBrokerAccountContext } from './BrokerAccountLayout';
|
||||||
import { BrokerAccountDetailPage } from './BrokerAccountDetailPage';
|
import { BrokerAccountDetailPage } from './BrokerAccountDetailPage';
|
||||||
|
import { BrokerAccountOverviewPage } from './BrokerAccountOverviewPage';
|
||||||
import { BrokerAccountsPage } from './BrokerAccountsPage';
|
import { BrokerAccountsPage } from './BrokerAccountsPage';
|
||||||
|
|
||||||
function renderWithClient(ui: ReactElement, initialEntries = ['/broker']) {
|
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 . */
|
/** Spy on useBrokerPositions and return only positions matching query.type . */
|
||||||
function mockUseBrokerPositions(...positions: BrokerPosition[]) {
|
function mockUseBrokerPositions(...positions: BrokerPosition[]) {
|
||||||
return vi.spyOn(positionsHook, 'useBrokerPositions').mockImplementation((_accountId, query) => {
|
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(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined });
|
||||||
expect(withinOperations.getByText('1')).toBeInTheDocument();
|
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;
|
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) {
|
@media (max-width: 720px) {
|
||||||
.broker-account__workspace {
|
.broker-account__workspace {
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
@ -131,4 +228,20 @@ a {
|
|||||||
flex: none;
|
flex: none;
|
||||||
white-space: nowrap;
|
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