diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.module.css b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.module.css index 99cc82b..267a89b 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.module.css +++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.module.css @@ -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; } } diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.test.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.test.tsx index 494f200..5657ce9 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.test.tsx +++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.test.tsx @@ -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"); }); diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx index 462334f..719dea6 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx +++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx @@ -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 = (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 ( - + - {formatPercent(item.percent)} + {displayedPercent} @@ -200,35 +196,35 @@ const BrokerageAccountSummary: FC = (props) => { return ( - - - {summary.totals.map(total => ( - - {formatMoney(total, { withRoundedCents: true })} - - ))} + + {summary.totals.map((total) => { + const item = summary.portfolio.unrealizedReturnByCurrency.find( + returnItem => returnItem.currency === total.currency, + ); - {!!summary.remainingCurrencyCount && ( - - {t("accounts.remainingCurrencies", { - count: summary.remainingCurrencyCount, - })} - - )} - + return ( + + + {formatMoney(total, { withRoundedCents: true })} + - - {summary.totals.map((total) => { - const item = summary.portfolio.unrealizedReturnByCurrency.find( - returnItem => returnItem.currency === total.currency, - ); + {item && } + + ); + })} - return item - ? - : null; - })} - - + {!!summary.remainingCurrencyCount && ( + + {t("accounts.remainingCurrencies", { + count: summary.remainingCurrencyCount, + })} + + )} + {summary.portfolio.cashBalances.length > 0 && ( diff --git a/src/entities/brokerage-event/ui/BrokerageEventGroup.test.tsx b/src/entities/brokerage-event/ui/BrokerageEventGroup.test.tsx index f08e78f..c17a9bf 100644 --- a/src/entities/brokerage-event/ui/BrokerageEventGroup.test.tsx +++ b/src/entities/brokerage-event/ui/BrokerageEventGroup.test.tsx @@ -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(); }); diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx index 586caa0..2af0697 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx @@ -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(); diff --git a/src/pages/account/ui/AccountPage/AccountPage.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.test.tsx index 9ab30b9..82bc249 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.test.tsx @@ -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(); diff --git a/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.test.tsx b/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.test.tsx index 0f41c4f..ef48215 100644 --- a/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.test.tsx +++ b/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.test.tsx @@ -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 ₽"); diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx b/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx index 9fc2416..67a7293 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx @@ -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( diff --git a/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.module.css b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.module.css new file mode 100644 index 0000000..a63fa3b --- /dev/null +++ b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.module.css @@ -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); +} diff --git a/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx index ef5e51e..6ef0deb 100644 --- a/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx +++ b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx @@ -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 = (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 ( - - {t("accounts.title")} + {t("accounts.title")} + - {t("accounts.subtitle")} + {t("accounts.currentValue")} - - - - - - {t("accounts.currentValue")} - - - {totals.length > 0 - ? totals.map(total => ( - - {formatMoney(total, { withRoundedCents: true })} - - )) - : {t("accounts.summaryNoTotals")}} - - - - {t("accounts.count", { - count: props.accounts.length, - })} - - - {excludedCount > 0 && ( - - {t("accounts.summaryExcluded", { count: excludedCount })} + {primaryTotal + ? ( + + {formatMoney(primaryTotal, { withRoundedCents: true })} + ) + : ( + {t("accounts.summaryNoTotals")} )} - - - + {otherTotals.length > 0 && ( + + {otherTotals.map(total => ( + + {formatMoney(total, { withRoundedCents: true })} + + ))} + + )} + + + + {t("accounts.count", { count: props.accounts.length })} + + + + + + {excludedCount > 0 && ( + + {t("accounts.summaryExcluded", { count: excludedCount })} + + )} {!!props.readRetry && } diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts index ffb76a4..e6583d2 100644 --- a/src/shared/i18n/locales/ru.ts +++ b/src/shared/i18n/locales/ru.ts @@ -51,7 +51,6 @@ const ru = { incomplete: "{{category}}: доля неизвестна", label: "Структура в {{currency}}: {{value}}", }, - subtitle: "Ваши инвестиции по счетам", summaryEmpty: "Нет текущих сумм", summaryExcluded_few: "Ещё {{count}} счёта не включены: нет актуального портфеля", diff --git a/src/shared/lib/display.test.ts b/src/shared/lib/display.test.ts index 2b2c656..fae1dce 100644 --- a/src/shared/lib/display.test.ts +++ b/src/shared/lib/display.test.ts @@ -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 ₽", ); diff --git a/src/shared/lib/display.ts b/src/shared/lib/display.ts index 345cea4..48b6910 100644 --- a/src/shared/lib/display.ts +++ b/src/shared/lib/display.ts @@ -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;