diff --git a/AGENTS.md b/AGENTS.md
index c6709be..4425c28 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -30,7 +30,7 @@ Gate non-mutating и проверяет runtime, OpenAPI snapshot, hygiene, stri
- Code style и i18n: [`docs/CONVENTIONS.md`](docs/CONVENTIONS.md), исполняются Biome и TypeScript.
- FSD и public API: [`docs/STRUCTURE.md`](docs/STRUCTURE.md), исполняются dependency-cruiser и Knip; направление `app → pages → features → entities → shared`.
- HTTP и types: [`docs/API-LAYER.md`](docs/API-LAYER.md); только typed client и generated OpenAPI `paths[...]`, без ручных transport DTO и прямых T-Bank calls.
-- UI states: [`docs/UI-PATTERN.md`](docs/UI-PATTERN.md); route runtime failures обрабатывает единый route-keyed ErrorBoundary, expected API states остаются локальными.
+- UI states и VM/view boundary: [`docs/UI-PATTERN.md`](docs/UI-PATTERN.md); container вызывает page VM hook, VM владеет data/state/derived presentation data, `*.view.tsx` получает props и рендерит. View files дробить по semantic responsibility, когда section/state можно назвать отдельно.
- Тесты: [`docs/TESTING.md`](docs/TESTING.md); новый тест обязателен по risk trigger, а не из-за появления файла или coverage процента.
Непроверяемые эстетические предпочтения считаются guidance. Не добавлять Storybook, Playwright, CI, browser MSW worker или mock development mode без утверждённого change.
diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md
index c8fc142..cbc9bbc 100644
--- a/docs/CONVENTIONS.md
+++ b/docs/CONVENTIONS.md
@@ -13,8 +13,12 @@
## File roles
-- `Component.tsx` - контейнер или публичный компонент.
-- `Component.vm.ts` - hook с данными и действиями.
-- `Component.view.tsx` - чистое представление.
+- `Component.tsx` - контейнер или публичный компонент; для data-driven pages вызывает page VM hook и выбирает top-level state view.
+- `Component.vm.ts` - hook с данными, действиями, state selection и derived presentation data.
+- `Component.view.tsx` - чистое представление: получает props, рендерит JSX и не вызывает data/auth/router/navigation/VM hooks.
- `Component.types.ts` - props и локальные типы.
- `Component.module.css` - локальные стили.
+
+Большие view files дробить по ответственности. Если section/state можно
+самостоятельно назвать, вынести его в соседний presentation component вместо
+накопления смысловой нагрузки в одном файле.
diff --git a/docs/UI-PATTERN.md b/docs/UI-PATTERN.md
index 2b5013c..af140f6 100644
--- a/docs/UI-PATTERN.md
+++ b/docs/UI-PATTERN.md
@@ -6,4 +6,29 @@
Запросные экраны без успешных данных выбирают состояние в порядке loading, error, empty, loaded. Если query уже содержит данные, они остаются видимыми при фоновом refetch и его ошибке: рядом показываются неблокирующий progress и локальное предупреждение с retry. Такое состояние называется Client Cached Snapshot; оно не заменяет доменное server-side stale/fallback состояние.
-Для finance-экранов container вызывает query hooks, VM выбирает состояние и передает только presentation props в Loading/Error/Empty/Loaded view. Known API errors, retry и stale snapshot остаются local views, а непойманные route errors обрабатывает общий route-keyed ErrorBoundary.
+Для finance-экранов используется граница `container -> ViewModel -> view`.
+`*.tsx` без суффикса — container: он вызывает единственный page VM hook и
+выбирает top-level Loading/Error/Empty/Loaded view. `*.vm.ts` владеет data
+fetching, auth/route inputs, state selection, retry callbacks, domain grouping
+и derived presentation data. `*.view.tsx` и соседние presentation components
+получают props и рендерят JSX.
+
+View modules не вызывают data/auth/router/navigation/VM hooks: никаких
+`useBrokerage*`, `usePortfolio`, `useAuth`, `useParams`, `useNavigate` или
+page VM hooks внутри `*.view.tsx` и `components/*.view.tsx`. View modules также
+не группируют domain collections и не считают derived collection values вроде
+долей Portfolio Position. Эти значения готовит VM.
+
+Допустимые hooks во view: `useTranslation()` для labels, form-context hooks в
+form views и локальный UI-only state для rendering fallback, например failed
+image state. Простое форматирование одного переданного значения через
+существующий formatter остаётся presentation concern; вычисления из всей
+коллекции остаются во VM.
+
+View files дробятся по смысловой нагрузке, а не только по количеству строк. Если
+section или state имеет самостоятельный смысл и может быть назван, его нужно
+выносить в nearby presentation component вместо накопления внутри одного
+большого `*.view.tsx`.
+
+Known API errors, retry и stale snapshot остаются local views, а непойманные
+route errors обрабатывает общий route-keyed ErrorBoundary.
diff --git a/scripts/lazy-route-bundle-baseline.json b/scripts/lazy-route-bundle-baseline.json
index f8254bd..08b2014 100644
--- a/scripts/lazy-route-bundle-baseline.json
+++ b/scripts/lazy-route-bundle-baseline.json
@@ -2,7 +2,7 @@
"src/app/routes/ProtectedLayout.tsx": 3137,
"src/pages/account-events/index.ts": 4468,
"src/pages/account-operations/index.ts": 13861,
- "src/pages/account/index.ts": 21872,
+ "src/pages/account/index.ts": 22366,
"src/pages/accounts/index.ts": 6174,
"src/pages/login/index.ts": 669,
"src/pages/not-found/index.ts": 850,
diff --git a/src/pages/account/ui/AccountPage.test.tsx b/src/pages/account/ui/AccountPage.test.tsx
index eb5104c..1e23361 100644
--- a/src/pages/account/ui/AccountPage.test.tsx
+++ b/src/pages/account/ui/AccountPage.test.tsx
@@ -13,6 +13,11 @@ import {
AccountPageLoaded,
AccountPageLoading,
} from "./AccountPage";
+import type {
+ AccountPageRecentEvents,
+ AccountPageRecentOperations,
+} from "./AccountPage.vm";
+import { toAccountPagePositionSections } from "./AccountPage.vm";
vi.mock("@/features/auth", () => ({
useAuth: () => ({ accessToken: "test-token" }),
@@ -73,13 +78,39 @@ const portfolio: Portfolio = {
cashBalances: [{ currency: "USD", value: "0.5" }],
};
+const emptyRecentOperations: AccountPageRecentOperations = {
+ state: "empty",
+ to: "/accounts/account-1/operations",
+};
+
+const emptyRecentEvents: AccountPageRecentEvents = {
+ state: "empty",
+ to: "/accounts/account-1/events",
+};
+
+function loadedProps(nextPortfolio: Portfolio = portfolio) {
+ return {
+ portfolio: nextPortfolio,
+ positionSections: toAccountPagePositionSections(nextPortfolio),
+ recentOperations: emptyRecentOperations,
+ recentEvents: emptyRecentEvents,
+ };
+}
+
+function emptyProps() {
+ return {
+ recentOperations: emptyRecentOperations,
+ recentEvents: emptyRecentEvents,
+ };
+}
+
describe("AccountPage states", () => {
afterEach(cleanup);
it("renders loading and empty states", () => {
renderPage();
expect(screen.getByRole("status")).toBeInTheDocument();
- renderPage();
+ renderPage();
expect(
screen.getByRole("heading", { name: "Портфель пока пуст" }),
).toBeInTheDocument();
@@ -104,7 +135,7 @@ describe("AccountPage states", () => {
});
it("renders a stale portfolio in upstream order with nullable values hidden", () => {
- renderPage();
+ renderPage();
expect(
screen.getByRole("heading", { level: 1, name: "Портфель" }),
@@ -138,7 +169,7 @@ describe("AccountPage states", () => {
it("renders stock cards with logo fallback and signed unlabeled return", () => {
renderPage(
{
logoName: null,
},
],
- }}
+ })}
/>,
);
expect(screen.getByText("А")).toBeInTheDocument();
@@ -177,7 +208,7 @@ describe("AccountPage states", () => {
it("renders bond cards with logo, fallback, and signed unlabeled return like stocks", () => {
renderPage(
{
logoName: null,
},
],
- }}
+ })}
/>,
);
expect(screen.getByRole("img", { name: "Долг" })).toHaveAttribute(
@@ -222,10 +253,10 @@ describe("AccountPage states", () => {
it("renders the bonds section as a single-column grid on any screen width", () => {
renderPage(
,
);
const singleColumnGrids = Array.from(document.querySelectorAll("style"))
@@ -242,7 +273,7 @@ describe("AccountPage states", () => {
it("keeps other-position cards compact with labeled return and no logo", () => {
renderPage(
{
logoName: "fond.png",
},
],
- }}
+ })}
/>,
);
expect(screen.getByText("Доход: -5 ₽")).toBeInTheDocument();
@@ -267,7 +298,7 @@ describe("AccountPage states", () => {
it("uses the cached account title when available", () => {
renderPage(
{
).toBeInTheDocument();
});
- it("keeps the portfolio visible when recent events fail", async () => {
- server.use(
- http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
- HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
- ),
- );
+ it("keeps the portfolio visible when recent operations fail", () => {
renderPage(
,
);
- await waitFor(() =>
- expect(
- screen.getByText("Не удалось загрузить последние события."),
- ).toBeInTheDocument(),
- );
+ expect(
+ screen.getByText("Не удалось загрузить последние события."),
+ ).toBeInTheDocument();
expect(screen.getByText("Вторая")).toBeInTheDocument();
});
- it("keeps the portfolio visible when upcoming events fail", async () => {
- server.use(
- http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
- HttpResponse.json({ code: "MOEX_UNAVAILABLE" }, { status: 503 }),
- ),
- );
+ it("keeps the portfolio visible when upcoming events fail", () => {
renderPage(
,
);
- await waitFor(() =>
- expect(
- screen.getByText("Не удалось загрузить будущие события."),
- ).toBeInTheDocument(),
- );
+ expect(
+ screen.getByText("Не удалось загрузить будущие события."),
+ ).toBeInTheDocument();
expect(screen.getByText("Вторая")).toBeInTheDocument();
});
diff --git a/src/pages/account/ui/AccountPage.tsx b/src/pages/account/ui/AccountPage.tsx
index cb2abbc..9024ed7 100644
--- a/src/pages/account/ui/AccountPage.tsx
+++ b/src/pages/account/ui/AccountPage.tsx
@@ -14,22 +14,23 @@ export function AccountPage() {
return (
);
return (
);
}
diff --git a/src/pages/account/ui/AccountPage.view.tsx b/src/pages/account/ui/AccountPage.view.tsx
index 9730b9c..8847ac2 100644
--- a/src/pages/account/ui/AccountPage.view.tsx
+++ b/src/pages/account/ui/AccountPage.view.tsx
@@ -1,35 +1,26 @@
-import {
- Alert,
- Avatar,
- Button,
- Card,
- Group,
- Loader,
- Paper,
- SimpleGrid,
- Stack,
- Text,
- Title,
-} from "@mantine/core";
-import { useState } from "react";
+import { Alert, Button, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
-import { useBrokerageOperations } from "@/entities/broker-operation";
import type { BrokerageAccount } from "@/entities/brokerage-account";
-import { accountTypeKey } from "@/entities/brokerage-account";
-import {
- type BrokerageEvent,
- useBrokerageEvents,
-} from "@/entities/brokerage-event";
import type { Portfolio } from "@/entities/portfolio";
-import {
- formatMoney,
- formatShare,
- formatSnapshotTime,
- instrumentLogoUrl,
-} from "@/entities/portfolio";
+import { formatSnapshotTime } from "@/entities/portfolio";
import { StateView } from "@/shared/ui";
import classes from "./AccountPage.module.css";
+import type {
+ AccountPagePositionSection,
+ AccountPageRecentEvents,
+ AccountPageRecentOperations,
+} from "./AccountPage.vm";
+import {
+ AccountHeading,
+ CashBalances,
+ PortfolioRefreshState,
+ PortfolioTotals,
+ PositionSection,
+ RecentEvents,
+ RecentOperations,
+ Section,
+} from "./components";
export function AccountPageLoading() {
const { t } = useTranslation();
@@ -41,6 +32,7 @@ export function AccountPageLoading() {
/>
);
}
+
export function AccountPageError({
code,
retry,
@@ -93,20 +85,21 @@ export function AccountPageError({
/>
);
}
+
export function AccountPageEmpty({
account,
- accountId,
- accessToken = null,
refreshing = false,
refreshError = false,
retry,
+ recentOperations,
+ recentEvents,
}: {
account?: BrokerageAccount;
- accountId?: string;
- accessToken?: string | null;
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
+ recentOperations: AccountPageRecentOperations;
+ recentEvents: AccountPageRecentEvents;
}) {
const { t } = useTranslation();
return (
@@ -121,43 +114,32 @@ export function AccountPageEmpty({
title={t("portfolio.emptyTitle")}
text={t("portfolio.emptyText")}
/>
-
-
+
+
);
}
+
export function AccountPageLoaded({
account,
- accountId,
- accessToken,
portfolio,
+ positionSections,
refreshing = false,
refreshError = false,
retry,
+ recentOperations,
+ recentEvents,
}: {
account?: BrokerageAccount;
- accountId?: string;
- accessToken?: string | null;
portfolio: Portfolio;
+ positionSections: AccountPagePositionSection[];
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
+ recentOperations: AccountPageRecentOperations;
+ recentEvents: AccountPageRecentEvents;
}) {
const { t } = useTranslation();
- const stocks = portfolio.positions.filter((p) => p.type === "STOCK");
- const bonds = portfolio.positions.filter((p) => p.type === "BOND");
- const other = portfolio.positions.filter(
- (p) => p.type !== "STOCK" && p.type !== "BOND",
- );
- const denominators = new Map();
- for (const position of portfolio.positions) {
- if (!position.currentValue) continue;
- const currency = position.currentValue.currency;
- denominators.set(
- currency,
- (denominators.get(currency) ?? 0) + Number(position.currentValue.value),
- );
- }
return (
@@ -174,336 +156,25 @@ export function AccountPageLoaded({
date: formatSnapshotTime(portfolio.lastSuccessfulAt),
})}
-
- {portfolio.totalsByCurrency.map((total) => (
-
-
- {total.currency}
-
-
- {formatMoney(total)}
-
-
- ))}
-
- {stocks.length ? (
+
+ {positionSections.map((section) => (
- ) : null}
- {bonds.length ? (
-
- ) : null}
- {other.length ? (
-
- ) : null}
+ ))}
- {portfolio.cashBalances.map((balance) => (
-
- {balance.currency}
- {formatMoney(balance)}
-
- ))}
+
-
-
+
+
);
}
-function PortfolioRefreshState({
- refreshing,
- refreshError,
- retry,
-}: {
- refreshing: boolean;
- refreshError: boolean;
- retry?: () => void;
-}) {
- const { t } = useTranslation();
- if (!refreshing && !refreshError) return null;
- return (
- <>
- {refreshing ? (
-
-
-
- {t("portfolio.refreshing")}
-
-
- ) : null}
- {refreshError ? (
-
-
- {t("portfolio.refreshError")}
-
-
-
- ) : null}
- >
- );
-}
-function RecentOperations({
- accountId,
- accessToken,
-}: {
- accountId: string | undefined;
- accessToken: string | null;
-}) {
- const { t } = useTranslation();
- const operations = useBrokerageOperations(accountId, accessToken, {
- limit: 5,
- });
- return (
-
- {operations.isLoading ? (
- {t("ledger.recentLoading")}
- ) : null}
- {operations.isError ? (
- {t("ledger.recentError")}
- ) : null}
- {operations.data?.items.length === 0 ? (
- {t("ledger.recentEmpty")}
- ) : null}
- {operations.data?.items.map((operation) => (
-
- {operation.description || operation.type}
-
- {operation.payment
- ? formatMoney(operation.payment)
- : t("ledger.noMoney")}
-
-
- ))}
-
-
- );
-}
-function RecentEvents({
- accountId,
- accessToken,
-}: {
- accountId: string | undefined;
- accessToken: string | null;
-}) {
- const { t } = useTranslation();
- const events = useBrokerageEvents(accountId, accessToken);
- return (
-
- {events.isLoading ? (
- {t("events.recentLoading")}
- ) : null}
- {events.isError ? (
- {t("events.recentError")}
- ) : null}
- {events.data?.events.length === 0 ? (
- {t("events.recentEmpty")}
- ) : null}
- {events.data?.events.slice(0, 5).map((event) => (
-
- ))}
-
-
- );
-}
-function RecentEvent({ event }: { event: BrokerageEvent }) {
- const { t } = useTranslation();
- return (
-
- {`${t(`events.type.${event.type}`)} · ${event.displayName}`}
-
- {event.amount && event.currency
- ? formatMoney({ currency: event.currency, value: event.amount })
- : t("events.noAmount")}
-
-
- );
-}
-function PositionSection({
- title,
- positions,
- denominators,
- wide = false,
-}: {
- title: string;
- positions: Portfolio["positions"];
- denominators: Map;
- wide?: boolean;
-}) {
- return (
-
-
- {positions.map((position) => (
-
- ))}
-
-
- );
-}
-function PositionCard({
- position,
- denominators,
- wide = false,
-}: {
- position: Portfolio["positions"][number];
- denominators: Map;
- wide?: boolean;
-}) {
- const { t } = useTranslation();
- const share = position.currentValue?.currency
- ? formatShare(position.currentValue, {
- currency: position.currentValue.currency,
- value: String(denominators.get(position.currentValue.currency) ?? 0),
- })
- : null;
- const returnClass = position.absoluteReturn
- ? Number(position.absoluteReturn.value) > 0
- ? classes.positive
- : Number(position.absoluteReturn.value) < 0
- ? classes.negative
- : undefined
- : undefined;
- const returnText = position.absoluteReturn
- ? wide
- ? `${Number(position.absoluteReturn.value) > 0 ? "+" : ""}${formatMoney(position.absoluteReturn)}`
- : t("portfolio.return", {
- value: formatMoney(position.absoluteReturn),
- })
- : null;
- const logoSrc = wide ? instrumentLogoUrl(position.logoName) : null;
- return (
-
-
- {wide ? (
-
- ) : null}
-
-
- {position.displayName}
-
- {position.isin ? (
-
- {position.isin}
-
- ) : null}
-
- {t("portfolio.quantity", { value: position.quantity })}
-
-
-
- {position.currentValue ? (
-
- {formatMoney(position.currentValue)}
-
- ) : null}
- {returnText ? (
-
- {returnText}
-
- ) : null}
-
-
- {position.averagePurchasePrice || share ? (
-
- {position.averagePurchasePrice ? (
-
- {t("portfolio.avgPrice", {
- value: formatMoney(position.averagePurchasePrice),
- })}
-
- ) : null}
- {share ? (
-
- {t("portfolio.share", { value: share })}
-
- ) : null}
-
- ) : null}
-
- );
-}
-function PositionLogo({ src, label }: { src: string | null; label: string }) {
- const [failed, setFailed] = useState(false);
- if (src && !failed)
- return (
- setFailed(true)}
- />
- );
- return (
-
- {(label.trim()[0] ?? "?").toUpperCase()}
-
- );
-}
-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}
-
- );
+
+function positionSectionTitleKey(kind: AccountPagePositionSection["kind"]) {
+ if (kind === "stocks") return "portfolio.stocks";
+ if (kind === "bonds") return "portfolio.bonds";
+ return "portfolio.otherPositions";
}
diff --git a/src/pages/account/ui/AccountPage.vm.test.tsx b/src/pages/account/ui/AccountPage.vm.test.tsx
new file mode 100644
index 0000000..9693c76
--- /dev/null
+++ b/src/pages/account/ui/AccountPage.vm.test.tsx
@@ -0,0 +1,232 @@
+import { MantineProvider } from "@mantine/core";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { renderHook, waitFor } from "@testing-library/react";
+import { HttpResponse, http } from "msw";
+import type { ReactNode } from "react";
+import { MemoryRouter, Route, Routes } from "react-router";
+import { describe, expect, it, vi } from "vitest";
+import type { Portfolio } from "@/entities/portfolio";
+import { server } from "@/test/server";
+import { useAccountPageViewModel } from "./AccountPage.vm";
+
+vi.mock("@/features/auth", () => ({
+ useAuth: () => ({ accessToken: "test-token" }),
+}));
+
+const portfolio: Portfolio = {
+ lastSuccessfulAt: "2026-08-13T10:00:00Z",
+ isStale: true,
+ totalsByCurrency: [{ currency: "RUB", value: "150" }],
+ positions: [
+ {
+ figi: "FIGI-STOCK",
+ displayName: "Акция",
+ type: "STOCK",
+ quantity: "2",
+ currentValue: { currency: "RUB", value: "100" },
+ averagePurchasePrice: null,
+ absoluteReturn: { currency: "RUB", value: "10" },
+ isin: null,
+ logoName: "akciya.png",
+ },
+ {
+ figi: "FIGI-BOND",
+ displayName: "Облигация",
+ type: "BOND",
+ quantity: "1",
+ currentValue: { currency: "RUB", value: "50" },
+ averagePurchasePrice: { currency: "RUB", value: "45" },
+ absoluteReturn: { currency: "RUB", value: "-5" },
+ isin: "RU000A0JX0J2",
+ logoName: null,
+ },
+ {
+ figi: "FIGI-FUND",
+ displayName: "Фонд",
+ type: "FUND",
+ quantity: "3",
+ currentValue: null,
+ averagePurchasePrice: null,
+ absoluteReturn: null,
+ isin: null,
+ logoName: "fond.png",
+ },
+ ],
+ cashBalances: [{ currency: "USD", value: "1" }],
+};
+
+function wrapper({ children }: { children: ReactNode }) {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function mockLoadedAccountPage() {
+ server.use(
+ http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
+ HttpResponse.json(portfolio),
+ ),
+ http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
+ HttpResponse.json({
+ accountId: "account-1",
+ items: [
+ {
+ id: "operation-1",
+ occurredAt: "2026-08-13T10:00:00Z",
+ type: "Buy",
+ category: "trade",
+ description: "Покупка акции",
+ figi: "FIGI-STOCK",
+ instrumentUid: null,
+ ticker: "STK",
+ instrumentType: "share",
+ payment: { currency: "RUB", value: "-100" },
+ quantity: "1",
+ price: null,
+ accruedInterest: null,
+ commission: null,
+ },
+ ],
+ nextCursor: null,
+ hasNext: false,
+ observedAt: "2026-08-13T10:00:00Z",
+ }),
+ ),
+ http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
+ HttpResponse.json({
+ accountId: "account-1",
+ calculatedAt: "2026-08-13T10:00:00Z",
+ events: Array.from({ length: 6 }, (_, index) => ({
+ type: "coupon",
+ date: `2026-09-${String(index + 1).padStart(2, "0")}`,
+ dateKind: "event",
+ figi: `FIGI-EVENT-${index}`,
+ instrumentUid: null,
+ ticker: `BND${index}`,
+ displayName: `Купон ${index}`,
+ currency: "RUB",
+ amount: String(index + 1),
+ })),
+ }),
+ ),
+ );
+}
+
+describe("useAccountPageViewModel", () => {
+ it("prepares grouped position sections with derived display data", async () => {
+ mockLoadedAccountPage();
+
+ const { result } = renderHook(() => useAccountPageViewModel(), { wrapper });
+
+ await waitFor(() => expect(result.current.state).toBe("loaded"));
+ const loaded = result.current as Extract<
+ typeof result.current,
+ { state: "loaded" }
+ >;
+ expect(loaded.positionSections).toMatchObject([
+ {
+ kind: "stocks",
+ wide: true,
+ positions: [
+ {
+ displayName: "Акция",
+ shareText: "66,7 %",
+ returnText: "+10 ₽",
+ returnTone: "positive",
+ logoSrc: "https://invest-brands.cdn-tinkoff.ru/akciyax160.png",
+ },
+ ],
+ },
+ {
+ kind: "bonds",
+ wide: true,
+ positions: [
+ {
+ displayName: "Облигация",
+ shareText: "33,3 %",
+ returnText: "-5 ₽",
+ returnTone: "negative",
+ logoSrc: null,
+ },
+ ],
+ },
+ {
+ kind: "other",
+ wide: false,
+ positions: [
+ {
+ displayName: "Фонд",
+ shareText: null,
+ returnText: null,
+ returnTone: undefined,
+ logoSrc: null,
+ },
+ ],
+ },
+ ]);
+ });
+
+ it("prepares recent operations and events state for presentation views", async () => {
+ mockLoadedAccountPage();
+
+ const { result } = renderHook(() => useAccountPageViewModel(), { wrapper });
+
+ await waitFor(() =>
+ expect(
+ (result.current as { recentEvents?: { state: string } }).recentEvents,
+ )?.toMatchObject({ state: "loaded" }),
+ );
+ const loaded = result.current as Extract<
+ typeof result.current,
+ { state: "loaded" }
+ >;
+ expect(loaded.recentOperations).toMatchObject({
+ state: "loaded",
+ to: "/accounts/account-1/operations",
+ items: [{ id: "operation-1", description: "Покупка акции" }],
+ });
+ expect(loaded.recentEvents).toMatchObject({
+ state: "loaded",
+ to: "/accounts/account-1/events",
+ items: [
+ { ticker: "BND0" },
+ { ticker: "BND1" },
+ { ticker: "BND2" },
+ { ticker: "BND3" },
+ { ticker: "BND4" },
+ ],
+ });
+ });
+
+ it("keeps recent errors isolated in the ViewModel presentation state", async () => {
+ server.use(
+ http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
+ HttpResponse.json(portfolio),
+ ),
+ http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
+ HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
+ ),
+ http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
+ HttpResponse.json({ code: "MOEX_UNAVAILABLE" }, { status: 503 }),
+ ),
+ );
+
+ const { result } = renderHook(() => useAccountPageViewModel(), { wrapper });
+
+ await waitFor(() =>
+ expect(result.current).toMatchObject({
+ state: "loaded",
+ recentOperations: { state: "error" },
+ recentEvents: { state: "error" },
+ }),
+ );
+ });
+});
diff --git a/src/pages/account/ui/AccountPage.vm.ts b/src/pages/account/ui/AccountPage.vm.ts
index 88703fd..fff597d 100644
--- a/src/pages/account/ui/AccountPage.vm.ts
+++ b/src/pages/account/ui/AccountPage.vm.ts
@@ -1,19 +1,102 @@
import { useQueryClient } from "@tanstack/react-query";
import { useParams } from "react-router";
+import type { BrokerageOperation } from "@/entities/broker-operation";
+import { useBrokerageOperations } from "@/entities/broker-operation";
import {
type BrokerageAccount,
brokerageAccountsQueryKey,
} from "@/entities/brokerage-account";
-import { portfolioErrorCode, usePortfolio } from "@/entities/portfolio";
+import {
+ type BrokerageEvent,
+ useBrokerageEvents,
+} from "@/entities/brokerage-event";
+import type { Portfolio } from "@/entities/portfolio";
+import {
+ formatMoney,
+ formatShare,
+ instrumentLogoUrl,
+ portfolioErrorCode,
+ usePortfolio,
+} from "@/entities/portfolio";
import { useAuth } from "@/features/auth";
-export function useAccountPageViewModel() {
+type PositionSectionKind = "stocks" | "bonds" | "other";
+type AccountPageReturnTone = "positive" | "negative";
+
+export type AccountPagePosition = Portfolio["positions"][number] & {
+ shareText: string | null;
+ returnText: string | null;
+ returnTone?: AccountPageReturnTone;
+ logoSrc: string | null;
+};
+
+export type AccountPagePositionSection = {
+ kind: PositionSectionKind;
+ wide: boolean;
+ positions: AccountPagePosition[];
+};
+
+export type AccountPageRecentOperations =
+ | {
+ state: "loading" | "error" | "empty";
+ to: string;
+ }
+ | {
+ state: "loaded";
+ to: string;
+ items: BrokerageOperation[];
+ };
+
+export type AccountPageRecentEvents =
+ | {
+ state: "loading" | "error" | "empty";
+ to: string;
+ }
+ | {
+ state: "loaded";
+ to: string;
+ items: BrokerageEvent[];
+ };
+
+type AccountPageContent = {
+ account?: BrokerageAccount;
+ accountId?: string;
+ refreshing: boolean;
+ refreshError: boolean;
+ retry: () => void;
+ recentOperations: AccountPageRecentOperations;
+ recentEvents: AccountPageRecentEvents;
+};
+
+type AccountPageLoadedViewModel = AccountPageContent & {
+ state: "loaded";
+ portfolio: Portfolio;
+ positionSections: AccountPagePositionSection[];
+};
+
+type AccountPageEmptyViewModel = AccountPageContent & {
+ state: "empty";
+};
+
+export type AccountPageViewModel =
+ | { state: "loading" }
+ | { state: "error"; code?: string; retry: () => void }
+ | AccountPageEmptyViewModel
+ | AccountPageLoadedViewModel;
+
+export function useAccountPageViewModel(): AccountPageViewModel {
const { accountId } = useParams();
const { accessToken } = useAuth();
const portfolio = usePortfolio(accountId, accessToken);
+ const operations = useBrokerageOperations(accountId, accessToken, {
+ limit: 5,
+ });
+ const events = useBrokerageEvents(accountId, accessToken);
const account = useQueryClient()
.getQueryData(brokerageAccountsQueryKey)
?.find((item) => item.id === accountId);
+ const recentOperations = toRecentOperations(accountId, operations);
+ const recentEvents = toRecentEvents(accountId, events);
if (!portfolio.data) {
if (portfolio.isError)
return {
@@ -33,15 +116,110 @@ export function useAccountPageViewModel() {
state: "empty" as const,
account,
accountId,
- accessToken,
+ recentOperations,
+ recentEvents,
...refresh,
};
return {
state: "loaded" as const,
account,
accountId,
- accessToken,
portfolio: portfolio.data,
+ positionSections: toAccountPagePositionSections(portfolio.data),
+ recentOperations,
+ recentEvents,
...refresh,
};
}
+
+function toRecentOperations(
+ accountId: string | undefined,
+ operations: ReturnType,
+): AccountPageRecentOperations {
+ const to = `/accounts/${accountId}/operations`;
+ if (operations.isLoading) return { state: "loading", to };
+ if (operations.isError) return { state: "error", to };
+ if (!operations.data?.items.length) return { state: "empty", to };
+ return { state: "loaded", to, items: operations.data.items };
+}
+
+function toRecentEvents(
+ accountId: string | undefined,
+ events: ReturnType,
+): AccountPageRecentEvents {
+ const to = `/accounts/${accountId}/events`;
+ if (events.isLoading) return { state: "loading", to };
+ if (events.isError) return { state: "error", to };
+ const items = events.data?.events.slice(0, 5) ?? [];
+ if (!items.length) return { state: "empty", to };
+ return { state: "loaded", to, items };
+}
+
+export function toAccountPagePositionSections(
+ portfolio: Portfolio,
+): AccountPagePositionSection[] {
+ const denominators = new Map();
+ for (const position of portfolio.positions) {
+ if (!position.currentValue) continue;
+ const currency = position.currentValue.currency;
+ denominators.set(
+ currency,
+ (denominators.get(currency) ?? 0) + Number(position.currentValue.value),
+ );
+ }
+ const stocks = toSection("stocks", true, portfolio, denominators);
+ const bonds = toSection("bonds", true, portfolio, denominators);
+ const other = toSection("other", false, portfolio, denominators);
+ return [stocks, bonds, other].filter(
+ (section) => section.positions.length > 0,
+ );
+}
+
+function toSection(
+ kind: PositionSectionKind,
+ wide: boolean,
+ portfolio: Portfolio,
+ denominators: Map,
+): AccountPagePositionSection {
+ return {
+ kind,
+ wide,
+ positions: portfolio.positions
+ .filter((position) => {
+ if (kind === "stocks") return position.type === "STOCK";
+ if (kind === "bonds") return position.type === "BOND";
+ return position.type !== "STOCK" && position.type !== "BOND";
+ })
+ .map((position) => toPosition(position, denominators, wide)),
+ };
+}
+
+function toPosition(
+ position: Portfolio["positions"][number],
+ denominators: Map,
+ wide: boolean,
+): AccountPagePosition {
+ const returnValue = position.absoluteReturn
+ ? Number(position.absoluteReturn.value)
+ : null;
+ const returnTone =
+ returnValue === null || returnValue === 0
+ ? undefined
+ : returnValue > 0
+ ? "positive"
+ : "negative";
+ return {
+ ...position,
+ shareText: position.currentValue?.currency
+ ? formatShare(position.currentValue, {
+ currency: position.currentValue.currency,
+ value: String(denominators.get(position.currentValue.currency) ?? 0),
+ })
+ : null,
+ returnText: position.absoluteReturn
+ ? `${wide && returnValue !== null && returnValue > 0 ? "+" : ""}${formatMoney(position.absoluteReturn)}`
+ : null,
+ returnTone,
+ logoSrc: wide ? instrumentLogoUrl(position.logoName) : null,
+ };
+}
diff --git a/src/pages/account/ui/components/AccountHeading.view.tsx b/src/pages/account/ui/components/AccountHeading.view.tsx
new file mode 100644
index 0000000..019ec1c
--- /dev/null
+++ b/src/pages/account/ui/components/AccountHeading.view.tsx
@@ -0,0 +1,18 @@
+import { Stack, Text, Title } from "@mantine/core";
+import { useTranslation } from "react-i18next";
+import type { BrokerageAccount } from "@/entities/brokerage-account";
+import { accountTypeKey } from "@/entities/brokerage-account";
+
+export function AccountHeading({ account }: { account?: BrokerageAccount }) {
+ const { t } = useTranslation();
+ return (
+
+ {account?.name ?? t("portfolio.title")}
+ {account ? (
+
+ {t(accountTypeKey(account.type))}
+
+ ) : null}
+
+ );
+}
diff --git a/src/pages/account/ui/components/CashBalances.view.tsx b/src/pages/account/ui/components/CashBalances.view.tsx
new file mode 100644
index 0000000..9c8ad44
--- /dev/null
+++ b/src/pages/account/ui/components/CashBalances.view.tsx
@@ -0,0 +1,21 @@
+import { Paper, Text } from "@mantine/core";
+import type { Portfolio } from "@/entities/portfolio";
+import { formatMoney } from "@/entities/portfolio";
+import classes from "../AccountPage.module.css";
+
+export function CashBalances({
+ balances,
+}: {
+ balances: Portfolio["cashBalances"];
+}) {
+ return (
+ <>
+ {balances.map((balance) => (
+
+ {balance.currency}
+ {formatMoney(balance)}
+
+ ))}
+ >
+ );
+}
diff --git a/src/pages/account/ui/components/PortfolioRefreshState.view.tsx b/src/pages/account/ui/components/PortfolioRefreshState.view.tsx
new file mode 100644
index 0000000..4649690
--- /dev/null
+++ b/src/pages/account/ui/components/PortfolioRefreshState.view.tsx
@@ -0,0 +1,37 @@
+import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core";
+import { useTranslation } from "react-i18next";
+
+export function PortfolioRefreshState({
+ refreshing,
+ refreshError,
+ retry,
+}: {
+ refreshing: boolean;
+ refreshError: boolean;
+ retry?: () => void;
+}) {
+ const { t } = useTranslation();
+ if (!refreshing && !refreshError) return null;
+ return (
+ <>
+ {refreshing ? (
+
+
+
+ {t("portfolio.refreshing")}
+
+
+ ) : null}
+ {refreshError ? (
+
+
+ {t("portfolio.refreshError")}
+
+
+
+ ) : null}
+ >
+ );
+}
diff --git a/src/pages/account/ui/components/PortfolioTotals.view.tsx b/src/pages/account/ui/components/PortfolioTotals.view.tsx
new file mode 100644
index 0000000..76a71a3
--- /dev/null
+++ b/src/pages/account/ui/components/PortfolioTotals.view.tsx
@@ -0,0 +1,25 @@
+import { Paper, SimpleGrid, Text } from "@mantine/core";
+import type { Portfolio } from "@/entities/portfolio";
+import { formatMoney } from "@/entities/portfolio";
+import classes from "../AccountPage.module.css";
+
+export function PortfolioTotals({
+ totals,
+}: {
+ totals: Portfolio["totalsByCurrency"];
+}) {
+ return (
+
+ {totals.map((total) => (
+
+
+ {total.currency}
+
+
+ {formatMoney(total)}
+
+
+ ))}
+
+ );
+}
diff --git a/src/pages/account/ui/components/PositionCard.view.tsx b/src/pages/account/ui/components/PositionCard.view.tsx
new file mode 100644
index 0000000..a1427a2
--- /dev/null
+++ b/src/pages/account/ui/components/PositionCard.view.tsx
@@ -0,0 +1,81 @@
+import { Card, Group, Stack, Text } from "@mantine/core";
+import { useTranslation } from "react-i18next";
+import { formatMoney } from "@/entities/portfolio";
+import classes from "../AccountPage.module.css";
+import type { AccountPagePosition } from "../AccountPage.vm";
+import { PositionLogo } from "./PositionLogo.view";
+
+export function PositionCard({
+ position,
+ wide = false,
+}: {
+ position: AccountPagePosition;
+ wide?: boolean;
+}) {
+ const { t } = useTranslation();
+ const returnClass = position.returnTone
+ ? classes[position.returnTone]
+ : undefined;
+ const returnText =
+ position.returnText && wide
+ ? position.returnText
+ : position.returnText
+ ? t("portfolio.return", {
+ value: position.returnText,
+ })
+ : null;
+ return (
+
+
+ {wide ? (
+
+ ) : null}
+
+
+ {position.displayName}
+
+ {position.isin ? (
+
+ {position.isin}
+
+ ) : null}
+
+ {t("portfolio.quantity", { value: position.quantity })}
+
+
+
+ {position.currentValue ? (
+
+ {formatMoney(position.currentValue)}
+
+ ) : null}
+ {returnText ? (
+
+ {returnText}
+
+ ) : null}
+
+
+ {position.averagePurchasePrice || position.shareText ? (
+
+ {position.averagePurchasePrice ? (
+
+ {t("portfolio.avgPrice", {
+ value: formatMoney(position.averagePurchasePrice),
+ })}
+
+ ) : null}
+ {position.shareText ? (
+
+ {t("portfolio.share", { value: position.shareText })}
+
+ ) : null}
+
+ ) : null}
+
+ );
+}
diff --git a/src/pages/account/ui/components/PositionLogo.view.tsx b/src/pages/account/ui/components/PositionLogo.view.tsx
new file mode 100644
index 0000000..623f4af
--- /dev/null
+++ b/src/pages/account/ui/components/PositionLogo.view.tsx
@@ -0,0 +1,27 @@
+import { Avatar } from "@mantine/core";
+import { useState } from "react";
+
+export function PositionLogo({
+ src,
+ label,
+}: {
+ src: string | null;
+ label: string;
+}) {
+ const [failed, setFailed] = useState(false);
+ if (src && !failed)
+ return (
+ setFailed(true)}
+ />
+ );
+ return (
+
+ {(label.trim()[0] ?? "?").toUpperCase()}
+
+ );
+}
diff --git a/src/pages/account/ui/components/PositionSection.view.tsx b/src/pages/account/ui/components/PositionSection.view.tsx
new file mode 100644
index 0000000..5c352f0
--- /dev/null
+++ b/src/pages/account/ui/components/PositionSection.view.tsx
@@ -0,0 +1,26 @@
+import { SimpleGrid } from "@mantine/core";
+import type { AccountPagePositionSection } from "../AccountPage.vm";
+import { PositionCard } from "./PositionCard.view";
+import { Section } from "./Section.view";
+
+export function PositionSection({
+ title,
+ section,
+}: {
+ title: string;
+ section: AccountPagePositionSection;
+}) {
+ return (
+
+
+ {section.positions.map((position) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/pages/account/ui/components/RecentEvents.view.tsx b/src/pages/account/ui/components/RecentEvents.view.tsx
new file mode 100644
index 0000000..2e84b22
--- /dev/null
+++ b/src/pages/account/ui/components/RecentEvents.view.tsx
@@ -0,0 +1,50 @@
+import { Alert, Button, Paper, Text } from "@mantine/core";
+import { useTranslation } from "react-i18next";
+import { Link } from "react-router";
+import type { BrokerageEvent } from "@/entities/brokerage-event";
+import { formatMoney } from "@/entities/portfolio";
+import classes from "../AccountPage.module.css";
+import type { AccountPageRecentEvents } from "../AccountPage.vm";
+import { Section } from "./Section.view";
+
+export function RecentEvents({ events }: { events: AccountPageRecentEvents }) {
+ const { t } = useTranslation();
+ return (
+
+ {events.state === "loading" ? (
+ {t("events.recentLoading")}
+ ) : null}
+ {events.state === "error" ? (
+ {t("events.recentError")}
+ ) : null}
+ {events.state === "empty" ? (
+ {t("events.recentEmpty")}
+ ) : null}
+ {events.state === "loaded"
+ ? events.items.map((event) => (
+
+ ))
+ : null}
+
+
+ );
+}
+
+function RecentEvent({ event }: { event: BrokerageEvent }) {
+ const { t } = useTranslation();
+ return (
+
+ {`${t(`events.type.${event.type}`)} · ${event.displayName}`}
+
+ {event.amount && event.currency
+ ? formatMoney({ currency: event.currency, value: event.amount })
+ : t("events.noAmount")}
+
+
+ );
+}
diff --git a/src/pages/account/ui/components/RecentOperations.view.tsx b/src/pages/account/ui/components/RecentOperations.view.tsx
new file mode 100644
index 0000000..9219a85
--- /dev/null
+++ b/src/pages/account/ui/components/RecentOperations.view.tsx
@@ -0,0 +1,43 @@
+import { Alert, Button, Paper, Text } from "@mantine/core";
+import { useTranslation } from "react-i18next";
+import { Link } from "react-router";
+import { formatMoney } from "@/entities/portfolio";
+import classes from "../AccountPage.module.css";
+import type { AccountPageRecentOperations } from "../AccountPage.vm";
+import { Section } from "./Section.view";
+
+export function RecentOperations({
+ operations,
+}: {
+ operations: AccountPageRecentOperations;
+}) {
+ const { t } = useTranslation();
+ return (
+
+ {operations.state === "loading" ? (
+ {t("ledger.recentLoading")}
+ ) : null}
+ {operations.state === "error" ? (
+ {t("ledger.recentError")}
+ ) : null}
+ {operations.state === "empty" ? (
+ {t("ledger.recentEmpty")}
+ ) : null}
+ {operations.state === "loaded"
+ ? operations.items.map((operation) => (
+
+ {operation.description || operation.type}
+
+ {operation.payment
+ ? formatMoney(operation.payment)
+ : t("ledger.noMoney")}
+
+
+ ))
+ : null}
+
+
+ );
+}
diff --git a/src/pages/account/ui/components/Section.view.tsx b/src/pages/account/ui/components/Section.view.tsx
new file mode 100644
index 0000000..5b0e4a2
--- /dev/null
+++ b/src/pages/account/ui/components/Section.view.tsx
@@ -0,0 +1,16 @@
+import { Stack, Title } from "@mantine/core";
+
+export function Section({
+ title,
+ children,
+}: {
+ title: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {title}
+ {children}
+
+ );
+}
diff --git a/src/pages/account/ui/components/index.ts b/src/pages/account/ui/components/index.ts
new file mode 100644
index 0000000..8386eb4
--- /dev/null
+++ b/src/pages/account/ui/components/index.ts
@@ -0,0 +1,8 @@
+export { AccountHeading } from "./AccountHeading.view";
+export { CashBalances } from "./CashBalances.view";
+export { PortfolioRefreshState } from "./PortfolioRefreshState.view";
+export { PortfolioTotals } from "./PortfolioTotals.view";
+export { PositionSection } from "./PositionSection.view";
+export { RecentEvents } from "./RecentEvents.view";
+export { RecentOperations } from "./RecentOperations.view";
+export { Section } from "./Section.view";