173 lines
6.2 KiB
TypeScript
173 lines
6.2 KiB
TypeScript
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>
|
||
);
|
||
}
|