refactor: enforce account page view model boundary
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user