fix: polish accounts portfolio summary (#159)
This commit is contained in:
+20
-2
@@ -65,18 +65,32 @@
|
||||
|
||||
.summary {
|
||||
font-variant-numeric: tabular-nums lining-nums;
|
||||
white-space: nowrap;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.currencySummary {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.total {
|
||||
color: var(--wl-color-text);
|
||||
font-size: var(--mantine-font-size-lg);
|
||||
line-height: var(--mantine-line-height-lg);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.return,
|
||||
.cashValue {
|
||||
font-variant-numeric: tabular-nums lining-nums;
|
||||
}
|
||||
|
||||
.return {
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cashValue {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -138,7 +152,11 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.balance > div {
|
||||
.summary {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.return {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
+10
-9
@@ -119,25 +119,26 @@ describe("BrokerageAccountList", () => {
|
||||
expect(within(link).getByText("123 456 789 012 345 678 902,00 ₽"))
|
||||
.toBeInTheDocument();
|
||||
expect(within(link).getByText("Еще 1 валюта")).toBeInTheDocument();
|
||||
expect(within(link).getByText("+12,40 %")).toBeInTheDocument();
|
||||
expect(within(link).getByText("+12,4 %")).toBeInTheDocument();
|
||||
expect(within(link).getByText("+136 900 ₽")).toBeInTheDocument();
|
||||
expect(within(link).getByText("−3,05 %")).toBeInTheDocument();
|
||||
expect(within(link).getByText("−4,2 USD")).toBeInTheDocument();
|
||||
expect(within(link).getByText("−3,1 %")).toBeInTheDocument();
|
||||
expect(within(link).getByText("−4,2 $")).toBeInTheDocument();
|
||||
const nullReturnLink = screen.getByRole("link", { name: /Без доходности/ });
|
||||
expect(within(nullReturnLink).getByText("—")).toBeInTheDocument();
|
||||
expect(within(link).getByText("частично")).toBeInTheDocument();
|
||||
expect(within(link).getByText("82 500,50 ₽")).toBeInTheDocument();
|
||||
expect(within(link).getAllByText("20,00 USD")).not.toHaveLength(0);
|
||||
expect(within(link).getAllByText("20,00 $")).not.toHaveLength(0);
|
||||
expect(within(link).getByText("17 позиций")).toBeInTheDocument();
|
||||
expect(screen.getByRole("img", {
|
||||
name: "Структура в RUB: Акции 50 %; Облигации 30 %; "
|
||||
+ "Другие активы 0 %; Свободные деньги 20 %",
|
||||
name: "Структура в RUB: Акции 50,0 %; Облигации 30,0 %; "
|
||||
+ "Другие активы 0,0 %; Свободные деньги 20,0 %",
|
||||
})).toBeInTheDocument();
|
||||
const stockLegend = within(link).getByText(/Акции 50 %/);
|
||||
expect(stockLegend).toHaveTextContent("● Акции 50 %");
|
||||
const stockLegend = within(link).getByText(/Акции 50,0 %/);
|
||||
expect(stockLegend).toHaveTextContent("● Акции 50,0 %");
|
||||
// eslint-disable-next-line testing-library/no-node-access -- The legend is aria-hidden, so only DOM traversal reaches its container.
|
||||
expect(stockLegend.parentElement).toHaveAttribute("aria-hidden", "true");
|
||||
expect(within(link).queryByText(/Другие активы 0 %/)).not.toBeInTheDocument();
|
||||
expect(within(link).queryByText(/Другие активы 0,0 %/))
|
||||
.not.toBeInTheDocument();
|
||||
expect(link).toHaveTextContent("По данным брокера");
|
||||
expect(link).toHaveTextContent("Структура RUB: Акции 100");
|
||||
});
|
||||
|
||||
@@ -15,6 +15,11 @@ 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" }
|
||||
@@ -37,20 +42,15 @@ 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;
|
||||
const rounded = percentFormatter.format(Math.abs(Number(value)));
|
||||
let sign = "";
|
||||
if (rounded !== "0,0") sign = value.startsWith("-") ? "−" : "+";
|
||||
|
||||
return value.startsWith("-") ? `−${formatted.slice(1)}` : `+${formatted}`;
|
||||
return `${sign}${rounded}\u{A0}%`;
|
||||
}
|
||||
|
||||
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(".", ",");
|
||||
return percentFormatter.format(Number(value));
|
||||
}
|
||||
|
||||
function isZero(value: string) {
|
||||
@@ -65,22 +65,18 @@ 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);
|
||||
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 (
|
||||
<Group className={classes.return} gap="xs" wrap="nowrap">
|
||||
<Group className={classes.return} gap="xs" wrap="wrap">
|
||||
<Text className={toneClass} fw={700} size="sm">
|
||||
{formatPercent(item.percent)}
|
||||
{displayedPercent}
|
||||
</Text>
|
||||
|
||||
<Text className={toneClass} size="sm">
|
||||
@@ -200,35 +196,35 @@ const BrokerageAccountSummary: FC<BrokerageAccountSummaryProps> = (props) => {
|
||||
|
||||
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>
|
||||
))}
|
||||
<Stack className={classes.summary} gap="sm">
|
||||
{summary.totals.map((total) => {
|
||||
const item = summary.portfolio.unrealizedReturnByCurrency.find(
|
||||
returnItem => returnItem.currency === total.currency,
|
||||
);
|
||||
|
||||
{!!summary.remainingCurrencyCount && (
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("accounts.remainingCurrencies", {
|
||||
count: summary.remainingCurrencyCount,
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
return (
|
||||
<Stack
|
||||
className={classes.currencySummary}
|
||||
gap={2}
|
||||
key={total.currency}
|
||||
>
|
||||
<Text className={classes.total} fw={700}>
|
||||
{formatMoney(total, { withRoundedCents: true })}
|
||||
</Text>
|
||||
|
||||
<Stack gap={4}>
|
||||
{summary.totals.map((total) => {
|
||||
const item = summary.portfolio.unrealizedReturnByCurrency.find(
|
||||
returnItem => returnItem.currency === total.currency,
|
||||
);
|
||||
{item && <ReturnValue item={item} />}
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
|
||||
return item
|
||||
? <ReturnValue item={item} key={total.currency} />
|
||||
: null;
|
||||
})}
|
||||
</Stack>
|
||||
</Group>
|
||||
{!!summary.remainingCurrencyCount && (
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("accounts.remainingCurrencies", {
|
||||
count: summary.remainingCurrencyCount,
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{summary.portfolio.cashBalances.length > 0 && (
|
||||
<Group className={classes.cash} gap="xs" justify="flex-end">
|
||||
|
||||
@@ -103,7 +103,7 @@ describe("BrokerageEventGroup", () => {
|
||||
event({ id: "iis", name: "ИИС" }, "200"),
|
||||
]);
|
||||
|
||||
expect(screen.getByRole("list", { name: /10 USD.*200 ₽/ }))
|
||||
expect(screen.getByRole("list", { name: /10 \$.*200 ₽/ }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -785,7 +785,7 @@ describe("income period and snapshot boundary", () => {
|
||||
expect(screen.queryByText("USD")).not.toBeInTheDocument();
|
||||
const cards = screen.getAllByRole("listitem");
|
||||
expect(cards).toHaveLength(3);
|
||||
expect(cards[0]).toHaveTextContent("+10 USD");
|
||||
expect(cards[0]).toHaveTextContent("+10 $");
|
||||
expect(cards[1]).toHaveTextContent("−2 ₽");
|
||||
expect(cards[2]).toHaveTextContent("−3 ₽");
|
||||
expect(screen.queryByText(/Итого:|Выплат:/)).not.toBeInTheDocument();
|
||||
|
||||
@@ -1191,7 +1191,7 @@ describe("AccountPage states", () => {
|
||||
expect(
|
||||
content.getByText("11.09.2026 · Дата реестра · RU000A0JX0J2"),
|
||||
).toBeVisible();
|
||||
for (const amount of ["850,50 ₽", "0 USD", "1 000 ₽", "Сумма неизвестна"])
|
||||
for (const amount of ["850,50 ₽", "0 $", "1 000 ₽", "Сумма неизвестна"])
|
||||
expect(content.getByText(amount)).toBeVisible();
|
||||
expect(content.getAllByText("Прогноз")).toHaveLength(4);
|
||||
expect(content.queryByText("Без движения денег")).not.toBeInTheDocument();
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ describe("portfolio structure", () => {
|
||||
screen.getByRole("combobox", { name: "Валюта структуры портфеля" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("option", { name: "USD" }));
|
||||
expect(screen.getByRole("list")).toHaveTextContent("60 USD");
|
||||
expect(screen.getByRole("list")).toHaveTextContent("60 $");
|
||||
expect(screen.getByRole("list")).not.toHaveTextContent("₽");
|
||||
view.rerender(ui({ currencies: [rub], unvaluedCategories: [] }));
|
||||
expect(screen.getByRole("list")).toHaveTextContent("60 ₽");
|
||||
|
||||
@@ -110,7 +110,9 @@ describe("AccountsPage", () => {
|
||||
renderPage(client);
|
||||
|
||||
expect(await screen.findByText("175,30 ₽")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("20,00 USD")).not.toHaveLength(0);
|
||||
expect(screen.getAllByText("20,00 $")).not.toHaveLength(0);
|
||||
expect(screen.queryByText("Ваши инвестиции по счетам"))
|
||||
.not.toBeInTheDocument();
|
||||
expect(screen.getByText("5 счетов")).toBeInTheDocument();
|
||||
expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument();
|
||||
expect(screen.getByText(
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
.summary {
|
||||
min-width: 0;
|
||||
padding-bottom: var(--mantine-spacing-lg);
|
||||
border-bottom: 1px solid var(--wl-color-border);
|
||||
}
|
||||
|
||||
.primaryTotal {
|
||||
font-size: clamp(1.75rem, 4vw, 2.25rem);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.2;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.otherTotal {
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.meta {
|
||||
padding-top: var(--mantine-spacing-xs);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { Button, SimpleGrid, Stack, Text, Title } from "@mantine/core";
|
||||
import { Button, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
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;
|
||||
@@ -87,52 +89,55 @@ function sumDecimals(values: string[]) {
|
||||
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">
|
||||
<Stack gap={4}>
|
||||
<Title order={1}>{t("accounts.title")}</Title>
|
||||
<Title order={1}>{t("accounts.title")}</Title>
|
||||
|
||||
<Stack className={classes.summary} gap="xs">
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("accounts.subtitle")}
|
||||
{t("accounts.currentValue")}
|
||||
</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 })}
|
||||
{primaryTotal
|
||||
? (
|
||||
<Text className={classes.primaryTotal} fw={700}>
|
||||
{formatMoney(primaryTotal, { withRoundedCents: true })}
|
||||
</Text>
|
||||
)
|
||||
: (
|
||||
<Text c="dimmed">{t("accounts.summaryNoTotals")}</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
|
||||
<SourceSyncStatus
|
||||
lastAttempt={props.meta.lastAttempt}
|
||||
lastSuccessfulAt={props.meta.lastSuccessfulAt}
|
||||
stale={props.meta.freshness === "stale"}
|
||||
/>
|
||||
{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>
|
||||
|
||||
@@ -51,7 +51,6 @@ const ru = {
|
||||
incomplete: "{{category}}: доля неизвестна",
|
||||
label: "Структура в {{currency}}: {{value}}",
|
||||
},
|
||||
subtitle: "Ваши инвестиции по счетам",
|
||||
summaryEmpty: "Нет текущих сумм",
|
||||
summaryExcluded_few:
|
||||
"Ещё {{count}} счёта не включены: нет актуального портфеля",
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("shared display helpers", () => {
|
||||
"-1 234 567,89 ₽",
|
||||
);
|
||||
expect(formatMoney({ currency: "RUB", value: "10" })).toBe("10 ₽");
|
||||
expect(formatMoney({ currency: "USD", value: "0.5" })).toBe("0,5 USD");
|
||||
expect(formatMoney({ currency: "USD", value: "0.5" })).toBe("0,5 $");
|
||||
expect(formatMoney({ currency: "RUB", value: "0001234.50" })).toBe(
|
||||
"0 001 234,50 ₽",
|
||||
);
|
||||
|
||||
@@ -86,7 +86,10 @@ function formatMoney(
|
||||
? `${integerPart}.${normalizedFraction}`
|
||||
: integerPart;
|
||||
|
||||
const currency = money.currency === "RUB" ? "₽" : money.currency;
|
||||
let currency = money.currency;
|
||||
if (currency === "RUB") currency = "₽";
|
||||
else if (currency === "USD") currency = "$";
|
||||
|
||||
const formatted = `${formatDecimal(decimal)} ${currency}`;
|
||||
|
||||
if (!withSign) return formatted;
|
||||
|
||||
Reference in New Issue
Block a user