import type { CSSProperties, FC } from "react"; import { Badge, Group, Stack, Text, VisuallyHidden } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import { formatMoney, portfolioCategoryPresentation } from "@/shared/lib"; import { SourceSyncStatus } from "@/shared/ui"; import type { BrokerageAccountCard } from "../../model/display"; import { accountTypeKey } from "../../model/display"; import classes from "./BrokerageAccountList.module.css"; const DECIMAL_PART_COUNT = 2; const STRUCTURE_CATEGORY_ORDER = ["STOCK", "BOND", "OTHER", "CASH"] as const; const percentFormatter = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 1, minimumFractionDigits: 1, }); type AvailableSummary = Extract< BrokerageAccountCard["summary"], { state: "available" } >; type BrokerageAccountListProps = { accounts: BrokerageAccountCard[] }; type BrokerageAccountSummaryProps = { summary: BrokerageAccountCard["summary"]; }; type PortfolioSummary = AvailableSummary["portfolio"]; type ReturnValueProps = { item: PortfolioSummary["unrealizedReturn"]; }; type StructureCurrency = PortfolioSummary["structure"]; function formatPercent(value: null | string) { if (value === null) return "—"; const rounded = percentFormatter.format(Math.abs(Number(value))); let sign = ""; if (rounded !== "0,0") sign = value.startsWith("-") ? "−" : "+"; return `${sign}${rounded}\u{A0}%`; } function formatStructurePercent(value: string) { return percentFormatter.format(Number(value)); } function isZero(value: string) { const unsigned = value.startsWith("-") ? value.slice(1) : value; const [integer = "", fraction = ""] = unsigned.split(".", DECIMAL_PART_COUNT); return integer.replaceAll("0", "") === "" && fraction.replaceAll("0", "") === ""; } const ReturnValue: FC = (props) => { const { t } = useTranslation(); const { item } = props; const displayedPercent = formatPercent(item.percent); const isNegative = displayedPercent.startsWith("−"); const isPositive = displayedPercent.startsWith("+"); let toneClass = classes.neutral; if (isNegative) toneClass = classes.negative; else if (isPositive) toneClass = classes.positive; return ( {displayedPercent} {formatMoney( { currency: "RUB", value: item.absolute }, { withSign: true }, )} {item.partial && ( {t("accounts.partial")} )} ); }; type StructureBarProps = { item: StructureCurrency }; const StructureBar: FC = (props) => { const { t } = useTranslation(); const categories = STRUCTURE_CATEGORY_ORDER.flatMap((type) => { const category = props.item.categories.find(item => item.type === type); return category ? [category] : []; }); const description = categories.map((category) => { const label = t(portfolioCategoryPresentation[category.type].label); const percent = category.sharePercent === null ? null : formatStructurePercent(category.sharePercent); return percent === null ? t("accounts.structure.incomplete", { category: label }) : t("accounts.structure.category", { category: label, percent }); }).join("; "); const label = t("accounts.structure.label", { value: description, }); const legendCategories = categories.filter(category => category.sharePercent === null || !isZero(category.sharePercent)); return (
{categories.map((category) => { const presentation = portfolioCategoryPresentation[category.type]; const style = { "--category-color": `var(--mantine-color-${presentation.color.replace(".", "-")})`, "width": `${category.sharePercent ?? 0}%`, } as CSSProperties; return (
); })}
{legendCategories.map((category) => { const presentation = portfolioCategoryPresentation[category.type]; const categoryLabel = t(presentation.label); const text = category.sharePercent === null ? categoryLabel : t("accounts.structure.category", { category: categoryLabel, percent: formatStructurePercent(category.sharePercent), }); return ( ● {" "} {text} ); })} ); }; const BrokerageAccountSummary: FC = (props) => { const { summary } = props; const { t } = useTranslation(); if (summary.state === "unavailable") return ( {t("accounts.summaryUnavailable")} ); if (summary.state === "empty") return ( {t("accounts.summaryEmpty")} ); return ( {formatMoney(summary.totals, { withRoundedCents: true })} {t("accounts.cash")} {formatMoney(summary.portfolio.cash, { withRoundedCents: true })} ); }; type BrokerageAccountSourceStatusProps = { source: BrokerageAccountCard["source"]; }; const BrokerageAccountSourceStatus: FC = ( props, ) => { const { t } = useTranslation(); const { source } = props; const isSyncing = source?.lastAttempt.state === "running"; const errorText = source?.lastAttempt.state === "failed" ? t("accounts.refreshError") : null; return ( <> {isSyncing && ( {t("accounts.refreshing")} )} ); }; type AccountDescriptionProps = { account: BrokerageAccountCard; }; const AccountDescription: FC = (props) => { const { t } = useTranslation(); const { account, summary } = props.account; if (summary.state !== "available") return null; const returns = [t("accounts.description.return", { absolute: formatMoney( { currency: "RUB", value: summary.portfolio.unrealizedReturn.absolute }, { withSign: true }, ), percent: formatPercent(summary.portfolio.unrealizedReturn.percent), })]; const structures = (() => { const categories = summary.portfolio.structure.categories.map(category => t("accounts.description.structureCategory", { category: t(portfolioCategoryPresentation[category.type].label), value: formatMoney( { currency: "RUB", value: category.value }, { withRoundedCents: true }, ), }), ).join(", "); return t("accounts.description.structure", { value: categories }); })(); const hasPartialReturn = summary.portfolio.unrealizedReturn.partial; const details = [returns, structures].join(". "); return ( {t("accounts.description.start", { name: account.name, type: t(accountTypeKey(account.type)), })} {t("accounts.description.currentValue", { value: formatMoney(summary.portfolio.totals), })} {details ? `${details}. ` : ""} {hasPartialReturn ? t("accounts.brokerDescription") : ""} ); }; const BrokerageAccountList: FC = (props) => { const { t } = useTranslation(); return (
    {props.accounts.map(({ account, positionCounts, source, summary }) => (
  • {account.name}

    {t(accountTypeKey(account.type))}
    {positionCounts && ( {t("accounts.positions", { count: positionCounts.total, })} )} {summary.state === "available" && ( )}
  • ))}
); }; export { BrokerageAccountList };