refactor: little structure refactor

This commit is contained in:
2026-08-14 10:24:24 +03:00
parent da585b157a
commit 2cca503519
18 changed files with 622 additions and 388 deletions
+20 -252
View File
@@ -1,260 +1,28 @@
import {
Alert,
Button,
Card,
Group,
Paper,
SimpleGrid,
Stack,
Text,
Title,
} from "@mantine/core";
import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Link, useParams } from "react-router";
import {
accountTypeKey,
type BrokerageAccount,
} from "@/entities/brokerage-account";
import type { Portfolio } from "@/entities/portfolio";
import {
formatMoney,
formatSnapshotTime,
portfolioErrorCode,
usePortfolio,
} from "@/entities/portfolio";
import { useAuth } from "@/features/auth";
import { StateView } from "@/shared/ui";
import classes from "./AccountPage.module.css";
const accountsQueryKey = ["brokerage-accounts"] as const;
AccountPageEmpty,
AccountPageError,
AccountPageLoaded,
AccountPageLoading,
} from "./AccountPage.view";
import { useAccountPageViewModel } from "./AccountPage.vm";
export function AccountPage() {
const { accountId } = useParams();
const { accessToken } = useAuth();
const portfolio = usePortfolio(accountId, accessToken);
const account = useQueryClient()
.getQueryData<BrokerageAccount[]>(accountsQueryKey)
?.find((item) => item.id === accountId);
if (portfolio.isFetching) return <AccountPageLoading />;
if (portfolio.isError)
return (
<AccountPageError
code={portfolioErrorCode(portfolio.error)}
retry={() => portfolio.refetch()}
/>
);
if (!portfolio.data) return <AccountPageLoading />;
if (!portfolio.data.positions.length && !portfolio.data.cashBalances.length)
return <AccountPageEmpty account={account} />;
return <AccountPageLoaded account={account} portfolio={portfolio.data} />;
}
export function AccountPageLoading() {
const { t } = useTranslation();
const viewModel = useAccountPageViewModel();
if (viewModel.state === "loading") return <AccountPageLoading />;
if (viewModel.state === "error") return <AccountPageError {...viewModel} />;
if (viewModel.state === "empty")
return <AccountPageEmpty account={viewModel.account} />;
return (
<StateView
loading
title={t("states.loadingTitle")}
text={t("states.loadingText")}
<AccountPageLoaded
account={viewModel.account}
portfolio={viewModel.portfolio}
/>
);
}
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 }: { account?: BrokerageAccount }) {
const { t } = useTranslation();
return (
<Stack>
<AccountHeading account={account} />
<StateView
title={t("portfolio.emptyTitle")}
text={t("portfolio.emptyText")}
/>
</Stack>
);
}
export function AccountPageLoaded({
account,
portfolio,
}: {
account?: BrokerageAccount;
portfolio: Portfolio;
}) {
const { t } = useTranslation();
const stocks = portfolio.positions.filter(
(position) => position.type === "STOCK",
);
const bonds = portfolio.positions.filter(
(position) => position.type === "BOND",
);
const otherPositions = portfolio.positions.filter(
(position) => position.type !== "STOCK" && position.type !== "BOND",
);
return (
<Stack className={classes.page} gap="lg">
<AccountHeading account={account} />
{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} />
) : null}
{bonds.length ? (
<PositionSection title={t("portfolio.bonds")} positions={bonds} />
) : null}
{otherPositions.length ? (
<PositionSection
title={t("portfolio.otherPositions")}
positions={otherPositions}
/>
) : 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>
</Stack>
);
}
function PositionSection({
title,
positions,
}: {
title: string;
positions: Portfolio["positions"];
}) {
const { t } = useTranslation();
return (
<Section title={title}>
{positions.map((position) => (
<Card
className={classes.row}
key={`${position.figi}-${position.displayName}-${position.quantity}`}
withBorder
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Stack gap={2}>
<Text fw={700}>{position.displayName}</Text>
<Text c="dimmed" size="sm">
{t("portfolio.quantity", { value: position.quantity })}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Text fw={700}>{formatMoney(position.currentValue)}</Text>
<Text c="dimmed" size="sm">
{t("portfolio.return", {
value: formatMoney(position.absoluteReturn),
})}
</Text>
</Stack>
</Group>
</Card>
))}
</Section>
);
}
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>
);
}
export {
AccountPageEmpty,
AccountPageError,
AccountPageLoaded,
AccountPageLoading,
} from "./AccountPage.view";
+217
View File
@@ -0,0 +1,217 @@
import {
Alert,
Button,
Card,
Group,
Paper,
SimpleGrid,
Stack,
Text,
Title,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import { accountTypeKey } from "@/entities/brokerage-account";
import type { Portfolio } from "@/entities/portfolio";
import { formatMoney, 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 }: { account?: BrokerageAccount }) {
const { t } = useTranslation();
return (
<Stack>
<AccountHeading account={account} />
<StateView
title={t("portfolio.emptyTitle")}
text={t("portfolio.emptyText")}
/>
</Stack>
);
}
export function AccountPageLoaded({
account,
portfolio,
}: {
account?: BrokerageAccount;
portfolio: Portfolio;
}) {
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",
);
return (
<Stack className={classes.page} gap="lg">
<AccountHeading account={account} />
{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} />
) : null}
{bonds.length ? (
<PositionSection title={t("portfolio.bonds")} positions={bonds} />
) : null}
{other.length ? (
<PositionSection
title={t("portfolio.otherPositions")}
positions={other}
/>
) : 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>
</Stack>
);
}
function PositionSection({
title,
positions,
}: {
title: string;
positions: Portfolio["positions"];
}) {
const { t } = useTranslation();
return (
<Section title={title}>
{positions.map((position) => (
<Card
className={classes.row}
key={`${position.figi}-${position.displayName}-${position.quantity}`}
withBorder
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Stack gap={2}>
<Text fw={700}>{position.displayName}</Text>
<Text c="dimmed" size="sm">
{t("portfolio.quantity", { value: position.quantity })}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Text fw={700}>{formatMoney(position.currentValue)}</Text>
<Text c="dimmed" size="sm">
{t("portfolio.return", {
value: formatMoney(position.absoluteReturn),
})}
</Text>
</Stack>
</Group>
</Card>
))}
</Section>
);
}
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>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { useQueryClient } from "@tanstack/react-query";
import { useParams } from "react-router";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import { portfolioErrorCode, usePortfolio } from "@/entities/portfolio";
import { useAuth } from "@/features/auth";
const accountsQueryKey = ["brokerage-accounts"] as const;
export function useAccountPageViewModel() {
const { accountId } = useParams();
const { accessToken } = useAuth();
const portfolio = usePortfolio(accountId, accessToken);
const account = useQueryClient()
.getQueryData<BrokerageAccount[]>(accountsQueryKey)
?.find((item) => item.id === accountId);
if (portfolio.isError)
return {
state: "error" as const,
code: portfolioErrorCode(portfolio.error),
retry: () => portfolio.refetch(),
};
if (portfolio.isFetching || !portfolio.data)
return { state: "loading" as const };
if (!portfolio.data.positions.length && !portfolio.data.cashBalances.length)
return { state: "empty" as const, account };
return { state: "loaded" as const, account, portfolio: portfolio.data };
}
+16 -98
View File
@@ -1,103 +1,21 @@
import { Button, Stack, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import {
BrokerageAccountList,
errorCode,
useBrokerageAccounts,
} from "@/entities/brokerage-account";
import { useAuth } from "@/features/auth";
import { StateView } from "@/shared/ui";
AccountsPageEmpty,
AccountsPageError,
AccountsPageLoaded,
AccountsPageLoading,
} from "./AccountsPage.view";
import { useAccountsPageViewModel } from "./AccountsPage.vm";
export function AccountsPage() {
const { t } = useTranslation();
const { accessToken } = useAuth();
const accounts = useBrokerageAccounts(accessToken);
const code = errorCode(accounts.error);
if (accounts.isFetching) return <AccountsPageLoading />;
if (accounts.isError)
return <AccountsPageError code={code} retry={() => accounts.refetch()} />;
if (!accounts.data?.length) return <AccountsPageEmpty />;
return (
<Stack>
<Title order={1}>{t("accounts.title")}</Title>
<BrokerageAccountList accounts={accounts.data} />
</Stack>
);
const viewModel = useAccountsPageViewModel();
if (viewModel.state === "loading") return <AccountsPageLoading />;
if (viewModel.state === "error") return <AccountsPageError {...viewModel} />;
if (viewModel.state === "empty") return <AccountsPageEmpty />;
return <AccountsPageLoaded accounts={viewModel.accounts} />;
}
export function AccountsPageLoading() {
const { t } = useTranslation();
return (
<StateView
loading
title={t("states.loadingTitle")}
text={t("states.loadingText")}
/>
);
}
export function AccountsPageError({
code,
retry,
}: {
code?: string;
retry?: () => void;
}) {
const { t } = useTranslation();
const settingsAction = (
<Button component={Link} to="/settings" variant="light">
{t("accounts.openSettings")}
</Button>
);
if (code === "TBANK_NOT_CONNECTED")
return (
<StateView
error
title={t("accounts.notConnectedTitle")}
text={t("accounts.notConnectedText")}
action={settingsAction}
/>
);
if (code === "INVALID_TBANK_TOKEN")
return (
<StateView
error
title={t("accounts.invalidTokenTitle")}
text={t("accounts.invalidTokenText")}
action={settingsAction}
/>
);
if (code === "TBANK_UNAVAILABLE")
return (
<StateView
error
title={t("accounts.unavailableTitle")}
text={t("accounts.unavailableText")}
action={
<Stack align="flex-start">
<Button onClick={retry}>{t("accounts.retry")}</Button>
{settingsAction}
</Stack>
}
/>
);
return (
<StateView
error
title={t("states.errorTitle")}
text={t("states.errorText")}
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
/>
);
}
export function AccountsPageEmpty() {
const { t } = useTranslation();
return (
<StateView
title={t("accounts.emptyTitle")}
text={t("accounts.emptyText")}
/>
);
}
export {
AccountsPageEmpty,
AccountsPageError,
AccountsPageLoading,
} from "./AccountsPage.view";
@@ -0,0 +1,96 @@
import { Button, Stack, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import { BrokerageAccountList } from "@/entities/brokerage-account";
import { StateView } from "@/shared/ui";
export function AccountsPageLoading() {
const { t } = useTranslation();
return (
<StateView
loading
title={t("states.loadingTitle")}
text={t("states.loadingText")}
/>
);
}
export function AccountsPageError({
code,
retry,
}: {
code?: string;
retry?: () => void;
}) {
const { t } = useTranslation();
const settingsAction = (
<Button component={Link} to="/settings" variant="light">
{t("accounts.openSettings")}
</Button>
);
if (code === "TBANK_NOT_CONNECTED")
return (
<StateView
error
title={t("accounts.notConnectedTitle")}
text={t("accounts.notConnectedText")}
action={settingsAction}
/>
);
if (code === "INVALID_TBANK_TOKEN")
return (
<StateView
error
title={t("accounts.invalidTokenTitle")}
text={t("accounts.invalidTokenText")}
action={settingsAction}
/>
);
if (code === "TBANK_UNAVAILABLE")
return (
<StateView
error
title={t("accounts.unavailableTitle")}
text={t("accounts.unavailableText")}
action={
<Stack align="flex-start">
<Button onClick={retry}>{t("accounts.retry")}</Button>
{settingsAction}
</Stack>
}
/>
);
return (
<StateView
error
title={t("states.errorTitle")}
text={t("states.errorText")}
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
/>
);
}
export function AccountsPageEmpty() {
const { t } = useTranslation();
return (
<StateView
title={t("accounts.emptyTitle")}
text={t("accounts.emptyText")}
/>
);
}
export function AccountsPageLoaded({
accounts,
}: {
accounts: BrokerageAccount[];
}) {
const { t } = useTranslation();
return (
<Stack>
<Title order={1}>{t("accounts.title")}</Title>
<BrokerageAccountList accounts={accounts} />
</Stack>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { errorCode, useBrokerageAccounts } from "@/entities/brokerage-account";
import { useAuth } from "@/features/auth";
export function useAccountsPageViewModel() {
const { accessToken } = useAuth();
const accounts = useBrokerageAccounts(accessToken);
if (accounts.isFetching) return { state: "loading" as const };
if (accounts.isError)
return {
state: "error" as const,
code: errorCode(accounts.error),
retry: () => accounts.refetch(),
};
if (!accounts.data?.length) return { state: "empty" as const };
return { state: "loaded" as const, accounts: accounts.data };
}
+9 -16
View File
@@ -8,22 +8,15 @@ export function NotFoundPage() {
return (
<Stack>
<Title order={1}>{t("notFound.title")}</Title>
<NotFoundPageEmpty />
<StateView
title={t("states.emptyTitle")}
text={t("states.emptyText")}
action={
<Button component={Link} to="/accounts">
{t("notFound.action")}
</Button>
}
/>
</Stack>
);
}
function NotFoundPageEmpty() {
const { t } = useTranslation();
return (
<StateView
title={t("states.emptyTitle")}
text={t("states.emptyText")}
action={
<Button component={Link} to="/accounts">
{t("notFound.action")}
</Button>
}
/>
);
}