refactor: enforce account page view model boundary

This commit is contained in:
2026-08-25 13:17:45 +03:00
parent fd72fb2239
commit fff36f252a
20 changed files with 912 additions and 428 deletions
+60 -39
View File
@@ -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(<AccountPageLoading />);
expect(screen.getByRole("status")).toBeInTheDocument();
renderPage(<AccountPageEmpty />);
renderPage(<AccountPageEmpty {...emptyProps()} />);
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(<AccountPageLoaded portfolio={portfolio} />);
renderPage(<AccountPageLoaded {...loadedProps()} />);
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(
<AccountPageLoaded
portfolio={{
{...loadedProps({
...portfolio,
positions: [
{
@@ -164,7 +195,7 @@ describe("AccountPage states", () => {
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(
<AccountPageLoaded
portfolio={{
{...loadedProps({
...portfolio,
positions: [
{
@@ -203,7 +234,7 @@ describe("AccountPage states", () => {
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(
<AccountPageLoaded
portfolio={{
{...loadedProps({
...portfolio,
positions: [portfolio.positions[1]],
}}
})}
/>,
);
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(
<AccountPageLoaded
portfolio={{
{...loadedProps({
...portfolio,
positions: [
{
@@ -257,7 +288,7 @@ describe("AccountPage states", () => {
logoName: "fond.png",
},
],
}}
})}
/>,
);
expect(screen.getByText("Доход: -5 ₽")).toBeInTheDocument();
@@ -267,7 +298,7 @@ describe("AccountPage states", () => {
it("uses the cached account title when available", () => {
renderPage(
<AccountPageLoaded
portfolio={portfolio}
{...loadedProps()}
account={{
id: "account-1",
name: "Основной",
@@ -281,47 +312,37 @@ describe("AccountPage states", () => {
).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(
<AccountPageLoaded
accountId="account-1"
accessToken="token"
portfolio={portfolio}
{...loadedProps()}
recentOperations={{
state: "error",
to: "/accounts/account-1/operations",
}}
/>,
);
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(
<AccountPageLoaded
accountId="account-1"
accessToken="token"
portfolio={portfolio}
{...loadedProps()}
recentEvents={{
state: "error",
to: "/accounts/account-1/events",
}}
/>,
);
await waitFor(() =>
expect(
screen.getByText("Не удалось загрузить будущие события."),
).toBeInTheDocument(),
);
expect(
screen.getByText("Не удалось загрузить будущие события."),
).toBeInTheDocument();
expect(screen.getByText("Вторая")).toBeInTheDocument();
});
+5 -4
View File
@@ -14,22 +14,23 @@ export function AccountPage() {
return (
<AccountPageEmpty
account={viewModel.account}
accountId={viewModel.accountId}
accessToken={viewModel.accessToken}
refreshing={viewModel.refreshing}
refreshError={viewModel.refreshError}
retry={viewModel.retry}
recentOperations={viewModel.recentOperations}
recentEvents={viewModel.recentEvents}
/>
);
return (
<AccountPageLoaded
account={viewModel.account}
accountId={viewModel.accountId}
accessToken={viewModel.accessToken}
portfolio={viewModel.portfolio}
positionSections={viewModel.positionSections}
refreshing={viewModel.refreshing}
refreshError={viewModel.refreshError}
retry={viewModel.retry}
recentOperations={viewModel.recentOperations}
recentEvents={viewModel.recentEvents}
/>
);
}
+46 -375
View File
@@ -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")}
/>
<RecentOperations accountId={accountId} accessToken={accessToken} />
<RecentEvents accountId={accountId} accessToken={accessToken} />
<RecentOperations operations={recentOperations} />
<RecentEvents events={recentEvents} />
</Stack>
);
}
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<string, number>();
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 (
<Stack className={classes.page} gap="lg">
<AccountHeading account={account} />
@@ -174,336 +156,25 @@ export function AccountPageLoaded({
date: formatSnapshotTime(portfolio.lastSuccessfulAt),
})}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}>
{portfolio.totalsByCurrency.map((total) => (
<Paper className={classes.total} key={total.currency} withBorder>
<Text c="dimmed" size="xs">
{total.currency}
</Text>
<Text fw={800} size="xl">
{formatMoney(total)}
</Text>
</Paper>
))}
</SimpleGrid>
{stocks.length ? (
<PortfolioTotals totals={portfolio.totalsByCurrency} />
{positionSections.map((section) => (
<PositionSection
title={t("portfolio.stocks")}
positions={stocks}
denominators={denominators}
wide
key={section.kind}
title={t(positionSectionTitleKey(section.kind))}
section={section}
/>
) : null}
{bonds.length ? (
<PositionSection
title={t("portfolio.bonds")}
positions={bonds}
denominators={denominators}
wide
/>
) : null}
{other.length ? (
<PositionSection
title={t("portfolio.otherPositions")}
positions={other}
denominators={denominators}
/>
) : null}
))}
<Section title={t("portfolio.cashBalances")}>
{portfolio.cashBalances.map((balance) => (
<Paper className={classes.cash} key={balance.currency} withBorder>
<Text>{balance.currency}</Text>
<Text fw={700}>{formatMoney(balance)}</Text>
</Paper>
))}
<CashBalances balances={portfolio.cashBalances} />
</Section>
<RecentOperations
accountId={accountId}
accessToken={accessToken ?? null}
/>
<RecentEvents accountId={accountId} accessToken={accessToken ?? null} />
<RecentOperations operations={recentOperations} />
<RecentEvents events={recentEvents} />
</Stack>
);
}
function PortfolioRefreshState({
refreshing,
refreshError,
retry,
}: {
refreshing: boolean;
refreshError: boolean;
retry?: () => void;
}) {
const { t } = useTranslation();
if (!refreshing && !refreshError) return null;
return (
<>
{refreshing ? (
<Group gap="xs" role="status" aria-label={t("portfolio.refreshing")}>
<Loader size="sm" />
<Text c="dimmed" size="sm">
{t("portfolio.refreshing")}
</Text>
</Group>
) : null}
{refreshError ? (
<Alert color="orange">
<Stack gap="sm">
<Text>{t("portfolio.refreshError")}</Text>
<Button onClick={retry} variant="light">
{t("accounts.retry")}
</Button>
</Stack>
</Alert>
) : null}
</>
);
}
function RecentOperations({
accountId,
accessToken,
}: {
accountId: string | undefined;
accessToken: string | null;
}) {
const { t } = useTranslation();
const operations = useBrokerageOperations(accountId, accessToken, {
limit: 5,
});
return (
<Section title={t("ledger.recentTitle")}>
{operations.isLoading ? (
<Text c="dimmed">{t("ledger.recentLoading")}</Text>
) : null}
{operations.isError ? (
<Alert color="red">{t("ledger.recentError")}</Alert>
) : null}
{operations.data?.items.length === 0 ? (
<Text c="dimmed">{t("ledger.recentEmpty")}</Text>
) : null}
{operations.data?.items.map((operation) => (
<Paper className={classes.cash} key={operation.id} withBorder>
<Text>{operation.description || operation.type}</Text>
<Text fw={700}>
{operation.payment
? formatMoney(operation.payment)
: t("ledger.noMoney")}
</Text>
</Paper>
))}
<Button
component={Link}
to={`/accounts/${accountId}/operations`}
variant="subtle"
>
{t("ledger.open")}
</Button>
</Section>
);
}
function RecentEvents({
accountId,
accessToken,
}: {
accountId: string | undefined;
accessToken: string | null;
}) {
const { t } = useTranslation();
const events = useBrokerageEvents(accountId, accessToken);
return (
<Section title={t("events.recentTitle")}>
{events.isLoading ? (
<Text c="dimmed">{t("events.recentLoading")}</Text>
) : null}
{events.isError ? (
<Alert color="red">{t("events.recentError")}</Alert>
) : null}
{events.data?.events.length === 0 ? (
<Text c="dimmed">{t("events.recentEmpty")}</Text>
) : null}
{events.data?.events.slice(0, 5).map((event) => (
<RecentEvent
key={`${event.type}-${event.date}-${event.ticker}`}
event={event}
/>
))}
<Button
component={Link}
to={`/accounts/${accountId}/events`}
variant="subtle"
>
{t("events.open")}
</Button>
</Section>
);
}
function RecentEvent({ event }: { event: BrokerageEvent }) {
const { t } = useTranslation();
return (
<Paper className={classes.cash} withBorder>
<Text>{`${t(`events.type.${event.type}`)} · ${event.displayName}`}</Text>
<Text fw={700}>
{event.amount && event.currency
? formatMoney({ currency: event.currency, value: event.amount })
: t("events.noAmount")}
</Text>
</Paper>
);
}
function PositionSection({
title,
positions,
denominators,
wide = false,
}: {
title: string;
positions: Portfolio["positions"];
denominators: Map<string, number>;
wide?: boolean;
}) {
return (
<Section title={title}>
<SimpleGrid cols={wide ? 1 : { base: 1, sm: 2, md: 3 }}>
{positions.map((position) => (
<PositionCard
key={`${position.figi}-${position.displayName}-${position.quantity}`}
position={position}
denominators={denominators}
wide={wide}
/>
))}
</SimpleGrid>
</Section>
);
}
function PositionCard({
position,
denominators,
wide = false,
}: {
position: Portfolio["positions"][number];
denominators: Map<string, number>;
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 (
<Card className={classes.tile} withBorder>
<Group justify="space-between" align="flex-start" wrap="nowrap">
{wide ? (
<PositionLogo
key={logoSrc ?? "no-logo"}
src={logoSrc}
label={position.displayName}
/>
) : null}
<Stack gap={2} miw={0}>
<Text fw={700} lineClamp={1}>
{position.displayName}
</Text>
{position.isin ? (
<Text c="dimmed" size="xs" className={classes.mono}>
{position.isin}
</Text>
) : null}
<Text c="dimmed" size="xs">
{t("portfolio.quantity", { value: position.quantity })}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
{position.currentValue ? (
<Text fw={800} size="lg">
{formatMoney(position.currentValue)}
</Text>
) : null}
{returnText ? (
<Text size="sm" fw={600} className={returnClass}>
{returnText}
</Text>
) : null}
</Stack>
</Group>
{position.averagePurchasePrice || share ? (
<Group justify="space-between" mt="xs">
{position.averagePurchasePrice ? (
<Text c="dimmed" size="xs">
{t("portfolio.avgPrice", {
value: formatMoney(position.averagePurchasePrice),
})}
</Text>
) : null}
{share ? (
<Text c="dimmed" size="xs">
{t("portfolio.share", { value: share })}
</Text>
) : null}
</Group>
) : null}
</Card>
);
}
function PositionLogo({ src, label }: { src: string | null; label: string }) {
const [failed, setFailed] = useState(false);
if (src && !failed)
return (
<Avatar
src={src}
alt={label}
size={40}
radius="md"
onError={() => setFailed(true)}
/>
);
return (
<Avatar size={40} radius="md">
{(label.trim()[0] ?? "?").toUpperCase()}
</Avatar>
);
}
function AccountHeading({ account }: { account?: BrokerageAccount }) {
const { t } = useTranslation();
return (
<Stack gap={2}>
<Title order={1}>{account?.name ?? t("portfolio.title")}</Title>
{account ? (
<Text c="dimmed" size="sm">
{t(accountTypeKey(account.type))}
</Text>
) : null}
</Stack>
);
}
function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<Stack gap="sm">
<Title order={2}>{title}</Title>
{children}
</Stack>
);
function positionSectionTitleKey(kind: AccountPagePositionSection["kind"]) {
if (kind === "stocks") return "portfolio.stocks";
if (kind === "bonds") return "portfolio.bonds";
return "portfolio.otherPositions";
}
@@ -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 (
<QueryClientProvider client={new QueryClient()}>
<MantineProvider>
<MemoryRouter initialEntries={["/accounts/account-1"]}>
<Routes>
<Route path="/accounts/:accountId" element={children} />
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>
);
}
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" },
}),
);
});
});
+182 -4
View File
@@ -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<BrokerageAccount[]>(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<typeof useBrokerageOperations>,
): 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<typeof useBrokerageEvents>,
): 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<string, number>();
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<string, number>,
): 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<string, number>,
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,
};
}
@@ -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 (
<Stack gap={2}>
<Title order={1}>{account?.name ?? t("portfolio.title")}</Title>
{account ? (
<Text c="dimmed" size="sm">
{t(accountTypeKey(account.type))}
</Text>
) : null}
</Stack>
);
}
@@ -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) => (
<Paper className={classes.cash} key={balance.currency} withBorder>
<Text>{balance.currency}</Text>
<Text fw={700}>{formatMoney(balance)}</Text>
</Paper>
))}
</>
);
}
@@ -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 ? (
<Group gap="xs" role="status" aria-label={t("portfolio.refreshing")}>
<Loader size="sm" />
<Text c="dimmed" size="sm">
{t("portfolio.refreshing")}
</Text>
</Group>
) : null}
{refreshError ? (
<Alert color="orange">
<Stack gap="sm">
<Text>{t("portfolio.refreshError")}</Text>
<Button onClick={retry} variant="light">
{t("accounts.retry")}
</Button>
</Stack>
</Alert>
) : null}
</>
);
}
@@ -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 (
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}>
{totals.map((total) => (
<Paper className={classes.total} key={total.currency} withBorder>
<Text c="dimmed" size="xs">
{total.currency}
</Text>
<Text fw={800} size="xl">
{formatMoney(total)}
</Text>
</Paper>
))}
</SimpleGrid>
);
}
@@ -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 (
<Card className={classes.tile} withBorder>
<Group justify="space-between" align="flex-start" wrap="nowrap">
{wide ? (
<PositionLogo
key={position.logoSrc ?? "no-logo"}
src={position.logoSrc}
label={position.displayName}
/>
) : null}
<Stack gap={2} miw={0}>
<Text fw={700} lineClamp={1}>
{position.displayName}
</Text>
{position.isin ? (
<Text c="dimmed" size="xs" className={classes.mono}>
{position.isin}
</Text>
) : null}
<Text c="dimmed" size="xs">
{t("portfolio.quantity", { value: position.quantity })}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
{position.currentValue ? (
<Text fw={800} size="lg">
{formatMoney(position.currentValue)}
</Text>
) : null}
{returnText ? (
<Text size="sm" fw={600} className={returnClass}>
{returnText}
</Text>
) : null}
</Stack>
</Group>
{position.averagePurchasePrice || position.shareText ? (
<Group justify="space-between" mt="xs">
{position.averagePurchasePrice ? (
<Text c="dimmed" size="xs">
{t("portfolio.avgPrice", {
value: formatMoney(position.averagePurchasePrice),
})}
</Text>
) : null}
{position.shareText ? (
<Text c="dimmed" size="xs">
{t("portfolio.share", { value: position.shareText })}
</Text>
) : null}
</Group>
) : null}
</Card>
);
}
@@ -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 (
<Avatar
src={src}
alt={label}
size={40}
radius="md"
onError={() => setFailed(true)}
/>
);
return (
<Avatar size={40} radius="md">
{(label.trim()[0] ?? "?").toUpperCase()}
</Avatar>
);
}
@@ -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 title={title}>
<SimpleGrid cols={section.wide ? 1 : { base: 1, sm: 2, md: 3 }}>
{section.positions.map((position) => (
<PositionCard
key={`${position.figi}-${position.displayName}-${position.quantity}`}
position={position}
wide={section.wide}
/>
))}
</SimpleGrid>
</Section>
);
}
@@ -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 (
<Section title={t("events.recentTitle")}>
{events.state === "loading" ? (
<Text c="dimmed">{t("events.recentLoading")}</Text>
) : null}
{events.state === "error" ? (
<Alert color="red">{t("events.recentError")}</Alert>
) : null}
{events.state === "empty" ? (
<Text c="dimmed">{t("events.recentEmpty")}</Text>
) : null}
{events.state === "loaded"
? events.items.map((event) => (
<RecentEvent
key={`${event.type}-${event.date}-${event.ticker}`}
event={event}
/>
))
: null}
<Button component={Link} to={events.to} variant="subtle">
{t("events.open")}
</Button>
</Section>
);
}
function RecentEvent({ event }: { event: BrokerageEvent }) {
const { t } = useTranslation();
return (
<Paper className={classes.cash} withBorder>
<Text>{`${t(`events.type.${event.type}`)} · ${event.displayName}`}</Text>
<Text fw={700}>
{event.amount && event.currency
? formatMoney({ currency: event.currency, value: event.amount })
: t("events.noAmount")}
</Text>
</Paper>
);
}
@@ -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 (
<Section title={t("ledger.recentTitle")}>
{operations.state === "loading" ? (
<Text c="dimmed">{t("ledger.recentLoading")}</Text>
) : null}
{operations.state === "error" ? (
<Alert color="red">{t("ledger.recentError")}</Alert>
) : null}
{operations.state === "empty" ? (
<Text c="dimmed">{t("ledger.recentEmpty")}</Text>
) : null}
{operations.state === "loaded"
? operations.items.map((operation) => (
<Paper className={classes.cash} key={operation.id} withBorder>
<Text>{operation.description || operation.type}</Text>
<Text fw={700}>
{operation.payment
? formatMoney(operation.payment)
: t("ledger.noMoney")}
</Text>
</Paper>
))
: null}
<Button component={Link} to={operations.to} variant="subtle">
{t("ledger.open")}
</Button>
</Section>
);
}
@@ -0,0 +1,16 @@
import { Stack, Title } from "@mantine/core";
export function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<Stack gap="sm">
<Title order={2}>{title}</Title>
{children}
</Stack>
);
}
+8
View File
@@ -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";