469 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|