refactor: align frontend ownership boundaries
Close audit gaps across API drift, query policy, UI, and tests.
This commit is contained in:
@@ -19,12 +19,13 @@ npm run dev
|
||||
## Проверка
|
||||
|
||||
```bash
|
||||
nvm use --silent
|
||||
npm run verify
|
||||
```
|
||||
|
||||
Команда сначала отклоняет runtime вне Node 22, затем проверяет repository
|
||||
hygiene, strict TypeScript, Biome, один coverage-backed запуск Vitest, Knip,
|
||||
dependency rules и production build. Если backend-контракт изменился,
|
||||
Команда проверяет, что generated OpenAPI types соответствуют committed snapshot,
|
||||
затем запускает strict TypeScript, Biome, один coverage-backed запуск Vitest,
|
||||
Knip, dependency rules и production build. Если backend-контракт изменился,
|
||||
обновите `src/shared/api/spec/openapi.json` копированием backend OpenAPI
|
||||
document и затем выполните `npm run openapi:generate`.
|
||||
|
||||
|
||||
@@ -45,5 +45,6 @@ module.exports = {
|
||||
options: {
|
||||
doNotFollow: { path: "node_modules" },
|
||||
tsConfig: { fileName: "tsconfig.app.json" },
|
||||
tsPreCompilationDeps: true,
|
||||
},
|
||||
};
|
||||
|
||||
+3
-1
@@ -2,7 +2,9 @@
|
||||
|
||||
OpenAPI backend является источником истины. После изменения backend-контракта committed snapshot `src/shared/api/spec/openapi.json` обновляют простым копированием опубликованного документа backend, а затем регенерируют `src/shared/api/generated/schema.d.ts` командой `npm run openapi:generate`. Оба файла committed; generated файл не редактируется вручную.
|
||||
|
||||
`npm run openapi:generate` использует committed snapshot offline. `verify` никогда не требует backend и не пытается автоматически синхронизировать OpenAPI artifacts.
|
||||
`npm run openapi:generate` использует committed snapshot offline. `verify`
|
||||
повторно генерирует types во временную директорию и сравнивает их с committed
|
||||
`schema.d.ts`; backend для этого не требуется, а tracked files gate не меняет.
|
||||
|
||||
Все HTTP-вызовы используют `apiClient` и `apiQueryClient` из `src/shared/api`. Прямой `fetch` в features, pages и entities не используется. Query options берутся из `apiQueryClient`, а сохранённые cache keys и policies задаются вызывающим slice.
|
||||
|
||||
|
||||
+8
-11
@@ -9,9 +9,8 @@
|
||||
- Публичный импорт слайса идёт через его `index.ts`.
|
||||
- Тесты лежат рядом с исходником как `*.test.tsx`.
|
||||
- Зависимости используют exact versions; не добавлять semver ranges.
|
||||
- Главный `*.view.tsx` страницы не содержит несколько state-компонентов:
|
||||
каждый state/section — отдельный `*.view.tsx` в `components/` (см. File roles).
|
||||
- Полный локальный gate: `npm run verify`. Pre-commit запускает staged formatting, OpenAPI check и FSD dependency checks; commit message должен следовать Conventional Commits.
|
||||
- Полный локальный gate: `npm run verify`. Pre-commit запускает staged formatting
|
||||
и FSD dependency checks; commit message должен следовать Conventional Commits.
|
||||
|
||||
## React component style
|
||||
|
||||
@@ -39,11 +38,9 @@ Mandatory review conventions:
|
||||
- `Component.types.ts` - props и локальные типы.
|
||||
- `Component.module.css` - локальные стили.
|
||||
|
||||
State-компоненты страницы не колоцируются в одном `*.view.tsx`. Каждый
|
||||
top-level state (loading, error, empty, loaded) и каждый именуемый section
|
||||
выносится в отдельный `*.view.tsx` в подпапку `components/`. Имя файла
|
||||
отражает state/section: `AccountOperationsPageLoaded.view.tsx`,
|
||||
`AccountOperationsPageLoading.view.tsx`, `AccountOperationsPageError.view.tsx`.
|
||||
Контейнер (`AccountOperationsPage.tsx`) импортирует их из `./components` и
|
||||
выбирает нужный по `viewModel.state`. Нельзя держать несколько state components
|
||||
внутри одного файла view — это mandatory review convention.
|
||||
Самостоятельный top-level state или именуемый section обычно выносится в
|
||||
соседний `*.view.tsx`, когда у него есть отдельная responsibility, reuse либо
|
||||
change cadence или он скрывает principal component. Небольшой tightly-coupled
|
||||
renderer остаётся рядом с parent. Предпочтительный flat pattern —
|
||||
`components/AccountOperationsPageLoaded.view.tsx` без отдельной директории,
|
||||
barrel, test и stylesheet для каждого компонента.
|
||||
|
||||
+5
-6
@@ -27,12 +27,11 @@ image state. Простое форматирование одного перед
|
||||
существующий formatter остаётся presentation concern; вычисления из всей
|
||||
коллекции остаются во VM.
|
||||
|
||||
View files дробятся по смысловой нагрузке, а не только по количеству строк.
|
||||
Каждый top-level state и каждый именуемый section страницы — отдельный
|
||||
`*.view.tsx` в `components/`, а не накопление внутри одного большого
|
||||
`*.view.tsx`. Контейнер импортирует state views из `./components` и выбирает
|
||||
нужный по `viewModel.state`. Держать loading/error/empty/loaded components в
|
||||
одном файле view нельзя.
|
||||
View files дробятся по ownership и смысловой нагрузке, а не по количеству строк.
|
||||
Самостоятельные top-level states и sections выносятся в flat `components/*.view.tsx`,
|
||||
если это делает principal component и границы изменений яснее. Маленькие
|
||||
tightly-coupled renderers остаются рядом с parent; механическое правило «один
|
||||
component — один file» и line-count gate не используются.
|
||||
|
||||
Known API errors, retry и stale snapshot остаются local views, а непойманные
|
||||
route errors обрабатывает общий route-keyed ErrorBoundary.
|
||||
|
||||
+2
-1
@@ -15,9 +15,10 @@
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"openapi:generate": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts",
|
||||
"openapi:check": "node scripts/check-openapi.mjs",
|
||||
"knip": "knip",
|
||||
"deps": "depcruise src --config dependency-cruiser.cjs --output-type err-long",
|
||||
"verify": "npm run typecheck && npm run lint && npm run test:coverage && npm run knip && npm run deps && npm run build",
|
||||
"verify": "npm run openapi:check && npm run typecheck && npm run lint && npm run test:coverage && npm run knip && npm run deps && npm run build",
|
||||
"prepare": "husky",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
const temporaryDirectory = await mkdtemp(
|
||||
join(tmpdir(), "wealth-ledger-openapi-"),
|
||||
);
|
||||
const generatedPath = join(temporaryDirectory, "schema.d.ts");
|
||||
const committedPath = resolve("src/shared/api/generated/schema.d.ts");
|
||||
const cliPath = resolve("node_modules/openapi-typescript/bin/cli.js");
|
||||
let exitCode = 0;
|
||||
|
||||
try {
|
||||
const generated = spawnSync(
|
||||
process.execPath,
|
||||
[cliPath, "src/shared/api/spec/openapi.json", "-o", generatedPath],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
|
||||
if (generated.status !== 0) {
|
||||
process.stderr.write(generated.stderr || generated.stdout);
|
||||
exitCode = generated.status ?? 1;
|
||||
} else {
|
||||
const [committed, current] = await Promise.all([
|
||||
readFile(committedPath, "utf8"),
|
||||
readFile(generatedPath, "utf8"),
|
||||
]);
|
||||
if (committed !== current) {
|
||||
console.error(
|
||||
"Generated OpenAPI types are stale. Run `npm run openapi:generate` and commit the result.",
|
||||
);
|
||||
exitCode = 1;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
process.exitCode = exitCode;
|
||||
@@ -1,25 +1,20 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { AuthProvider } from "@/features/auth";
|
||||
import {
|
||||
AccountsPageEmpty,
|
||||
AccountsPageError,
|
||||
AccountsPageLoading,
|
||||
} from "@/pages/accounts";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { server } from "@/test/server";
|
||||
import { theme } from "../theme";
|
||||
import { AppRoutes } from "./AppRoutes";
|
||||
|
||||
describe("AppRoutes", () => {
|
||||
afterEach(cleanup);
|
||||
function renderRoutes(path: string) {
|
||||
return render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<MantineProvider theme={theme}>
|
||||
<AuthProvider>
|
||||
@@ -50,37 +45,6 @@ describe("AppRoutes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the T-Bank settings action for a missing connection", () => {
|
||||
render(
|
||||
<MantineProvider theme={theme}>
|
||||
<MemoryRouter>
|
||||
<AccountsPageError code="TBANK_NOT_CONNECTED" />
|
||||
</MemoryRouter>
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Открыть настройки T-Bank" }),
|
||||
).toHaveAttribute("href", "/settings");
|
||||
});
|
||||
|
||||
it("renders retry and settings actions when T-Bank is unavailable", () => {
|
||||
const retry = vi.fn();
|
||||
render(
|
||||
<MantineProvider theme={theme}>
|
||||
<MemoryRouter>
|
||||
<AccountsPageError code="TBANK_UNAVAILABLE" retry={retry} />
|
||||
</MemoryRouter>
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
screen.getByRole("button", { name: "Повторить" }).click();
|
||||
expect(retry).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Открыть настройки T-Bank" }),
|
||||
).toHaveAttribute("href", "/settings");
|
||||
});
|
||||
|
||||
it("redirects anonymous users from accounts", async () => {
|
||||
renderRoutes("/accounts");
|
||||
|
||||
@@ -91,6 +55,40 @@ describe("AppRoutes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("logs in through the public form and reaches the protected landing", async () => {
|
||||
let credentials: unknown;
|
||||
server.use(
|
||||
http.post("*/api/v1/auth/login", async ({ request }) => {
|
||||
credentials = await request.json();
|
||||
return HttpResponse.json({
|
||||
accessToken: "login-token",
|
||||
user: { id: "user-1", email: "user@example.com" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
renderRoutes("/accounts");
|
||||
|
||||
await screen.findByRole("heading", { level: 1, name: "Вход" });
|
||||
fireEvent.change(screen.getByLabelText("Email"), {
|
||||
target: { value: " User@Example.com " },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Пароль"), {
|
||||
target: { value: "a secure password" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Вход" }));
|
||||
|
||||
expect(
|
||||
await screen.findByRole("heading", {
|
||||
level: 2,
|
||||
name: "Нет инвестиционных счетов",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(credentials).toEqual({
|
||||
email: "user@example.com",
|
||||
password: "a secure password",
|
||||
});
|
||||
});
|
||||
|
||||
it("lazy-loads the protected shell for an authenticated accounts route", async () => {
|
||||
server.use(
|
||||
http.post("*/api/v1/auth/refresh", () =>
|
||||
@@ -114,37 +112,4 @@ describe("AppRoutes", () => {
|
||||
screen.getByRole("navigation", { name: "WealthLedger" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["loading", AccountsPageLoading, "status"],
|
||||
["error", AccountsPageError, "alert"],
|
||||
["empty", AccountsPageEmpty, undefined],
|
||||
] as const)("renders only the Accounts %s state", (_name, State, role) => {
|
||||
render(
|
||||
<MantineProvider theme={theme}>
|
||||
<State />
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
if (role) {
|
||||
expect(screen.getByRole(role)).toBeInTheDocument();
|
||||
} else {
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
level: 2,
|
||||
name: "Нет инвестиционных счетов",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
}
|
||||
|
||||
expect(screen.queryAllByRole("status")).toHaveLength(
|
||||
role === "status" ? 1 : 0,
|
||||
);
|
||||
expect(screen.queryAllByRole("alert")).toHaveLength(
|
||||
role === "alert" ? 1 : 0,
|
||||
);
|
||||
expect(screen.queryAllByText("Нет инвестиционных счетов")).toHaveLength(
|
||||
role ? 0 : 1,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+89
-181
@@ -1,9 +1,8 @@
|
||||
import { Container, Group, Skeleton, Stack } from "@mantine/core";
|
||||
import type { FC, PropsWithChildren } from "react";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Navigate, Route, Routes, useLocation } from "react-router";
|
||||
import { useAuth } from "@/features/auth";
|
||||
import type { FC } from "react";
|
||||
import { lazy } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router";
|
||||
import { LazyPage } from "./RouteFallback";
|
||||
import { Protected, Public } from "./RouteGuards";
|
||||
|
||||
const AccountPage = lazy(async () => {
|
||||
const { AccountPage } = await import("@/pages/account");
|
||||
@@ -46,187 +45,96 @@ const ProtectedLayout = lazy(async () => {
|
||||
return { default: ProtectedLayout };
|
||||
});
|
||||
|
||||
type RouteSkeletonVariant = "auth" | "content" | "shell";
|
||||
|
||||
type LazyPageProps = PropsWithChildren<{
|
||||
variant: RouteSkeletonVariant;
|
||||
}>;
|
||||
|
||||
const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
|
||||
const { variant } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (variant === "auth")
|
||||
return (
|
||||
<Container size={420} py="xl">
|
||||
<Stack
|
||||
aria-busy="true"
|
||||
aria-label={t("states.loadingTitle")}
|
||||
role="status"
|
||||
>
|
||||
<Skeleton height={32} width="45%" />
|
||||
<Skeleton height={48} radius="sm" />
|
||||
<Skeleton height={48} radius="sm" />
|
||||
<Skeleton height={36} radius="sm" />
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
|
||||
if (variant === "shell")
|
||||
return (
|
||||
<Stack
|
||||
aria-busy="true"
|
||||
aria-label={t("states.loadingTitle")}
|
||||
gap={0}
|
||||
role="status"
|
||||
>
|
||||
<Group justify="space-between" p="md">
|
||||
<Skeleton height={28} width={160} />
|
||||
<Skeleton height={28} width={96} />
|
||||
</Group>
|
||||
<Container fluid p="md">
|
||||
<Skeleton height={20} width="30%" />
|
||||
<Stack mt="lg">
|
||||
<Skeleton height={32} width="30%" />
|
||||
<Skeleton height={160} radius="md" />
|
||||
</Stack>
|
||||
</Container>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack aria-busy="true" aria-label={t("states.loadingTitle")} role="status">
|
||||
<Skeleton height={32} width="30%" />
|
||||
<Skeleton height={160} radius="md" />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const LazyPage: FC<LazyPageProps> = (props) => {
|
||||
const { children, variant } = props;
|
||||
|
||||
return (
|
||||
<Suspense fallback={<RouteSkeleton variant={variant} />}>
|
||||
{children}
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
const Protected: FC<PropsWithChildren> = (props) => {
|
||||
const { children } = props;
|
||||
const { user, ready } = useAuth();
|
||||
const location = useLocation();
|
||||
if (!ready) return <RouteSkeleton variant="shell" />;
|
||||
return user ? (
|
||||
children
|
||||
) : (
|
||||
<Navigate
|
||||
to="/login"
|
||||
state={{ returnTo: `${location.pathname}${location.search}` }}
|
||||
replace
|
||||
const AppRoutes: FC = () => (
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/accounts" replace />} />
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<Public>
|
||||
<LazyPage variant="auth">
|
||||
<LoginPage />
|
||||
</LazyPage>
|
||||
</Public>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Public: FC<PropsWithChildren> = (props) => {
|
||||
const { children } = props;
|
||||
const { user, ready } = useAuth();
|
||||
if (!ready) return <RouteSkeleton variant="shell" />;
|
||||
return user ? <Navigate to="/accounts" replace /> : children;
|
||||
};
|
||||
|
||||
const AppRoutes: FC = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/accounts" replace />} />
|
||||
<Route
|
||||
path="/register"
|
||||
element={
|
||||
<Public>
|
||||
<LazyPage variant="auth">
|
||||
<RegisterPage />
|
||||
</LazyPage>
|
||||
</Public>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<Protected>
|
||||
<LazyPage variant="shell">
|
||||
<ProtectedLayout />
|
||||
</LazyPage>
|
||||
</Protected>
|
||||
}
|
||||
>
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<Public>
|
||||
<LazyPage variant="auth">
|
||||
<LoginPage />
|
||||
</LazyPage>
|
||||
</Public>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/register"
|
||||
element={
|
||||
<Public>
|
||||
<LazyPage variant="auth">
|
||||
<RegisterPage />
|
||||
</LazyPage>
|
||||
</Public>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<Protected>
|
||||
<LazyPage variant="shell">
|
||||
<ProtectedLayout />
|
||||
</LazyPage>
|
||||
</Protected>
|
||||
}
|
||||
>
|
||||
<Route
|
||||
path="/accounts"
|
||||
element={
|
||||
<LazyPage variant="content">
|
||||
<AccountsPage />
|
||||
</LazyPage>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/accounts/:accountId"
|
||||
element={
|
||||
<LazyPage variant="content">
|
||||
<AccountPage />
|
||||
</LazyPage>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/accounts/:accountId/events"
|
||||
element={
|
||||
<LazyPage variant="content">
|
||||
<AccountEventsPage />
|
||||
</LazyPage>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/accounts/:accountId/income"
|
||||
element={
|
||||
<LazyPage variant="content">
|
||||
<AccountIncomePage />
|
||||
</LazyPage>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/accounts/:accountId/operations"
|
||||
element={
|
||||
<LazyPage variant="content">
|
||||
<AccountOperationsPage />
|
||||
</LazyPage>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<LazyPage variant="content">
|
||||
<SettingsPage />
|
||||
</LazyPage>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route
|
||||
path="*"
|
||||
path="/accounts"
|
||||
element={
|
||||
<LazyPage variant="content">
|
||||
<NotFoundPage />
|
||||
<AccountsPage />
|
||||
</LazyPage>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
<Route
|
||||
path="/accounts/:accountId"
|
||||
element={
|
||||
<LazyPage variant="content">
|
||||
<AccountPage />
|
||||
</LazyPage>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/accounts/:accountId/events"
|
||||
element={
|
||||
<LazyPage variant="content">
|
||||
<AccountEventsPage />
|
||||
</LazyPage>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/accounts/:accountId/income"
|
||||
element={
|
||||
<LazyPage variant="content">
|
||||
<AccountIncomePage />
|
||||
</LazyPage>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/accounts/:accountId/operations"
|
||||
element={
|
||||
<LazyPage variant="content">
|
||||
<AccountOperationsPage />
|
||||
</LazyPage>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<LazyPage variant="content">
|
||||
<SettingsPage />
|
||||
</LazyPage>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<LazyPage variant="content">
|
||||
<NotFoundPage />
|
||||
</LazyPage>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
|
||||
export { AppRoutes };
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import type { FC } from "react";
|
||||
import { MemoryRouter, useLocation, useNavigate } from "react-router";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { RouteErrorBoundary } from "./RouteErrorBoundary";
|
||||
|
||||
let authenticated = false;
|
||||
@@ -52,7 +52,6 @@ function renderBoundary(path: string) {
|
||||
}
|
||||
|
||||
describe("RouteErrorBoundary", () => {
|
||||
afterEach(cleanup);
|
||||
it("offers login after a public route error without error details", () => {
|
||||
authenticated = false;
|
||||
renderBoundary("/login");
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Container, Group, Skeleton, Stack } from "@mantine/core";
|
||||
import type { FC, PropsWithChildren } from "react";
|
||||
import { Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type RouteSkeletonVariant = "auth" | "content" | "shell";
|
||||
type LazyPageProps = PropsWithChildren<{ variant: RouteSkeletonVariant }>;
|
||||
|
||||
const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
|
||||
const { variant } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (variant === "auth")
|
||||
return (
|
||||
<Container size={420} py="xl">
|
||||
<Stack
|
||||
aria-busy="true"
|
||||
aria-label={t("states.loadingTitle")}
|
||||
role="status"
|
||||
>
|
||||
<Skeleton height={32} width="45%" />
|
||||
<Skeleton height={48} radius="sm" />
|
||||
<Skeleton height={48} radius="sm" />
|
||||
<Skeleton height={36} radius="sm" />
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
|
||||
if (variant === "shell")
|
||||
return (
|
||||
<Stack
|
||||
aria-busy="true"
|
||||
aria-label={t("states.loadingTitle")}
|
||||
gap={0}
|
||||
role="status"
|
||||
>
|
||||
<Group justify="space-between" p="md">
|
||||
<Skeleton height={28} width={160} />
|
||||
<Skeleton height={28} width={96} />
|
||||
</Group>
|
||||
<Container fluid p="md">
|
||||
<Skeleton height={20} width="30%" />
|
||||
<Stack mt="lg">
|
||||
<Skeleton height={32} width="30%" />
|
||||
<Skeleton height={160} radius="md" />
|
||||
</Stack>
|
||||
</Container>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack aria-busy="true" aria-label={t("states.loadingTitle")} role="status">
|
||||
<Skeleton height={32} width="30%" />
|
||||
<Skeleton height={160} radius="md" />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const LazyPage: FC<LazyPageProps> = (props) => {
|
||||
const { children, variant } = props;
|
||||
return (
|
||||
<Suspense fallback={<RouteSkeleton variant={variant} />}>
|
||||
{children}
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export { LazyPage, RouteSkeleton };
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { FC, PropsWithChildren } from "react";
|
||||
import { Navigate, useLocation } from "react-router";
|
||||
import { useAuth } from "@/features/auth";
|
||||
import { RouteSkeleton } from "./RouteFallback";
|
||||
|
||||
const Protected: FC<PropsWithChildren> = (props) => {
|
||||
const { children } = props;
|
||||
const { user, ready } = useAuth();
|
||||
const location = useLocation();
|
||||
if (!ready) return <RouteSkeleton variant="shell" />;
|
||||
return user ? (
|
||||
children
|
||||
) : (
|
||||
<Navigate
|
||||
to="/login"
|
||||
state={{ returnTo: `${location.pathname}${location.search}` }}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Public: FC<PropsWithChildren> = (props) => {
|
||||
const { children } = props;
|
||||
const { user, ready } = useAuth();
|
||||
if (!ready) return <RouteSkeleton variant="shell" />;
|
||||
return user ? <Navigate to="/accounts" replace /> : children;
|
||||
};
|
||||
|
||||
export { Protected, Public };
|
||||
+2
-69
@@ -1,79 +1,12 @@
|
||||
:root {
|
||||
--ft-color-bg-page: #e8ecf1;
|
||||
--ft-color-bg-app: #f5f7fa;
|
||||
--ft-color-bg-surface: #ffffff;
|
||||
--ft-color-bg-muted: #f1f5f9;
|
||||
--ft-color-text-primary: #1e293b;
|
||||
--ft-color-text-secondary: #64748b;
|
||||
--ft-color-border: #e2e8f0;
|
||||
--ft-color-accent: #2563eb;
|
||||
--ft-color-danger: #ef4444;
|
||||
--ft-radius-card: 12px;
|
||||
--ft-shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--ft-color-bg-page);
|
||||
color: var(--ft-color-text-primary);
|
||||
background: var(--mantine-color-gray-2);
|
||||
color: var(--mantine-color-dark-7);
|
||||
}
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.app-header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
.bottom-nav {
|
||||
display: none;
|
||||
}
|
||||
[data-depth] {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
[data-depth] a {
|
||||
color: var(--ft-color-accent);
|
||||
}
|
||||
.auth-page {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-height: 100dvh;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.auth-card {
|
||||
box-shadow: 0 12px 32px rgba(30, 41, 59, 0.1);
|
||||
width: min(100%, 26rem);
|
||||
}
|
||||
.auth-card fieldset {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
@media (max-width: 47.99375em) {
|
||||
.bottom-nav {
|
||||
background: var(--ft-color-bg-surface);
|
||||
border-top: 1px solid var(--ft-color-border);
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
left: 0;
|
||||
padding: 0.75rem;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
z-index: 200;
|
||||
}
|
||||
.bottom-nav .active {
|
||||
color: var(--ft-color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
[data-depth="3"] > :nth-child(-n + 2) {
|
||||
display: none;
|
||||
}
|
||||
.mantine-AppShell-main {
|
||||
padding-bottom: 5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useBrokerageAccounts } from "@/entities/brokerage-account";
|
||||
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
||||
|
||||
type AccountBreadcrumbsProps = {
|
||||
accountId: string;
|
||||
section?: "events" | "income" | "operations";
|
||||
};
|
||||
|
||||
const AccountBreadcrumbs: FC<AccountBreadcrumbsProps> = (props) => {
|
||||
const { accountId, section } = props;
|
||||
const { data: accounts } = useBrokerageAccounts();
|
||||
const { t } = useTranslation();
|
||||
const accountName =
|
||||
accounts?.find((account) => account.id === accountId)?.name ??
|
||||
t("breadcrumbs.accountFallback");
|
||||
const accountPath = `/accounts/${accountId}`;
|
||||
const items = [
|
||||
{ label: t("breadcrumbs.accounts"), to: "/accounts" },
|
||||
section ? { label: accountName, to: accountPath } : { label: accountName },
|
||||
...(section ? [{ label: t(`breadcrumbs.${section}`) }] : []),
|
||||
];
|
||||
|
||||
return <BreadcrumbTrail items={items} />;
|
||||
};
|
||||
|
||||
export { AccountBreadcrumbs };
|
||||
+3
-1
@@ -8,7 +8,9 @@ import { theme } from "@/app/theme";
|
||||
import { AuthProvider } from "@/features/auth";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { refetchOnWindowFocus: false } },
|
||||
defaultOptions: {
|
||||
queries: { refetchOnWindowFocus: false, retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
const App: FC = () => {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
.trail {
|
||||
margin-bottom: var(--mantine-spacing-md);
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--mantine-primary-color-filled);
|
||||
}
|
||||
|
||||
@media (max-width: 47.99375em) {
|
||||
.trail[data-depth="3"] > :nth-child(-n + 2) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { AppBreadcrumbs } from "./AppBreadcrumbs";
|
||||
@@ -26,7 +26,6 @@ function renderBreadcrumbs(
|
||||
|
||||
describe("AppBreadcrumbs", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
|
||||
+16
-113
@@ -1,141 +1,44 @@
|
||||
import { Breadcrumbs } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useMatch } from "react-router";
|
||||
import { useBrokerageAccounts } from "@/entities/brokerage-account";
|
||||
|
||||
type Crumb = { label: string; to?: string };
|
||||
|
||||
type BreadcrumbTrailProps = {
|
||||
items: Crumb[];
|
||||
};
|
||||
|
||||
type AccountBreadcrumbsProps = {
|
||||
accountId: string;
|
||||
events: boolean;
|
||||
income: boolean;
|
||||
operations: boolean;
|
||||
};
|
||||
|
||||
const BreadcrumbTrail: FC<BreadcrumbTrailProps> = (props) => {
|
||||
const { items } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Breadcrumbs
|
||||
aria-label={t("breadcrumbs.label")}
|
||||
component="nav"
|
||||
data-depth={items.length}
|
||||
separator={<span aria-hidden="true">→</span>}
|
||||
>
|
||||
{items.map((item) =>
|
||||
item.to ? (
|
||||
<Link key={item.to} to={item.to}>
|
||||
{item.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span aria-current="page" key={item.label}>
|
||||
{item.label}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</Breadcrumbs>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountBreadcrumbs: FC<AccountBreadcrumbsProps> = (props) => {
|
||||
const { accountId, events, income, operations } = props;
|
||||
const { data: accounts } = useBrokerageAccounts();
|
||||
const { t } = useTranslation();
|
||||
const accountName =
|
||||
accounts?.find((account) => account.id === accountId)?.name ??
|
||||
t("breadcrumbs.accountFallback");
|
||||
const accountPath = `/accounts/${accountId}`;
|
||||
|
||||
return (
|
||||
<BreadcrumbTrail
|
||||
items={
|
||||
events
|
||||
? [
|
||||
{ label: t("breadcrumbs.accounts"), to: "/accounts" },
|
||||
{ label: accountName, to: accountPath },
|
||||
{ label: t("breadcrumbs.events") },
|
||||
]
|
||||
: operations
|
||||
? [
|
||||
{ label: t("breadcrumbs.accounts"), to: "/accounts" },
|
||||
{ label: accountName, to: accountPath },
|
||||
{ label: t("breadcrumbs.operations") },
|
||||
]
|
||||
: income
|
||||
? [
|
||||
{ label: t("breadcrumbs.accounts"), to: "/accounts" },
|
||||
{ label: accountName, to: accountPath },
|
||||
{ label: t("breadcrumbs.income") },
|
||||
]
|
||||
: [
|
||||
{ label: t("breadcrumbs.accounts"), to: "/accounts" },
|
||||
{ label: accountName },
|
||||
]
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
import { useMatch } from "react-router";
|
||||
import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
|
||||
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
||||
|
||||
const AppBreadcrumbs: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const accountOperations = useMatch({
|
||||
const operations = useMatch({
|
||||
path: "/accounts/:accountId/operations",
|
||||
end: true,
|
||||
});
|
||||
const accountEvents = useMatch({
|
||||
path: "/accounts/:accountId/events",
|
||||
end: true,
|
||||
});
|
||||
const accountIncome = useMatch({
|
||||
path: "/accounts/:accountId/income",
|
||||
end: true,
|
||||
});
|
||||
const events = useMatch({ path: "/accounts/:accountId/events", end: true });
|
||||
const income = useMatch({ path: "/accounts/:accountId/income", end: true });
|
||||
const account = useMatch({ path: "/accounts/:accountId", end: true });
|
||||
const accounts = useMatch({ path: "/accounts", end: true });
|
||||
const settings = useMatch({ path: "/settings", end: true });
|
||||
|
||||
if (accountOperations)
|
||||
if (operations)
|
||||
return (
|
||||
<AccountBreadcrumbs
|
||||
accountId={accountOperations.params.accountId ?? ""}
|
||||
events={false}
|
||||
income={false}
|
||||
operations
|
||||
accountId={operations.params.accountId ?? ""}
|
||||
section="operations"
|
||||
/>
|
||||
);
|
||||
if (accountEvents)
|
||||
if (events)
|
||||
return (
|
||||
<AccountBreadcrumbs
|
||||
accountId={accountEvents.params.accountId ?? ""}
|
||||
events
|
||||
income={false}
|
||||
operations={false}
|
||||
accountId={events.params.accountId ?? ""}
|
||||
section="events"
|
||||
/>
|
||||
);
|
||||
if (accountIncome)
|
||||
if (income)
|
||||
return (
|
||||
<AccountBreadcrumbs
|
||||
accountId={accountIncome.params.accountId ?? ""}
|
||||
events={false}
|
||||
income
|
||||
operations={false}
|
||||
accountId={income.params.accountId ?? ""}
|
||||
section="income"
|
||||
/>
|
||||
);
|
||||
if (account)
|
||||
return (
|
||||
<AccountBreadcrumbs
|
||||
accountId={account.params.accountId ?? ""}
|
||||
events={false}
|
||||
income={false}
|
||||
operations={false}
|
||||
/>
|
||||
);
|
||||
return <AccountBreadcrumbs accountId={account.params.accountId ?? ""} />;
|
||||
if (accounts)
|
||||
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />;
|
||||
if (settings)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Breadcrumbs } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import classes from "./AppBreadcrumbs.module.css";
|
||||
|
||||
type Crumb = { label: string; to?: string };
|
||||
type BreadcrumbTrailProps = { items: Crumb[] };
|
||||
|
||||
const BreadcrumbTrail: FC<BreadcrumbTrailProps> = (props) => {
|
||||
const { items } = props;
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Breadcrumbs
|
||||
aria-label={t("breadcrumbs.label")}
|
||||
className={classes.trail}
|
||||
component="nav"
|
||||
data-depth={items.length}
|
||||
separator={<span aria-hidden="true">→</span>}
|
||||
>
|
||||
{items.map((item) =>
|
||||
item.to ? (
|
||||
<Link className={classes.link} key={item.to} to={item.to}>
|
||||
{item.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span aria-current="page" key={item.label}>
|
||||
{item.label}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</Breadcrumbs>
|
||||
);
|
||||
};
|
||||
|
||||
export { BreadcrumbTrail };
|
||||
@@ -1,17 +1,13 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import type { FC, PropsWithChildren } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createQueryWrapper } from "@/test/query";
|
||||
import {
|
||||
brokerageOperationsQueryParams,
|
||||
useBrokerageOperations,
|
||||
} from "./useBrokerageOperations";
|
||||
|
||||
describe("useBrokerageOperations", () => {
|
||||
afterEach(() => configureAuthClient(null));
|
||||
|
||||
it("normalizes query parameters and loads a page without retries", async () => {
|
||||
configureAuthClient({
|
||||
accessToken: () => "token",
|
||||
@@ -24,20 +20,9 @@ describe("useBrokerageOperations", () => {
|
||||
cursor: "next",
|
||||
}),
|
||||
).toEqual({ categories: ["income", "tax"], cursor: "next" });
|
||||
const client = new QueryClient();
|
||||
const wrapper: FC<PropsWithChildren> = (props) => {
|
||||
const { children } = props;
|
||||
|
||||
return (
|
||||
<MantineProvider>
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useBrokerageOperations("account-1", { limit: 5 }),
|
||||
{ wrapper },
|
||||
{ wrapper: createQueryWrapper().wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
|
||||
import type { paths } from "@/shared/api/generated/schema";
|
||||
import {
|
||||
apiQueryClient,
|
||||
type paths,
|
||||
useAuthenticatedQuery,
|
||||
} from "@/shared/api";
|
||||
|
||||
type OperationsEndpoint =
|
||||
paths["/api/v1/brokerage-accounts/{accountId}/operations"]["get"];
|
||||
@@ -32,25 +35,36 @@ function brokerageOperationsQueryParams(query: BrokerageOperationsQuery) {
|
||||
};
|
||||
}
|
||||
|
||||
function brokerageOperationsQueryOptions(
|
||||
accountId: string | undefined,
|
||||
query: BrokerageOperationsQuery = {},
|
||||
) {
|
||||
return apiQueryClient.queryOptions(
|
||||
"get",
|
||||
"/api/v1/brokerage-accounts/{accountId}/operations",
|
||||
{
|
||||
params: {
|
||||
path: { accountId: accountId ?? "" },
|
||||
query: brokerageOperationsQueryParams(query),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function useBrokerageOperations(
|
||||
accountId: string | undefined,
|
||||
query: BrokerageOperationsQuery = {},
|
||||
) {
|
||||
const params = brokerageOperationsQueryParams(query);
|
||||
return useAuthenticatedQuery({
|
||||
...apiQueryClient.queryOptions(
|
||||
"get",
|
||||
"/api/v1/brokerage-accounts/{accountId}/operations",
|
||||
{ params: { path: { accountId: accountId ?? "" }, query: params } },
|
||||
),
|
||||
...brokerageOperationsQueryOptions(accountId, query),
|
||||
enabled: Boolean(accountId),
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
type BrokerageOperation,
|
||||
brokerageOperationsQueryOptions,
|
||||
brokerageOperationsQueryParams,
|
||||
type OperationCategory,
|
||||
operationCategories,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export {
|
||||
type BrokerageOperation,
|
||||
brokerageOperationsQueryOptions,
|
||||
type OperationCategory,
|
||||
operationCategories,
|
||||
useBrokerageOperations,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
import type { BrokerageOperation } from "../api/useBrokerageOperations";
|
||||
|
||||
type PaymentPresentation = {
|
||||
amount: string;
|
||||
label: string;
|
||||
tone: "positive" | "negative" | "neutral";
|
||||
};
|
||||
|
||||
function entryLabelKey(operation: BrokerageOperation): string {
|
||||
if (operation.category === "income")
|
||||
return operation.type === "OPERATION_TYPE_COUPON"
|
||||
? "ledger.entryLabel.income.coupon"
|
||||
: "ledger.entryLabel.income.dividend";
|
||||
return `ledger.category.${operation.category}`;
|
||||
}
|
||||
|
||||
function paymentPresentation(
|
||||
operation: BrokerageOperation,
|
||||
): PaymentPresentation {
|
||||
if (!operation.payment)
|
||||
return {
|
||||
amount: "—",
|
||||
label: "ledger.noMoney",
|
||||
tone: "neutral",
|
||||
};
|
||||
if (isZeroDecimal(operation.payment.value))
|
||||
return {
|
||||
amount: formatMoney(operation.payment),
|
||||
label: "ledger.noMoney",
|
||||
tone: "neutral",
|
||||
};
|
||||
if (operation.payment.value.startsWith("-"))
|
||||
return {
|
||||
amount: `−${formatMoney({
|
||||
...operation.payment,
|
||||
value: operation.payment.value.slice(1),
|
||||
})}`,
|
||||
label: entryLabelKey(operation),
|
||||
tone: "negative",
|
||||
};
|
||||
return {
|
||||
amount: `+${formatMoney(operation.payment)}`,
|
||||
label: entryLabelKey(operation),
|
||||
tone: "positive",
|
||||
};
|
||||
}
|
||||
|
||||
function isZeroDecimal(value: string): boolean {
|
||||
return /^-?0(?:\.0+)?$/.test(value);
|
||||
}
|
||||
|
||||
export { paymentPresentation };
|
||||
@@ -2,32 +2,15 @@ import { Avatar, Paper, Stack, Text } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
formatMoney,
|
||||
formatSnapshotTime,
|
||||
instrumentLogoUrl,
|
||||
} from "@/entities/portfolio";
|
||||
import { formatSnapshotTime, instrumentLogoUrl } from "@/shared/lib";
|
||||
import type { BrokerageOperation } from "../api/useBrokerageOperations";
|
||||
import { paymentPresentation } from "../model/display";
|
||||
import classes from "./LedgerEntryRow.module.css";
|
||||
|
||||
type LedgerEntryRowProps = {
|
||||
operation: BrokerageOperation;
|
||||
};
|
||||
|
||||
type PaymentPresentation = {
|
||||
amount: string;
|
||||
label: string;
|
||||
tone: "positive" | "negative" | "neutral";
|
||||
};
|
||||
|
||||
function entryLabelKey(operation: BrokerageOperation): string {
|
||||
if (operation.category === "income")
|
||||
return operation.type === "OPERATION_TYPE_COUPON"
|
||||
? "ledger.entryLabel.income.coupon"
|
||||
: "ledger.entryLabel.income.dividend";
|
||||
return `ledger.category.${operation.category}`;
|
||||
}
|
||||
|
||||
const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
|
||||
const { operation } = props;
|
||||
const { t } = useTranslation();
|
||||
@@ -72,39 +55,4 @@ const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
function paymentPresentation(
|
||||
operation: BrokerageOperation,
|
||||
): PaymentPresentation {
|
||||
if (!operation.payment)
|
||||
return {
|
||||
amount: "—",
|
||||
label: "ledger.noMoney",
|
||||
tone: "neutral",
|
||||
};
|
||||
if (isZeroDecimal(operation.payment.value))
|
||||
return {
|
||||
amount: formatMoney(operation.payment),
|
||||
label: "ledger.noMoney",
|
||||
tone: "neutral",
|
||||
};
|
||||
if (operation.payment.value.startsWith("-"))
|
||||
return {
|
||||
amount: `−${formatMoney({
|
||||
...operation.payment,
|
||||
value: operation.payment.value.slice(1),
|
||||
})}`,
|
||||
label: entryLabelKey(operation),
|
||||
tone: "negative",
|
||||
};
|
||||
return {
|
||||
amount: `+${formatMoney(operation.payment)}`,
|
||||
label: entryLabelKey(operation),
|
||||
tone: "positive",
|
||||
};
|
||||
}
|
||||
|
||||
function isZeroDecimal(value: string): boolean {
|
||||
return /^-?0(?:\.0+)?$/.test(value);
|
||||
}
|
||||
|
||||
export { LedgerEntryRow };
|
||||
|
||||
@@ -1,31 +1,12 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import type { FC, PropsWithChildren } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createQueryWrapper } from "@/test/query";
|
||||
import { server } from "@/test/server";
|
||||
import { useBrokerageAccounts } from "./useBrokerageAccounts";
|
||||
|
||||
function createWrapper() {
|
||||
const client = new QueryClient();
|
||||
const TestWrapper: FC<PropsWithChildren> = (props) => {
|
||||
const { children } = props;
|
||||
|
||||
return (
|
||||
<MantineProvider>
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
};
|
||||
|
||||
return TestWrapper;
|
||||
}
|
||||
|
||||
describe("useBrokerageAccounts", () => {
|
||||
afterEach(() => configureAuthClient(null));
|
||||
|
||||
it("loads accounts through the authenticated query seam", async () => {
|
||||
configureAuthClient({
|
||||
accessToken: () => "token",
|
||||
@@ -46,7 +27,7 @@ describe("useBrokerageAccounts", () => {
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useBrokerageAccounts(), {
|
||||
wrapper: createWrapper(),
|
||||
wrapper: createQueryWrapper().wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
@@ -83,7 +64,7 @@ describe("useBrokerageAccounts", () => {
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() => useBrokerageAccounts(), {
|
||||
wrapper: createWrapper(),
|
||||
wrapper: createQueryWrapper().wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
|
||||
import type { paths } from "@/shared/api/generated/schema";
|
||||
import {
|
||||
apiQueryClient,
|
||||
type paths,
|
||||
useAuthenticatedQuery,
|
||||
} from "@/shared/api";
|
||||
|
||||
const brokerageAccountsQueryOptions = () =>
|
||||
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts");
|
||||
@@ -13,7 +16,6 @@ function useBrokerageAccounts() {
|
||||
return useAuthenticatedQuery({
|
||||
...brokerageAccountsQueryOptions(),
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,5 @@ export {
|
||||
accountTypeKey,
|
||||
type BrokerageAccountCard,
|
||||
brokerageAccountCards,
|
||||
errorCode,
|
||||
} from "./model/display";
|
||||
export { BrokerageAccountList } from "./ui/BrokerageAccountList";
|
||||
|
||||
@@ -16,12 +16,6 @@ function accountTypeKey(type: BrokerageAccount["type"]) {
|
||||
return `accounts.types.${type}` as const;
|
||||
}
|
||||
|
||||
function errorCode(error: unknown): string | undefined {
|
||||
return typeof error === "object" && error !== null && "code" in error
|
||||
? String(error.code)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function brokerageAccountCards(
|
||||
accounts: BrokerageAccount[],
|
||||
): BrokerageAccountCard[] {
|
||||
@@ -44,9 +38,4 @@ function brokerageAccountCards(
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
accountTypeKey,
|
||||
type BrokerageAccountCard,
|
||||
brokerageAccountCards,
|
||||
errorCode,
|
||||
};
|
||||
export { accountTypeKey, type BrokerageAccountCard, brokerageAccountCards };
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Card, Group, Stack, Text } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { formatMoney } from "@/entities/portfolio";
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
import type { BrokerageAccountCard } from "../model/display";
|
||||
import { accountTypeKey } from "../model/display";
|
||||
import classes from "./BrokerageAccountList.module.css";
|
||||
|
||||
@@ -1,31 +1,12 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import type { FC, PropsWithChildren } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createQueryWrapper } from "@/test/query";
|
||||
import { server } from "@/test/server";
|
||||
import { useBrokerageEvents } from "./useBrokerageEvents";
|
||||
|
||||
function createWrapper() {
|
||||
const client = new QueryClient();
|
||||
const TestWrapper: FC<PropsWithChildren> = (props) => {
|
||||
const { children } = props;
|
||||
|
||||
return (
|
||||
<MantineProvider>
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
};
|
||||
|
||||
return TestWrapper;
|
||||
}
|
||||
|
||||
describe("useBrokerageEvents", () => {
|
||||
afterEach(() => configureAuthClient(null));
|
||||
|
||||
it("loads future cash events through the authenticated query seam", async () => {
|
||||
configureAuthClient({
|
||||
accessToken: () => "token",
|
||||
@@ -54,7 +35,7 @@ describe("useBrokerageEvents", () => {
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useBrokerageEvents("account-1"), {
|
||||
wrapper: createWrapper(),
|
||||
wrapper: createQueryWrapper().wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
|
||||
import type { paths } from "@/shared/api/generated/schema";
|
||||
import {
|
||||
apiQueryClient,
|
||||
type paths,
|
||||
useAuthenticatedQuery,
|
||||
} from "@/shared/api";
|
||||
|
||||
type EventsEndpoint =
|
||||
paths["/api/v1/brokerage-accounts/{accountId}/events"]["get"];
|
||||
@@ -15,7 +18,6 @@ function useBrokerageEvents(accountId: string | undefined) {
|
||||
),
|
||||
enabled: Boolean(accountId),
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
|
||||
import type { paths } from "@/shared/api/generated/schema";
|
||||
import {
|
||||
apiQueryClient,
|
||||
type paths,
|
||||
useAuthenticatedQuery,
|
||||
} from "@/shared/api";
|
||||
|
||||
type IncomeSummary =
|
||||
paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"];
|
||||
@@ -15,7 +18,6 @@ function useIncomeSummary(accountId: string | undefined) {
|
||||
),
|
||||
enabled: Boolean(accountId),
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -36,8 +38,6 @@ function useIncomeAnalytics(
|
||||
),
|
||||
enabled: Boolean(accountId),
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +1,21 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import type { FC, PropsWithChildren } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createQueryWrapper } from "@/test/query";
|
||||
import { server } from "@/test/server";
|
||||
import { usePortfolio } from "./usePortfolio";
|
||||
|
||||
describe("usePortfolio", () => {
|
||||
afterEach(() => configureAuthClient(null));
|
||||
|
||||
it("uses the account endpoint with a 30 second cache and no retry", async () => {
|
||||
configureAuthClient({
|
||||
accessToken: () => "token",
|
||||
refresh: async () => true,
|
||||
clear: vi.fn(),
|
||||
});
|
||||
const client = new QueryClient();
|
||||
const wrapper: FC<PropsWithChildren> = (props) => {
|
||||
const { children } = props;
|
||||
|
||||
return (
|
||||
<MantineProvider>
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => usePortfolio("account-1"), {
|
||||
wrapper,
|
||||
wrapper: createQueryWrapper().wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
@@ -58,16 +43,9 @@ describe("usePortfolio", () => {
|
||||
: HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
|
||||
}),
|
||||
);
|
||||
const client = new QueryClient();
|
||||
const wrapper: FC<PropsWithChildren> = (props) => {
|
||||
const { children } = props;
|
||||
return (
|
||||
<MantineProvider>
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
};
|
||||
const { result } = renderHook(() => usePortfolio("account-1"), { wrapper });
|
||||
const { result } = renderHook(() => usePortfolio("account-1"), {
|
||||
wrapper: createQueryWrapper().wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
void result.current.refetch();
|
||||
|
||||
@@ -1,24 +1,30 @@
|
||||
import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
|
||||
import type { paths } from "@/shared/api/generated/schema";
|
||||
import {
|
||||
apiQueryClient,
|
||||
type paths,
|
||||
useAuthenticatedQuery,
|
||||
} from "@/shared/api";
|
||||
|
||||
const portfolioQueryPolicy = {
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
} as const;
|
||||
|
||||
type Portfolio =
|
||||
paths["/api/v1/brokerage-accounts/{accountId}/portfolio"]["get"]["responses"][200]["content"]["application/json"];
|
||||
|
||||
function portfolioQueryOptions(accountId: string | undefined) {
|
||||
return apiQueryClient.queryOptions(
|
||||
"get",
|
||||
"/api/v1/brokerage-accounts/{accountId}/portfolio",
|
||||
{ params: { path: { accountId: accountId ?? "" } } },
|
||||
);
|
||||
}
|
||||
|
||||
function usePortfolio(accountId: string | undefined) {
|
||||
return useAuthenticatedQuery({
|
||||
...apiQueryClient.queryOptions(
|
||||
"get",
|
||||
"/api/v1/brokerage-accounts/{accountId}/portfolio",
|
||||
{ params: { path: { accountId: accountId ?? "" } } },
|
||||
),
|
||||
...portfolioQueryOptions(accountId),
|
||||
enabled: Boolean(accountId),
|
||||
...portfolioQueryPolicy,
|
||||
});
|
||||
}
|
||||
|
||||
export { type Portfolio, usePortfolio };
|
||||
export { type Portfolio, portfolioQueryOptions, usePortfolio };
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
export { type Portfolio, usePortfolio } from "./api/usePortfolio";
|
||||
export {
|
||||
formatMoney,
|
||||
formatShare,
|
||||
formatSnapshotTime,
|
||||
instrumentLogoUrl,
|
||||
portfolioErrorCode,
|
||||
} from "./model/display";
|
||||
type Portfolio,
|
||||
portfolioQueryOptions,
|
||||
usePortfolio,
|
||||
} from "./api/usePortfolio";
|
||||
export { formatShare } from "./model/display";
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatMoney, formatShare, instrumentLogoUrl } from "./display";
|
||||
import { formatShare } from "./display";
|
||||
|
||||
describe("portfolio display helpers", () => {
|
||||
it("formats decimal strings without financial arithmetic", () => {
|
||||
expect(formatMoney({ value: "-1234567.89", currency: "RUB" })).toBe(
|
||||
"-1 234 567,89 ₽",
|
||||
);
|
||||
expect(formatMoney({ value: "10", currency: "RUB" })).toBe("10 ₽");
|
||||
expect(formatMoney({ value: "0.5", currency: "USD" })).toBe("0,5 USD");
|
||||
expect(formatMoney(null)).toBe("—");
|
||||
});
|
||||
|
||||
it("formats a share as a display-only percentage", () => {
|
||||
expect(
|
||||
formatShare(
|
||||
@@ -33,15 +24,4 @@ describe("portfolio display helpers", () => {
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("builds the brand logo url from an upstream file name", () => {
|
||||
expect(instrumentLogoUrl("sber3.png")).toBe(
|
||||
"https://invest-brands.cdn-tinkoff.ru/sber3x160.png",
|
||||
);
|
||||
expect(instrumentLogoUrl(" RU000A0JPNM1.png ")).toBe(
|
||||
"https://invest-brands.cdn-tinkoff.ru/RU000A0JPNM1x160.png",
|
||||
);
|
||||
expect(instrumentLogoUrl(null)).toBeNull();
|
||||
expect(instrumentLogoUrl(" ")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,21 +2,6 @@ import type { Portfolio } from "../api/usePortfolio";
|
||||
|
||||
type Money = Portfolio["totalsByCurrency"][number];
|
||||
|
||||
function formatDecimal(value: string): string {
|
||||
const [integer, fraction] = value.split(".");
|
||||
const sign = integer.startsWith("-") ? "-" : "";
|
||||
const grouped = (sign ? integer.slice(1) : integer).replace(
|
||||
/\B(?=(\d{3})+(?!\d))/g,
|
||||
" ",
|
||||
);
|
||||
return `${sign}${grouped}${fraction ? `,${fraction}` : ""}`;
|
||||
}
|
||||
|
||||
function formatMoney(money: Money | null): string {
|
||||
if (!money) return "—";
|
||||
return `${formatDecimal(money.value)} ${money.currency === "RUB" ? "₽" : money.currency}`;
|
||||
}
|
||||
|
||||
function formatShare(
|
||||
numerator: Money | null,
|
||||
denominator: Money | null,
|
||||
@@ -32,33 +17,4 @@ function formatShare(
|
||||
return `${rounded} %`;
|
||||
}
|
||||
|
||||
function formatSnapshotTime(value: string): string {
|
||||
return new Intl.DateTimeFormat("ru-RU", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
timeZone: "UTC",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function portfolioErrorCode(error: unknown): string | undefined {
|
||||
return typeof error === "object" && error !== null && "code" in error
|
||||
? String(error.code)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const logoSize = 160;
|
||||
|
||||
function instrumentLogoUrl(logoName: string | null | undefined): string | null {
|
||||
const stem = logoName?.trim().replace(/\.png$/i, "");
|
||||
return stem
|
||||
? `https://invest-brands.cdn-tinkoff.ru/${stem}x${logoSize}.png`
|
||||
: null;
|
||||
}
|
||||
|
||||
export {
|
||||
formatMoney,
|
||||
formatShare,
|
||||
formatSnapshotTime,
|
||||
instrumentLogoUrl,
|
||||
portfolioErrorCode,
|
||||
};
|
||||
export { formatShare };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { AuthProvider, useAuth } from "./model/auth";
|
||||
export { AuthPageLayout } from "./ui/AuthPageLayout";
|
||||
export { LoginForm } from "./ui/LoginForm";
|
||||
export { RegisterForm } from "./ui/RegisterForm";
|
||||
|
||||
@@ -8,8 +8,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { apiClient, configureAuthClient } from "@/shared/api";
|
||||
import type { paths } from "@/shared/api/generated/schema";
|
||||
import { apiClient, configureAuthClient, type paths } from "@/shared/api";
|
||||
|
||||
type User =
|
||||
paths["/api/v1/auth/me"]["get"]["responses"][200]["content"]["application/json"];
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { z } from "zod";
|
||||
import { validationMessage } from "@/shared/i18n";
|
||||
|
||||
function isEmailCharacterInvalid(character: string): boolean {
|
||||
return (
|
||||
/\s/.test(character) ||
|
||||
character.charCodeAt(0) < 32 ||
|
||||
character.charCodeAt(0) === 127
|
||||
);
|
||||
}
|
||||
|
||||
const email = z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.max(254)
|
||||
.refine(
|
||||
(value) =>
|
||||
value.split("@").length === 2 &&
|
||||
value.split("@").every(Boolean) &&
|
||||
!value.split("").some(isEmailCharacterInvalid),
|
||||
validationMessage("auth.validation.invalidEmail"),
|
||||
);
|
||||
const password = z
|
||||
.string()
|
||||
.refine(
|
||||
(value) =>
|
||||
new TextEncoder().encode(value).length >= 12 &&
|
||||
new TextEncoder().encode(value).length <= 72,
|
||||
validationMessage("auth.validation.invalidPasswordLength"),
|
||||
);
|
||||
|
||||
const loginSchema = z.object({
|
||||
email,
|
||||
password,
|
||||
passwordConfirmation: z.string().optional(),
|
||||
});
|
||||
const registerSchema = loginSchema.refine(
|
||||
(value) => value.password === value.passwordConfirmation,
|
||||
(value) => ({
|
||||
path: ["passwordConfirmation"],
|
||||
...validationMessage("auth.validation.passwordsDoNotMatch")(value),
|
||||
}),
|
||||
);
|
||||
|
||||
type AuthFormValues = z.input<typeof loginSchema>;
|
||||
|
||||
export { type AuthFormValues, loginSchema, registerSchema };
|
||||
@@ -0,0 +1,4 @@
|
||||
.fieldset {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -1,18 +1,10 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AuthForm } from "./AuthForm";
|
||||
|
||||
describe("AuthForm", () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it("does not submit registration when password confirmation differs", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(
|
||||
|
||||
@@ -5,52 +5,13 @@ import { useState } from "react";
|
||||
import { FormProvider, type Resolver, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
type AuthFormValues,
|
||||
loginSchema,
|
||||
registerSchema,
|
||||
} from "../model/validation";
|
||||
import classes from "./AuthForm.module.css";
|
||||
|
||||
import { validationMessage } from "@/shared/i18n";
|
||||
|
||||
function isEmailCharacterInvalid(character: string): boolean {
|
||||
return (
|
||||
/\s/.test(character) ||
|
||||
character.charCodeAt(0) < 32 ||
|
||||
character.charCodeAt(0) === 127
|
||||
);
|
||||
}
|
||||
|
||||
const email = z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.max(254)
|
||||
.refine(
|
||||
(value) =>
|
||||
value.split("@").length === 2 &&
|
||||
value.split("@").every(Boolean) &&
|
||||
!value.split("").some(isEmailCharacterInvalid),
|
||||
validationMessage("auth.validation.invalidEmail"),
|
||||
);
|
||||
const password = z
|
||||
.string()
|
||||
.refine(
|
||||
(value) =>
|
||||
new TextEncoder().encode(value).length >= 12 &&
|
||||
new TextEncoder().encode(value).length <= 72,
|
||||
validationMessage("auth.validation.invalidPasswordLength"),
|
||||
);
|
||||
|
||||
const loginSchema = z.object({
|
||||
email,
|
||||
password,
|
||||
passwordConfirmation: z.string().optional(),
|
||||
});
|
||||
const registerSchema = loginSchema.refine(
|
||||
(value) => value.password === value.passwordConfirmation,
|
||||
(value) => ({
|
||||
path: ["passwordConfirmation"],
|
||||
...validationMessage("auth.validation.passwordsDoNotMatch")(value),
|
||||
}),
|
||||
);
|
||||
type AuthFormValues = z.input<typeof loginSchema>;
|
||||
type AuthFormProps = {
|
||||
register?: true;
|
||||
onSubmit: (email: string, password: string) => Promise<void>;
|
||||
@@ -79,7 +40,7 @@ const AuthForm: FC<AuthFormProps> = (props) => {
|
||||
}
|
||||
})}
|
||||
>
|
||||
<fieldset disabled={isSubmitting}>
|
||||
<fieldset className={classes.fieldset} disabled={isSubmitting}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label={t("auth.email")}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
.page {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-height: 100dvh;
|
||||
padding: var(--mantine-spacing-xl);
|
||||
}
|
||||
|
||||
.card {
|
||||
box-shadow: 0 12px 32px rgba(30, 41, 59, 0.1);
|
||||
width: min(100%, 26rem);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Paper, Stack, Title } from "@mantine/core";
|
||||
import type { FC, PropsWithChildren } from "react";
|
||||
import classes from "./AuthPageLayout.module.css";
|
||||
|
||||
type AuthPageLayoutProps = PropsWithChildren<{ title: string }>;
|
||||
|
||||
const AuthPageLayout: FC<AuthPageLayoutProps> = (props) => {
|
||||
const { children, title } = props;
|
||||
return (
|
||||
<main className={classes.page}>
|
||||
<Paper className={classes.card} p="xl" radius="lg" withBorder>
|
||||
<Stack gap="lg">
|
||||
<Title order={1}>{title}</Title>
|
||||
{children}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export { AuthPageLayout };
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { apiQueryClient } from "@/shared/api";
|
||||
import type { paths } from "@/shared/api/generated/schema";
|
||||
import { apiQueryClient, type paths } from "@/shared/api";
|
||||
|
||||
type TbankConnectionStatus =
|
||||
paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"];
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { apiQueryClient } from "@/shared/api";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { apiQueryClient, configureAuthClient } from "@/shared/api";
|
||||
import { server } from "@/test/server";
|
||||
import { TbankConnectionSection } from "./TbankConnectionSection";
|
||||
|
||||
vi.mock("@/features/auth", () => ({
|
||||
useAuth: () => ({ accessToken: "test-token" }),
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const brokerageAccountsQueryKey = apiQueryClient.queryOptions(
|
||||
"get",
|
||||
"/api/v1/brokerage-accounts",
|
||||
@@ -39,6 +27,11 @@ const unrelatedQueryKey = apiQueryClient.queryOptions(
|
||||
).queryKey;
|
||||
|
||||
function renderSection(client: QueryClient) {
|
||||
configureAuthClient({
|
||||
accessToken: () => "test-token",
|
||||
refresh: async () => true,
|
||||
clear: vi.fn(),
|
||||
});
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<QueryClientProvider client={client}>
|
||||
|
||||
@@ -1,36 +1,10 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Modal,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { apiQueryClient } from "@/shared/api";
|
||||
import {
|
||||
commitTbankConnectionBoundary,
|
||||
type TbankConnectionStatus,
|
||||
} from "../model/connection-boundary";
|
||||
import {
|
||||
TbankConnectionError,
|
||||
TbankConnectionLoading,
|
||||
} from "./TbankConnectionSection.view";
|
||||
import { useTbankConnectionViewModel } from "./TbankConnectionSection.vm";
|
||||
|
||||
type TbankConnectionSectionLoadedProps = {
|
||||
status: TbankConnectionStatus;
|
||||
};
|
||||
|
||||
function errorCode(error: unknown): string | undefined {
|
||||
return typeof error === "object" && error !== null && "code" in error
|
||||
? String(error.code)
|
||||
: undefined;
|
||||
}
|
||||
import { TbankConnectionSectionLoaded } from "./TbankConnectionSectionLoaded";
|
||||
|
||||
const TbankConnectionSection: FC = () => {
|
||||
const viewModel = useTbankConnectionViewModel();
|
||||
@@ -40,119 +14,4 @@ const TbankConnectionSection: FC = () => {
|
||||
return <TbankConnectionSectionLoaded status={viewModel.status} />;
|
||||
};
|
||||
|
||||
const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
|
||||
props,
|
||||
) => {
|
||||
const { status } = props;
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [deleteOpened, setDeleteOpened] = useState(false);
|
||||
const [requestError, setRequestError] = useState<string | null>(null);
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<{ token: string }>();
|
||||
const update = apiQueryClient.useMutation("put", "/api/v1/tbank-connection", {
|
||||
onSuccess: (data) => {
|
||||
commitTbankConnectionBoundary(queryClient, data);
|
||||
reset();
|
||||
},
|
||||
onError: (error) => setRequestError(errorCode(error) ?? "REQUEST_ERROR"),
|
||||
});
|
||||
const remove = apiQueryClient.useMutation(
|
||||
"delete",
|
||||
"/api/v1/tbank-connection",
|
||||
{
|
||||
onSuccess: () => {
|
||||
commitTbankConnectionBoundary(queryClient, {
|
||||
connected: false,
|
||||
connectedAt: null,
|
||||
});
|
||||
setDeleteOpened(false);
|
||||
},
|
||||
onError: () => setRequestError("REQUEST_ERROR"),
|
||||
},
|
||||
);
|
||||
const pending = update.isPending || remove.isPending;
|
||||
|
||||
const connected = status.connected;
|
||||
return (
|
||||
<Stack mt="xl">
|
||||
<Text fw={600}>{t("tbank.title")}</Text>
|
||||
<Text size="sm">
|
||||
{connected
|
||||
? t("tbank.connected", {
|
||||
date: new Date(String(status.connectedAt)).toLocaleDateString(
|
||||
"ru-RU",
|
||||
),
|
||||
})
|
||||
: t("tbank.disconnected")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("tbank.hint")}
|
||||
</Text>
|
||||
<form
|
||||
onSubmit={handleSubmit(({ token }) => {
|
||||
setRequestError(null);
|
||||
update.mutate({ body: { token } });
|
||||
})}
|
||||
>
|
||||
<fieldset disabled={pending}>
|
||||
<Stack>
|
||||
<PasswordInput
|
||||
label={t("tbank.token")}
|
||||
{...register("token", { required: t("tbank.tokenRequired") })}
|
||||
error={errors.token?.message}
|
||||
/>
|
||||
{requestError && (
|
||||
<Alert color="red">
|
||||
{t(
|
||||
requestError === "INVALID_TBANK_TOKEN"
|
||||
? "tbank.invalidToken"
|
||||
: requestError === "TBANK_UNAVAILABLE"
|
||||
? "tbank.unavailable"
|
||||
: "tbank.requestError",
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
<Button type="submit" loading={update.isPending}>
|
||||
{t(connected ? "tbank.replace" : "tbank.connect")}
|
||||
</Button>
|
||||
{connected && (
|
||||
<Button
|
||||
variant="outline"
|
||||
color="red"
|
||||
onClick={() => setDeleteOpened(true)}
|
||||
>
|
||||
{t("tbank.delete")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</fieldset>
|
||||
</form>
|
||||
<Modal
|
||||
opened={deleteOpened}
|
||||
onClose={() => !pending && setDeleteOpened(false)}
|
||||
title={t("tbank.deleteTitle")}
|
||||
closeOnClickOutside={!pending}
|
||||
closeOnEscape={!pending}
|
||||
>
|
||||
<Stack>
|
||||
<Text>{t("tbank.deleteText")}</Text>
|
||||
<Button
|
||||
color="red"
|
||||
loading={remove.isPending}
|
||||
disabled={pending}
|
||||
onClick={() => remove.mutate({})}
|
||||
>
|
||||
{t("tbank.deleteConfirm")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export { TbankConnectionSection };
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/features/auth";
|
||||
import { useAuthenticatedQuery } from "@/shared/api";
|
||||
import { tbankConnectionQueryOptions } from "../model/connection-boundary";
|
||||
|
||||
function useTbankConnectionViewModel() {
|
||||
const { accessToken } = useAuth();
|
||||
const status = useQuery({
|
||||
const status = useAuthenticatedQuery({
|
||||
...tbankConnectionQueryOptions(),
|
||||
enabled: Boolean(accessToken),
|
||||
});
|
||||
if (status.isPending) return { state: "loading" as const };
|
||||
if (status.isError)
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Modal,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { apiErrorCode, apiQueryClient } from "@/shared/api";
|
||||
import {
|
||||
commitTbankConnectionBoundary,
|
||||
type TbankConnectionStatus,
|
||||
} from "../model/connection-boundary";
|
||||
|
||||
type TbankConnectionSectionLoadedProps = {
|
||||
status: TbankConnectionStatus;
|
||||
};
|
||||
|
||||
const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
|
||||
props,
|
||||
) => {
|
||||
const { status } = props;
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [deleteOpened, setDeleteOpened] = useState(false);
|
||||
const [requestError, setRequestError] = useState<string | null>(null);
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<{ token: string }>();
|
||||
const update = apiQueryClient.useMutation("put", "/api/v1/tbank-connection", {
|
||||
onSuccess: (data) => {
|
||||
commitTbankConnectionBoundary(queryClient, data);
|
||||
reset();
|
||||
},
|
||||
onError: (error) => setRequestError(apiErrorCode(error) ?? "REQUEST_ERROR"),
|
||||
});
|
||||
const remove = apiQueryClient.useMutation(
|
||||
"delete",
|
||||
"/api/v1/tbank-connection",
|
||||
{
|
||||
onSuccess: () => {
|
||||
commitTbankConnectionBoundary(queryClient, {
|
||||
connected: false,
|
||||
connectedAt: null,
|
||||
});
|
||||
setDeleteOpened(false);
|
||||
},
|
||||
onError: () => setRequestError("REQUEST_ERROR"),
|
||||
},
|
||||
);
|
||||
const pending = update.isPending || remove.isPending;
|
||||
const connected = status.connected;
|
||||
|
||||
return (
|
||||
<Stack mt="xl">
|
||||
<Text fw={600}>{t("tbank.title")}</Text>
|
||||
<Text size="sm">
|
||||
{connected
|
||||
? t("tbank.connected", {
|
||||
date: new Date(status.connectedAt ?? "").toLocaleDateString(
|
||||
"ru-RU",
|
||||
),
|
||||
})
|
||||
: t("tbank.disconnected")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("tbank.hint")}
|
||||
</Text>
|
||||
<form
|
||||
onSubmit={handleSubmit(({ token }) => {
|
||||
setRequestError(null);
|
||||
update.mutate({ body: { token } });
|
||||
})}
|
||||
>
|
||||
<fieldset disabled={pending}>
|
||||
<Stack>
|
||||
<PasswordInput
|
||||
label={t("tbank.token")}
|
||||
{...register("token", { required: t("tbank.tokenRequired") })}
|
||||
error={errors.token?.message}
|
||||
/>
|
||||
{requestError && (
|
||||
<Alert color="red">
|
||||
{t(
|
||||
requestError === "INVALID_TBANK_TOKEN"
|
||||
? "tbank.invalidToken"
|
||||
: requestError === "TBANK_UNAVAILABLE"
|
||||
? "tbank.unavailable"
|
||||
: "tbank.requestError",
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
<Button type="submit" loading={update.isPending}>
|
||||
{t(connected ? "tbank.replace" : "tbank.connect")}
|
||||
</Button>
|
||||
{connected && (
|
||||
<Button
|
||||
variant="outline"
|
||||
color="red"
|
||||
onClick={() => setDeleteOpened(true)}
|
||||
>
|
||||
{t("tbank.delete")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</fieldset>
|
||||
</form>
|
||||
<Modal
|
||||
opened={deleteOpened}
|
||||
onClose={() => !pending && setDeleteOpened(false)}
|
||||
title={t("tbank.deleteTitle")}
|
||||
closeOnClickOutside={!pending}
|
||||
closeOnEscape={!pending}
|
||||
>
|
||||
<Stack>
|
||||
<Text>{t("tbank.deleteText")}</Text>
|
||||
<Button
|
||||
color="red"
|
||||
loading={remove.isPending}
|
||||
disabled={pending}
|
||||
onClick={() => remove.mutate({})}
|
||||
>
|
||||
{t("tbank.deleteConfirm")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export { TbankConnectionSectionLoaded };
|
||||
@@ -1,33 +1,10 @@
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useParams } from "react-router";
|
||||
import {
|
||||
type BrokerageEvent,
|
||||
useBrokerageEvents,
|
||||
} from "@/entities/brokerage-event";
|
||||
import { formatMoney } from "@/entities/portfolio";
|
||||
import { LoadingSkeleton, StateView } from "@/shared/ui";
|
||||
|
||||
type AccountEventsPageErrorProps = {
|
||||
retry: () => void;
|
||||
};
|
||||
|
||||
type AccountEventsPageLoadedProps = {
|
||||
events: BrokerageEvent[];
|
||||
};
|
||||
|
||||
type AccountEventRowProps = {
|
||||
event: BrokerageEvent;
|
||||
};
|
||||
import { useBrokerageEvents } from "@/entities/brokerage-event";
|
||||
import { AccountEventsPageEmpty } from "./components/AccountEventsPageEmpty.view";
|
||||
import { AccountEventsPageError } from "./components/AccountEventsPageError.view";
|
||||
import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view";
|
||||
import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view";
|
||||
|
||||
const AccountEventsPage: FC = () => {
|
||||
const { accountId } = useParams();
|
||||
@@ -40,98 +17,4 @@ const AccountEventsPage: FC = () => {
|
||||
return <AccountEventsPageLoaded events={events.data.events} />;
|
||||
};
|
||||
|
||||
const AccountEventsPageLoading: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<LoadingSkeleton label={t("states.loadingTitle")}>
|
||||
<Skeleton height={32} width="40%" />
|
||||
<Skeleton height={18} width="70%" />
|
||||
{[0, 1, 2, 3].map((index) => (
|
||||
<Skeleton key={index} height={82} radius="md" />
|
||||
))}
|
||||
</LoadingSkeleton>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
|
||||
const { retry } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<StateView
|
||||
error
|
||||
title={t("events.errorTitle")}
|
||||
text={t("events.errorText")}
|
||||
action={<Button onClick={retry}>{t("events.retry")}</Button>}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountEventsPageEmpty: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack maw={960} gap="lg">
|
||||
<Title order={1}>{t("events.title")}</Title>
|
||||
<Text c="dimmed">{t("events.hint")}</Text>
|
||||
<StateView title={t("events.emptyTitle")} text={t("events.emptyText")} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
|
||||
const { events } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack maw={960} gap="lg">
|
||||
<Title order={1}>{t("events.title")}</Title>
|
||||
<Text c="dimmed">{t("events.hint")}</Text>
|
||||
{events.map((event) => (
|
||||
<AccountEventRow
|
||||
event={event}
|
||||
key={`${event.type}-${event.date}-${event.ticker}`}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountEventRow: FC<AccountEventRowProps> = (props) => {
|
||||
const { event } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Stack gap={2}>
|
||||
<Text fw={700}>{event.displayName}</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
{formatCalendarDate(event.date)} ·{" "}
|
||||
{t(`events.dateKind.${event.dateKind}`)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={2} align="flex-end">
|
||||
<Text fw={700}>
|
||||
{event.projectedAmount
|
||||
? formatMoney(event.projectedAmount)
|
||||
: t("events.noAmount")}
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
{t(`events.type.${event.type}`)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
function formatCalendarDate(date: string) {
|
||||
return new Intl.DateTimeFormat("ru-RU", {
|
||||
dateStyle: "medium",
|
||||
timeZone: "UTC",
|
||||
}).format(new Date(`${date}T00:00:00Z`));
|
||||
}
|
||||
|
||||
export { AccountEventsPage };
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Card, Group, Stack, Text } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { BrokerageEvent } from "@/entities/brokerage-event";
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
|
||||
type AccountEventRowProps = { event: BrokerageEvent };
|
||||
|
||||
const AccountEventRow: FC<AccountEventRowProps> = (props) => {
|
||||
const { event } = props;
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Stack gap={2}>
|
||||
<Text fw={700}>{event.displayName}</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
{formatCalendarDate(event.date)} ·{" "}
|
||||
{t(`events.dateKind.${event.dateKind}`)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={2} align="flex-end">
|
||||
<Text fw={700}>
|
||||
{event.projectedAmount
|
||||
? formatMoney(event.projectedAmount)
|
||||
: t("events.noAmount")}
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
{t(`events.type.${event.type}`)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
function formatCalendarDate(date: string) {
|
||||
return new Intl.DateTimeFormat("ru-RU", {
|
||||
dateStyle: "medium",
|
||||
timeZone: "UTC",
|
||||
}).format(new Date(`${date}T00:00:00Z`));
|
||||
}
|
||||
|
||||
export { AccountEventRow };
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Stack, Text, Title } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StateView } from "@/shared/ui";
|
||||
|
||||
const AccountEventsPageEmpty: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack maw={960} gap="lg">
|
||||
<Title order={1}>{t("events.title")}</Title>
|
||||
<Text c="dimmed">{t("events.hint")}</Text>
|
||||
<StateView title={t("events.emptyTitle")} text={t("events.emptyText")} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export { AccountEventsPageEmpty };
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Button } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StateView } from "@/shared/ui";
|
||||
|
||||
type AccountEventsPageErrorProps = { retry: () => void };
|
||||
|
||||
const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StateView
|
||||
error
|
||||
title={t("events.errorTitle")}
|
||||
text={t("events.errorText")}
|
||||
action={<Button onClick={props.retry}>{t("events.retry")}</Button>}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { AccountEventsPageError };
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Stack, Text, Title } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { BrokerageEvent } from "@/entities/brokerage-event";
|
||||
import { AccountEventRow } from "./AccountEventRow.view";
|
||||
|
||||
type AccountEventsPageLoadedProps = { events: BrokerageEvent[] };
|
||||
|
||||
const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack maw={960} gap="lg">
|
||||
<Title order={1}>{t("events.title")}</Title>
|
||||
<Text c="dimmed">{t("events.hint")}</Text>
|
||||
{props.events.map((event) => (
|
||||
<AccountEventRow
|
||||
event={event}
|
||||
key={`${event.type}-${event.date}-${event.ticker}`}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export { AccountEventsPageLoaded };
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Skeleton } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingSkeleton } from "@/shared/ui";
|
||||
|
||||
const AccountEventsPageLoading: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<LoadingSkeleton label={t("states.loadingTitle")}>
|
||||
<Skeleton height={32} width="40%" />
|
||||
<Skeleton height={18} width="70%" />
|
||||
{[0, 1, 2, 3].map((index) => (
|
||||
<Skeleton key={index} height={82} radius="md" />
|
||||
))}
|
||||
</LoadingSkeleton>
|
||||
);
|
||||
};
|
||||
|
||||
export { AccountEventsPageLoading };
|
||||
@@ -1,13 +1,14 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import type { FC, PropsWithChildren } from "react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { IncomeAnalytics } from "@/entities/income";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createQueryWrapper } from "@/test/query";
|
||||
import { server } from "@/test/server";
|
||||
import { AccountIncomePage } from "./AccountIncomePage";
|
||||
import { isDate, toIncomeDisplayModel } from "./AccountIncomePage.vm";
|
||||
|
||||
const analytics = {
|
||||
accountId: "account-1",
|
||||
@@ -41,7 +42,7 @@ const analytics = {
|
||||
},
|
||||
],
|
||||
entries: [entry()],
|
||||
};
|
||||
} satisfies IncomeAnalytics;
|
||||
|
||||
function money(currency: string, value: string) {
|
||||
return { currency, value };
|
||||
@@ -65,7 +66,7 @@ function comparison(currency: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function entry() {
|
||||
function entry(): IncomeAnalytics["entries"][number] {
|
||||
return {
|
||||
id: "coupon-1",
|
||||
occurredAt: "2026-08-01T12:00:00Z",
|
||||
@@ -91,36 +92,45 @@ function renderPage() {
|
||||
refresh: async () => true,
|
||||
clear: vi.fn(),
|
||||
});
|
||||
const Wrapper: FC<PropsWithChildren> = (props) => (
|
||||
<MantineProvider>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{props.children}
|
||||
</QueryClientProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
const { wrapper: QueryWrapper } = createQueryWrapper();
|
||||
return render(
|
||||
<Wrapper>
|
||||
<MemoryRouter
|
||||
initialEntries={[
|
||||
"/accounts/account-1/income?from=2026-08-01&to=2026-08-27",
|
||||
]}
|
||||
>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/accounts/:accountId/income"
|
||||
element={<AccountIncomePage />}
|
||||
/>
|
||||
<Route path="/accounts/:accountId/events" element={<h1>Events</h1>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</Wrapper>,
|
||||
<MantineProvider>
|
||||
<QueryWrapper>
|
||||
<MemoryRouter
|
||||
initialEntries={[
|
||||
"/accounts/account-1/income?from=2026-08-01&to=2026-08-27",
|
||||
]}
|
||||
>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/accounts/:accountId/income"
|
||||
element={<AccountIncomePage />}
|
||||
/>
|
||||
<Route
|
||||
path="/accounts/:accountId/events"
|
||||
element={<h1>Events</h1>}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryWrapper>
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("AccountIncomePage", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
configureAuthClient(null);
|
||||
it("rejects malformed calendar dates without throwing", () => {
|
||||
expect(isDate("2026-99-99")).toBe(false);
|
||||
expect(isDate("2026-02-29")).toBe(false);
|
||||
expect(isDate("2024-02-29")).toBe(true);
|
||||
});
|
||||
|
||||
it("groups income entries once in the view model", () => {
|
||||
const display = toIncomeDisplayModel(analytics);
|
||||
|
||||
expect(display.instruments[0].entries).toEqual([analytics.entries[0]]);
|
||||
expect(display.trend[0].buckets[0].total).toEqual(
|
||||
analytics.buckets[0].totalsByCurrency[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("renders realized analytics, drill-down, and the independent forecast", async () => {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import type { FC } from "react";
|
||||
import {
|
||||
AccountIncomePageError,
|
||||
AccountIncomePageLoaded,
|
||||
AccountIncomePageLoading,
|
||||
} from "./AccountIncomePage.view";
|
||||
import { useAccountIncomePageViewModel } from "./AccountIncomePage.vm";
|
||||
import { AccountIncomePageError } from "./components/AccountIncomePageError.view";
|
||||
import { AccountIncomePageLoaded } from "./components/AccountIncomePageLoaded.view";
|
||||
import { AccountIncomePageLoading } from "./components/AccountIncomePageLoading.view";
|
||||
|
||||
const AccountIncomePage: FC = () => {
|
||||
const viewModel = useAccountIncomePageViewModel();
|
||||
|
||||
@@ -1,388 +0,0 @@
|
||||
import {
|
||||
Accordion,
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { LedgerEntryRow } from "@/entities/broker-operation";
|
||||
import type { BrokerageEvent } from "@/entities/brokerage-event";
|
||||
import type { IncomeAnalytics } from "@/entities/income";
|
||||
import { formatMoney } from "@/entities/portfolio";
|
||||
import { LoadingSkeleton, StateView } from "@/shared/ui";
|
||||
import styles from "./AccountIncomePage.module.css";
|
||||
import type { AccountIncomePageViewModel } from "./AccountIncomePage.vm";
|
||||
|
||||
type LoadedProps = Extract<AccountIncomePageViewModel, { state: "loaded" }>;
|
||||
type ErrorProps = Extract<AccountIncomePageViewModel, { state: "error" }>;
|
||||
|
||||
const AccountIncomePageLoading: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<LoadingSkeleton label={t("states.loadingTitle")}>
|
||||
<Skeleton height={32} width="40%" />
|
||||
<Skeleton height={120} radius="md" />
|
||||
<Skeleton height={260} radius="md" />
|
||||
</LoadingSkeleton>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountIncomePageError: FC<ErrorProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StateView
|
||||
error
|
||||
title={t("income.errorTitle")}
|
||||
text={t("income.errorText")}
|
||||
action={<Button onClick={props.retry}>{t("income.retry")}</Button>}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
|
||||
const { analytics, form, forecast, onSubmit, onReset } = props;
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack maw={960} gap="lg">
|
||||
<Title order={1}>{t("income.title")}</Title>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<Group align="end">
|
||||
<TextInput
|
||||
error={form.formState.errors.from?.message}
|
||||
label={t("income.from")}
|
||||
type="date"
|
||||
{...form.register("from")}
|
||||
/>
|
||||
<TextInput
|
||||
error={form.formState.errors.to?.message}
|
||||
label={t("income.to")}
|
||||
type="date"
|
||||
{...form.register("to")}
|
||||
/>
|
||||
<Button type="submit">{t("income.apply")}</Button>
|
||||
<Button onClick={onReset} variant="subtle">
|
||||
{t("income.reset")}
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
<Text c="dimmed">
|
||||
{analytics.period.from} — {analytics.period.to}
|
||||
</Text>
|
||||
{analytics.totalsByCurrency.length ? (
|
||||
<IncomeSummary totals={analytics.totalsByCurrency} />
|
||||
) : (
|
||||
<StateView
|
||||
title={t("income.emptyTitle")}
|
||||
text={t("income.emptyText")}
|
||||
/>
|
||||
)}
|
||||
<Alert color="blue">{t("income.taxBoundary")}</Alert>
|
||||
<IncomeComparisons analytics={analytics} />
|
||||
<IncomeTrend analytics={analytics} />
|
||||
<IncomeBreakdown analytics={analytics} />
|
||||
<IncomeForecast forecast={forecast} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const IncomeSummary: FC<{ totals: IncomeAnalytics["totalsByCurrency"] }> = (
|
||||
props,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack>
|
||||
{props.totals.map((total) => (
|
||||
<Paper key={total.currency} p="md" withBorder>
|
||||
<Text fw={700}>{formatMoney(total.total)}</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("income.coupon")}: {formatMoney(total.coupon)} ·{" "}
|
||||
{t("income.dividend")}: {formatMoney(total.dividend)}
|
||||
</Text>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const IncomeComparisons: FC<{ analytics: IncomeAnalytics }> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack>
|
||||
<Title order={2}>{t("income.comparisons")}</Title>
|
||||
<Comparison
|
||||
title={t("income.previous", {
|
||||
from: props.analytics.previous.period.from,
|
||||
to: props.analytics.previous.period.to,
|
||||
})}
|
||||
values={props.analytics.previous.totalsByCurrency}
|
||||
/>
|
||||
{props.analytics.yearComparison ? (
|
||||
<Comparison
|
||||
title={t("income.yearAgo", {
|
||||
from: props.analytics.yearComparison.period.from,
|
||||
to: props.analytics.yearComparison.period.to,
|
||||
})}
|
||||
values={props.analytics.yearComparison.totalsByCurrency}
|
||||
/>
|
||||
) : (
|
||||
<Text c="dimmed">{t("income.yearUnavailable")}</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const Comparison: FC<{
|
||||
title: string;
|
||||
values: IncomeAnalytics["previous"]["totalsByCurrency"];
|
||||
}> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Paper p="sm" withBorder>
|
||||
<Text fw={700}>{props.title}</Text>
|
||||
{props.values.map((value) => (
|
||||
<Stack gap={2} key={value.currency}>
|
||||
<Text size="sm">{value.currency}</Text>
|
||||
<Text size="sm">
|
||||
{t("income.selected")}: {t("income.coupon")}{" "}
|
||||
{formatMoney(value.selected.coupon)} · {t("income.dividend")}{" "}
|
||||
{formatMoney(value.selected.dividend)} · {t("income.total")}{" "}
|
||||
{formatMoney(value.selected.total)}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{t("income.compared")}: {t("income.coupon")}{" "}
|
||||
{formatMoney(value.compared.coupon)} · {t("income.dividend")}{" "}
|
||||
{formatMoney(value.compared.dividend)} · {t("income.total")}{" "}
|
||||
{formatMoney(value.compared.total)}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{t("income.difference")}: {t("income.coupon")}{" "}
|
||||
{formatMoney(value.difference.coupon)} · {t("income.dividend")}{" "}
|
||||
{formatMoney(value.difference.dividend)} · {t("income.total")}{" "}
|
||||
{formatMoney(value.difference.total)}
|
||||
</Text>
|
||||
</Stack>
|
||||
))}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const IncomeTrend: FC<{ analytics: IncomeAnalytics }> = (props) => {
|
||||
const currencies = props.analytics.totalsByCurrency.map(
|
||||
(total) => total.currency,
|
||||
);
|
||||
const [currency, setCurrency] = useState(currencies[0] ?? "");
|
||||
const { t } = useTranslation();
|
||||
const buckets = props.analytics.buckets.map((bucket) => ({
|
||||
period: bucket.period,
|
||||
total:
|
||||
bucket.totalsByCurrency.find((item) => item.currency === currency) ??
|
||||
zeroSummary(currency),
|
||||
}));
|
||||
const values = buckets.map((bucket) => ({
|
||||
...bucket,
|
||||
coupon: Number(bucket.total.coupon.value),
|
||||
dividend: Number(bucket.total.dividend.value),
|
||||
}));
|
||||
const scale = Math.max(
|
||||
1,
|
||||
...values.map((value) =>
|
||||
Math.max(
|
||||
positive(value.coupon) + positive(value.dividend),
|
||||
negative(value.coupon) + negative(value.dividend),
|
||||
),
|
||||
),
|
||||
);
|
||||
return (
|
||||
<Stack>
|
||||
<Title order={2}>{t("income.trend")}</Title>
|
||||
{currencies.length > 1 ? (
|
||||
<SegmentedControl
|
||||
aria-label={t("income.currency")}
|
||||
data={currencies}
|
||||
onChange={setCurrency}
|
||||
value={currency}
|
||||
/>
|
||||
) : null}
|
||||
<div aria-hidden="true" className={styles.trendChart}>
|
||||
{values.map((value) => (
|
||||
<div className={styles.trendColumn} key={value.period.from}>
|
||||
<span className={styles.baseline} />
|
||||
<div className={styles.positiveStack}>
|
||||
{value.coupon > 0 ? (
|
||||
<span
|
||||
className={styles.coupon}
|
||||
data-testid={`trend-positive-coupon-${value.period.from}`}
|
||||
style={{ flexBasis: chartHeight(value.coupon, scale) }}
|
||||
/>
|
||||
) : null}
|
||||
{value.dividend > 0 ? (
|
||||
<span
|
||||
className={styles.dividend}
|
||||
data-testid={`trend-positive-dividend-${value.period.from}`}
|
||||
style={{ flexBasis: chartHeight(value.dividend, scale) }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.negativeStack}>
|
||||
{value.coupon < 0 ? (
|
||||
<span
|
||||
className={styles.coupon}
|
||||
data-testid={`trend-negative-coupon-${value.period.from}`}
|
||||
style={{ flexBasis: chartHeight(value.coupon, scale) }}
|
||||
/>
|
||||
) : null}
|
||||
{value.dividend < 0 ? (
|
||||
<span
|
||||
className={styles.dividend}
|
||||
data-testid={`trend-negative-dividend-${value.period.from}`}
|
||||
style={{ flexBasis: chartHeight(value.dividend, scale) }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Stack gap={2}>
|
||||
{buckets.map((bucket) => (
|
||||
<Text key={bucket.period.from} size="sm">
|
||||
{bucket.period.from} — {bucket.period.to}: {t("income.coupon")}{" "}
|
||||
{formatMoney(bucket.total?.coupon ?? null)}, {t("income.dividend")}{" "}
|
||||
{formatMoney(bucket.total?.dividend ?? null)}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const IncomeBreakdown: FC<{ analytics: IncomeAnalytics }> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack>
|
||||
<Title order={2}>{t("income.instruments")}</Title>
|
||||
<Accordion>
|
||||
{props.analytics.instruments.map((instrument) => (
|
||||
<Accordion.Item key={instrument.key} value={instrument.key}>
|
||||
<Accordion.Control>
|
||||
{instrument.displayName} · {formatMoney(instrument.total)} ·{" "}
|
||||
{instrument.receiptCount}
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Text size="sm">
|
||||
{t("income.coupon")}: {formatMoney(instrument.coupon)} ·{" "}
|
||||
{t("income.dividend")}: {formatMoney(instrument.dividend)}
|
||||
</Text>
|
||||
<Stack mt="sm">
|
||||
{props.analytics.entries
|
||||
.filter((entry) => entryKey(entry) === instrument.key)
|
||||
.map((entry) => (
|
||||
<LedgerEntryRow key={entry.id} operation={entry} />
|
||||
))}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
))}
|
||||
</Accordion>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const IncomeForecast: FC<{ forecast: LoadedProps["forecast"] }> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
if (props.forecast.state === "loading")
|
||||
return <Text c="dimmed">{t("income.forecastLoading")}</Text>;
|
||||
if (props.forecast.state === "error")
|
||||
return (
|
||||
<StateView
|
||||
error
|
||||
title={t("income.forecastError")}
|
||||
text={t("income.errorText")}
|
||||
action={
|
||||
<Button onClick={props.forecast.retry}>{t("income.retry")}</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
if (props.forecast.state === "empty")
|
||||
return (
|
||||
<StateView
|
||||
title={t("income.forecastTitle")}
|
||||
text={t("income.forecastEmpty")}
|
||||
/>
|
||||
);
|
||||
if (props.forecast.state !== "loaded") return null;
|
||||
return (
|
||||
<Stack>
|
||||
<Title order={2}>{t("income.forecastTitle")}</Title>
|
||||
<Alert color="yellow">{t("income.forecastWarning")}</Alert>
|
||||
{props.forecast.totals.map((total) => (
|
||||
<Text key={total.currency} fw={700}>
|
||||
{formatMoney(total)}
|
||||
</Text>
|
||||
))}
|
||||
{props.forecast.events.map((event) => (
|
||||
<ForecastEvent
|
||||
event={event}
|
||||
key={`${event.type}-${event.date}-${event.ticker}`}
|
||||
/>
|
||||
))}
|
||||
<Button component={Link} to="../events" variant="subtle">
|
||||
{t("events.open")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const ForecastEvent: FC<{ event: BrokerageEvent }> = (props) => (
|
||||
<Paper p="sm" withBorder>
|
||||
<Text fw={700}>{props.event.displayName}</Text>
|
||||
<Text size="sm">
|
||||
{props.event.date} · {formatMoney(props.event.projectedAmount)}
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
function entryKey(entry: IncomeAnalytics["entries"][number]): string {
|
||||
const base = entry.instrumentUid
|
||||
? `uid:${entry.instrumentUid}`
|
||||
: entry.figi
|
||||
? `figi:${entry.figi}`
|
||||
: entry.ticker || entry.instrumentType
|
||||
? `ticker:${entry.ticker ?? ""}:${entry.instrumentType ?? ""}`
|
||||
: `operation:${entry.id}`;
|
||||
return `${base}:${entry.payment?.currency ?? ""}`;
|
||||
}
|
||||
|
||||
function zeroSummary(
|
||||
currency: string,
|
||||
): IncomeAnalytics["totalsByCurrency"][number] {
|
||||
const zero = { currency, value: "0" };
|
||||
return { currency, coupon: zero, dividend: zero, total: zero };
|
||||
}
|
||||
|
||||
function positive(value: number): number {
|
||||
return Math.max(0, value);
|
||||
}
|
||||
|
||||
function negative(value: number): number {
|
||||
return Math.max(0, -value);
|
||||
}
|
||||
|
||||
function chartHeight(value: number, scale: number): string {
|
||||
return `${(Math.abs(value) / scale) * 100}%`;
|
||||
}
|
||||
|
||||
export {
|
||||
AccountIncomePageError,
|
||||
AccountIncomePageLoaded,
|
||||
AccountIncomePageLoading,
|
||||
};
|
||||
@@ -20,12 +20,26 @@ type IncomeForecast =
|
||||
events: BrokerageEvent[];
|
||||
};
|
||||
|
||||
type IncomeDisplayModel = {
|
||||
trend: {
|
||||
currency: string;
|
||||
buckets: {
|
||||
period: IncomeAnalytics["buckets"][number]["period"];
|
||||
total: IncomeAnalytics["totalsByCurrency"][number];
|
||||
}[];
|
||||
}[];
|
||||
instruments: (IncomeAnalytics["instruments"][number] & {
|
||||
entries: IncomeAnalytics["entries"];
|
||||
})[];
|
||||
};
|
||||
|
||||
type AccountIncomePageViewModel =
|
||||
| { state: "loading" }
|
||||
| { state: "error"; retry: () => void; period: IncomePeriod }
|
||||
| {
|
||||
state: "loaded";
|
||||
analytics: IncomeAnalytics;
|
||||
display: IncomeDisplayModel;
|
||||
forecast: IncomeForecast;
|
||||
form: ReturnType<typeof useForm<IncomeForm>>;
|
||||
onReset: () => void;
|
||||
@@ -81,6 +95,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
|
||||
return {
|
||||
state: "loaded",
|
||||
analytics: analytics.data,
|
||||
display: toIncomeDisplayModel(analytics.data),
|
||||
forecast: toForecast(events),
|
||||
form,
|
||||
onSubmit,
|
||||
@@ -126,12 +141,59 @@ function validPeriod(period: IncomePeriod): period is IncomePeriod {
|
||||
}
|
||||
|
||||
function isDate(value: string): boolean {
|
||||
if (!datePattern.test(value)) return false;
|
||||
const parsed = new Date(`${value}T00:00:00Z`);
|
||||
return (
|
||||
datePattern.test(value) &&
|
||||
new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value
|
||||
Number.isFinite(parsed.valueOf()) &&
|
||||
parsed.toISOString().slice(0, 10) === value
|
||||
);
|
||||
}
|
||||
|
||||
function toIncomeDisplayModel(analytics: IncomeAnalytics): IncomeDisplayModel {
|
||||
const entriesByInstrument = new Map<string, IncomeAnalytics["entries"]>();
|
||||
for (const entry of analytics.entries) {
|
||||
const key = incomeEntryKey(entry);
|
||||
entriesByInstrument.set(key, [
|
||||
...(entriesByInstrument.get(key) ?? []),
|
||||
entry,
|
||||
]);
|
||||
}
|
||||
|
||||
return {
|
||||
trend: analytics.totalsByCurrency.map(({ currency }) => ({
|
||||
currency,
|
||||
buckets: analytics.buckets.map((bucket) => ({
|
||||
period: bucket.period,
|
||||
total:
|
||||
bucket.totalsByCurrency.find((item) => item.currency === currency) ??
|
||||
zeroSummary(currency),
|
||||
})),
|
||||
})),
|
||||
instruments: analytics.instruments.map((instrument) => ({
|
||||
...instrument,
|
||||
entries: entriesByInstrument.get(instrument.key) ?? [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function incomeEntryKey(entry: IncomeAnalytics["entries"][number]): string {
|
||||
const base = entry.instrumentUid
|
||||
? `uid:${entry.instrumentUid}`
|
||||
: entry.figi
|
||||
? `figi:${entry.figi}`
|
||||
: entry.ticker || entry.instrumentType
|
||||
? `ticker:${entry.ticker ?? ""}:${entry.instrumentType ?? ""}`
|
||||
: `operation:${entry.id}`;
|
||||
return `${base}:${entry.payment?.currency ?? ""}`;
|
||||
}
|
||||
|
||||
function zeroSummary(
|
||||
currency: string,
|
||||
): IncomeAnalytics["totalsByCurrency"][number] {
|
||||
const zero = { currency, value: "0" };
|
||||
return { currency, coupon: zero, dividend: zero, total: zero };
|
||||
}
|
||||
|
||||
function moscowDate(value: Date): string {
|
||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Moscow",
|
||||
@@ -144,4 +206,9 @@ function moscowDate(value: Date): string {
|
||||
return `${part("year")}-${part("month")}-${part("day")}`;
|
||||
}
|
||||
|
||||
export { type AccountIncomePageViewModel, useAccountIncomePageViewModel };
|
||||
export {
|
||||
type AccountIncomePageViewModel,
|
||||
isDate,
|
||||
toIncomeDisplayModel,
|
||||
useAccountIncomePageViewModel,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Button } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StateView } from "@/shared/ui";
|
||||
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
|
||||
|
||||
type ErrorProps = Extract<AccountIncomePageViewModel, { state: "error" }>;
|
||||
|
||||
const AccountIncomePageError: FC<ErrorProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StateView
|
||||
error
|
||||
title={t("income.errorTitle")}
|
||||
text={t("income.errorText")}
|
||||
action={<Button onClick={props.retry}>{t("income.retry")}</Button>}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { AccountIncomePageError };
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StateView } from "@/shared/ui";
|
||||
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
|
||||
import { IncomeBreakdown } from "./IncomeBreakdown.view";
|
||||
import { IncomeComparisons } from "./IncomeComparisons.view";
|
||||
import { IncomeForecast } from "./IncomeForecast.view";
|
||||
import { IncomeSummary } from "./IncomeSummary.view";
|
||||
import { IncomeTrend } from "./IncomeTrend.view";
|
||||
|
||||
type LoadedProps = Extract<AccountIncomePageViewModel, { state: "loaded" }>;
|
||||
|
||||
const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
|
||||
const { analytics, display, form, forecast, onSubmit, onReset } = props;
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack maw={960} gap="lg">
|
||||
<Title order={1}>{t("income.title")}</Title>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<Group align="end">
|
||||
<TextInput
|
||||
error={form.formState.errors.from?.message}
|
||||
label={t("income.from")}
|
||||
type="date"
|
||||
{...form.register("from")}
|
||||
/>
|
||||
<TextInput
|
||||
error={form.formState.errors.to?.message}
|
||||
label={t("income.to")}
|
||||
type="date"
|
||||
{...form.register("to")}
|
||||
/>
|
||||
<Button type="submit">{t("income.apply")}</Button>
|
||||
<Button onClick={onReset} variant="subtle">
|
||||
{t("income.reset")}
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
<Text c="dimmed">
|
||||
{analytics.period.from} — {analytics.period.to}
|
||||
</Text>
|
||||
{analytics.totalsByCurrency.length ? (
|
||||
<IncomeSummary totals={analytics.totalsByCurrency} />
|
||||
) : (
|
||||
<StateView
|
||||
title={t("income.emptyTitle")}
|
||||
text={t("income.emptyText")}
|
||||
/>
|
||||
)}
|
||||
<Alert color="blue">{t("income.taxBoundary")}</Alert>
|
||||
<IncomeComparisons analytics={analytics} />
|
||||
<IncomeTrend trend={display.trend} />
|
||||
<IncomeBreakdown instruments={display.instruments} />
|
||||
<IncomeForecast forecast={forecast} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export { AccountIncomePageLoaded };
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Skeleton } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingSkeleton } from "@/shared/ui";
|
||||
|
||||
const AccountIncomePageLoading: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<LoadingSkeleton label={t("states.loadingTitle")}>
|
||||
<Skeleton height={32} width="40%" />
|
||||
<Skeleton height={120} radius="md" />
|
||||
<Skeleton height={260} radius="md" />
|
||||
</LoadingSkeleton>
|
||||
);
|
||||
};
|
||||
|
||||
export { AccountIncomePageLoading };
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Accordion, Stack, Text, Title } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LedgerEntryRow } from "@/entities/broker-operation";
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
|
||||
|
||||
type LoadedProps = Extract<AccountIncomePageViewModel, { state: "loaded" }>;
|
||||
type IncomeBreakdownProps = {
|
||||
instruments: LoadedProps["display"]["instruments"];
|
||||
};
|
||||
|
||||
const IncomeBreakdown: FC<IncomeBreakdownProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack>
|
||||
<Title order={2}>{t("income.instruments")}</Title>
|
||||
<Accordion>
|
||||
{props.instruments.map((instrument) => (
|
||||
<Accordion.Item key={instrument.key} value={instrument.key}>
|
||||
<Accordion.Control>
|
||||
{instrument.displayName} · {formatMoney(instrument.total)} ·{" "}
|
||||
{instrument.receiptCount}
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Text size="sm">
|
||||
{t("income.coupon")}: {formatMoney(instrument.coupon)} ·{" "}
|
||||
{t("income.dividend")}: {formatMoney(instrument.dividend)}
|
||||
</Text>
|
||||
<Stack mt="sm">
|
||||
{instrument.entries.map((entry) => (
|
||||
<LedgerEntryRow key={entry.id} operation={entry} />
|
||||
))}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
))}
|
||||
</Accordion>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export { IncomeBreakdown };
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Paper, Stack, Text, Title } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { IncomeAnalytics } from "@/entities/income";
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
|
||||
type IncomeComparisonsProps = { analytics: IncomeAnalytics };
|
||||
type ComparisonProps = {
|
||||
title: string;
|
||||
values: IncomeAnalytics["previous"]["totalsByCurrency"];
|
||||
};
|
||||
|
||||
const IncomeComparisons: FC<IncomeComparisonsProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack>
|
||||
<Title order={2}>{t("income.comparisons")}</Title>
|
||||
<Comparison
|
||||
title={t("income.previous", {
|
||||
from: props.analytics.previous.period.from,
|
||||
to: props.analytics.previous.period.to,
|
||||
})}
|
||||
values={props.analytics.previous.totalsByCurrency}
|
||||
/>
|
||||
{props.analytics.yearComparison ? (
|
||||
<Comparison
|
||||
title={t("income.yearAgo", {
|
||||
from: props.analytics.yearComparison.period.from,
|
||||
to: props.analytics.yearComparison.period.to,
|
||||
})}
|
||||
values={props.analytics.yearComparison.totalsByCurrency}
|
||||
/>
|
||||
) : (
|
||||
<Text c="dimmed">{t("income.yearUnavailable")}</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const Comparison: FC<ComparisonProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Paper p="sm" withBorder>
|
||||
<Text fw={700}>{props.title}</Text>
|
||||
{props.values.map((value) => (
|
||||
<Stack gap={2} key={value.currency}>
|
||||
<Text size="sm">{value.currency}</Text>
|
||||
<Text size="sm">
|
||||
{t("income.selected")}: {t("income.coupon")}{" "}
|
||||
{formatMoney(value.selected.coupon)} · {t("income.dividend")}{" "}
|
||||
{formatMoney(value.selected.dividend)} · {t("income.total")}{" "}
|
||||
{formatMoney(value.selected.total)}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{t("income.compared")}: {t("income.coupon")}{" "}
|
||||
{formatMoney(value.compared.coupon)} · {t("income.dividend")}{" "}
|
||||
{formatMoney(value.compared.dividend)} · {t("income.total")}{" "}
|
||||
{formatMoney(value.compared.total)}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{t("income.difference")}: {t("income.coupon")}{" "}
|
||||
{formatMoney(value.difference.coupon)} · {t("income.dividend")}{" "}
|
||||
{formatMoney(value.difference.dividend)} · {t("income.total")}{" "}
|
||||
{formatMoney(value.difference.total)}
|
||||
</Text>
|
||||
</Stack>
|
||||
))}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export { IncomeComparisons };
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Alert, Button, Paper, Stack, Text, Title } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import type { BrokerageEvent } from "@/entities/brokerage-event";
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
import { StateView } from "@/shared/ui";
|
||||
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
|
||||
|
||||
type LoadedProps = Extract<AccountIncomePageViewModel, { state: "loaded" }>;
|
||||
type IncomeForecastProps = { forecast: LoadedProps["forecast"] };
|
||||
type ForecastEventProps = { event: BrokerageEvent };
|
||||
|
||||
const IncomeForecast: FC<IncomeForecastProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
if (props.forecast.state === "loading")
|
||||
return <Text c="dimmed">{t("income.forecastLoading")}</Text>;
|
||||
if (props.forecast.state === "error")
|
||||
return (
|
||||
<StateView
|
||||
error
|
||||
title={t("income.forecastError")}
|
||||
text={t("income.errorText")}
|
||||
action={
|
||||
<Button onClick={props.forecast.retry}>{t("income.retry")}</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
if (props.forecast.state === "empty")
|
||||
return (
|
||||
<StateView
|
||||
title={t("income.forecastTitle")}
|
||||
text={t("income.forecastEmpty")}
|
||||
/>
|
||||
);
|
||||
if (props.forecast.state !== "loaded") return null;
|
||||
return (
|
||||
<Stack>
|
||||
<Title order={2}>{t("income.forecastTitle")}</Title>
|
||||
<Alert color="yellow">{t("income.forecastWarning")}</Alert>
|
||||
{props.forecast.totals.map((total) => (
|
||||
<Text key={total.currency} fw={700}>
|
||||
{formatMoney(total)}
|
||||
</Text>
|
||||
))}
|
||||
{props.forecast.events.map((event) => (
|
||||
<ForecastEvent
|
||||
event={event}
|
||||
key={`${event.type}-${event.date}-${event.ticker}`}
|
||||
/>
|
||||
))}
|
||||
<Button component={Link} to="../events" variant="subtle">
|
||||
{t("events.open")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const ForecastEvent: FC<ForecastEventProps> = (props) => (
|
||||
<Paper p="sm" withBorder>
|
||||
<Text fw={700}>{props.event.displayName}</Text>
|
||||
<Text size="sm">
|
||||
{props.event.date} · {formatMoney(props.event.projectedAmount)}
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
export { IncomeForecast };
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Paper, Stack, Text } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { IncomeAnalytics } from "@/entities/income";
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
|
||||
type IncomeSummaryProps = { totals: IncomeAnalytics["totalsByCurrency"] };
|
||||
|
||||
const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack>
|
||||
{props.totals.map((total) => (
|
||||
<Paper key={total.currency} p="md" withBorder>
|
||||
<Text fw={700}>{formatMoney(total.total)}</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("income.coupon")}: {formatMoney(total.coupon)} ·{" "}
|
||||
{t("income.dividend")}: {formatMoney(total.dividend)}
|
||||
</Text>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export { IncomeSummary };
|
||||
@@ -0,0 +1,107 @@
|
||||
import { SegmentedControl, Stack, Text, Title } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
import styles from "../AccountIncomePage.module.css";
|
||||
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
|
||||
|
||||
type LoadedProps = Extract<AccountIncomePageViewModel, { state: "loaded" }>;
|
||||
type IncomeTrendProps = { trend: LoadedProps["display"]["trend"] };
|
||||
|
||||
const IncomeTrend: FC<IncomeTrendProps> = (props) => {
|
||||
const currencies = props.trend.map(({ currency }) => currency);
|
||||
const [currency, setCurrency] = useState(currencies[0] ?? "");
|
||||
const { t } = useTranslation();
|
||||
const buckets =
|
||||
props.trend.find((item) => item.currency === currency)?.buckets ?? [];
|
||||
const values = buckets.map((bucket) => ({
|
||||
...bucket,
|
||||
coupon: Number(bucket.total.coupon.value),
|
||||
dividend: Number(bucket.total.dividend.value),
|
||||
}));
|
||||
const scale = Math.max(
|
||||
1,
|
||||
...values.map((value) =>
|
||||
Math.max(
|
||||
positive(value.coupon) + positive(value.dividend),
|
||||
negative(value.coupon) + negative(value.dividend),
|
||||
),
|
||||
),
|
||||
);
|
||||
return (
|
||||
<Stack>
|
||||
<Title order={2}>{t("income.trend")}</Title>
|
||||
{currencies.length > 1 ? (
|
||||
<SegmentedControl
|
||||
aria-label={t("income.currency")}
|
||||
data={currencies}
|
||||
onChange={setCurrency}
|
||||
value={currency}
|
||||
/>
|
||||
) : null}
|
||||
<div aria-hidden="true" className={styles.trendChart}>
|
||||
{values.map((value) => (
|
||||
<div className={styles.trendColumn} key={value.period.from}>
|
||||
<span className={styles.baseline} />
|
||||
<div className={styles.positiveStack}>
|
||||
{value.coupon > 0 ? (
|
||||
<span
|
||||
className={styles.coupon}
|
||||
data-testid={`trend-positive-coupon-${value.period.from}`}
|
||||
style={{ flexBasis: chartHeight(value.coupon, scale) }}
|
||||
/>
|
||||
) : null}
|
||||
{value.dividend > 0 ? (
|
||||
<span
|
||||
className={styles.dividend}
|
||||
data-testid={`trend-positive-dividend-${value.period.from}`}
|
||||
style={{ flexBasis: chartHeight(value.dividend, scale) }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.negativeStack}>
|
||||
{value.coupon < 0 ? (
|
||||
<span
|
||||
className={styles.coupon}
|
||||
data-testid={`trend-negative-coupon-${value.period.from}`}
|
||||
style={{ flexBasis: chartHeight(value.coupon, scale) }}
|
||||
/>
|
||||
) : null}
|
||||
{value.dividend < 0 ? (
|
||||
<span
|
||||
className={styles.dividend}
|
||||
data-testid={`trend-negative-dividend-${value.period.from}`}
|
||||
style={{ flexBasis: chartHeight(value.dividend, scale) }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Stack gap={2}>
|
||||
{buckets.map((bucket) => (
|
||||
<Text key={bucket.period.from} size="sm">
|
||||
{bucket.period.from} — {bucket.period.to}: {t("income.coupon")}{" "}
|
||||
{formatMoney(bucket.total.coupon)}, {t("income.dividend")}{" "}
|
||||
{formatMoney(bucket.total.dividend)}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
function positive(value: number): number {
|
||||
return Math.max(0, value);
|
||||
}
|
||||
|
||||
function negative(value: number): number {
|
||||
return Math.max(0, -value);
|
||||
}
|
||||
|
||||
function chartHeight(value: number, scale: number): string {
|
||||
return `${(Math.abs(value) / scale) * 100}%`;
|
||||
}
|
||||
|
||||
export { IncomeTrend };
|
||||
@@ -1,19 +1,17 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { MemoryRouter, Route, Routes } from "react-router";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { BrokerageOperation } from "@/entities/broker-operation";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type BrokerageOperation,
|
||||
LedgerEntryRow,
|
||||
} from "@/entities/broker-operation";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { server } from "@/test/server";
|
||||
import { AccountOperationsPage, OperationRow } from "./AccountOperationsPage";
|
||||
import { AccountOperationsPage } from "./AccountOperationsPage";
|
||||
|
||||
const operation: BrokerageOperation = {
|
||||
id: "operation-1",
|
||||
@@ -41,14 +39,29 @@ function configureTestAuth() {
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
function renderPage() {
|
||||
return render(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<MantineProvider>
|
||||
<MemoryRouter initialEntries={["/accounts/account-1/operations"]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/accounts/:accountId/operations"
|
||||
element={<AccountOperationsPage />}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("OperationRow", () => {
|
||||
describe("LedgerEntryRow", () => {
|
||||
it("marks cashless operations without rendering operation details", () => {
|
||||
render(
|
||||
<MantineProvider>
|
||||
<MemoryRouter>
|
||||
<OperationRow operation={operation} />
|
||||
<LedgerEntryRow operation={operation} />
|
||||
</MemoryRouter>
|
||||
</MantineProvider>,
|
||||
);
|
||||
@@ -65,7 +78,7 @@ describe("OperationRow", () => {
|
||||
render(
|
||||
<MantineProvider>
|
||||
<MemoryRouter>
|
||||
<OperationRow
|
||||
<LedgerEntryRow
|
||||
operation={{
|
||||
...operation,
|
||||
payment: { currency: "RUB", value: "15" },
|
||||
@@ -83,7 +96,7 @@ describe("OperationRow", () => {
|
||||
render(
|
||||
<MantineProvider>
|
||||
<MemoryRouter>
|
||||
<OperationRow
|
||||
<LedgerEntryRow
|
||||
operation={{
|
||||
...operation,
|
||||
type: "OPERATION_TYPE_COUPON",
|
||||
@@ -103,7 +116,7 @@ describe("OperationRow", () => {
|
||||
render(
|
||||
<MantineProvider>
|
||||
<MemoryRouter>
|
||||
<OperationRow
|
||||
<LedgerEntryRow
|
||||
operation={{
|
||||
...operation,
|
||||
type: "OPERATION_TYPE_DIVIDEND",
|
||||
@@ -123,7 +136,7 @@ describe("OperationRow", () => {
|
||||
render(
|
||||
<MantineProvider>
|
||||
<MemoryRouter>
|
||||
<OperationRow
|
||||
<LedgerEntryRow
|
||||
operation={{
|
||||
...operation,
|
||||
type: "OPERATION_TYPE_SERVICE_FEE",
|
||||
@@ -143,7 +156,7 @@ describe("OperationRow", () => {
|
||||
render(
|
||||
<MantineProvider>
|
||||
<MemoryRouter>
|
||||
<OperationRow
|
||||
<LedgerEntryRow
|
||||
operation={{
|
||||
...operation,
|
||||
payment: { currency: "RUB", value: "0" },
|
||||
@@ -161,7 +174,7 @@ describe("OperationRow", () => {
|
||||
render(
|
||||
<MantineProvider>
|
||||
<MemoryRouter>
|
||||
<OperationRow operation={operation} />
|
||||
<LedgerEntryRow operation={operation} />
|
||||
</MemoryRouter>
|
||||
</MantineProvider>,
|
||||
);
|
||||
@@ -171,8 +184,6 @@ describe("OperationRow", () => {
|
||||
});
|
||||
|
||||
describe("AccountOperationsPage", () => {
|
||||
afterEach(() => configureAuthClient(null));
|
||||
|
||||
it("resets cursor pagination when filters change", async () => {
|
||||
configureTestAuth();
|
||||
const searches: string[] = [];
|
||||
@@ -192,20 +203,7 @@ describe("AccountOperationsPage", () => {
|
||||
},
|
||||
),
|
||||
);
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<MantineProvider>
|
||||
<MemoryRouter initialEntries={["/accounts/account-1/operations"]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/accounts/:accountId/operations"
|
||||
element={<AccountOperationsPage />}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
renderPage();
|
||||
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "Журнал операций" }),
|
||||
@@ -241,20 +239,7 @@ describe("AccountOperationsPage", () => {
|
||||
},
|
||||
),
|
||||
);
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<MantineProvider>
|
||||
<MemoryRouter initialEntries={["/accounts/account-1/operations"]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/accounts/:accountId/operations"
|
||||
element={<AccountOperationsPage />}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
renderPage();
|
||||
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "Журнал операций" }),
|
||||
@@ -287,20 +272,7 @@ describe("AccountOperationsPage", () => {
|
||||
},
|
||||
),
|
||||
);
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<MantineProvider>
|
||||
<MemoryRouter initialEntries={["/accounts/account-1/operations"]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/accounts/:accountId/operations"
|
||||
element={<AccountOperationsPage />}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
renderPage();
|
||||
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "Журнал операций" }),
|
||||
@@ -323,20 +295,7 @@ describe("AccountOperationsPage", () => {
|
||||
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
|
||||
),
|
||||
);
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<MantineProvider>
|
||||
<MemoryRouter initialEntries={["/accounts/account-1/operations"]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/accounts/:accountId/operations"
|
||||
element={<AccountOperationsPage />}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
renderPage();
|
||||
expect(
|
||||
await screen.findByText("Не удалось загрузить журнал"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { FC } from "react";
|
||||
import { LedgerEntryRow } from "@/entities/broker-operation";
|
||||
import { useAccountOperationsPageVm } from "./AccountOperationsPage.vm";
|
||||
import { AccountOperationsPageError } from "./components/AccountOperationsPageError.view";
|
||||
import { AccountOperationsPageLoaded } from "./components/AccountOperationsPageLoaded.view";
|
||||
@@ -19,4 +18,4 @@ const AccountOperationsPage: FC = () => {
|
||||
return <AccountOperationsPageLoaded {...viewModel} />;
|
||||
};
|
||||
|
||||
export { AccountOperationsPage, LedgerEntryRow as OperationRow };
|
||||
export { AccountOperationsPage };
|
||||
|
||||
@@ -3,10 +3,10 @@ import { useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import {
|
||||
type BrokerageOperation,
|
||||
brokerageOperationsQueryOptions,
|
||||
type OperationCategory,
|
||||
useBrokerageOperations,
|
||||
} from "@/entities/broker-operation";
|
||||
import { apiQueryClient } from "@/shared/api";
|
||||
|
||||
type AccountOperationsPageViewModel =
|
||||
| { state: "loading" }
|
||||
@@ -51,11 +51,9 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
|
||||
...(cursor ? { cursor } : {}),
|
||||
limit: 100,
|
||||
});
|
||||
const resetQueryKey = apiQueryClient.queryOptions(
|
||||
"get",
|
||||
"/api/v1/brokerage-accounts/{accountId}/operations",
|
||||
{ params: { path: { accountId: accountId ?? "" }, query: { limit: 100 } } },
|
||||
).queryKey;
|
||||
const resetQueryKey = brokerageOperationsQueryOptions(accountId, {
|
||||
limit: 100,
|
||||
}).queryKey;
|
||||
|
||||
const resetCursors = () => setCursors([]);
|
||||
|
||||
|
||||
@@ -32,61 +32,36 @@
|
||||
color: var(--mantine-color-red-6);
|
||||
}
|
||||
|
||||
.stockCard {
|
||||
.expandedCard {
|
||||
padding: var(--mantine-spacing-md);
|
||||
}
|
||||
|
||||
.stockMain {
|
||||
.expandedMain {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: var(--mantine-spacing-sm);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stockIdentity,
|
||||
.stockValue {
|
||||
.expandedIdentity,
|
||||
.expandedValue {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stockIdentifiers {
|
||||
.expandedIdentifiers {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.stockValue {
|
||||
.expandedValue {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stockMeta,
|
||||
.bondMeta {
|
||||
.expandedMeta {
|
||||
padding-top: var(--mantine-spacing-xs);
|
||||
margin-top: var(--mantine-spacing-sm);
|
||||
border-top: 1px solid var(--mantine-color-gray-2);
|
||||
}
|
||||
|
||||
.bondCard {
|
||||
padding: var(--mantine-spacing-md);
|
||||
}
|
||||
|
||||
.bondMain {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: var(--mantine-spacing-sm);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bondIdentity,
|
||||
.bondValue {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bondIdentifiers {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.bondValue {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--mantine-font-family-monospace);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { MemoryRouter, Route, Routes } from "react-router";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { server } from "@/test/server";
|
||||
import {
|
||||
AccountPage,
|
||||
AccountPageEmpty,
|
||||
AccountPageError,
|
||||
AccountPageLoaded,
|
||||
AccountPageLoading,
|
||||
} from "./AccountPage";
|
||||
import { AccountPage } from "./AccountPage";
|
||||
import type {
|
||||
AccountPageRecentEvents,
|
||||
AccountPageRecentOperations,
|
||||
} from "./AccountPage.vm";
|
||||
import { toAccountPagePositionSections } from "./AccountPage.vm";
|
||||
import { AccountPageEmpty } from "./components/AccountPageEmpty.view";
|
||||
import { AccountPageError } from "./components/AccountPageError.view";
|
||||
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
|
||||
import { AccountPageLoading } from "./components/AccountPageLoading.view";
|
||||
|
||||
function renderPage(ui: React.ReactNode) {
|
||||
const queryClient = createTestQueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MantineProvider>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</MantineProvider>
|
||||
@@ -113,10 +113,6 @@ function emptyProps() {
|
||||
}
|
||||
|
||||
describe("AccountPage states", () => {
|
||||
afterEach(() => {
|
||||
configureAuthClient(null);
|
||||
cleanup();
|
||||
});
|
||||
it("renders loading and empty states", () => {
|
||||
renderPage(<AccountPageLoading />);
|
||||
expect(screen.getByRole("status")).toBeInTheDocument();
|
||||
@@ -528,12 +524,12 @@ describe("AccountPage states", () => {
|
||||
);
|
||||
}),
|
||||
);
|
||||
const queryClient = new QueryClient();
|
||||
const queryClient = createTestQueryClient();
|
||||
renderAccountRoute(queryClient);
|
||||
|
||||
await screen.findByText("Вторая");
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["get", "/api/v1/brokerage-accounts/{accountId}/portfolio"],
|
||||
queryKey: portfolioQueryOptions("account-1").queryKey,
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -563,12 +559,12 @@ describe("AccountPage states", () => {
|
||||
});
|
||||
}),
|
||||
);
|
||||
const queryClient = new QueryClient();
|
||||
const queryClient = createTestQueryClient();
|
||||
renderAccountRoute(queryClient);
|
||||
|
||||
await screen.findByText("Вторая");
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["get", "/api/v1/brokerage-accounts/{accountId}/portfolio"],
|
||||
queryKey: portfolioQueryOptions("account-1").queryKey,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(requestCount).toBe(2));
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { FC } from "react";
|
||||
import {
|
||||
AccountPageEmpty,
|
||||
AccountPageError,
|
||||
AccountPageLoaded,
|
||||
AccountPageLoading,
|
||||
} from "./AccountPage.view";
|
||||
import { useAccountPageViewModel } from "./AccountPage.vm";
|
||||
import { AccountPageEmpty } from "./components/AccountPageEmpty.view";
|
||||
import { AccountPageError } from "./components/AccountPageError.view";
|
||||
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
|
||||
import { AccountPageLoading } from "./components/AccountPageLoading.view";
|
||||
|
||||
const AccountPage: FC = () => {
|
||||
const viewModel = useAccountPageViewModel();
|
||||
@@ -38,10 +36,4 @@ const AccountPage: FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
AccountPage,
|
||||
AccountPageEmpty,
|
||||
AccountPageError,
|
||||
AccountPageLoaded,
|
||||
AccountPageLoading,
|
||||
};
|
||||
export { AccountPage };
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
import { Accordion, Alert, Button, Skeleton, Stack, Text } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import type { BrokerageAccount } from "@/entities/brokerage-account";
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
import { formatSnapshotTime } from "@/entities/portfolio";
|
||||
import { LoadingSkeleton, StateView } from "@/shared/ui";
|
||||
import classes from "./AccountPage.module.css";
|
||||
import type {
|
||||
AccountPageIncomeSummary,
|
||||
AccountPagePositionSection,
|
||||
AccountPageRecentEvents,
|
||||
AccountPageRecentOperations,
|
||||
} from "./AccountPage.vm";
|
||||
import {
|
||||
AccountHeading,
|
||||
CashBalances,
|
||||
IncomeSummary,
|
||||
PortfolioRefreshState,
|
||||
PortfolioTotals,
|
||||
PositionSection,
|
||||
RecentEvents,
|
||||
RecentOperations,
|
||||
} from "./components";
|
||||
|
||||
type AccountPageErrorProps = {
|
||||
code?: string;
|
||||
retry: () => void;
|
||||
};
|
||||
|
||||
type AccountPageEmptyProps = {
|
||||
account?: BrokerageAccount;
|
||||
refreshing?: boolean;
|
||||
refreshError?: boolean;
|
||||
retry?: () => void;
|
||||
recentOperations: AccountPageRecentOperations;
|
||||
recentEvents: AccountPageRecentEvents;
|
||||
incomeSummary?: AccountPageIncomeSummary;
|
||||
};
|
||||
|
||||
type AccountPageLoadedProps = {
|
||||
account?: BrokerageAccount;
|
||||
portfolio: Portfolio;
|
||||
positionSections: AccountPagePositionSection[];
|
||||
refreshing?: boolean;
|
||||
refreshError?: boolean;
|
||||
retry?: () => void;
|
||||
recentOperations: AccountPageRecentOperations;
|
||||
recentEvents: AccountPageRecentEvents;
|
||||
incomeSummary?: AccountPageIncomeSummary;
|
||||
};
|
||||
|
||||
const AccountPageLoading: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<LoadingSkeleton label={t("states.loadingTitle")}>
|
||||
<Skeleton height={32} width="35%" />
|
||||
<Skeleton height={120} radius="md" />
|
||||
{[0, 1, 2].map((index) => (
|
||||
<Skeleton key={index} height={52} radius="sm" />
|
||||
))}
|
||||
</LoadingSkeleton>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountPageError: FC<AccountPageErrorProps> = (props) => {
|
||||
const { code, retry } = props;
|
||||
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>}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
|
||||
const {
|
||||
account,
|
||||
refreshing = false,
|
||||
refreshError = false,
|
||||
retry,
|
||||
recentOperations,
|
||||
recentEvents,
|
||||
incomeSummary,
|
||||
} = props;
|
||||
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 operations={recentOperations} />
|
||||
<RecentEvents events={recentEvents} />
|
||||
{incomeSummary ? <IncomeSummary {...incomeSummary} /> : null}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
const {
|
||||
account,
|
||||
portfolio,
|
||||
positionSections,
|
||||
refreshing = false,
|
||||
refreshError = false,
|
||||
retry,
|
||||
recentOperations,
|
||||
recentEvents,
|
||||
incomeSummary,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
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>
|
||||
<PortfolioTotals totals={portfolio.totalsByCurrency} />
|
||||
<Accordion
|
||||
multiple
|
||||
defaultValue={[
|
||||
...positionSections.map((s) => s.kind),
|
||||
"cash",
|
||||
"recentOperations",
|
||||
"recentEvents",
|
||||
"incomeSummary",
|
||||
]}
|
||||
classNames={{
|
||||
control: classes.accordionControl,
|
||||
label: classes.accordionLabel,
|
||||
chevron: classes.accordionChevron,
|
||||
panel: classes.accordionPanel,
|
||||
}}
|
||||
>
|
||||
{positionSections.map((section) => (
|
||||
<Accordion.Item key={section.kind} value={section.kind}>
|
||||
<Accordion.Control>
|
||||
{t(positionSectionTitleKey(section.kind))}
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<PositionSection section={section} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
))}
|
||||
<Accordion.Item value="cash">
|
||||
<Accordion.Control>{t("portfolio.cashBalances")}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<CashBalances balances={portfolio.cashBalances} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="recentOperations">
|
||||
<Accordion.Control>{t("ledger.recentTitle")}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<RecentOperations operations={recentOperations} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="recentEvents">
|
||||
<Accordion.Control>{t("events.recentTitle")}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<RecentEvents events={recentEvents} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="incomeSummary">
|
||||
<Accordion.Control>{t("income.summaryTitle")}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
{incomeSummary ? <IncomeSummary {...incomeSummary} /> : null}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
function positionSectionTitleKey(kind: AccountPagePositionSection["kind"]) {
|
||||
if (kind === "stocks") return "portfolio.stocks";
|
||||
if (kind === "bonds") return "portfolio.bonds";
|
||||
return "portfolio.otherPositions";
|
||||
}
|
||||
|
||||
export {
|
||||
AccountPageEmpty,
|
||||
AccountPageError,
|
||||
AccountPageLoaded,
|
||||
AccountPageLoading,
|
||||
};
|
||||
@@ -1,12 +1,12 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import type { FC, PropsWithChildren } from "react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createQueryWrapper } from "@/test/query";
|
||||
import { server } from "@/test/server";
|
||||
import { useAccountPageViewModel } from "./AccountPage.vm";
|
||||
|
||||
@@ -63,21 +63,21 @@ function configureTestAuth() {
|
||||
});
|
||||
}
|
||||
|
||||
const wrapper: FC<PropsWithChildren> = (props) => {
|
||||
const { children } = props;
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
function createWrapper() {
|
||||
const { wrapper: QueryWrapper } = createQueryWrapper();
|
||||
const Wrapper: FC<PropsWithChildren> = (props) => (
|
||||
<QueryWrapper>
|
||||
<MantineProvider>
|
||||
<MemoryRouter initialEntries={["/accounts/account-1"]}>
|
||||
<Routes>
|
||||
<Route path="/accounts/:accountId" element={children} />
|
||||
<Route path="/accounts/:accountId" element={props.children} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>
|
||||
</QueryWrapper>
|
||||
);
|
||||
};
|
||||
return Wrapper;
|
||||
}
|
||||
|
||||
function mockLoadedAccountPage() {
|
||||
server.use(
|
||||
@@ -130,13 +130,13 @@ function mockLoadedAccountPage() {
|
||||
}
|
||||
|
||||
describe("useAccountPageViewModel", () => {
|
||||
afterEach(() => configureAuthClient(null));
|
||||
|
||||
it("prepares grouped position sections with derived display data", async () => {
|
||||
configureTestAuth();
|
||||
mockLoadedAccountPage();
|
||||
|
||||
const { result } = renderHook(() => useAccountPageViewModel(), { wrapper });
|
||||
const { result } = renderHook(() => useAccountPageViewModel(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.state).toBe("loaded"));
|
||||
const loaded = result.current as Extract<
|
||||
@@ -146,7 +146,6 @@ describe("useAccountPageViewModel", () => {
|
||||
expect(loaded.positionSections).toMatchObject([
|
||||
{
|
||||
kind: "stocks",
|
||||
wide: true,
|
||||
positions: [
|
||||
{
|
||||
displayName: "Акция",
|
||||
@@ -159,7 +158,6 @@ describe("useAccountPageViewModel", () => {
|
||||
},
|
||||
{
|
||||
kind: "bonds",
|
||||
wide: true,
|
||||
positions: [
|
||||
{
|
||||
displayName: "Облигация",
|
||||
@@ -172,7 +170,6 @@ describe("useAccountPageViewModel", () => {
|
||||
},
|
||||
{
|
||||
kind: "other",
|
||||
wide: false,
|
||||
positions: [
|
||||
{
|
||||
displayName: "Фонд",
|
||||
@@ -190,7 +187,9 @@ describe("useAccountPageViewModel", () => {
|
||||
configureTestAuth();
|
||||
mockLoadedAccountPage();
|
||||
|
||||
const { result } = renderHook(() => useAccountPageViewModel(), { wrapper });
|
||||
const { result } = renderHook(() => useAccountPageViewModel(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
@@ -233,7 +232,9 @@ describe("useAccountPageViewModel", () => {
|
||||
),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAccountPageViewModel(), { wrapper });
|
||||
const { result } = renderHook(() => useAccountPageViewModel(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current).toMatchObject({
|
||||
|
||||
@@ -12,13 +12,9 @@ import {
|
||||
} from "@/entities/brokerage-event";
|
||||
import { type IncomeSummary, useIncomeSummary } from "@/entities/income";
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
import {
|
||||
formatMoney,
|
||||
formatShare,
|
||||
instrumentLogoUrl,
|
||||
portfolioErrorCode,
|
||||
usePortfolio,
|
||||
} from "@/entities/portfolio";
|
||||
import { formatShare, usePortfolio } from "@/entities/portfolio";
|
||||
import { apiErrorCode } from "@/shared/api";
|
||||
import { formatMoney, instrumentLogoUrl } from "@/shared/lib";
|
||||
|
||||
type PositionSectionKind = "stocks" | "bonds" | "other";
|
||||
type AccountPageReturnTone = "positive" | "negative";
|
||||
@@ -32,7 +28,6 @@ type AccountPagePosition = Portfolio["positions"][number] & {
|
||||
|
||||
type AccountPagePositionSection = {
|
||||
kind: PositionSectionKind;
|
||||
wide: boolean;
|
||||
positions: AccountPagePosition[];
|
||||
};
|
||||
|
||||
@@ -108,7 +103,7 @@ function useAccountPageViewModel(): AccountPageViewModel {
|
||||
if (portfolio.isError)
|
||||
return {
|
||||
state: "error" as const,
|
||||
code: portfolioErrorCode(portfolio.error),
|
||||
code: apiErrorCode(portfolio.error),
|
||||
retry: () => portfolio.refetch(),
|
||||
};
|
||||
return { state: "loading" as const };
|
||||
@@ -188,9 +183,9 @@ function toAccountPagePositionSections(
|
||||
(denominators.get(currency) ?? 0) + Number(position.currentValue.value),
|
||||
);
|
||||
}
|
||||
const stocks = toSection("stocks", true, portfolio, denominators);
|
||||
const bonds = toSection("bonds", true, portfolio, denominators);
|
||||
const other = toSection("other", false, portfolio, denominators);
|
||||
const stocks = toSection("stocks", portfolio, denominators);
|
||||
const bonds = toSection("bonds", portfolio, denominators);
|
||||
const other = toSection("other", portfolio, denominators);
|
||||
return [stocks, bonds, other].filter(
|
||||
(section) => section.positions.length > 0,
|
||||
);
|
||||
@@ -198,28 +193,27 @@ function toAccountPagePositionSections(
|
||||
|
||||
function toSection(
|
||||
kind: PositionSectionKind,
|
||||
wide: boolean,
|
||||
portfolio: Portfolio,
|
||||
denominators: Map<string, number>,
|
||||
): AccountPagePositionSection {
|
||||
return {
|
||||
kind,
|
||||
wide,
|
||||
positions: portfolio.positions
|
||||
.filter((position) => {
|
||||
if (kind === "stocks") return position.type === "STOCK";
|
||||
if (kind === "bonds") return position.type === "BOND";
|
||||
return position.type !== "STOCK" && position.type !== "BOND";
|
||||
})
|
||||
.map((position) => toPosition(position, denominators, wide)),
|
||||
.map((position) => toPosition(position, denominators, kind)),
|
||||
};
|
||||
}
|
||||
|
||||
function toPosition(
|
||||
position: Portfolio["positions"][number],
|
||||
denominators: Map<string, number>,
|
||||
wide: boolean,
|
||||
kind: PositionSectionKind,
|
||||
): AccountPagePosition {
|
||||
const expanded = kind !== "other";
|
||||
const returnValue = position.absoluteReturn
|
||||
? Number(position.absoluteReturn.value)
|
||||
: null;
|
||||
@@ -238,10 +232,10 @@ function toPosition(
|
||||
})
|
||||
: null,
|
||||
returnText: position.absoluteReturn
|
||||
? `${wide && returnValue !== null && returnValue > 0 ? "+" : ""}${formatMoney(position.absoluteReturn)}`
|
||||
? `${expanded && returnValue !== null && returnValue > 0 ? "+" : ""}${formatMoney(position.absoluteReturn)}`
|
||||
: null,
|
||||
returnTone,
|
||||
logoSrc: wide ? instrumentLogoUrl(position.logoName) : null,
|
||||
logoSrc: expanded ? instrumentLogoUrl(position.logoName) : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Stack } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { BrokerageAccount } from "@/entities/brokerage-account";
|
||||
import { StateView } from "@/shared/ui";
|
||||
import type {
|
||||
AccountPageIncomeSummary,
|
||||
AccountPageRecentEvents,
|
||||
AccountPageRecentOperations,
|
||||
} from "../AccountPage.vm";
|
||||
import { AccountHeading } from "./AccountHeading.view";
|
||||
import { IncomeSummary } from "./IncomeSummary.view";
|
||||
import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
|
||||
import { RecentEvents } from "./RecentEvents.view";
|
||||
import { RecentOperations } from "./RecentOperations.view";
|
||||
|
||||
type AccountPageEmptyProps = {
|
||||
account?: BrokerageAccount;
|
||||
refreshing?: boolean;
|
||||
refreshError?: boolean;
|
||||
retry?: () => void;
|
||||
recentOperations: AccountPageRecentOperations;
|
||||
recentEvents: AccountPageRecentEvents;
|
||||
incomeSummary?: AccountPageIncomeSummary;
|
||||
};
|
||||
|
||||
const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack>
|
||||
<AccountHeading account={props.account} />
|
||||
<PortfolioRefreshState
|
||||
refreshing={props.refreshing ?? false}
|
||||
refreshError={props.refreshError ?? false}
|
||||
retry={props.retry}
|
||||
/>
|
||||
<StateView
|
||||
title={t("portfolio.emptyTitle")}
|
||||
text={t("portfolio.emptyText")}
|
||||
/>
|
||||
<RecentOperations operations={props.recentOperations} />
|
||||
<RecentEvents events={props.recentEvents} />
|
||||
{props.incomeSummary ? <IncomeSummary {...props.incomeSummary} /> : null}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export { AccountPageEmpty };
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Button } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { StateView } from "@/shared/ui";
|
||||
|
||||
type AccountPageErrorProps = { code?: string; retry: () => void };
|
||||
|
||||
const AccountPageError: FC<AccountPageErrorProps> = (props) => {
|
||||
const { code, retry } = props;
|
||||
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 { AccountPageError };
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Accordion, Alert, Stack, Text } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { BrokerageAccount } from "@/entities/brokerage-account";
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
import { formatSnapshotTime } from "@/shared/lib";
|
||||
import classes from "../AccountPage.module.css";
|
||||
import type {
|
||||
AccountPageIncomeSummary,
|
||||
AccountPagePositionSection,
|
||||
AccountPageRecentEvents,
|
||||
AccountPageRecentOperations,
|
||||
} from "../AccountPage.vm";
|
||||
import { AccountHeading } from "./AccountHeading.view";
|
||||
import { CashBalances } from "./CashBalances.view";
|
||||
import { IncomeSummary } from "./IncomeSummary.view";
|
||||
import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
|
||||
import { PortfolioTotals } from "./PortfolioTotals.view";
|
||||
import { PositionSection } from "./PositionSection.view";
|
||||
import { RecentEvents } from "./RecentEvents.view";
|
||||
import { RecentOperations } from "./RecentOperations.view";
|
||||
|
||||
type AccountPageLoadedProps = {
|
||||
account?: BrokerageAccount;
|
||||
portfolio: Portfolio;
|
||||
positionSections: AccountPagePositionSection[];
|
||||
refreshing?: boolean;
|
||||
refreshError?: boolean;
|
||||
retry?: () => void;
|
||||
recentOperations: AccountPageRecentOperations;
|
||||
recentEvents: AccountPageRecentEvents;
|
||||
incomeSummary?: AccountPageIncomeSummary;
|
||||
};
|
||||
|
||||
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack className={classes.page} gap="lg">
|
||||
<AccountHeading account={props.account} />
|
||||
<PortfolioRefreshState
|
||||
refreshing={props.refreshing ?? false}
|
||||
refreshError={props.refreshError ?? false}
|
||||
retry={props.retry}
|
||||
/>
|
||||
{props.portfolio.isStale ? (
|
||||
<Alert color="blue">{t("portfolio.staleWarning")}</Alert>
|
||||
) : null}
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("portfolio.updatedAt", {
|
||||
date: formatSnapshotTime(props.portfolio.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
<PortfolioTotals totals={props.portfolio.totalsByCurrency} />
|
||||
<Accordion
|
||||
multiple
|
||||
defaultValue={[
|
||||
...props.positionSections.map((section) => section.kind),
|
||||
"cash",
|
||||
"recentOperations",
|
||||
"recentEvents",
|
||||
"incomeSummary",
|
||||
]}
|
||||
classNames={{
|
||||
control: classes.accordionControl,
|
||||
label: classes.accordionLabel,
|
||||
chevron: classes.accordionChevron,
|
||||
panel: classes.accordionPanel,
|
||||
}}
|
||||
>
|
||||
{props.positionSections.map((section) => (
|
||||
<Accordion.Item key={section.kind} value={section.kind}>
|
||||
<Accordion.Control>
|
||||
{t(positionSectionTitleKey(section.kind))}
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<PositionSection section={section} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
))}
|
||||
<Accordion.Item value="cash">
|
||||
<Accordion.Control>{t("portfolio.cashBalances")}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<CashBalances balances={props.portfolio.cashBalances} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="recentOperations">
|
||||
<Accordion.Control>{t("ledger.recentTitle")}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<RecentOperations operations={props.recentOperations} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="recentEvents">
|
||||
<Accordion.Control>{t("events.recentTitle")}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<RecentEvents events={props.recentEvents} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="incomeSummary">
|
||||
<Accordion.Control>{t("income.summaryTitle")}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
{props.incomeSummary ? (
|
||||
<IncomeSummary {...props.incomeSummary} />
|
||||
) : null}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
function positionSectionTitleKey(kind: AccountPagePositionSection["kind"]) {
|
||||
if (kind === "stocks") return "portfolio.stocks";
|
||||
if (kind === "bonds") return "portfolio.bonds";
|
||||
return "portfolio.otherPositions";
|
||||
}
|
||||
|
||||
export { AccountPageLoaded };
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Skeleton } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingSkeleton } from "@/shared/ui";
|
||||
|
||||
const AccountPageLoading: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<LoadingSkeleton label={t("states.loadingTitle")}>
|
||||
<Skeleton height={32} width="35%" />
|
||||
<Skeleton height={120} radius="md" />
|
||||
{[0, 1, 2].map((index) => (
|
||||
<Skeleton key={index} height={52} radius="sm" />
|
||||
))}
|
||||
</LoadingSkeleton>
|
||||
);
|
||||
};
|
||||
|
||||
export { AccountPageLoading };
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Paper, Text } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
import { formatMoney } from "@/entities/portfolio";
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
import classes from "../AccountPage.module.css";
|
||||
|
||||
type CashBalancesProps = {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import type { IncomeSummary as IncomeSummaryData } from "@/entities/income";
|
||||
import { formatMoney } from "@/entities/portfolio";
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
import { StateView } from "@/shared/ui";
|
||||
|
||||
type IncomeSummaryProps =
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Paper, SimpleGrid, Text } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
import { formatMoney } from "@/entities/portfolio";
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
import classes from "../AccountPage.module.css";
|
||||
|
||||
type PortfolioTotalsProps = {
|
||||
|
||||
@@ -1,44 +1,28 @@
|
||||
import { Card, Group, Stack, Text } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatMoney } from "@/entities/portfolio";
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
import classes from "../AccountPage.module.css";
|
||||
import type { AccountPagePosition } from "../AccountPage.vm";
|
||||
import type {
|
||||
AccountPagePosition,
|
||||
AccountPagePositionSection,
|
||||
} from "../AccountPage.vm";
|
||||
import { PositionLogo } from "./PositionLogo.view";
|
||||
|
||||
type PositionCardProps = {
|
||||
kind: AccountPagePositionSection["kind"];
|
||||
position: AccountPagePosition;
|
||||
wide?: boolean;
|
||||
variant?: "stock" | "bond";
|
||||
};
|
||||
|
||||
const PositionCard: FC<PositionCardProps> = (props) => {
|
||||
const { position, wide = false, variant } = props;
|
||||
const { kind, position } = props;
|
||||
const { t } = useTranslation();
|
||||
const isExpanded = variant === "stock" || variant === "bond";
|
||||
const ec =
|
||||
variant === "bond"
|
||||
? {
|
||||
card: classes.bondCard,
|
||||
main: classes.bondMain,
|
||||
identity: classes.bondIdentity,
|
||||
identifiers: classes.bondIdentifiers,
|
||||
value: classes.bondValue,
|
||||
meta: classes.bondMeta,
|
||||
}
|
||||
: {
|
||||
card: classes.stockCard,
|
||||
main: classes.stockMain,
|
||||
identity: classes.stockIdentity,
|
||||
identifiers: classes.stockIdentifiers,
|
||||
value: classes.stockValue,
|
||||
meta: classes.stockMeta,
|
||||
};
|
||||
const isExpanded = kind !== "other";
|
||||
const returnClass = position.returnTone
|
||||
? classes[position.returnTone]
|
||||
: undefined;
|
||||
const returnText =
|
||||
position.returnText && wide
|
||||
position.returnText && isExpanded
|
||||
? position.returnText
|
||||
: position.returnText
|
||||
? t("portfolio.return", {
|
||||
@@ -46,7 +30,7 @@ const PositionCard: FC<PositionCardProps> = (props) => {
|
||||
})
|
||||
: null;
|
||||
const identifier =
|
||||
variant === "stock" ? (position.ticker ?? position.isin) : position.isin;
|
||||
kind === "stocks" ? (position.ticker ?? position.isin) : position.isin;
|
||||
const identifierText = [
|
||||
t("portfolio.quantity", { value: position.quantity }),
|
||||
identifier,
|
||||
@@ -68,22 +52,22 @@ const PositionCard: FC<PositionCardProps> = (props) => {
|
||||
|
||||
if (isExpanded)
|
||||
return (
|
||||
<Card className={`${classes.tile} ${ec.card}`} withBorder>
|
||||
<div className={ec.main}>
|
||||
<Card className={`${classes.tile} ${classes.expandedCard}`} withBorder>
|
||||
<div className={classes.expandedMain}>
|
||||
<PositionLogo
|
||||
key={position.logoSrc ?? "no-logo"}
|
||||
src={position.logoSrc}
|
||||
label={position.displayName}
|
||||
/>
|
||||
<Stack gap={2} miw={0} className={ec.identity}>
|
||||
<Stack gap={2} miw={0} className={classes.expandedIdentity}>
|
||||
<Text fw={600} lineClamp={1}>
|
||||
{position.displayName}
|
||||
</Text>
|
||||
<Text c="dimmed" size="xs" className={ec.identifiers}>
|
||||
<Text c="dimmed" size="xs" className={classes.expandedIdentifiers}>
|
||||
{identifierText}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={2} align="flex-end" className={ec.value}>
|
||||
<Stack gap={2} align="flex-end" className={classes.expandedValue}>
|
||||
{position.currentValue ? (
|
||||
<Text fw={700} size="lg">
|
||||
{formatMoney(position.currentValue)}
|
||||
@@ -97,7 +81,7 @@ const PositionCard: FC<PositionCardProps> = (props) => {
|
||||
</Stack>
|
||||
</div>
|
||||
{secondaryText ? (
|
||||
<Text c="dimmed" size="xs" className={ec.meta}>
|
||||
<Text c="dimmed" size="xs" className={classes.expandedMeta}>
|
||||
{secondaryText}
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -106,13 +90,6 @@ const PositionCard: FC<PositionCardProps> = (props) => {
|
||||
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}
|
||||
|
||||
@@ -9,21 +9,15 @@ type PositionSectionProps = {
|
||||
|
||||
const PositionSection: FC<PositionSectionProps> = (props) => {
|
||||
const { section } = props;
|
||||
const expanded = section.kind !== "other";
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={section.wide ? 1 : { base: 1, sm: 2, md: 3 }}>
|
||||
<SimpleGrid cols={expanded ? 1 : { base: 1, sm: 2, md: 3 }}>
|
||||
{section.positions.map((position) => (
|
||||
<PositionCard
|
||||
kind={section.kind}
|
||||
key={`${position.figi}-${position.displayName}-${position.quantity}`}
|
||||
position={position}
|
||||
wide={section.wide}
|
||||
variant={
|
||||
section.kind === "stocks"
|
||||
? "stock"
|
||||
: section.kind === "bonds"
|
||||
? "bond"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import type { BrokerageEvent } from "@/entities/brokerage-event";
|
||||
import { formatMoney } from "@/entities/portfolio";
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
import { LoadingSkeleton } from "@/shared/ui";
|
||||
import classes from "../AccountPage.module.css";
|
||||
import type { AccountPageRecentEvents } from "../AccountPage.vm";
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
export { AccountHeading } from "./AccountHeading.view";
|
||||
export { CashBalances } from "./CashBalances.view";
|
||||
export { IncomeSummary } from "./IncomeSummary.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";
|
||||
@@ -1,6 +1 @@
|
||||
export {
|
||||
AccountsPage,
|
||||
AccountsPageEmpty,
|
||||
AccountsPageError,
|
||||
AccountsPageLoading,
|
||||
} from "./ui/AccountsPage";
|
||||
export { AccountsPage } from "./ui/AccountsPage";
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { brokerageAccountsQueryKey } from "@/entities/brokerage-account";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { server } from "@/test/server";
|
||||
import { AccountsPage } from "./AccountsPage";
|
||||
import { AccountsPageEmpty } from "./components/AccountsPageEmpty.view";
|
||||
import { AccountsPageError } from "./components/AccountsPageError.view";
|
||||
import { AccountsPageLoading } from "./components/AccountsPageLoading.view";
|
||||
|
||||
const account = {
|
||||
id: "account-1",
|
||||
@@ -38,9 +42,36 @@ function renderPage(queryClient: QueryClient) {
|
||||
}
|
||||
|
||||
describe("AccountsPage", () => {
|
||||
afterEach(() => {
|
||||
configureAuthClient(null);
|
||||
cleanup();
|
||||
it.each([
|
||||
["loading", AccountsPageLoading, "status"],
|
||||
["empty", AccountsPageEmpty, undefined],
|
||||
] as const)("renders only the %s state", (_name, State, role) => {
|
||||
render(
|
||||
<MantineProvider>
|
||||
<State />
|
||||
</MantineProvider>,
|
||||
);
|
||||
if (role) expect(screen.getByRole(role)).toBeInTheDocument();
|
||||
else
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Нет инвестиционных счетов" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("routes connection errors to settings and keeps retry available", () => {
|
||||
const retry = vi.fn();
|
||||
render(
|
||||
<MantineProvider>
|
||||
<MemoryRouter>
|
||||
<AccountsPageError code="TBANK_UNAVAILABLE" retry={retry} />
|
||||
</MemoryRouter>
|
||||
</MantineProvider>,
|
||||
);
|
||||
screen.getByRole("button", { name: "Повторить" }).click();
|
||||
expect(retry).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Открыть настройки T-Bank" }),
|
||||
).toHaveAttribute("href", "/settings");
|
||||
});
|
||||
|
||||
it("keeps the cached account list visible during a background refresh", async () => {
|
||||
@@ -55,7 +86,7 @@ describe("AccountsPage", () => {
|
||||
});
|
||||
}),
|
||||
);
|
||||
const queryClient = new QueryClient();
|
||||
const queryClient = createTestQueryClient();
|
||||
renderPage(queryClient);
|
||||
|
||||
await screen.findByText(account.name);
|
||||
@@ -86,7 +117,7 @@ describe("AccountsPage", () => {
|
||||
return HttpResponse.json([account]);
|
||||
}),
|
||||
);
|
||||
const queryClient = new QueryClient();
|
||||
const queryClient = createTestQueryClient();
|
||||
renderPage(queryClient);
|
||||
|
||||
await screen.findByText(account.name);
|
||||
@@ -116,7 +147,7 @@ describe("AccountsPage", () => {
|
||||
);
|
||||
}),
|
||||
);
|
||||
const queryClient = new QueryClient();
|
||||
const queryClient = createTestQueryClient();
|
||||
renderPage(queryClient);
|
||||
|
||||
await screen.findByText("Нет инвестиционных счетов");
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { FC } from "react";
|
||||
import {
|
||||
AccountsPageEmpty,
|
||||
AccountsPageError,
|
||||
AccountsPageLoaded,
|
||||
AccountsPageLoading,
|
||||
} from "./AccountsPage.view";
|
||||
import { useAccountsPageViewModel } from "./AccountsPage.vm";
|
||||
import { AccountsPageEmpty } from "./components/AccountsPageEmpty.view";
|
||||
import { AccountsPageError } from "./components/AccountsPageError.view";
|
||||
import { AccountsPageLoaded } from "./components/AccountsPageLoaded.view";
|
||||
import { AccountsPageLoading } from "./components/AccountsPageLoading.view";
|
||||
|
||||
const AccountsPage: FC = () => {
|
||||
const viewModel = useAccountsPageViewModel();
|
||||
@@ -29,9 +27,4 @@ const AccountsPage: FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
AccountsPage,
|
||||
AccountsPageEmpty,
|
||||
AccountsPageError,
|
||||
AccountsPageLoading,
|
||||
};
|
||||
export { AccountsPage };
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import type { BrokerageAccountCard } from "@/entities/brokerage-account";
|
||||
import { BrokerageAccountList } from "@/entities/brokerage-account";
|
||||
import { LoadingSkeleton, StateView } from "@/shared/ui";
|
||||
|
||||
type AccountsPageErrorProps = {
|
||||
code?: string;
|
||||
retry?: () => void;
|
||||
};
|
||||
|
||||
type AccountsPageEmptyProps = {
|
||||
refreshing?: boolean;
|
||||
refreshError?: boolean;
|
||||
retry?: () => void;
|
||||
};
|
||||
|
||||
type AccountsPageLoadedProps = {
|
||||
accounts: BrokerageAccountCard[];
|
||||
refreshing?: boolean;
|
||||
refreshError?: boolean;
|
||||
retry?: () => void;
|
||||
};
|
||||
|
||||
type AccountsRefreshStateProps = {
|
||||
refreshing: boolean;
|
||||
refreshError: boolean;
|
||||
retry?: () => void;
|
||||
};
|
||||
|
||||
const AccountsPageLoading: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<LoadingSkeleton label={t("states.loadingTitle")}>
|
||||
<Skeleton height={32} width="30%" />
|
||||
{[0, 1, 2].map((index) => (
|
||||
<Skeleton key={index} height={116} radius="md" />
|
||||
))}
|
||||
</LoadingSkeleton>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
|
||||
const { code, retry } = props;
|
||||
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>}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountsPageEmpty: FC<AccountsPageEmptyProps> = (props) => {
|
||||
const { refreshing = false, refreshError = false, retry } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<AccountsRefreshState
|
||||
refreshing={refreshing}
|
||||
refreshError={refreshError}
|
||||
retry={retry}
|
||||
/>
|
||||
<StateView
|
||||
title={t("accounts.emptyTitle")}
|
||||
text={t("accounts.emptyText")}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
|
||||
const { accounts, refreshing = false, refreshError = false, retry } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Title order={1}>{t("accounts.title")}</Title>
|
||||
<AccountsRefreshState
|
||||
refreshing={refreshing}
|
||||
refreshError={refreshError}
|
||||
retry={retry}
|
||||
/>
|
||||
<BrokerageAccountList accounts={accounts} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountsRefreshState: FC<AccountsRefreshStateProps> = (props) => {
|
||||
const { refreshing, refreshError, retry } = props;
|
||||
const { t } = useTranslation();
|
||||
if (!refreshing && !refreshError) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{refreshing ? (
|
||||
<Group gap="xs" role="status" aria-label={t("accounts.refreshing")}>
|
||||
<Loader size="sm" />
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("accounts.refreshing")}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
{refreshError ? (
|
||||
<Alert color="orange">
|
||||
<Stack gap="sm">
|
||||
<Text>{t("accounts.refreshError")}</Text>
|
||||
<Button onClick={retry} variant="light">
|
||||
{t("accounts.retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
AccountsPageEmpty,
|
||||
AccountsPageError,
|
||||
AccountsPageLoaded,
|
||||
AccountsPageLoading,
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
brokerageAccountCards,
|
||||
errorCode,
|
||||
useBrokerageAccounts,
|
||||
} from "@/entities/brokerage-account";
|
||||
import { apiErrorCode } from "@/shared/api";
|
||||
|
||||
function useAccountsPageViewModel() {
|
||||
const accounts = useBrokerageAccounts();
|
||||
@@ -11,7 +11,7 @@ function useAccountsPageViewModel() {
|
||||
if (accounts.isError)
|
||||
return {
|
||||
state: "error" as const,
|
||||
code: errorCode(accounts.error),
|
||||
code: apiErrorCode(accounts.error),
|
||||
retry: () => accounts.refetch(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Stack } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StateView } from "@/shared/ui";
|
||||
import {
|
||||
AccountsRefreshState,
|
||||
type AccountsRefreshStateProps,
|
||||
} from "./AccountsRefreshState.view";
|
||||
|
||||
const AccountsPageEmpty: FC<AccountsRefreshStateProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack>
|
||||
<AccountsRefreshState {...props} />
|
||||
<StateView
|
||||
title={t("accounts.emptyTitle")}
|
||||
text={t("accounts.emptyText")}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export { AccountsPageEmpty };
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Button, Stack } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { StateView } from "@/shared/ui";
|
||||
|
||||
type AccountsPageErrorProps = { code?: string; retry?: () => void };
|
||||
|
||||
const AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
|
||||
const { code, retry } = props;
|
||||
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 { AccountsPageError };
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Stack, Title } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
type BrokerageAccountCard,
|
||||
BrokerageAccountList,
|
||||
} from "@/entities/brokerage-account";
|
||||
import {
|
||||
AccountsRefreshState,
|
||||
type AccountsRefreshStateProps,
|
||||
} from "./AccountsRefreshState.view";
|
||||
|
||||
type AccountsPageLoadedProps = AccountsRefreshStateProps & {
|
||||
accounts: BrokerageAccountCard[];
|
||||
};
|
||||
|
||||
const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
|
||||
const { accounts, ...refresh } = props;
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack>
|
||||
<Title order={1}>{t("accounts.title")}</Title>
|
||||
<AccountsRefreshState {...refresh} />
|
||||
<BrokerageAccountList accounts={accounts} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export { AccountsPageLoaded };
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Skeleton } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingSkeleton } from "@/shared/ui";
|
||||
|
||||
const AccountsPageLoading: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<LoadingSkeleton label={t("states.loadingTitle")}>
|
||||
<Skeleton height={32} width="30%" />
|
||||
{[0, 1, 2].map((index) => (
|
||||
<Skeleton key={index} height={116} radius="md" />
|
||||
))}
|
||||
</LoadingSkeleton>
|
||||
);
|
||||
};
|
||||
|
||||
export { AccountsPageLoading };
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type AccountsRefreshStateProps = {
|
||||
refreshing?: boolean;
|
||||
refreshError?: boolean;
|
||||
retry?: () => void;
|
||||
};
|
||||
|
||||
const AccountsRefreshState: FC<AccountsRefreshStateProps> = (props) => {
|
||||
const { refreshing = false, refreshError = false, retry } = props;
|
||||
const { t } = useTranslation();
|
||||
if (!refreshing && !refreshError) return null;
|
||||
return (
|
||||
<>
|
||||
{refreshing ? (
|
||||
<Group gap="xs" role="status" aria-label={t("accounts.refreshing")}>
|
||||
<Loader size="sm" />
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("accounts.refreshing")}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
{refreshError ? (
|
||||
<Alert color="orange">
|
||||
<Stack gap="sm">
|
||||
<Text>{t("accounts.refreshError")}</Text>
|
||||
<Button onClick={retry} variant="light">
|
||||
{t("accounts.retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { AccountsRefreshState, type AccountsRefreshStateProps };
|
||||
@@ -1,20 +1,14 @@
|
||||
import { Paper, Stack, Title } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoginForm } from "@/features/auth";
|
||||
import { AuthPageLayout, LoginForm } from "@/features/auth";
|
||||
|
||||
const LoginPage: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<main className="auth-page">
|
||||
<Paper className="auth-card" p="xl" radius="lg" withBorder>
|
||||
<Stack gap="lg">
|
||||
<Title order={1}>{t("auth.login")}</Title>
|
||||
<LoginForm />
|
||||
</Stack>
|
||||
</Paper>
|
||||
</main>
|
||||
<AuthPageLayout title={t("auth.login")}>
|
||||
<LoginForm />
|
||||
</AuthPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Stack, Title } from "@mantine/core";
|
||||
import { Button, Stack, Title } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { Button, StateView } from "@/shared/ui";
|
||||
import { StateView } from "@/shared/ui";
|
||||
|
||||
const NotFoundPage: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user