diff --git a/src/entities/brokerage-account/index.ts b/src/entities/brokerage-account/index.ts
index b4c45d6..bcd0559 100644
--- a/src/entities/brokerage-account/index.ts
+++ b/src/entities/brokerage-account/index.ts
@@ -1,6 +1,7 @@
+export type { BrokerageAccount } from "./api/useBrokerageAccounts";
export {
brokerageAccountsQueryKey,
useBrokerageAccounts,
} from "./api/useBrokerageAccounts";
-export { errorCode } from "./model/display";
+export { accountTypeKey, errorCode } from "./model/display";
export { BrokerageAccountList } from "./ui/BrokerageAccountList";
diff --git a/src/entities/portfolio/api/usePortfolio.test.tsx b/src/entities/portfolio/api/usePortfolio.test.tsx
new file mode 100644
index 0000000..1731992
--- /dev/null
+++ b/src/entities/portfolio/api/usePortfolio.test.tsx
@@ -0,0 +1,45 @@
+import { MantineProvider } from "@mantine/core";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { renderHook, waitFor } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { describe, expect, it, vi } from "vitest";
+
+const { get } = vi.hoisted(() => ({ get: vi.fn() }));
+
+vi.mock("../../../shared/api", () => ({ apiClient: { GET: get } }));
+
+import { portfolioQueryPolicy, usePortfolio } from "./usePortfolio";
+
+describe("usePortfolio", () => {
+ it("uses the account endpoint with a 30 second cache and no retry", async () => {
+ const client = new QueryClient();
+ get.mockResolvedValueOnce({
+ data: {
+ lastSuccessfulAt: "2026-08-13T10:00:00Z",
+ isStale: false,
+ totalsByCurrency: [],
+ positions: [],
+ cashBalances: [],
+ },
+ });
+ const wrapper = ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ );
+
+ const { result } = renderHook(() => usePortfolio("account-1", "token"), {
+ wrapper,
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(get).toHaveBeenCalledWith(
+ "/api/v1/brokerage-accounts/{accountId}/portfolio",
+ {
+ params: { path: { accountId: "account-1" } },
+ headers: { Authorization: "Bearer token" },
+ },
+ );
+ expect(portfolioQueryPolicy).toEqual({ staleTime: 30_000, retry: false });
+ });
+});
diff --git a/src/entities/portfolio/api/usePortfolio.ts b/src/entities/portfolio/api/usePortfolio.ts
new file mode 100644
index 0000000..aaf9480
--- /dev/null
+++ b/src/entities/portfolio/api/usePortfolio.ts
@@ -0,0 +1,36 @@
+import { useQuery } from "@tanstack/react-query";
+import { apiClient } from "../../../shared/api";
+import type { paths } from "../../../shared/api/generated/schema";
+
+export const portfolioQueryKey = (accountId: string) =>
+ ["portfolio", accountId] as const;
+
+export const portfolioQueryPolicy = {
+ staleTime: 30_000,
+ retry: false,
+} as const;
+
+export type Portfolio =
+ paths["/api/v1/brokerage-accounts/{accountId}/portfolio"]["get"]["responses"][200]["content"]["application/json"];
+
+export function usePortfolio(
+ accountId: string | undefined,
+ accessToken: string | null,
+) {
+ return useQuery({
+ queryKey: portfolioQueryKey(accountId ?? ""),
+ enabled: Boolean(accountId && accessToken),
+ ...portfolioQueryPolicy,
+ queryFn: async () => {
+ const { data, error } = await apiClient.GET(
+ "/api/v1/brokerage-accounts/{accountId}/portfolio",
+ {
+ params: { path: { accountId: accountId ?? "" } },
+ headers: { Authorization: `Bearer ${accessToken}` },
+ },
+ );
+ if (error) throw error;
+ return data as Portfolio;
+ },
+ });
+}
diff --git a/src/entities/portfolio/index.ts b/src/entities/portfolio/index.ts
new file mode 100644
index 0000000..ffcc189
--- /dev/null
+++ b/src/entities/portfolio/index.ts
@@ -0,0 +1,12 @@
+export type { Portfolio } from "./api/usePortfolio";
+export {
+ portfolioQueryKey,
+ portfolioQueryPolicy,
+ usePortfolio,
+} from "./api/usePortfolio";
+export {
+ formatDecimal,
+ formatMoney,
+ formatSnapshotTime,
+ portfolioErrorCode,
+} from "./model/display";
diff --git a/src/entities/portfolio/model/display.test.ts b/src/entities/portfolio/model/display.test.ts
new file mode 100644
index 0000000..23e2e09
--- /dev/null
+++ b/src/entities/portfolio/model/display.test.ts
@@ -0,0 +1,11 @@
+import { describe, expect, it } from "vitest";
+import { formatDecimal, formatMoney } from "./display";
+
+describe("portfolio display helpers", () => {
+ it("formats decimal strings without financial arithmetic", () => {
+ expect(formatDecimal("-1234567.89")).toBe("-1 234 567,89");
+ expect(formatMoney({ value: "10", currency: "RUB" })).toBe("10 ₽");
+ expect(formatMoney({ value: "0.5", currency: "USD" })).toBe("0,5 USD");
+ expect(formatMoney(null)).toBe("—");
+ });
+});
diff --git a/src/entities/portfolio/model/display.ts b/src/entities/portfolio/model/display.ts
new file mode 100644
index 0000000..ea89d9c
--- /dev/null
+++ b/src/entities/portfolio/model/display.ts
@@ -0,0 +1,32 @@
+import type { Portfolio } from "../api/usePortfolio";
+
+type Money = Portfolio["totalsByCurrency"][number];
+
+export function formatDecimal(value: string): string {
+ const [integer, fraction] = value.split(".");
+ const sign = integer.startsWith("-") ? "-" : "";
+ const grouped = (sign ? integer.slice(1) : integer).replace(
+ /\B(?=(\d{3})+(?!\d))/g,
+ " ",
+ );
+ return `${sign}${grouped}${fraction ? `,${fraction}` : ""}`;
+}
+
+export function formatMoney(money: Money | null): string {
+ if (!money) return "—";
+ return `${formatDecimal(money.value)} ${money.currency === "RUB" ? "₽" : money.currency}`;
+}
+
+export function formatSnapshotTime(value: string): string {
+ return new Intl.DateTimeFormat("ru-RU", {
+ dateStyle: "medium",
+ timeStyle: "short",
+ timeZone: "UTC",
+ }).format(new Date(value));
+}
+
+export function portfolioErrorCode(error: unknown): string | undefined {
+ return typeof error === "object" && error !== null && "code" in error
+ ? String(error.code)
+ : undefined;
+}
diff --git a/src/pages/account/ui/AccountPage.module.css b/src/pages/account/ui/AccountPage.module.css
new file mode 100644
index 0000000..109a4dc
--- /dev/null
+++ b/src/pages/account/ui/AccountPage.module.css
@@ -0,0 +1,31 @@
+.page {
+ max-width: 960px;
+}
+
+.total,
+.row,
+.cash {
+ border-color: var(--mantine-color-gray-3);
+ background: var(--mantine-color-gray-0);
+}
+
+.total {
+ padding: var(--mantine-spacing-md);
+ border-top: 3px solid var(--mantine-color-blue-6);
+}
+
+.row,
+.cash {
+ padding: var(--mantine-spacing-md);
+}
+
+.cash {
+ display: flex;
+ justify-content: space-between;
+}
+
+@media (max-width: 36em) {
+ .row :global(.mantine-Group-root) {
+ align-items: flex-start;
+ }
+}
diff --git a/src/pages/account/ui/AccountPage.test.tsx b/src/pages/account/ui/AccountPage.test.tsx
new file mode 100644
index 0000000..0680178
--- /dev/null
+++ b/src/pages/account/ui/AccountPage.test.tsx
@@ -0,0 +1,117 @@
+import { MantineProvider } from "@mantine/core";
+import { cleanup, render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { Portfolio } from "../../../entities/portfolio";
+import {
+ AccountPageEmpty,
+ AccountPageError,
+ AccountPageLoaded,
+ AccountPageLoading,
+} from "./AccountPage";
+
+function renderPage(ui: React.ReactNode) {
+ return render(
+
+ {ui}
+ ,
+ );
+}
+
+const portfolio: Portfolio = {
+ lastSuccessfulAt: "2026-08-13T10:00:00Z",
+ isStale: true,
+ totalsByCurrency: [{ currency: "RUB", value: "1234.5" }],
+ positions: [
+ {
+ figi: "FIGI-2",
+ displayName: "Вторая",
+ type: "STOCK",
+ quantity: "2",
+ currentValue: null,
+ averagePurchasePrice: null,
+ absoluteReturn: null,
+ },
+ {
+ figi: "FIGI-1",
+ displayName: "Первая",
+ type: "BOND",
+ quantity: "1",
+ currentValue: { currency: "RUB", value: "10" },
+ averagePurchasePrice: null,
+ absoluteReturn: { currency: "RUB", value: "-1" },
+ },
+ ],
+ cashBalances: [{ currency: "USD", value: "0.5" }],
+};
+
+describe("AccountPage states", () => {
+ afterEach(cleanup);
+ it("renders loading and empty states", () => {
+ renderPage();
+ expect(screen.getByRole("status")).toBeInTheDocument();
+
+ renderPage();
+ expect(
+ screen.getByRole("heading", { name: "Портфель пока пуст" }),
+ ).toBeInTheDocument();
+ });
+
+ it("renders allowed error actions", () => {
+ const retry = vi.fn();
+ renderPage();
+ expect(screen.getByRole("link", { name: "К счетам" })).toHaveAttribute(
+ "href",
+ "/accounts",
+ );
+
+ renderPage();
+ expect(
+ screen.getByRole("link", { name: "Открыть настройки T-Bank" }),
+ ).toHaveAttribute("href", "/settings");
+
+ renderPage();
+ screen.getByRole("button", { name: "Повторить" }).click();
+ expect(retry).toHaveBeenCalledOnce();
+ });
+
+ it("renders a stale portfolio in upstream order with nullable values", () => {
+ renderPage();
+
+ expect(
+ screen.getByRole("heading", { level: 1, name: "Портфель" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ "T-Bank временно недоступен. Показан последний успешный снимок.",
+ ),
+ ).toBeInTheDocument();
+ expect(screen.getByText("—")).toBeInTheDocument();
+ expect(screen.getByText("Доход: —")).toBeInTheDocument();
+ const positions = screen
+ .getAllByText(/Вторая|Первая/)
+ .map((element) => element.textContent);
+ expect(positions).toEqual(["Вторая", "Первая"]);
+ expect(screen.getByRole("heading", { name: "Акции" })).toBeInTheDocument();
+ expect(
+ screen.getByRole("heading", { name: "Облигации" }),
+ ).toBeInTheDocument();
+ });
+
+ it("uses the cached account title when available", () => {
+ renderPage(
+ ,
+ );
+ expect(
+ screen.getByRole("heading", { level: 1, name: "Основной" }),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/account/ui/AccountPage.tsx b/src/pages/account/ui/AccountPage.tsx
index 5caeed9..6407679 100644
--- a/src/pages/account/ui/AccountPage.tsx
+++ b/src/pages/account/ui/AccountPage.tsx
@@ -1,15 +1,54 @@
-import { Stack, Title } from "@mantine/core";
+import {
+ Alert,
+ Button,
+ Card,
+ Group,
+ Paper,
+ SimpleGrid,
+ Stack,
+ Text,
+ Title,
+} from "@mantine/core";
+import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
+import { Link, useParams } from "react-router";
+import {
+ accountTypeKey,
+ type BrokerageAccount,
+} from "../../../entities/brokerage-account";
+import type { Portfolio } from "../../../entities/portfolio";
+import {
+ formatMoney,
+ formatSnapshotTime,
+ portfolioErrorCode,
+ usePortfolio,
+} from "../../../entities/portfolio";
+import { useAuth } from "../../../features/auth";
import { StateView } from "../../../shared/ui/StateView";
+import classes from "./AccountPage.module.css";
+
+const accountsQueryKey = ["brokerage-accounts"] as const;
export function AccountPage() {
- const { t } = useTranslation();
- return (
-
- {t("account.title")}
-
-
- );
+ const { accountId } = useParams();
+ const { accessToken } = useAuth();
+ const portfolio = usePortfolio(accountId, accessToken);
+ const account = useQueryClient()
+ .getQueryData(accountsQueryKey)
+ ?.find((item) => item.id === accountId);
+
+ if (portfolio.isFetching) return ;
+ if (portfolio.isError)
+ return (
+ portfolio.refetch()}
+ />
+ );
+ if (!portfolio.data) return ;
+ if (!portfolio.data.positions.length && !portfolio.data.cashBalances.length)
+ return ;
+ return ;
}
export function AccountPageLoading() {
@@ -23,20 +62,199 @@ export function AccountPageLoading() {
);
}
-export function AccountPageError() {
+export function AccountPageError({
+ code,
+ retry,
+}: {
+ code?: string;
+ retry: () => void;
+}) {
const { t } = useTranslation();
+ if (code === "NOT_FOUND")
+ return (
+
+ {t("portfolio.back")}
+
+ }
+ />
+ );
+ if (code === "TBANK_NOT_CONNECTED" || code === "INVALID_TBANK_TOKEN")
+ return (
+
+ {t("accounts.openSettings")}
+
+ }
+ />
+ );
+ if (code === "TBANK_UNAVAILABLE")
+ return (
+ {t("accounts.retry")}}
+ />
+ );
return (
{t("accounts.retry")}}
/>
);
}
-export function AccountPageEmpty() {
+export function AccountPageEmpty({ account }: { account?: BrokerageAccount }) {
const { t } = useTranslation();
return (
-
+
+
+
+
+ );
+}
+
+export function AccountPageLoaded({
+ account,
+ portfolio,
+}: {
+ account?: BrokerageAccount;
+ portfolio: Portfolio;
+}) {
+ const { t } = useTranslation();
+ const stocks = portfolio.positions.filter(
+ (position) => position.type === "STOCK",
+ );
+ const bonds = portfolio.positions.filter(
+ (position) => position.type === "BOND",
+ );
+ const otherPositions = portfolio.positions.filter(
+ (position) => position.type !== "STOCK" && position.type !== "BOND",
+ );
+ return (
+
+
+ {portfolio.isStale ? (
+ {t("portfolio.staleWarning")}
+ ) : null}
+
+ {t("portfolio.updatedAt", {
+ date: formatSnapshotTime(portfolio.lastSuccessfulAt),
+ })}
+
+
+ {portfolio.totalsByCurrency.map((total) => (
+
+
+ {total.currency}
+
+
+ {formatMoney(total)}
+
+
+ ))}
+
+ {stocks.length ? (
+
+ ) : null}
+ {bonds.length ? (
+
+ ) : null}
+ {otherPositions.length ? (
+
+ ) : null}
+
+ {portfolio.cashBalances.map((balance) => (
+
+ {balance.currency}
+ {formatMoney(balance)}
+
+ ))}
+
+
+ );
+}
+
+function PositionSection({
+ title,
+ positions,
+}: {
+ title: string;
+ positions: Portfolio["positions"];
+}) {
+ const { t } = useTranslation();
+ return (
+
+ {positions.map((position) => (
+
+
+
+ {position.displayName}
+
+ {t("portfolio.quantity", { value: position.quantity })}
+
+
+
+ {formatMoney(position.currentValue)}
+
+ {t("portfolio.return", {
+ value: formatMoney(position.absoluteReturn),
+ })}
+
+
+
+
+ ))}
+
+ );
+}
+
+function AccountHeading({ account }: { account?: BrokerageAccount }) {
+ const { t } = useTranslation();
+ return (
+
+ {account?.name ?? t("portfolio.title")}
+ {account ? (
+
+ {t(accountTypeKey(account.type))}
+
+ ) : null}
+
+ );
+}
+
+function Section({
+ title,
+ children,
+}: {
+ title: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {title}
+ {children}
+
);
}
diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts
index c5d411d..798e6e4 100644
--- a/src/shared/api/generated/schema.d.ts
+++ b/src/shared/api/generated/schema.d.ts
@@ -116,6 +116,22 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/v1/brokerage-accounts/{accountId}/portfolio": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get: operations["PortfolioController_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
}
export type webhooks = Record;
export interface components {
@@ -150,6 +166,28 @@ export interface components {
/** Format: date */
openedOn: string | null;
};
+ MoneyDto: {
+ currency: string;
+ value: string;
+ };
+ PortfolioPositionDto: {
+ figi: string | null;
+ displayName: string;
+ /** @enum {string} */
+ type: "STOCK" | "BOND" | "FUND" | "CURRENCY" | "FUTURE" | "OPTION" | "STRUCTURED_NOTE" | "DIGITAL_FINANCIAL_ASSET" | "OTHER";
+ quantity: string;
+ currentValue: components["schemas"]["MoneyDto"] | null;
+ averagePurchasePrice: components["schemas"]["MoneyDto"] | null;
+ absoluteReturn: components["schemas"]["MoneyDto"] | null;
+ };
+ PortfolioDto: {
+ /** Format: date-time */
+ lastSuccessfulAt: string;
+ isStale: boolean;
+ totalsByCurrency: components["schemas"]["MoneyDto"][];
+ positions: components["schemas"]["PortfolioPositionDto"][];
+ cashBalances: components["schemas"]["MoneyDto"][];
+ };
};
responses: never;
parameters: never;
@@ -338,4 +376,25 @@ export interface operations {
};
};
};
+ PortfolioController_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ accountId: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["PortfolioDto"];
+ };
+ };
+ };
+ };
}
diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts
index 1371811..a976323 100644
--- a/src/shared/i18n/locales/ru.ts
+++ b/src/shared/i18n/locales/ru.ts
@@ -42,6 +42,26 @@ export const ru = {
},
},
account: { title: "Счет" },
+ portfolio: {
+ title: "Портфель",
+ emptyTitle: "Портфель пока пуст",
+ emptyText: "На счете нет позиций и доступных денежных остатков.",
+ accountUnavailableTitle: "Счет недоступен",
+ accountUnavailableText: "Вернитесь к списку и выберите доступный счет.",
+ connectionErrorTitle: "Проверьте подключение T-Bank",
+ connectionErrorText: "Настройте подключение, чтобы посмотреть портфель.",
+ back: "К счетам",
+ staleWarning:
+ "T-Bank временно недоступен. Показан последний успешный снимок.",
+ updatedAt: "Данные получены {{date}}",
+ positions: "Позиции",
+ stocks: "Акции",
+ bonds: "Облигации",
+ otherPositions: "Другие позиции",
+ cashBalances: "Денежные остатки",
+ quantity: "Количество: {{value}}",
+ return: "Доход: {{value}}",
+ },
settings: { title: "Настройки" },
tbank: {
title: "T-Bank Invest",