diff --git a/src/pages/account/ui/AccountPage.module.css b/src/pages/account/ui/AccountPage.module.css
index 18aadb2..c215a8f 100644
--- a/src/pages/account/ui/AccountPage.module.css
+++ b/src/pages/account/ui/AccountPage.module.css
@@ -2,7 +2,6 @@
max-width: 960px;
}
-.total,
.tile,
.cash {
border-color: var(--mantine-color-gray-3);
@@ -10,8 +9,51 @@
}
.total {
- padding: var(--mantine-spacing-md);
- border-top: 3px solid var(--mantine-color-blue-6);
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ min-width: 0;
+ padding: 12px;
+ border-color: var(--mantine-color-gray-3);
+ border-radius: 8px;
+ background: var(--mantine-color-white);
+}
+
+.totalBadge {
+ min-width: 40px;
+ min-height: 24px;
+ max-width: 100%;
+ padding-inline: 8px;
+ border-radius: 4px;
+ background: var(--mantine-color-blue-0);
+ color: var(--mantine-color-blue-6);
+ font-size: 18px;
+ font-weight: 600;
+ line-height: 24px;
+ text-align: center;
+ overflow-wrap: anywhere;
+}
+
+.totalAmount {
+ flex-basis: max-content;
+ min-width: 0;
+ max-width: 100%;
+ color: var(--mantine-color-dark-7);
+ font-size: 20px;
+ font-weight: 600;
+ line-height: 1.55;
+ overflow-wrap: anywhere;
+}
+
+.totalLeadingDigits {
+ white-space: nowrap;
+}
+
+.totalAmountSkeleton {
+ max-width: calc(100% - 50px);
+ margin-block: 5px;
}
.tile,
diff --git a/src/pages/account/ui/AccountPage.test.tsx b/src/pages/account/ui/AccountPage.test.tsx
index 70ae459..9f053b5 100644
--- a/src/pages/account/ui/AccountPage.test.tsx
+++ b/src/pages/account/ui/AccountPage.test.tsx
@@ -1,6 +1,6 @@
import { MantineProvider } from "@mantine/core";
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { render, screen, waitFor } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { MemoryRouter, Route, Routes } from "react-router";
import { describe, expect, it, vi } from "vitest";
@@ -113,6 +113,67 @@ function emptyProps() {
}
describe("AccountPage states", () => {
+ it("keeps initial placeholders decorative without amounts or focus targets", () => {
+ const { container } = renderPage();
+ expect(screen.getByRole("status")).toHaveAttribute("aria-busy", "true");
+ expect(screen.queryByRole("definition")).not.toBeInTheDocument();
+ expect(container.querySelector("[tabindex], button, a, input")).toBeNull();
+ expect(container).not.toHaveTextContent(/₽|\$|€|0,00/);
+ });
+
+ it.each(["NOT_FOUND", "TBANK_NOT_CONNECTED", "INVALID_TBANK_TOKEN"])(
+ "does not offer retry without cached data for %s",
+ (code) => {
+ renderPage();
+ expect(
+ screen.queryByRole("button", { name: "Повторить" }),
+ ).not.toBeInTheDocument();
+ expect(screen.getByRole("link")).toHaveAttribute(
+ "href",
+ code === "NOT_FOUND" ? "/accounts" : "/settings",
+ );
+ },
+ );
+
+ it.each(["TBANK_UNAVAILABLE", "UNKNOWN"])(
+ "allows retry for %s",
+ async (code) => {
+ const retry = vi.fn();
+ renderPage();
+ fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
+ expect(retry).toHaveBeenCalledOnce();
+ },
+ );
+
+ it("shows only supplied totals despite cash and positions with unknown value", () => {
+ renderPage();
+ expect(
+ screen.getAllByRole("definition").map((node) => node.textContent),
+ ).toEqual(["1 234,5 ₽"]);
+ expect(
+ screen.queryByText(/полная оценка|общая стоимость/i),
+ ).not.toBeInTheDocument();
+ });
+
+ it.each([true, false])(
+ "keeps a nonempty portfolio without totals (positions: %s)",
+ async (hasPositions) => {
+ server.use(
+ http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
+ HttpResponse.json({
+ ...portfolio,
+ totalsByCurrency: [],
+ positions: hasPositions ? portfolio.positions : [],
+ }),
+ ),
+ );
+ renderAccountRoute(createTestQueryClient());
+ await screen.findByRole("heading", { name: "Портфель", level: 1 });
+ expect(screen.queryByRole("definition")).not.toBeInTheDocument();
+ expect(screen.queryByText("Портфель пока пуст")).not.toBeInTheDocument();
+ },
+ );
+
it("renders loading and empty states", () => {
renderPage();
expect(screen.getByRole("status")).toBeInTheDocument();
@@ -512,40 +573,76 @@ describe("AccountPage states", () => {
expect(screen.getByText("Вторая")).toBeInTheDocument();
});
- it("keeps the cached portfolio visible with distinct server and refresh warnings", async () => {
- let requestCount = 0;
- server.use(
- http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
- requestCount += 1;
- if (requestCount === 1) return HttpResponse.json(portfolio);
- return HttpResponse.json(
- { code: "TBANK_UNAVAILABLE" },
- { status: 503 },
- );
- }),
- );
- const queryClient = createTestQueryClient();
- renderAccountRoute(queryClient);
+ it.each([503, 404])(
+ "preserves totals and UTC snapshot through refresh error %i, retry and success",
+ async (status) => {
+ let requestCount = 0;
+ let resolveRetry: ((response: Response) => void) | undefined;
+ server.use(
+ http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
+ requestCount += 1;
+ if (requestCount === 1) return HttpResponse.json(portfolio);
+ if (requestCount === 3)
+ return new Promise((resolve) => {
+ resolveRetry = resolve;
+ });
+ return HttpResponse.json(
+ { code: status === 404 ? "NOT_FOUND" : "TBANK_UNAVAILABLE" },
+ { status },
+ );
+ }),
+ );
+ const queryClient = createTestQueryClient();
+ renderAccountRoute(queryClient);
- await screen.findByText("Вторая");
- void queryClient.invalidateQueries({
- queryKey: portfolioQueryOptions("account-1").queryKey,
- });
+ await screen.findByText("Вторая");
+ const snapshotText = screen.getByText(/Данные получены/).textContent;
+ expect(snapshotText).toContain("10:00");
+ void queryClient.invalidateQueries({
+ queryKey: portfolioQueryOptions("account-1").queryKey,
+ });
- expect(
- await screen.findByText(
- "Не удалось обновить портфель. Показан последний полученный снимок.",
- ),
- ).toBeInTheDocument();
- expect(
- screen.getByText(
- "T-Bank временно недоступен. Показан последний успешный снимок.",
- ),
- ).toBeInTheDocument();
- expect(screen.getByText("Вторая")).toBeInTheDocument();
- screen.getByRole("button", { name: "Повторить" }).click();
- await waitFor(() => expect(requestCount).toBe(3));
- });
+ expect(
+ await screen.findByText(
+ "Не удалось обновить портфель. Показан последний полученный снимок.",
+ ),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ "T-Bank временно недоступен. Показан последний успешный снимок.",
+ ),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Вторая")).toBeInTheDocument();
+ expect(screen.getByRole("definition")).toHaveTextContent("1 234,5 ₽");
+ expect(screen.getByText(/Данные получены/).textContent).toBe(
+ snapshotText,
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
+ await waitFor(() => expect(requestCount).toBe(3));
+ expect(screen.getByRole("definition")).toHaveTextContent("1 234,5 ₽");
+ expect(screen.getByText(/Данные получены/).textContent).toBe(
+ snapshotText,
+ );
+ resolveRetry?.(
+ HttpResponse.json({
+ ...portfolio,
+ isStale: false,
+ lastSuccessfulAt: "2026-08-14T11:30:00Z",
+ totalsByCurrency: [{ currency: "USD", value: "-0.5" }],
+ }),
+ );
+ await waitFor(() =>
+ expect(screen.getByRole("definition")).toHaveTextContent("-0,5 $"),
+ );
+ expect(screen.getByText(/Данные получены/)).toHaveTextContent("11:30");
+ expect(
+ screen.queryByText(/Не удалось обновить портфель/),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByText(/T-Bank временно недоступен/),
+ ).not.toBeInTheDocument();
+ },
+ );
it("keeps the cached portfolio visible during a background refresh", async () => {
let requestCount = 0;
@@ -569,6 +666,11 @@ describe("AccountPage states", () => {
await waitFor(() => expect(requestCount).toBe(2));
expect(screen.getByText("Вторая")).toBeInTheDocument();
+ expect(screen.getByRole("definition")).toHaveTextContent("1 234,5 ₽");
+ expect(screen.getByText(/Данные получены/)).toHaveTextContent("10:00");
+ expect(
+ screen.queryByRole("status", { name: "Загрузка" }),
+ ).not.toBeInTheDocument();
expect(
screen.getByRole("status", { name: "Обновляем портфель" }),
).toBeInTheDocument();
diff --git a/src/pages/account/ui/components/AccountPageLoading.view.tsx b/src/pages/account/ui/components/AccountPageLoading.view.tsx
index bb1ee89..43483b3 100644
--- a/src/pages/account/ui/components/AccountPageLoading.view.tsx
+++ b/src/pages/account/ui/components/AccountPageLoading.view.tsx
@@ -1,14 +1,31 @@
-import { Skeleton } from "@mantine/core";
+import { Paper, SimpleGrid, Skeleton } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { LoadingSkeleton } from "@/shared/ui";
+import classes from "../AccountPage.module.css";
const AccountPageLoading: FC = () => {
const { t } = useTranslation();
return (
-
+
+ {[0, 1, 2].map((index) => (
+
+
+
+
+ ))}
+
{[0, 1, 2].map((index) => (
))}
diff --git a/src/pages/account/ui/components/PortfolioTotals.test.tsx b/src/pages/account/ui/components/PortfolioTotals.test.tsx
new file mode 100644
index 0000000..f66ca1a
--- /dev/null
+++ b/src/pages/account/ui/components/PortfolioTotals.test.tsx
@@ -0,0 +1,85 @@
+import { MantineProvider } from "@mantine/core";
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { PortfolioTotals } from "./PortfolioTotals.view";
+
+describe("PortfolioTotals currency presentation", () => {
+ it.each([0, 1, 3, 5])(
+ "renders only the %i supplied totals in upstream order",
+ (count) => {
+ const totals = [
+ { currency: "EUR", value: "3.00" },
+ { currency: "USD", value: "2.00" },
+ { currency: "RUB", value: "1.00" },
+ { currency: "CAD", value: "4.00" },
+ { currency: "XYZ", value: "5.00" },
+ ].slice(0, count);
+ const { container } = render(
+
+
+ ,
+ );
+ expect(
+ screen.queryAllByRole("definition").map((node) => node.textContent),
+ ).toEqual(
+ ["3,00 €", "2,00 $", "1,00 ₽", "4,00 CAD", "5,00 XYZ"].slice(0, count),
+ );
+ expect(container.querySelectorAll("dl")).toHaveLength(count);
+ expect(
+ container.querySelector("[tabindex], button, a, input"),
+ ).toBeNull();
+ },
+ );
+
+ it.each([
+ ["RUB", "125000.50", "125 000,50 ₽", "Российский рубль", "₽"],
+ ["USD", "320.00", "320,00 $", "Доллар США", "$"],
+ ["EUR", "9876.54321", "9 876,54321 €", "Евро", "€"],
+ [
+ "RUB",
+ "123456789012.345678901",
+ "123 456 789 012,345678901 ₽",
+ "Российский рубль",
+ "₽",
+ ],
+ ["USD", "-1234567.890123", "-1 234 567,890123 $", "Доллар США", "$"],
+ ["EUR", "987654321.000001", "987 654 321,000001 €", "Евро", "€"],
+ ["USD", "-0.5", "-0,5 $", "Доллар США", "$"],
+ ["CAD", "20.00", "20,00 CAD", "CAD", "CAD"],
+ ["XYZ", "1.000", "1,000 XYZ", "XYZ", "XYZ"],
+ ])(
+ "preserves %s %s and its currency identity",
+ (currency, value, expected, name, symbol) => {
+ render(
+
+
+ ,
+ );
+ const term = screen.getByRole("term");
+ expect(term).toHaveTextContent(name);
+ expect(term.querySelector('[aria-hidden="true"]')).toHaveTextContent(
+ symbol,
+ );
+ expect(screen.getByRole("definition")).toHaveTextContent(expected);
+ expect(term.parentElement).toBe(
+ screen.getByRole("definition").parentElement,
+ );
+ },
+ );
+
+ it("keeps USD and CAD as distinct supplied values", () => {
+ render(
+
+
+ ,
+ );
+ expect(
+ screen.getAllByRole("definition").map((node) => node.textContent),
+ ).toEqual(["1,00 $", "2,00 CAD"]);
+ });
+});
diff --git a/src/pages/account/ui/components/PortfolioTotals.view.tsx b/src/pages/account/ui/components/PortfolioTotals.view.tsx
index e0f6b83..f261307 100644
--- a/src/pages/account/ui/components/PortfolioTotals.view.tsx
+++ b/src/pages/account/ui/components/PortfolioTotals.view.tsx
@@ -1,7 +1,8 @@
-import { Paper, SimpleGrid, Text } from "@mantine/core";
+import { Paper, SimpleGrid, Text, VisuallyHidden } from "@mantine/core";
import type { FC } from "react";
+import { useTranslation } from "react-i18next";
import type { Portfolio } from "@/entities/portfolio";
-import { formatMoney } from "@/shared/lib";
+import { formatDecimal } from "@/shared/lib";
import classes from "../AccountPage.module.css";
type PortfolioTotalsProps = {
@@ -10,19 +11,49 @@ type PortfolioTotalsProps = {
const PortfolioTotals: FC = (props) => {
const { totals } = props;
+ const { t } = useTranslation();
+ const currencies: Record = {
+ RUB: { symbol: "₽", name: t("portfolio.currencies.RUB") },
+ USD: { symbol: "$", name: t("portfolio.currencies.USD") },
+ EUR: { symbol: "€", name: t("portfolio.currencies.EUR") },
+ };
return (
-
- {totals.map((total) => (
-
-
- {total.currency}
-
-
- {formatMoney(total)}
-
-
- ))}
+
+ {totals.map((total) => {
+ const amount = formatDecimal(total.value);
+ return (
+
+
+
+ {currencies[total.currency]?.symbol ?? total.currency}
+
+
+ {currencies[total.currency]?.name ?? total.currency}
+
+
+
+ {amount.startsWith("-") ? (
+ <>
+
+ {amount.slice(0, 2)}
+
+ {amount.slice(2)}
+ >
+ ) : (
+ amount
+ )}{" "}
+ {currencies[total.currency]?.symbol ?? total.currency}
+
+
+ );
+ })}
);
};
diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts
index 6764df3..0e3eb4e 100644
--- a/src/shared/i18n/locales/ru.ts
+++ b/src/shared/i18n/locales/ru.ts
@@ -62,6 +62,11 @@ const ru = {
},
account: { title: "Счет" },
portfolio: {
+ currencies: {
+ RUB: "Российский рубль",
+ USD: "Доллар США",
+ EUR: "Евро",
+ },
title: "Портфель",
emptyTitle: "Портфель пока пуст",
emptyText: "На счете нет позиций и доступных денежных остатков.",
diff --git a/src/shared/lib/display.ts b/src/shared/lib/display.ts
index 33f7b73..d5518ca 100644
--- a/src/shared/lib/display.ts
+++ b/src/shared/lib/display.ts
@@ -32,4 +32,4 @@ function instrumentLogoUrl(logoName: string | null | undefined): string | null {
: null;
}
-export { formatMoney, formatSnapshotTime, instrumentLogoUrl };
+export { formatDecimal, formatMoney, formatSnapshotTime, instrumentLogoUrl };
diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts
index 4363ab8..796a25d 100644
--- a/src/shared/lib/index.ts
+++ b/src/shared/lib/index.ts
@@ -1,4 +1,5 @@
export {
+ formatDecimal,
formatMoney,
formatSnapshotTime,
instrumentLogoUrl,