feat: show return, cash and structure on account cards (#114)
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

This commit was merged in pull request #116.
This commit is contained in:
2026-10-27 05:29:11 +03:00
parent 0e4bbec2a7
commit 431559be0f
12 changed files with 984 additions and 431 deletions
@@ -31,6 +31,19 @@ function configureTestAuth() {
});
}
function portfolioSummary(totals: { currency: string; value: string }[]) {
return {
bondPositionCount: 1,
cashBalances: [],
fundPositionCount: 0,
stockPositionCount: 0,
structure: { currencies: [], unvaluedCategories: [] },
totalPositionCount: 1,
totalsByCurrency: totals,
unrealizedReturnByCurrency: [],
};
}
function renderPage(queryClient: QueryClient) {
configureTestAuth();
@@ -46,6 +59,65 @@ function renderPage(queryClient: QueryClient) {
}
describe("AccountsPage", () => {
it("summarizes currencies separately and reports excluded accounts", async () => {
server.use(http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json(resource([
{
...account,
portfolioSummary: portfolioSummary([
{ currency: "RUB", value: "100.10" },
{ currency: "USD", value: "20" },
]),
},
{
...account,
id: "account-2",
name: "Второй",
portfolioSummary: portfolioSummary([
{ currency: "RUB", value: "50.20" },
]),
},
{
...account,
id: "account-3",
name: "Без сводки",
portfolioSummary: null,
},
{
...account,
id: "account-4",
name: "Только деньги",
portfolioSummary: {
...portfolioSummary([{ currency: "RUB", value: "25" }]),
bondPositionCount: 0,
cashBalances: [{ currency: "RUB", value: "25" }],
totalPositionCount: 0,
},
},
{
...account,
id: "account-5",
name: "Без сумм",
portfolioSummary: {
...portfolioSummary([]),
totalPositionCount: 0,
},
},
])),
));
const client = createTestQueryClient();
renderPage(client);
expect(await screen.findByText("175,30 ₽")).toBeInTheDocument();
expect(screen.getAllByText("20,00 USD")).not.toHaveLength(0);
expect(screen.getByText("5 счетов")).toBeInTheDocument();
expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument();
expect(screen.getByText(
"Ещё 1 счёт не включён: нет актуального портфеля",
)).toBeInTheDocument();
});
it("loads accounts after retrying an initial HTTP failure", async () => {
const modes: (null | string)[] = [];
server.use(
@@ -1,6 +1,6 @@
import type { FC } from "react";
import { Button, Stack, Text, Title } from "@mantine/core";
import { Button, SimpleGrid, Stack, Text, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
@@ -10,15 +10,82 @@ import {
type BrokerageAccountCard,
BrokerageAccountList,
} from "@/entities/brokerage-account";
import { formatMoney } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui";
const DECIMAL_BASE = 10n;
const DECIMAL_PART_COUNT = 2;
const ZERO = 0n;
type AccountsPageLoadedProps = {
accounts: BrokerageAccountCard[];
meta: InvestmentResourceMeta;
};
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);
return (
<Stack gap="lg">
@@ -28,6 +95,37 @@ const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
<Text c="dimmed" size="sm">
{t("accounts.subtitle")}
</Text>
</Stack>
<Stack gap="xs">
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="xs">
<Stack gap={4}>
<Text c="dimmed" size="sm">
{t("accounts.currentValue")}
</Text>
{totals.length > 0
? totals.map(total => (
<Text fw={700} key={total.currency} size="lg">
{formatMoney(total, { withRoundedCents: true })}
</Text>
))
: <Text c="dimmed">{t("accounts.summaryNoTotals")}</Text>}
</Stack>
<Stack gap={4}>
<Text fw={600}>{t("accounts.count", {
count: props.accounts.length,
})}
</Text>
{excludedCount > 0 && (
<Text c="dimmed" size="sm">
{t("accounts.summaryExcluded", { count: excludedCount })}
</Text>
)}
</Stack>
</SimpleGrid>
<SourceSyncStatus
lastAttempt={props.meta.lastAttempt}