160 lines
4.0 KiB
TypeScript
160 lines
4.0 KiB
TypeScript
import type { FC } from "react";
|
|
|
|
import { Button, Group, Stack, Text, Title } from "@mantine/core";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Link } from "react-router";
|
|
|
|
import type { InvestmentResourceMeta } from "@/shared/api";
|
|
|
|
import {
|
|
type BrokerageAccountCard,
|
|
BrokerageAccountList,
|
|
} from "@/entities/brokerage-account";
|
|
import { formatMoney } from "@/shared/lib";
|
|
import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui";
|
|
|
|
import classes from "./AccountsPageLoaded.module.css";
|
|
|
|
const DECIMAL_BASE = 10n;
|
|
const DECIMAL_PART_COUNT = 2;
|
|
const ZERO = 0n;
|
|
|
|
type AccountsPageLoadedProps = {
|
|
accounts: BrokerageAccountCard[];
|
|
meta: InvestmentResourceMeta;
|
|
readRetry?: ReadRetryProps;
|
|
};
|
|
|
|
type Money = { currency: string; value: string };
|
|
|
|
function accountTotals(accounts: BrokerageAccountCard[]) {
|
|
const values = new Map<string, string[]>();
|
|
let excludedCount = 0;
|
|
|
|
for (const { summary } of accounts) {
|
|
if (summary.state === "empty") continue;
|
|
|
|
if (summary.state === "unavailable") {
|
|
excludedCount += 1;
|
|
continue;
|
|
}
|
|
|
|
for (const total of summary.portfolio.totalsByCurrency) {
|
|
values.set(total.currency, [
|
|
...values.get(total.currency) ?? [],
|
|
total.value,
|
|
]);
|
|
}
|
|
}
|
|
|
|
const totals: Money[] = Array.from(values, ([currency, amounts]) => ({
|
|
currency,
|
|
value: sumDecimals(amounts),
|
|
}));
|
|
|
|
return { excludedCount, totals };
|
|
}
|
|
|
|
function sumDecimals(values: string[]) {
|
|
const scale = Math.max(
|
|
0,
|
|
...values.map(value =>
|
|
value.split(".", DECIMAL_PART_COUNT)[1]?.length ?? 0),
|
|
);
|
|
|
|
const factor = DECIMAL_BASE ** BigInt(scale);
|
|
let sum = ZERO;
|
|
|
|
for (const value of values) {
|
|
const isNegative = value.startsWith("-");
|
|
|
|
const [integer = "0", fraction = ""] = (isNegative
|
|
? value.slice(1)
|
|
: value).split(".", DECIMAL_PART_COUNT);
|
|
|
|
const units = BigInt(integer) * factor
|
|
+ BigInt(fraction.padEnd(scale, "0") || "0");
|
|
|
|
sum += isNegative ? -units : units;
|
|
}
|
|
|
|
const isNegative = sum < ZERO;
|
|
const digits = (isNegative ? -sum : sum).toString().padStart(scale + 1, "0");
|
|
const integer = scale ? digits.slice(0, -scale) : digits;
|
|
const fraction = scale ? `.${digits.slice(-scale)}` : "";
|
|
|
|
return `${isNegative ? "-" : ""}${integer}${fraction}`;
|
|
}
|
|
|
|
const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
|
|
const { t } = useTranslation();
|
|
const { excludedCount, totals } = accountTotals(props.accounts);
|
|
const primaryTotal = totals.find(total => total.currency === "RUB") ?? totals[0];
|
|
const otherTotals = totals.filter(total => total !== primaryTotal);
|
|
|
|
return (
|
|
<Stack gap="lg">
|
|
<Title order={1}>{t("accounts.title")}</Title>
|
|
|
|
<Stack className={classes.summary} gap="xs">
|
|
<Text c="dimmed" size="sm">
|
|
{t("accounts.currentValue")}
|
|
</Text>
|
|
|
|
{primaryTotal
|
|
? (
|
|
<Text className={classes.primaryTotal} fw={700}>
|
|
{formatMoney(primaryTotal, { withRoundedCents: true })}
|
|
</Text>
|
|
)
|
|
: (
|
|
<Text c="dimmed">{t("accounts.summaryNoTotals")}</Text>
|
|
)}
|
|
|
|
{otherTotals.length > 0 && (
|
|
<Group gap="md" wrap="wrap">
|
|
{otherTotals.map(total => (
|
|
<Text className={classes.otherTotal} key={total.currency}>
|
|
{formatMoney(total, { withRoundedCents: true })}
|
|
</Text>
|
|
))}
|
|
</Group>
|
|
)}
|
|
|
|
<Group className={classes.meta} gap="xs" wrap="wrap">
|
|
<Text c="dimmed" size="xs">
|
|
{t("accounts.count", { count: props.accounts.length })}
|
|
</Text>
|
|
|
|
<SourceSyncStatus
|
|
lastAttempt={props.meta.lastAttempt}
|
|
lastSuccessfulAt={props.meta.lastSuccessfulAt}
|
|
stale={props.meta.freshness === "stale"}
|
|
/>
|
|
</Group>
|
|
|
|
{excludedCount > 0 && (
|
|
<Text c="dimmed" size="sm">
|
|
{t("accounts.summaryExcluded", { count: excludedCount })}
|
|
</Text>
|
|
)}
|
|
|
|
{!!props.readRetry && <ReadRetry {...props.readRetry} />}
|
|
</Stack>
|
|
|
|
<Button
|
|
component={Link}
|
|
to="/accounts/all"
|
|
variant="light"
|
|
w="fit-content"
|
|
>
|
|
{t("accounts.openAll")}
|
|
</Button>
|
|
|
|
<BrokerageAccountList accounts={props.accounts} />
|
|
</Stack>
|
|
);
|
|
};
|
|
|
|
export { AccountsPageLoaded };
|