Files
frontend/src/pages/account/ui/AccountPage.view.tsx
T

469 lines
11 KiB
TypeScript

import {
Alert,
Button,
Card,
Group,
Loader,
Paper,
SimpleGrid,
Stack,
Text,
Title,
} 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,
} from "@/entities/portfolio";
import { StateView } from "@/shared/ui";
import classes from "./AccountPage.module.css";
export function AccountPageLoading() {
const { t } = useTranslation();
return (
<StateView
loading
title={t("states.loadingTitle")}
text={t("states.loadingText")}
/>
);
}
export function AccountPageError({
code,
retry,
}: {
code?: string;
retry: () => void;
}) {
const { t } = useTranslation();
if (code === "NOT_FOUND")
return (
<StateView
error
title={t("portfolio.accountUnavailableTitle")}
text={t("portfolio.accountUnavailableText")}
action={
<Button component={Link} to="/accounts">
{t("portfolio.back")}
</Button>
}
/>
);
if (code === "TBANK_NOT_CONNECTED" || code === "INVALID_TBANK_TOKEN")
return (
<StateView
error
title={t("portfolio.connectionErrorTitle")}
text={t("portfolio.connectionErrorText")}
action={
<Button component={Link} to="/settings">
{t("accounts.openSettings")}
</Button>
}
/>
);
if (code === "TBANK_UNAVAILABLE")
return (
<StateView
error
title={t("accounts.unavailableTitle")}
text={t("accounts.unavailableText")}
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
/>
);
return (
<StateView
error
title={t("states.errorTitle")}
text={t("states.errorText")}
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
/>
);
}
export function AccountPageEmpty({
account,
accountId,
accessToken = null,
refreshing = false,
refreshError = false,
retry,
}: {
account?: BrokerageAccount;
accountId?: string;
accessToken?: string | null;
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
}) {
const { t } = useTranslation();
return (
<Stack>
<AccountHeading account={account} />
<PortfolioRefreshState
refreshing={refreshing}
refreshError={refreshError}
retry={retry}
/>
<StateView
title={t("portfolio.emptyTitle")}
text={t("portfolio.emptyText")}
/>
<RecentOperations accountId={accountId} accessToken={accessToken} />
<RecentEvents accountId={accountId} accessToken={accessToken} />
</Stack>
);
}
export function AccountPageLoaded({
account,
accountId,
accessToken,
portfolio,
refreshing = false,
refreshError = false,
retry,
}: {
account?: BrokerageAccount;
accountId?: string;
accessToken?: string | null;
portfolio: Portfolio;
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
}) {
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} />
<PortfolioRefreshState
refreshing={refreshing}
refreshError={refreshError}
retry={retry}
/>
{portfolio.isStale ? (
<Alert color="blue">{t("portfolio.staleWarning")}</Alert>
) : null}
<Text c="dimmed" size="sm">
{t("portfolio.updatedAt", {
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 ? (
<PositionSection
title={t("portfolio.stocks")}
positions={stocks}
denominators={denominators}
/>
) : null}
{bonds.length ? (
<PositionSection
title={t("portfolio.bonds")}
positions={bonds}
denominators={denominators}
/>
) : 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>
))}
</Section>
<RecentOperations
accountId={accountId}
accessToken={accessToken ?? null}
/>
<RecentEvents accountId={accountId} accessToken={accessToken ?? null} />
</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,
}: {
title: string;
positions: Portfolio["positions"];
denominators: Map<string, number>;
}) {
return (
<Section title={title}>
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}>
{positions.map((position) => (
<PositionCard
key={`${position.figi}-${position.displayName}-${position.quantity}`}
position={position}
denominators={denominators}
/>
))}
</SimpleGrid>
</Section>
);
}
function PositionCard({
position,
denominators,
}: {
position: Portfolio["positions"][number];
denominators: Map<string, number>;
}) {
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;
return (
<Card className={classes.tile} withBorder>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<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}
{position.absoluteReturn ? (
<Text size="sm" fw={600} className={returnClass}>
{t("portfolio.return", {
value: formatMoney(position.absoluteReturn),
})}
</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 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>
);
}