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; type AvailableSummary = Extract< BrokerageAccountCard["summary"], { state: "available" } >; type BrokerageAccountListProps = { accounts: BrokerageAccountCard[] }; type BrokerageAccountSummaryProps = { summary: BrokerageAccountCard["summary"]; }; type PortfolioSummary = AvailableSummary["portfolio"]; type ReturnValueProps = { item: PortfolioSummary["unrealizedReturnByCurrency"][number]; }; type StructureCurrency = PortfolioSummary["structure"]["currencies"][number]; function formatPercent(value: null | string) { if (value === null) return "—"; const formatted = `${value.replace(".", ",")}\u{A0}%`; if (isZero(value)) return value.startsWith("-") ? formatted.slice(1) : formatted; return value.startsWith("-") ? `−${formatted.slice(1)}` : `+${formatted}`; } function formatStructurePercent(value: string) { let trimmed = value; while (trimmed.includes(".") && trimmed.endsWith("0")) trimmed = trimmed.slice(0, -1); if (trimmed.endsWith(".")) trimmed = trimmed.slice(0, -1); return trimmed.replace(".", ","); } 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 isNegative = item.percent !== null && item.percent.startsWith("-") && !isZero(item.percent); const isPositive = item.percent !== null && !item.percent.startsWith("-") && !isZero(item.percent); let toneClass = classes.neutral; if (isNegative) toneClass = classes.negative; else if (isPositive) toneClass = classes.positive; return ( {formatPercent(item.percent)} {formatMoney( { currency: item.currency, 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", { currency: props.item.currency, 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 ( {summary.totals.map(total => ( {formatMoney(total, { withRoundedCents: true })} ))} {!!summary.remainingCurrencyCount && ( {t("accounts.remainingCurrencies", { count: summary.remainingCurrencyCount, })} )} {summary.totals.map((total) => { const item = summary.portfolio.unrealizedReturnByCurrency.find( returnItem => returnItem.currency === total.currency, ); return item ? : null; })} {summary.portfolio.cashBalances.length > 0 && ( {t("accounts.cash")} {summary.portfolio.cashBalances.map(cash => ( {formatMoney(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 = summary.portfolio.unrealizedReturnByCurrency.map(item => t("accounts.description.return", { absolute: formatMoney( { currency: item.currency, value: item.absolute }, { withSign: true }, ), currency: item.currency, percent: formatPercent(item.percent), }), ); const structures = summary.portfolio.structure.currencies.map((item) => { const categories = item.categories.map(category => t("accounts.description.structureCategory", { category: t(portfolioCategoryPresentation[category.type].label), value: formatMoney( { currency: item.currency, value: category.value }, { withRoundedCents: true }, ), }), ).join(", "); return t("accounts.description.structure", { currency: item.currency, value: categories, }); }); const hasPartialReturn = summary.portfolio.unrealizedReturnByCurrency.some( item => item.partial, ); const details = [...returns, ...structures].join(". "); return ( {t("accounts.description.start", { name: account.name, type: t(accountTypeKey(account.type)), })} {t("accounts.description.currentValue", { value: summary.portfolio.totalsByCurrency .map(total => formatMoney(total)).join(", "), })} {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" && ( {summary.portfolio.structure.currencies.map(item => ( ))} )}
  • ))}
); }; export { BrokerageAccountList };