202 lines
5.8 KiB
TypeScript
202 lines
5.8 KiB
TypeScript
import { Link } from 'react-router-dom';
|
||
import { SkeletonBlock } from '@/shared/ui/SkeletonBlock';
|
||
import type { BrokerAccount, BrokerMoney, BrokerPortfolio } from '@/shared/api/responses';
|
||
import { buildBrokerAllocation } from '@/entities/broker-position';
|
||
import { BrokerAllocationBar } from '@/widgets/broker-allocation-chart';
|
||
|
||
function formatBrokerCurrencyValue(currency: string, value: number): string {
|
||
return new Intl.NumberFormat('ru-RU', {
|
||
style: 'currency',
|
||
currency: currency || 'RUB',
|
||
maximumFractionDigits: 2,
|
||
}).format(value);
|
||
}
|
||
|
||
function formatBrokerMoney(value: BrokerMoney | null | undefined): string {
|
||
if (!value) {
|
||
return '—';
|
||
}
|
||
|
||
return formatBrokerCurrencyValue(value.currency, value.value);
|
||
}
|
||
|
||
function formatBrokerSignedCurrencyValue(currency: string, value: number | null): string {
|
||
if (value === null) {
|
||
return '—';
|
||
}
|
||
|
||
const formatted = formatBrokerCurrencyValue(currency, Math.abs(value));
|
||
|
||
if (value > 0) {
|
||
return `+${formatted}`;
|
||
}
|
||
|
||
if (value < 0) {
|
||
return `−${formatted}`;
|
||
}
|
||
|
||
return formatted;
|
||
}
|
||
|
||
function formatBrokerSignedPercent(value: number | null): string {
|
||
if (value === null) {
|
||
return '—';
|
||
}
|
||
|
||
const formatted = `${new Intl.NumberFormat('ru-RU', {
|
||
maximumFractionDigits: 2,
|
||
}).format(Math.abs(value))}%`;
|
||
|
||
if (value > 0) {
|
||
return `+${formatted}`;
|
||
}
|
||
|
||
if (value < 0) {
|
||
return `−${formatted}`;
|
||
}
|
||
|
||
return formatted;
|
||
}
|
||
|
||
function formatBrokerDate(value: string | null | undefined): string | null {
|
||
if (!value) {
|
||
return null;
|
||
}
|
||
|
||
return new Date(value).toLocaleDateString('ru-RU');
|
||
}
|
||
|
||
function brokerAccountTypeLabel(type: 'brokerage' | 'iis'): string {
|
||
return type === 'iis' ? 'ИИС' : 'Брокерский счёт';
|
||
}
|
||
|
||
function BrokerAccountCardSkeleton({ name, typeLabel }: { name: string; typeLabel: string }) {
|
||
return (
|
||
<article className="broker-account-card broker-account-card--loading" aria-busy="true">
|
||
<div className="broker-account-card__header">
|
||
<div>
|
||
<p className="broker-account-card__eyebrow">{typeLabel}</p>
|
||
<h2 className="broker-account-card__title">{name}</h2>
|
||
</div>
|
||
</div>
|
||
<div className="broker-account-card__grid">
|
||
{[1, 2, 3, 4].map((item) => (
|
||
<div className="broker-account-card__stat" key={item}>
|
||
<SkeletonBlock height={12} width="50%" />
|
||
<SkeletonBlock height={24} width="75%" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
<SkeletonBlock height={16} width="100%" borderRadius={999} />
|
||
<SkeletonBlock height={16} width="65%" />
|
||
</article>
|
||
);
|
||
}
|
||
|
||
function BrokerAccountCardError({
|
||
account,
|
||
onRetry,
|
||
}: {
|
||
account: BrokerAccount;
|
||
onRetry: () => void;
|
||
}) {
|
||
return (
|
||
<article className="broker-account-card broker-account-card--error">
|
||
<div className="broker-account-card__header">
|
||
<div>
|
||
<p className="broker-account-card__eyebrow">{brokerAccountTypeLabel(account.type)}</p>
|
||
<h2 className="broker-account-card__title">{account.name}</h2>
|
||
</div>
|
||
</div>
|
||
<div className="broker-account-card__alert" role="alert">
|
||
<p>Не удалось загрузить данные счёта</p>
|
||
<button className="broker-account-card__retry" type="button" onClick={onRetry}>
|
||
Повторить
|
||
</button>
|
||
</div>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
function BrokerAccountCardSuccess({
|
||
account,
|
||
portfolio,
|
||
}: {
|
||
account: BrokerAccount;
|
||
portfolio: BrokerPortfolio;
|
||
}) {
|
||
const typeLabel = brokerAccountTypeLabel(account.type);
|
||
const openedAt = formatBrokerDate(account.openedAt);
|
||
const allocation = buildBrokerAllocation(portfolio);
|
||
|
||
return (
|
||
<Link
|
||
className="broker-account-card broker-account-card--link"
|
||
to={`/broker/${encodeURIComponent(account.id)}`}
|
||
>
|
||
<div className="broker-account-card__header">
|
||
<div>
|
||
<p className="broker-account-card__eyebrow">{typeLabel}</p>
|
||
<h2 className="broker-account-card__title">{account.name}</h2>
|
||
</div>
|
||
{openedAt ? <p className="broker-account-card__opened">Открыт {openedAt}</p> : null}
|
||
</div>
|
||
|
||
<div className="broker-account-card__grid">
|
||
<div className="broker-account-card__stat broker-account-card__stat--wide">
|
||
<span>Стоимость</span>
|
||
<strong>{formatBrokerMoney(portfolio.totals.portfolio)}</strong>
|
||
</div>
|
||
<div className="broker-account-card__stat">
|
||
<span>За день</span>
|
||
<strong>
|
||
{formatBrokerSignedCurrencyValue(
|
||
portfolio.totals.portfolio?.currency ?? 'RUB',
|
||
portfolio.yields.daily?.value ?? null,
|
||
)}
|
||
</strong>
|
||
</div>
|
||
<div className="broker-account-card__stat">
|
||
<span>Дневная динамика</span>
|
||
<strong>{formatBrokerSignedPercent(portfolio.yields.dailyPercent)}</strong>
|
||
</div>
|
||
<div className="broker-account-card__stat">
|
||
<span>Ожидаемая доходность</span>
|
||
<strong>{formatBrokerSignedPercent(portfolio.yields.expectedPercent)}</strong>
|
||
</div>
|
||
</div>
|
||
|
||
<BrokerAllocationBar title={`Структура счёта ${account.name}`} items={allocation.sectors} />
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
export function BrokerAccountCard({
|
||
account,
|
||
portfolio,
|
||
isLoading,
|
||
error,
|
||
onRetry,
|
||
}: {
|
||
account: BrokerAccount;
|
||
portfolio?: BrokerPortfolio;
|
||
isLoading: boolean;
|
||
error: Error | null;
|
||
onRetry: () => void;
|
||
}) {
|
||
if (isLoading && !portfolio) {
|
||
return (
|
||
<BrokerAccountCardSkeleton
|
||
name={account.name}
|
||
typeLabel={brokerAccountTypeLabel(account.type)}
|
||
/>
|
||
);
|
||
}
|
||
|
||
if (error || !portfolio) {
|
||
return <BrokerAccountCardError account={account} onRetry={onRetry} />;
|
||
}
|
||
|
||
return <BrokerAccountCardSuccess account={account} portfolio={portfolio} />;
|
||
}
|