refactor: little structure refactor
This commit is contained in:
@@ -19,6 +19,7 @@ Node 22 обязателен (`.nvmrc`). `npm run openapi:sync` - единств
|
|||||||
- Перед созданием формы, страницы или UI-состояний прочитать [`docs/UI-PATTERN.md`](docs/UI-PATTERN.md).
|
- Перед созданием формы, страницы или UI-состояний прочитать [`docs/UI-PATTERN.md`](docs/UI-PATTERN.md).
|
||||||
- Перед изменением структуры слайса или импортов прочитать [`docs/STRUCTURE.md`](docs/STRUCTURE.md).
|
- Перед изменением структуры слайса или импортов прочитать [`docs/STRUCTURE.md`](docs/STRUCTURE.md).
|
||||||
- Перед кодом, тестами или quality checks прочитать [`docs/CONVENTIONS.md`](docs/CONVENTIONS.md) и [`docs/TESTING.md`](docs/TESTING.md).
|
- Перед кодом, тестами или quality checks прочитать [`docs/CONVENTIONS.md`](docs/CONVENTIONS.md) и [`docs/TESTING.md`](docs/TESTING.md).
|
||||||
|
- Route-level runtime errors обрабатываются одним native route-keyed ErrorBoundary вокруг `AppRoutes`; expected API states остаются локальными.
|
||||||
|
|
||||||
## Границы
|
## Границы
|
||||||
|
|
||||||
|
|||||||
@@ -5,3 +5,5 @@
|
|||||||
MSW запускается только Vitest setup в `src/test/`; handlers покрывают текущий API. Не добавлять service worker, browser mocking или mock development mode. Для transport проверять заголовки и retry через MSW, не через реальный backend.
|
MSW запускается только Vitest setup в `src/test/`; handlers покрывают текущий API. Не добавлять service worker, browser mocking или mock development mode. Для transport проверять заголовки и retry через MSW, не через реальный backend.
|
||||||
|
|
||||||
Проверяйте наблюдаемое поведение, а не детали реализации. UI-тест строится как Arrange, Act, Assert; для интерактивных действий предпочитайте `userEvent`. Форма должна проверять валидацию и отсутствие запроса при невалидных данных; маршруты - редиректы и доступность экрана.
|
Проверяйте наблюдаемое поведение, а не детали реализации. UI-тест строится как Arrange, Act, Assert; для интерактивных действий предпочитайте `userEvent`. Форма должна проверять валидацию и отсутствие запроса при невалидных данных; маршруты - редиректы и доступность экрана.
|
||||||
|
|
||||||
|
Colocated tests finance views фиксируют выбор loading/error/empty/loaded и доступные recovery actions. Route boundary tests используют test-only throwing route и проверяют безопасную ссылку и reset после navigation.
|
||||||
|
|||||||
@@ -5,3 +5,5 @@
|
|||||||
Во время submit все действия формы блокируются. Ошибки Zod отображаются у поля; ошибки сети сохраняют введённые значения и показываются отдельным `Alert`.
|
Во время submit все действия формы блокируются. Ошибки Zod отображаются у поля; ошибки сети сохраняют введённые значения и показываются отдельным `Alert`.
|
||||||
|
|
||||||
Запросные экраны выбирают состояние в порядке loading, error, empty, loaded. Состояния имеют отдельные компоненты, если повторяются или содержат самостоятельную разметку.
|
Запросные экраны выбирают состояние в порядке loading, error, empty, loaded. Состояния имеют отдельные компоненты, если повторяются или содержат самостоятельную разметку.
|
||||||
|
|
||||||
|
Для finance-экранов container вызывает query hooks, VM выбирает состояние и передает только presentation props в Loading/Error/Empty/Loaded view. Known API errors, retry и stale snapshot остаются local views, а непойманные route errors обрабатывает общий route-keyed ErrorBoundary.
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { MemoryRouter, useLocation, useNavigate } from "react-router";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { RouteErrorBoundary } from "./RouteErrorBoundary";
|
||||||
|
|
||||||
|
let authenticated = false;
|
||||||
|
vi.mock("@/features/auth", () => ({
|
||||||
|
useAuth: () => ({ user: authenticated ? {} : null }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function ThrowingRoute(): never {
|
||||||
|
throw new Error("test route failure");
|
||||||
|
}
|
||||||
|
function HealthyRoute() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{useLocation().pathname === "/healthy" ? (
|
||||||
|
"healthy route"
|
||||||
|
) : (
|
||||||
|
<ThrowingRoute />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function RouteHarness() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={() => navigate("/healthy")}>
|
||||||
|
go healthy
|
||||||
|
</button>
|
||||||
|
<RouteErrorBoundary>
|
||||||
|
<HealthyRoute />
|
||||||
|
</RouteErrorBoundary>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function renderBoundary(path: string) {
|
||||||
|
return render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MemoryRouter initialEntries={[path]}>
|
||||||
|
<RouteHarness />
|
||||||
|
</MemoryRouter>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("RouteErrorBoundary", () => {
|
||||||
|
afterEach(cleanup);
|
||||||
|
it("offers login after a public route error without error details", () => {
|
||||||
|
authenticated = false;
|
||||||
|
renderBoundary("/login");
|
||||||
|
expect(screen.getByRole("link", { name: "Продолжить" })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"/login",
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("test route failure")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers accounts after a protected route error and resets on navigation", async () => {
|
||||||
|
authenticated = true;
|
||||||
|
renderBoundary("/accounts");
|
||||||
|
screen.getByRole("button", { name: "go healthy" }).click();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText("healthy route")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Button, Stack, Text, Title } from "@mantine/core";
|
||||||
|
import { Component } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link, useLocation } from "react-router";
|
||||||
|
import { useAuth } from "@/features/auth";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
routeKey: string;
|
||||||
|
authenticated: boolean;
|
||||||
|
};
|
||||||
|
type State = { hasError: boolean };
|
||||||
|
|
||||||
|
class Boundary extends Component<Props, State> {
|
||||||
|
state: State = { hasError: false };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(): State {
|
||||||
|
return { hasError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(previousProps: Props) {
|
||||||
|
if (previousProps.routeKey !== this.props.routeKey && this.state.hasError) {
|
||||||
|
this.setState({ hasError: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return <RouteErrorFallback authenticated={this.props.authenticated} />;
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouteErrorFallback({ authenticated }: { authenticated: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const to = authenticated ? "/accounts" : "/login";
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Title order={1}>{t("routeError.title")}</Title>
|
||||||
|
<Text>{t("routeError.text")}</Text>
|
||||||
|
<Button component={Link} to={to}>
|
||||||
|
{t("routeError.action")}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RouteErrorBoundary({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { pathname, search } = useLocation();
|
||||||
|
const { user } = useAuth();
|
||||||
|
return (
|
||||||
|
<Boundary authenticated={Boolean(user)} routeKey={`${pathname}${search}`}>
|
||||||
|
{children}
|
||||||
|
</Boundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
+4
-1
@@ -2,6 +2,7 @@ import { MantineProvider } from "@mantine/core";
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { BrowserRouter } from "react-router";
|
import { BrowserRouter } from "react-router";
|
||||||
import { AppRoutes } from "@/app/routes";
|
import { AppRoutes } from "@/app/routes";
|
||||||
|
import { RouteErrorBoundary } from "@/app/routes/RouteErrorBoundary";
|
||||||
import { theme } from "@/app/theme";
|
import { theme } from "@/app/theme";
|
||||||
import { AuthProvider } from "@/features/auth";
|
import { AuthProvider } from "@/features/auth";
|
||||||
|
|
||||||
@@ -15,7 +16,9 @@ export function App() {
|
|||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<AppRoutes />
|
<RouteErrorBoundary>
|
||||||
|
<AppRoutes />
|
||||||
|
</RouteErrorBoundary>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -6,14 +6,18 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { brokerageAccountsQueryKey } from "@/entities/brokerage-account";
|
import { brokerageAccountsQueryKey } from "@/entities/brokerage-account";
|
||||||
import { apiQueryClient } from "@/shared/api";
|
import { apiQueryClient } from "@/shared/api";
|
||||||
import type { paths } from "@/shared/api/generated/schema";
|
import type { paths } from "@/shared/api/generated/schema";
|
||||||
import { useAuth } from "../../auth";
|
import {
|
||||||
|
TbankConnectionError,
|
||||||
|
TbankConnectionLoading,
|
||||||
|
} from "./TbankConnectionSection.view";
|
||||||
|
import { useTbankConnectionViewModel } from "./TbankConnectionSection.vm";
|
||||||
|
|
||||||
type Status =
|
type Status =
|
||||||
paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"];
|
paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"];
|
||||||
@@ -25,8 +29,15 @@ function errorCode(error: unknown): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TbankConnectionSection() {
|
export function TbankConnectionSection() {
|
||||||
|
const viewModel = useTbankConnectionViewModel();
|
||||||
|
if (viewModel.state === "loading") return <TbankConnectionLoading />;
|
||||||
|
if (viewModel.state === "error")
|
||||||
|
return <TbankConnectionError retry={viewModel.retry} />;
|
||||||
|
return <TbankConnectionSectionLoaded status={viewModel.status} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TbankConnectionSectionLoaded({ status }: { status: Status }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { accessToken } = useAuth();
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [deleteOpened, setDeleteOpened] = useState(false);
|
const [deleteOpened, setDeleteOpened] = useState(false);
|
||||||
const [requestError, setRequestError] = useState<string | null>(null);
|
const [requestError, setRequestError] = useState<string | null>(null);
|
||||||
@@ -36,11 +47,6 @@ export function TbankConnectionSection() {
|
|||||||
reset,
|
reset,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<{ token: string }>();
|
} = useForm<{ token: string }>();
|
||||||
const status = useQuery({
|
|
||||||
queryKey: ["tbank-connection"],
|
|
||||||
enabled: Boolean(accessToken),
|
|
||||||
...apiQueryClient.queryOptions("get", "/api/v1/tbank-connection"),
|
|
||||||
});
|
|
||||||
const update = apiQueryClient.useMutation("put", "/api/v1/tbank-connection", {
|
const update = apiQueryClient.useMutation("put", "/api/v1/tbank-connection", {
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
queryClient.setQueryData(["tbank-connection"], data);
|
queryClient.setQueryData(["tbank-connection"], data);
|
||||||
@@ -66,25 +72,16 @@ export function TbankConnectionSection() {
|
|||||||
);
|
);
|
||||||
const pending = update.isPending || remove.isPending;
|
const pending = update.isPending || remove.isPending;
|
||||||
|
|
||||||
if (status.isPending) return <Text>{t("states.loadingText")}</Text>;
|
const connected = status.connected;
|
||||||
if (status.isError)
|
|
||||||
return (
|
|
||||||
<Alert color="red" title={t("tbank.statusError")}>
|
|
||||||
<Button variant="subtle" onClick={() => status.refetch()}>
|
|
||||||
{t("tbank.retry")}
|
|
||||||
</Button>
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
const connected = status.data.connected;
|
|
||||||
return (
|
return (
|
||||||
<Stack mt="xl">
|
<Stack mt="xl">
|
||||||
<Text fw={600}>{t("tbank.title")}</Text>
|
<Text fw={600}>{t("tbank.title")}</Text>
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
{connected
|
{connected
|
||||||
? t("tbank.connected", {
|
? t("tbank.connected", {
|
||||||
date: new Date(
|
date: new Date(String(status.connectedAt)).toLocaleDateString(
|
||||||
String(status.data.connectedAt),
|
"ru-RU",
|
||||||
).toLocaleDateString("ru-RU"),
|
),
|
||||||
})
|
})
|
||||||
: t("tbank.disconnected")}
|
: t("tbank.disconnected")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
TbankConnectionError,
|
||||||
|
TbankConnectionLoading,
|
||||||
|
} from "./TbankConnectionSection.view";
|
||||||
|
|
||||||
|
describe("TbankConnectionSection state views", () => {
|
||||||
|
it("renders loading and retries a status error", () => {
|
||||||
|
const retry = vi.fn();
|
||||||
|
const { rerender } = render(
|
||||||
|
<MantineProvider>
|
||||||
|
<TbankConnectionLoading />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Пожалуйста, подождите.")).toBeInTheDocument();
|
||||||
|
rerender(
|
||||||
|
<MantineProvider>
|
||||||
|
<TbankConnectionError retry={retry} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
screen.getByRole("button", { name: "Повторить" }).click();
|
||||||
|
expect(retry).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Alert, Button, Text } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export function TbankConnectionLoading() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return <Text>{t("states.loadingText")}</Text>;
|
||||||
|
}
|
||||||
|
export function TbankConnectionError({ retry }: { retry: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Alert color="red" title={t("tbank.statusError")}>
|
||||||
|
<Button variant="subtle" onClick={retry}>
|
||||||
|
{t("tbank.retry")}
|
||||||
|
</Button>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAuth } from "@/features/auth";
|
||||||
|
import { apiQueryClient } from "@/shared/api";
|
||||||
|
|
||||||
|
export function useTbankConnectionViewModel() {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
const status = useQuery({
|
||||||
|
queryKey: ["tbank-connection"],
|
||||||
|
enabled: Boolean(accessToken),
|
||||||
|
...apiQueryClient.queryOptions("get", "/api/v1/tbank-connection"),
|
||||||
|
});
|
||||||
|
if (status.isPending) return { state: "loading" as const };
|
||||||
|
if (status.isError)
|
||||||
|
return { state: "error" as const, retry: () => status.refetch() };
|
||||||
|
return { state: "loaded" as const, status: status.data };
|
||||||
|
}
|
||||||
@@ -1,260 +1,28 @@
|
|||||||
import {
|
import {
|
||||||
Alert,
|
AccountPageEmpty,
|
||||||
Button,
|
AccountPageError,
|
||||||
Card,
|
AccountPageLoaded,
|
||||||
Group,
|
AccountPageLoading,
|
||||||
Paper,
|
} from "./AccountPage.view";
|
||||||
SimpleGrid,
|
import { useAccountPageViewModel } from "./AccountPage.vm";
|
||||||
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;
|
|
||||||
|
|
||||||
export function AccountPage() {
|
export function AccountPage() {
|
||||||
const { accountId } = useParams();
|
const viewModel = useAccountPageViewModel();
|
||||||
const { accessToken } = useAuth();
|
if (viewModel.state === "loading") return <AccountPageLoading />;
|
||||||
const portfolio = usePortfolio(accountId, accessToken);
|
if (viewModel.state === "error") return <AccountPageError {...viewModel} />;
|
||||||
const account = useQueryClient()
|
if (viewModel.state === "empty")
|
||||||
.getQueryData<BrokerageAccount[]>(accountsQueryKey)
|
return <AccountPageEmpty account={viewModel.account} />;
|
||||||
?.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();
|
|
||||||
return (
|
return (
|
||||||
<StateView
|
<AccountPageLoaded
|
||||||
loading
|
account={viewModel.account}
|
||||||
title={t("states.loadingTitle")}
|
portfolio={viewModel.portfolio}
|
||||||
text={t("states.loadingText")}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AccountPageError({
|
export {
|
||||||
code,
|
AccountPageEmpty,
|
||||||
retry,
|
AccountPageError,
|
||||||
}: {
|
AccountPageLoaded,
|
||||||
code?: string;
|
AccountPageLoading,
|
||||||
retry: () => void;
|
} from "./AccountPage.view";
|
||||||
}) {
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -1,103 +1,21 @@
|
|||||||
import { Button, Stack, Title } from "@mantine/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Link } from "react-router";
|
|
||||||
import {
|
import {
|
||||||
BrokerageAccountList,
|
AccountsPageEmpty,
|
||||||
errorCode,
|
AccountsPageError,
|
||||||
useBrokerageAccounts,
|
AccountsPageLoaded,
|
||||||
} from "@/entities/brokerage-account";
|
AccountsPageLoading,
|
||||||
import { useAuth } from "@/features/auth";
|
} from "./AccountsPage.view";
|
||||||
import { StateView } from "@/shared/ui";
|
import { useAccountsPageViewModel } from "./AccountsPage.vm";
|
||||||
|
|
||||||
export function AccountsPage() {
|
export function AccountsPage() {
|
||||||
const { t } = useTranslation();
|
const viewModel = useAccountsPageViewModel();
|
||||||
const { accessToken } = useAuth();
|
if (viewModel.state === "loading") return <AccountsPageLoading />;
|
||||||
const accounts = useBrokerageAccounts(accessToken);
|
if (viewModel.state === "error") return <AccountsPageError {...viewModel} />;
|
||||||
const code = errorCode(accounts.error);
|
if (viewModel.state === "empty") return <AccountsPageEmpty />;
|
||||||
if (accounts.isFetching) return <AccountsPageLoading />;
|
return <AccountsPageLoaded accounts={viewModel.accounts} />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AccountsPageLoading() {
|
export {
|
||||||
const { t } = useTranslation();
|
AccountsPageEmpty,
|
||||||
return (
|
AccountsPageError,
|
||||||
<StateView
|
AccountsPageLoading,
|
||||||
loading
|
} from "./AccountsPage.view";
|
||||||
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")}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -8,22 +8,15 @@ export function NotFoundPage() {
|
|||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title order={1}>{t("notFound.title")}</Title>
|
<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>
|
</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>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -84,6 +84,11 @@ export const ru = {
|
|||||||
retry: "Повторить",
|
retry: "Повторить",
|
||||||
},
|
},
|
||||||
notFound: { title: "Страница не найдена", action: "К счетам" },
|
notFound: { title: "Страница не найдена", action: "К счетам" },
|
||||||
|
routeError: {
|
||||||
|
title: "Не удалось открыть страницу",
|
||||||
|
text: "Перейдите в безопасный раздел и попробуйте снова.",
|
||||||
|
action: "Продолжить",
|
||||||
|
},
|
||||||
states: {
|
states: {
|
||||||
loadingTitle: "Загрузка",
|
loadingTitle: "Загрузка",
|
||||||
loadingText: "Пожалуйста, подождите.",
|
loadingText: "Пожалуйста, подождите.",
|
||||||
|
|||||||
Reference in New Issue
Block a user