Frontend verify / commits (pull_request) Successful in 29s
Frontend verify / security (pull_request) Successful in 8s
Frontend verify / verify (pull_request) Successful in 5m25s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 5m20s
393 lines
10 KiB
TypeScript
393 lines
10 KiB
TypeScript
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<ReturnValueProps> = (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 (
|
||
<Group className={classes.return} gap="xs" wrap="nowrap">
|
||
<Text className={toneClass} fw={700} size="sm">
|
||
{formatPercent(item.percent)}
|
||
</Text>
|
||
|
||
<Text className={toneClass} size="sm">
|
||
{formatMoney(
|
||
{ currency: item.currency, value: item.absolute },
|
||
{ withSign: true },
|
||
)}
|
||
</Text>
|
||
|
||
{item.partial && (
|
||
<Text c="dimmed" size="xs">
|
||
{t("accounts.partial")}
|
||
</Text>
|
||
)}
|
||
</Group>
|
||
);
|
||
};
|
||
|
||
type StructureBarProps = { item: StructureCurrency };
|
||
|
||
const StructureBar: FC<StructureBarProps> = (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 (
|
||
<Stack gap={4}>
|
||
<div
|
||
aria-label={label}
|
||
className={classes.structureBar}
|
||
role="img"
|
||
title={label}
|
||
>
|
||
{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 (
|
||
<div
|
||
aria-hidden
|
||
className={classes.structureSection}
|
||
key={category.type}
|
||
style={style}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<Group aria-hidden gap="xs" wrap="wrap">
|
||
{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 c="dimmed" key={category.type} size="xs">
|
||
<Text c={presentation.color} component="span">●</Text>
|
||
{" "}
|
||
{text}
|
||
</Text>
|
||
);
|
||
})}
|
||
</Group>
|
||
</Stack>
|
||
);
|
||
};
|
||
|
||
const BrokerageAccountSummary: FC<BrokerageAccountSummaryProps> = (props) => {
|
||
const { summary } = props;
|
||
const { t } = useTranslation();
|
||
if (summary.state === "unavailable")
|
||
return (
|
||
<Text c="dimmed" size="sm">
|
||
{t("accounts.summaryUnavailable")}
|
||
</Text>
|
||
);
|
||
if (summary.state === "empty")
|
||
return (
|
||
<Text c="dimmed" size="sm">
|
||
{t("accounts.summaryEmpty")}
|
||
</Text>
|
||
);
|
||
|
||
return (
|
||
<Stack className={classes.balance} gap="xs">
|
||
<Group align="flex-start" gap="lg" justify="flex-end" wrap="nowrap">
|
||
<Stack className={classes.summary} gap={4}>
|
||
{summary.totals.map(total => (
|
||
<Text className={classes.total} fw={700} key={total.currency}>
|
||
{formatMoney(total, { withRoundedCents: true })}
|
||
</Text>
|
||
))}
|
||
|
||
{!!summary.remainingCurrencyCount && (
|
||
<Text c="dimmed" size="sm">
|
||
{t("accounts.remainingCurrencies", {
|
||
count: summary.remainingCurrencyCount,
|
||
})}
|
||
</Text>
|
||
)}
|
||
</Stack>
|
||
|
||
<Stack gap={4}>
|
||
{summary.totals.map((total) => {
|
||
const item = summary.portfolio.unrealizedReturnByCurrency.find(
|
||
returnItem => returnItem.currency === total.currency,
|
||
);
|
||
|
||
return item
|
||
? <ReturnValue item={item} key={total.currency} />
|
||
: null;
|
||
})}
|
||
</Stack>
|
||
</Group>
|
||
|
||
{summary.portfolio.cashBalances.length > 0 && (
|
||
<Group className={classes.cash} gap="xs" justify="flex-end">
|
||
<Text c="dimmed" size="xs">
|
||
{t("accounts.cash")}
|
||
</Text>
|
||
|
||
<Stack gap={0}>
|
||
{summary.portfolio.cashBalances.map(cash => (
|
||
<Text className={classes.cashValue} key={cash.currency} size="sm">
|
||
{formatMoney(cash, { withRoundedCents: true })}
|
||
</Text>
|
||
))}
|
||
</Stack>
|
||
</Group>
|
||
)}
|
||
</Stack>
|
||
);
|
||
};
|
||
|
||
type BrokerageAccountSourceStatusProps = {
|
||
source: BrokerageAccountCard["source"];
|
||
};
|
||
|
||
const BrokerageAccountSourceStatus: FC<BrokerageAccountSourceStatusProps> = (
|
||
props,
|
||
) => {
|
||
const { t } = useTranslation();
|
||
const { source } = props;
|
||
const isSyncing = source?.lastAttempt.state === "running";
|
||
|
||
const errorText = source?.lastAttempt.state === "failed"
|
||
? t("accounts.refreshError")
|
||
: null;
|
||
|
||
return (
|
||
<>
|
||
<SourceSyncStatus
|
||
errorText={errorText}
|
||
lastAttempt={source?.lastAttempt}
|
||
lastSuccessfulAt={source?.lastSuccessfulAt}
|
||
stale={source?.freshness === "stale"}
|
||
/>
|
||
|
||
{isSyncing && (
|
||
<Text role="status" size="xs">
|
||
{t("accounts.refreshing")}
|
||
</Text>
|
||
)}
|
||
</>
|
||
);
|
||
};
|
||
|
||
type AccountDescriptionProps = {
|
||
account: BrokerageAccountCard;
|
||
};
|
||
|
||
const AccountDescription: FC<AccountDescriptionProps> = (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 (
|
||
<VisuallyHidden>
|
||
{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") : ""}
|
||
</VisuallyHidden>
|
||
);
|
||
};
|
||
|
||
const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
|
||
const { t } = useTranslation();
|
||
|
||
return (
|
||
<ul className={classes.list}>
|
||
{props.accounts.map(({ account, positionCounts, source, summary }) => (
|
||
<li key={account.id}>
|
||
<Link className={classes.row} to={`/accounts/${account.id}`}>
|
||
<Group className={classes.identity} wrap="nowrap">
|
||
<h2 className={classes.name}>{account.name}</h2>
|
||
|
||
<Badge className={classes.accountType} variant="light">
|
||
{t(accountTypeKey(account.type))}
|
||
</Badge>
|
||
</Group>
|
||
|
||
<BrokerageAccountSummary summary={summary} />
|
||
|
||
<Stack className={classes.details} gap={2}>
|
||
{positionCounts && (
|
||
<Text c="dimmed" size="xs">
|
||
{t("accounts.positions", {
|
||
count: positionCounts.total,
|
||
})}
|
||
</Text>
|
||
)}
|
||
|
||
<BrokerageAccountSourceStatus source={source} />
|
||
</Stack>
|
||
|
||
{summary.state === "available" && (
|
||
<Stack className={classes.structure} gap={4}>
|
||
{summary.portfolio.structure.currencies.map(item => (
|
||
<StructureBar item={item} key={item.currency} />
|
||
))}
|
||
</Stack>
|
||
)}
|
||
|
||
<AccountDescription
|
||
account={{ account, positionCounts, source, summary }}
|
||
/>
|
||
</Link>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
);
|
||
};
|
||
|
||
export { BrokerageAccountList };
|