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
@@ -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";