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
+1 -1
View File
@@ -30,7 +30,7 @@ Gate non-mutating и проверяет runtime, OpenAPI snapshot, hygiene, stri
- Code style и i18n: [`docs/CONVENTIONS.md`](docs/CONVENTIONS.md), исполняются Biome и TypeScript. - 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`. - 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. - 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 процента. - Тесты: [`docs/TESTING.md`](docs/TESTING.md); новый тест обязателен по risk trigger, а не из-за появления файла или coverage процента.
Непроверяемые эстетические предпочтения считаются guidance. Не добавлять Storybook, Playwright, CI, browser MSW worker или mock development mode без утверждённого change. Непроверяемые эстетические предпочтения считаются guidance. Не добавлять Storybook, Playwright, CI, browser MSW worker или mock development mode без утверждённого change.
+7 -3
View File
@@ -13,8 +13,12 @@
## File roles ## File roles
- `Component.tsx` - контейнер или публичный компонент. - `Component.tsx` - контейнер или публичный компонент; для data-driven pages вызывает page VM hook и выбирает top-level state view.
- `Component.vm.ts` - hook с данными и действиями. - `Component.vm.ts` - hook с данными, действиями, state selection и derived presentation data.
- `Component.view.tsx` - чистое представление. - `Component.view.tsx` - чистое представление: получает props, рендерит JSX и не вызывает data/auth/router/navigation/VM hooks.
- `Component.types.ts` - props и локальные типы. - `Component.types.ts` - props и локальные типы.
- `Component.module.css` - локальные стили. - `Component.module.css` - локальные стили.
Большие view files дробить по ответственности. Если section/state можно
самостоятельно назвать, вынести его в соседний presentation component вместо
накопления смысловой нагрузки в одном файле.
+26 -1
View File
@@ -6,4 +6,29 @@
Запросные экраны без успешных данных выбирают состояние в порядке loading, error, empty, loaded. Если query уже содержит данные, они остаются видимыми при фоновом refetch и его ошибке: рядом показываются неблокирующий progress и локальное предупреждение с retry. Такое состояние называется Client Cached Snapshot; оно не заменяет доменное server-side stale/fallback состояние. Запросные экраны без успешных данных выбирают состояние в порядке 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.
+1 -1
View File
@@ -2,7 +2,7 @@
"src/app/routes/ProtectedLayout.tsx": 3137, "src/app/routes/ProtectedLayout.tsx": 3137,
"src/pages/account-events/index.ts": 4468, "src/pages/account-events/index.ts": 4468,
"src/pages/account-operations/index.ts": 13861, "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/accounts/index.ts": 6174,
"src/pages/login/index.ts": 669, "src/pages/login/index.ts": 669,
"src/pages/not-found/index.ts": 850, "src/pages/not-found/index.ts": 850,
+56 -35
View File
@@ -13,6 +13,11 @@ import {
AccountPageLoaded, AccountPageLoaded,
AccountPageLoading, AccountPageLoading,
} from "./AccountPage"; } from "./AccountPage";
import type {
AccountPageRecentEvents,
AccountPageRecentOperations,
} from "./AccountPage.vm";
import { toAccountPagePositionSections } from "./AccountPage.vm";
vi.mock("@/features/auth", () => ({ vi.mock("@/features/auth", () => ({
useAuth: () => ({ accessToken: "test-token" }), useAuth: () => ({ accessToken: "test-token" }),
@@ -73,13 +78,39 @@ const portfolio: Portfolio = {
cashBalances: [{ currency: "USD", value: "0.5" }], 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", () => { describe("AccountPage states", () => {
afterEach(cleanup); afterEach(cleanup);
it("renders loading and empty states", () => { it("renders loading and empty states", () => {
renderPage(<AccountPageLoading />); renderPage(<AccountPageLoading />);
expect(screen.getByRole("status")).toBeInTheDocument(); expect(screen.getByRole("status")).toBeInTheDocument();
renderPage(<AccountPageEmpty />); renderPage(<AccountPageEmpty {...emptyProps()} />);
expect( expect(
screen.getByRole("heading", { name: "Портфель пока пуст" }), screen.getByRole("heading", { name: "Портфель пока пуст" }),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -104,7 +135,7 @@ describe("AccountPage states", () => {
}); });
it("renders a stale portfolio in upstream order with nullable values hidden", () => { it("renders a stale portfolio in upstream order with nullable values hidden", () => {
renderPage(<AccountPageLoaded portfolio={portfolio} />); renderPage(<AccountPageLoaded {...loadedProps()} />);
expect( expect(
screen.getByRole("heading", { level: 1, name: "Портфель" }), screen.getByRole("heading", { level: 1, name: "Портфель" }),
@@ -138,7 +169,7 @@ describe("AccountPage states", () => {
it("renders stock cards with logo fallback and signed unlabeled return", () => { it("renders stock cards with logo fallback and signed unlabeled return", () => {
renderPage( renderPage(
<AccountPageLoaded <AccountPageLoaded
portfolio={{ {...loadedProps({
...portfolio, ...portfolio,
positions: [ positions: [
{ {
@@ -164,7 +195,7 @@ describe("AccountPage states", () => {
logoName: null, logoName: null,
}, },
], ],
}} })}
/>, />,
); );
expect(screen.getByText("А")).toBeInTheDocument(); expect(screen.getByText("А")).toBeInTheDocument();
@@ -177,7 +208,7 @@ describe("AccountPage states", () => {
it("renders bond cards with logo, fallback, and signed unlabeled return like stocks", () => { it("renders bond cards with logo, fallback, and signed unlabeled return like stocks", () => {
renderPage( renderPage(
<AccountPageLoaded <AccountPageLoaded
portfolio={{ {...loadedProps({
...portfolio, ...portfolio,
positions: [ positions: [
{ {
@@ -203,7 +234,7 @@ describe("AccountPage states", () => {
logoName: null, logoName: null,
}, },
], ],
}} })}
/>, />,
); );
expect(screen.getByRole("img", { name: "Долг" })).toHaveAttribute( 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", () => { it("renders the bonds section as a single-column grid on any screen width", () => {
renderPage( renderPage(
<AccountPageLoaded <AccountPageLoaded
portfolio={{ {...loadedProps({
...portfolio, ...portfolio,
positions: [portfolio.positions[1]], positions: [portfolio.positions[1]],
}} })}
/>, />,
); );
const singleColumnGrids = Array.from(document.querySelectorAll("style")) 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", () => { it("keeps other-position cards compact with labeled return and no logo", () => {
renderPage( renderPage(
<AccountPageLoaded <AccountPageLoaded
portfolio={{ {...loadedProps({
...portfolio, ...portfolio,
positions: [ positions: [
{ {
@@ -257,7 +288,7 @@ describe("AccountPage states", () => {
logoName: "fond.png", logoName: "fond.png",
}, },
], ],
}} })}
/>, />,
); );
expect(screen.getByText("Доход: -5 ₽")).toBeInTheDocument(); expect(screen.getByText("Доход: -5 ₽")).toBeInTheDocument();
@@ -267,7 +298,7 @@ describe("AccountPage states", () => {
it("uses the cached account title when available", () => { it("uses the cached account title when available", () => {
renderPage( renderPage(
<AccountPageLoaded <AccountPageLoaded
portfolio={portfolio} {...loadedProps()}
account={{ account={{
id: "account-1", id: "account-1",
name: "Основной", name: "Основной",
@@ -281,47 +312,37 @@ describe("AccountPage states", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("keeps the portfolio visible when recent events fail", async () => { it("keeps the portfolio visible when recent operations fail", () => {
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
),
);
renderPage( renderPage(
<AccountPageLoaded <AccountPageLoaded
accountId="account-1" {...loadedProps()}
accessToken="token" recentOperations={{
portfolio={portfolio} state: "error",
to: "/accounts/account-1/operations",
}}
/>, />,
); );
await waitFor(() =>
expect( expect(
screen.getByText("Не удалось загрузить последние события."), screen.getByText("Не удалось загрузить последние события."),
).toBeInTheDocument(), ).toBeInTheDocument();
);
expect(screen.getByText("Вторая")).toBeInTheDocument(); expect(screen.getByText("Вторая")).toBeInTheDocument();
}); });
it("keeps the portfolio visible when upcoming events fail", async () => { it("keeps the portfolio visible when upcoming events fail", () => {
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
HttpResponse.json({ code: "MOEX_UNAVAILABLE" }, { status: 503 }),
),
);
renderPage( renderPage(
<AccountPageLoaded <AccountPageLoaded
accountId="account-1" {...loadedProps()}
accessToken="token" recentEvents={{
portfolio={portfolio} state: "error",
to: "/accounts/account-1/events",
}}
/>, />,
); );
await waitFor(() =>
expect( expect(
screen.getByText("Не удалось загрузить будущие события."), screen.getByText("Не удалось загрузить будущие события."),
).toBeInTheDocument(), ).toBeInTheDocument();
);
expect(screen.getByText("Вторая")).toBeInTheDocument(); expect(screen.getByText("Вторая")).toBeInTheDocument();
}); });
+5 -4
View File
@@ -14,22 +14,23 @@ export function AccountPage() {
return ( return (
<AccountPageEmpty <AccountPageEmpty
account={viewModel.account} account={viewModel.account}
accountId={viewModel.accountId}
accessToken={viewModel.accessToken}
refreshing={viewModel.refreshing} refreshing={viewModel.refreshing}
refreshError={viewModel.refreshError} refreshError={viewModel.refreshError}
retry={viewModel.retry} retry={viewModel.retry}
recentOperations={viewModel.recentOperations}
recentEvents={viewModel.recentEvents}
/> />
); );
return ( return (
<AccountPageLoaded <AccountPageLoaded
account={viewModel.account} account={viewModel.account}
accountId={viewModel.accountId}
accessToken={viewModel.accessToken}
portfolio={viewModel.portfolio} portfolio={viewModel.portfolio}
positionSections={viewModel.positionSections}
refreshing={viewModel.refreshing} refreshing={viewModel.refreshing}
refreshError={viewModel.refreshError} refreshError={viewModel.refreshError}
retry={viewModel.retry} retry={viewModel.retry}
recentOperations={viewModel.recentOperations}
recentEvents={viewModel.recentEvents}
/> />
); );
} }
+47 -376
View File
@@ -1,35 +1,26 @@
import { import { Alert, Button, Stack, Text } from "@mantine/core";
Alert,
Avatar,
Button,
Card,
Group,
Loader,
Paper,
SimpleGrid,
Stack,
Text,
Title,
} from "@mantine/core";
import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { useBrokerageOperations } from "@/entities/broker-operation";
import type { BrokerageAccount } from "@/entities/brokerage-account"; 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 type { Portfolio } from "@/entities/portfolio";
import { import { formatSnapshotTime } from "@/entities/portfolio";
formatMoney,
formatShare,
formatSnapshotTime,
instrumentLogoUrl,
} from "@/entities/portfolio";
import { StateView } from "@/shared/ui"; import { StateView } from "@/shared/ui";
import classes from "./AccountPage.module.css"; 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() { export function AccountPageLoading() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -41,6 +32,7 @@ export function AccountPageLoading() {
/> />
); );
} }
export function AccountPageError({ export function AccountPageError({
code, code,
retry, retry,
@@ -93,20 +85,21 @@ export function AccountPageError({
/> />
); );
} }
export function AccountPageEmpty({ export function AccountPageEmpty({
account, account,
accountId,
accessToken = null,
refreshing = false, refreshing = false,
refreshError = false, refreshError = false,
retry, retry,
recentOperations,
recentEvents,
}: { }: {
account?: BrokerageAccount; account?: BrokerageAccount;
accountId?: string;
accessToken?: string | null;
refreshing?: boolean; refreshing?: boolean;
refreshError?: boolean; refreshError?: boolean;
retry?: () => void; retry?: () => void;
recentOperations: AccountPageRecentOperations;
recentEvents: AccountPageRecentEvents;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
@@ -121,43 +114,32 @@ export function AccountPageEmpty({
title={t("portfolio.emptyTitle")} title={t("portfolio.emptyTitle")}
text={t("portfolio.emptyText")} text={t("portfolio.emptyText")}
/> />
<RecentOperations accountId={accountId} accessToken={accessToken} /> <RecentOperations operations={recentOperations} />
<RecentEvents accountId={accountId} accessToken={accessToken} /> <RecentEvents events={recentEvents} />
</Stack> </Stack>
); );
} }
export function AccountPageLoaded({ export function AccountPageLoaded({
account, account,
accountId,
accessToken,
portfolio, portfolio,
positionSections,
refreshing = false, refreshing = false,
refreshError = false, refreshError = false,
retry, retry,
recentOperations,
recentEvents,
}: { }: {
account?: BrokerageAccount; account?: BrokerageAccount;
accountId?: string;
accessToken?: string | null;
portfolio: Portfolio; portfolio: Portfolio;
positionSections: AccountPagePositionSection[];
refreshing?: boolean; refreshing?: boolean;
refreshError?: boolean; refreshError?: boolean;
retry?: () => void; retry?: () => void;
recentOperations: AccountPageRecentOperations;
recentEvents: AccountPageRecentEvents;
}) { }) {
const { t } = useTranslation(); 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 ( return (
<Stack className={classes.page} gap="lg"> <Stack className={classes.page} gap="lg">
<AccountHeading account={account} /> <AccountHeading account={account} />
@@ -174,336 +156,25 @@ export function AccountPageLoaded({
date: formatSnapshotTime(portfolio.lastSuccessfulAt), date: formatSnapshotTime(portfolio.lastSuccessfulAt),
})} })}
</Text> </Text>
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}> <PortfolioTotals totals={portfolio.totalsByCurrency} />
{portfolio.totalsByCurrency.map((total) => ( {positionSections.map((section) => (
<Paper className={classes.total} key={total.currency} withBorder> <PositionSection
<Text c="dimmed" size="xs"> key={section.kind}
{total.currency} title={t(positionSectionTitleKey(section.kind))}
</Text> section={section}
<Text fw={800} size="xl"> />
{formatMoney(total)}
</Text>
</Paper>
))} ))}
</SimpleGrid>
{stocks.length ? (
<PositionSection
title={t("portfolio.stocks")}
positions={stocks}
denominators={denominators}
wide
/>
) : 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")}> <Section title={t("portfolio.cashBalances")}>
{portfolio.cashBalances.map((balance) => ( <CashBalances balances={portfolio.cashBalances} />
<Paper className={classes.cash} key={balance.currency} withBorder>
<Text>{balance.currency}</Text>
<Text fw={700}>{formatMoney(balance)}</Text>
</Paper>
))}
</Section> </Section>
<RecentOperations <RecentOperations operations={recentOperations} />
accountId={accountId} <RecentEvents events={recentEvents} />
accessToken={accessToken ?? null}
/>
<RecentEvents accountId={accountId} accessToken={accessToken ?? null} />
</Stack> </Stack>
); );
} }
function PortfolioRefreshState({
refreshing, function positionSectionTitleKey(kind: AccountPagePositionSection["kind"]) {
refreshError, if (kind === "stocks") return "portfolio.stocks";
retry, if (kind === "bonds") return "portfolio.bonds";
}: { return "portfolio.otherPositions";
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>
);
} }
@@ -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 { useQueryClient } from "@tanstack/react-query";
import { useParams } from "react-router"; import { useParams } from "react-router";
import type { BrokerageOperation } from "@/entities/broker-operation";
import { useBrokerageOperations } from "@/entities/broker-operation";
import { import {
type BrokerageAccount, type BrokerageAccount,
brokerageAccountsQueryKey, brokerageAccountsQueryKey,
} from "@/entities/brokerage-account"; } 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"; 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 { accountId } = useParams();
const { accessToken } = useAuth(); const { accessToken } = useAuth();
const portfolio = usePortfolio(accountId, accessToken); const portfolio = usePortfolio(accountId, accessToken);
const operations = useBrokerageOperations(accountId, accessToken, {
limit: 5,
});
const events = useBrokerageEvents(accountId, accessToken);
const account = useQueryClient() const account = useQueryClient()
.getQueryData<BrokerageAccount[]>(brokerageAccountsQueryKey) .getQueryData<BrokerageAccount[]>(brokerageAccountsQueryKey)
?.find((item) => item.id === accountId); ?.find((item) => item.id === accountId);
const recentOperations = toRecentOperations(accountId, operations);
const recentEvents = toRecentEvents(accountId, events);
if (!portfolio.data) { if (!portfolio.data) {
if (portfolio.isError) if (portfolio.isError)
return { return {
@@ -33,15 +116,110 @@ export function useAccountPageViewModel() {
state: "empty" as const, state: "empty" as const,
account, account,
accountId, accountId,
accessToken, recentOperations,
recentEvents,
...refresh, ...refresh,
}; };
return { return {
state: "loaded" as const, state: "loaded" as const,
account, account,
accountId, accountId,
accessToken,
portfolio: portfolio.data, portfolio: portfolio.data,
positionSections: toAccountPagePositionSections(portfolio.data),
recentOperations,
recentEvents,
...refresh, ...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";