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
|
```bash
|
||||||
|
nvm use --silent
|
||||||
npm run verify
|
npm run verify
|
||||||
```
|
```
|
||||||
|
|
||||||
Команда сначала отклоняет runtime вне Node 22, затем проверяет repository
|
Команда проверяет, что generated OpenAPI types соответствуют committed snapshot,
|
||||||
hygiene, strict TypeScript, Biome, один coverage-backed запуск Vitest, Knip,
|
затем запускает strict TypeScript, Biome, один coverage-backed запуск Vitest,
|
||||||
dependency rules и production build. Если backend-контракт изменился,
|
Knip, dependency rules и production build. Если backend-контракт изменился,
|
||||||
обновите `src/shared/api/spec/openapi.json` копированием backend OpenAPI
|
обновите `src/shared/api/spec/openapi.json` копированием backend OpenAPI
|
||||||
document и затем выполните `npm run openapi:generate`.
|
document и затем выполните `npm run openapi:generate`.
|
||||||
|
|
||||||
|
|||||||
@@ -45,5 +45,6 @@ module.exports = {
|
|||||||
options: {
|
options: {
|
||||||
doNotFollow: { path: "node_modules" },
|
doNotFollow: { path: "node_modules" },
|
||||||
tsConfig: { fileName: "tsconfig.app.json" },
|
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 файл не редактируется вручную.
|
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.
|
Все 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`.
|
- Публичный импорт слайса идёт через его `index.ts`.
|
||||||
- Тесты лежат рядом с исходником как `*.test.tsx`.
|
- Тесты лежат рядом с исходником как `*.test.tsx`.
|
||||||
- Зависимости используют exact versions; не добавлять semver ranges.
|
- Зависимости используют exact versions; не добавлять semver ranges.
|
||||||
- Главный `*.view.tsx` страницы не содержит несколько state-компонентов:
|
- Полный локальный gate: `npm run verify`. Pre-commit запускает staged formatting
|
||||||
каждый state/section — отдельный `*.view.tsx` в `components/` (см. File roles).
|
и FSD dependency checks; commit message должен следовать Conventional Commits.
|
||||||
- Полный локальный gate: `npm run verify`. Pre-commit запускает staged formatting, OpenAPI check и FSD dependency checks; commit message должен следовать Conventional Commits.
|
|
||||||
|
|
||||||
## React component style
|
## React component style
|
||||||
|
|
||||||
@@ -39,11 +38,9 @@ Mandatory review conventions:
|
|||||||
- `Component.types.ts` - props и локальные типы.
|
- `Component.types.ts` - props и локальные типы.
|
||||||
- `Component.module.css` - локальные стили.
|
- `Component.module.css` - локальные стили.
|
||||||
|
|
||||||
State-компоненты страницы не колоцируются в одном `*.view.tsx`. Каждый
|
Самостоятельный top-level state или именуемый section обычно выносится в
|
||||||
top-level state (loading, error, empty, loaded) и каждый именуемый section
|
соседний `*.view.tsx`, когда у него есть отдельная responsibility, reuse либо
|
||||||
выносится в отдельный `*.view.tsx` в подпапку `components/`. Имя файла
|
change cadence или он скрывает principal component. Небольшой tightly-coupled
|
||||||
отражает state/section: `AccountOperationsPageLoaded.view.tsx`,
|
renderer остаётся рядом с parent. Предпочтительный flat pattern —
|
||||||
`AccountOperationsPageLoading.view.tsx`, `AccountOperationsPageError.view.tsx`.
|
`components/AccountOperationsPageLoaded.view.tsx` без отдельной директории,
|
||||||
Контейнер (`AccountOperationsPage.tsx`) импортирует их из `./components` и
|
barrel, test и stylesheet для каждого компонента.
|
||||||
выбирает нужный по `viewModel.state`. Нельзя держать несколько state components
|
|
||||||
внутри одного файла view — это mandatory review convention.
|
|
||||||
|
|||||||
+5
-6
@@ -27,12 +27,11 @@ image state. Простое форматирование одного перед
|
|||||||
существующий formatter остаётся presentation concern; вычисления из всей
|
существующий formatter остаётся presentation concern; вычисления из всей
|
||||||
коллекции остаются во VM.
|
коллекции остаются во VM.
|
||||||
|
|
||||||
View files дробятся по смысловой нагрузке, а не только по количеству строк.
|
View files дробятся по ownership и смысловой нагрузке, а не по количеству строк.
|
||||||
Каждый top-level state и каждый именуемый section страницы — отдельный
|
Самостоятельные top-level states и sections выносятся в flat `components/*.view.tsx`,
|
||||||
`*.view.tsx` в `components/`, а не накопление внутри одного большого
|
если это делает principal component и границы изменений яснее. Маленькие
|
||||||
`*.view.tsx`. Контейнер импортирует state views из `./components` и выбирает
|
tightly-coupled renderers остаются рядом с parent; механическое правило «один
|
||||||
нужный по `viewModel.state`. Держать loading/error/empty/loaded components в
|
component — один file» и line-count gate не используются.
|
||||||
одном файле view нельзя.
|
|
||||||
|
|
||||||
Known API errors, retry и stale snapshot остаются local views, а непойманные
|
Known API errors, retry и stale snapshot остаются local views, а непойманные
|
||||||
route errors обрабатывает общий route-keyed ErrorBoundary.
|
route errors обрабатывает общий route-keyed ErrorBoundary.
|
||||||
|
|||||||
+2
-1
@@ -15,9 +15,10 @@
|
|||||||
"test:run": "vitest run",
|
"test:run": "vitest run",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"openapi:generate": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts",
|
"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",
|
"knip": "knip",
|
||||||
"deps": "depcruise src --config dependency-cruiser.cjs --output-type err-long",
|
"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",
|
"prepare": "husky",
|
||||||
"preview": "vite preview"
|
"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 { MantineProvider } from "@mantine/core";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import { MemoryRouter } from "react-router";
|
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 { AuthProvider } from "@/features/auth";
|
||||||
import {
|
import { createTestQueryClient } from "@/test/query";
|
||||||
AccountsPageEmpty,
|
|
||||||
AccountsPageError,
|
|
||||||
AccountsPageLoading,
|
|
||||||
} from "@/pages/accounts";
|
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import { theme } from "../theme";
|
import { theme } from "../theme";
|
||||||
import { AppRoutes } from "./AppRoutes";
|
import { AppRoutes } from "./AppRoutes";
|
||||||
|
|
||||||
describe("AppRoutes", () => {
|
describe("AppRoutes", () => {
|
||||||
afterEach(cleanup);
|
|
||||||
function renderRoutes(path: string) {
|
function renderRoutes(path: string) {
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={new QueryClient()}>
|
<QueryClientProvider client={createTestQueryClient()}>
|
||||||
<MemoryRouter initialEntries={[path]}>
|
<MemoryRouter initialEntries={[path]}>
|
||||||
<MantineProvider theme={theme}>
|
<MantineProvider theme={theme}>
|
||||||
<AuthProvider>
|
<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 () => {
|
it("redirects anonymous users from accounts", async () => {
|
||||||
renderRoutes("/accounts");
|
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 () => {
|
it("lazy-loads the protected shell for an authenticated accounts route", async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.post("*/api/v1/auth/refresh", () =>
|
http.post("*/api/v1/auth/refresh", () =>
|
||||||
@@ -114,37 +112,4 @@ describe("AppRoutes", () => {
|
|||||||
screen.getByRole("navigation", { name: "WealthLedger" }),
|
screen.getByRole("navigation", { name: "WealthLedger" }),
|
||||||
).toBeInTheDocument();
|
).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 } from "react";
|
||||||
import type { FC, PropsWithChildren } from "react";
|
import { lazy } from "react";
|
||||||
import { lazy, Suspense } from "react";
|
import { Navigate, Route, Routes } from "react-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { LazyPage } from "./RouteFallback";
|
||||||
import { Navigate, Route, Routes, useLocation } from "react-router";
|
import { Protected, Public } from "./RouteGuards";
|
||||||
import { useAuth } from "@/features/auth";
|
|
||||||
|
|
||||||
const AccountPage = lazy(async () => {
|
const AccountPage = lazy(async () => {
|
||||||
const { AccountPage } = await import("@/pages/account");
|
const { AccountPage } = await import("@/pages/account");
|
||||||
@@ -46,187 +45,96 @@ const ProtectedLayout = lazy(async () => {
|
|||||||
return { default: ProtectedLayout };
|
return { default: ProtectedLayout };
|
||||||
});
|
});
|
||||||
|
|
||||||
type RouteSkeletonVariant = "auth" | "content" | "shell";
|
const AppRoutes: FC = () => (
|
||||||
|
<Routes>
|
||||||
type LazyPageProps = PropsWithChildren<{
|
<Route path="/" element={<Navigate to="/accounts" replace />} />
|
||||||
variant: RouteSkeletonVariant;
|
<Route
|
||||||
}>;
|
path="/login"
|
||||||
|
element={
|
||||||
const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
|
<Public>
|
||||||
const { variant } = props;
|
<LazyPage variant="auth">
|
||||||
const { t } = useTranslation();
|
<LoginPage />
|
||||||
|
</LazyPage>
|
||||||
if (variant === "auth")
|
</Public>
|
||||||
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
|
|
||||||
/>
|
/>
|
||||||
);
|
<Route
|
||||||
};
|
path="/register"
|
||||||
|
element={
|
||||||
const Public: FC<PropsWithChildren> = (props) => {
|
<Public>
|
||||||
const { children } = props;
|
<LazyPage variant="auth">
|
||||||
const { user, ready } = useAuth();
|
<RegisterPage />
|
||||||
if (!ready) return <RouteSkeleton variant="shell" />;
|
</LazyPage>
|
||||||
return user ? <Navigate to="/accounts" replace /> : children;
|
</Public>
|
||||||
};
|
}
|
||||||
|
/>
|
||||||
const AppRoutes: FC = () => {
|
<Route
|
||||||
return (
|
element={
|
||||||
<Routes>
|
<Protected>
|
||||||
<Route path="/" element={<Navigate to="/accounts" replace />} />
|
<LazyPage variant="shell">
|
||||||
|
<ProtectedLayout />
|
||||||
|
</LazyPage>
|
||||||
|
</Protected>
|
||||||
|
}
|
||||||
|
>
|
||||||
<Route
|
<Route
|
||||||
path="/login"
|
path="/accounts"
|
||||||
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="*"
|
|
||||||
element={
|
element={
|
||||||
<LazyPage variant="content">
|
<LazyPage variant="content">
|
||||||
<NotFoundPage />
|
<AccountsPage />
|
||||||
</LazyPage>
|
</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 };
|
export { AppRoutes };
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
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 type { FC } from "react";
|
||||||
import { MemoryRouter, useLocation, useNavigate } from "react-router";
|
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";
|
import { RouteErrorBoundary } from "./RouteErrorBoundary";
|
||||||
|
|
||||||
let authenticated = false;
|
let authenticated = false;
|
||||||
@@ -52,7 +52,6 @@ function renderBoundary(path: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("RouteErrorBoundary", () => {
|
describe("RouteErrorBoundary", () => {
|
||||||
afterEach(cleanup);
|
|
||||||
it("offers login after a public route error without error details", () => {
|
it("offers login after a public route error without error details", () => {
|
||||||
authenticated = false;
|
authenticated = false;
|
||||||
renderBoundary("/login");
|
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;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: var(--ft-color-bg-page);
|
background: var(--mantine-color-gray-2);
|
||||||
color: var(--ft-color-text-primary);
|
color: var(--mantine-color-dark-7);
|
||||||
}
|
}
|
||||||
a {
|
a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
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";
|
import { AuthProvider } from "@/features/auth";
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: { queries: { refetchOnWindowFocus: false } },
|
defaultOptions: {
|
||||||
|
queries: { refetchOnWindowFocus: false, retry: false },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const App: FC = () => {
|
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 { 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 { MemoryRouter } from "react-router";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { AppBreadcrumbs } from "./AppBreadcrumbs";
|
import { AppBreadcrumbs } from "./AppBreadcrumbs";
|
||||||
@@ -26,7 +26,6 @@ function renderBreadcrumbs(
|
|||||||
|
|
||||||
describe("AppBreadcrumbs", () => {
|
describe("AppBreadcrumbs", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+16
-113
@@ -1,141 +1,44 @@
|
|||||||
import { Breadcrumbs } from "@mantine/core";
|
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link, useMatch } from "react-router";
|
import { useMatch } from "react-router";
|
||||||
import { useBrokerageAccounts } from "@/entities/brokerage-account";
|
import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
|
||||||
|
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
||||||
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 },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const AppBreadcrumbs: FC = () => {
|
const AppBreadcrumbs: FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const accountOperations = useMatch({
|
const operations = useMatch({
|
||||||
path: "/accounts/:accountId/operations",
|
path: "/accounts/:accountId/operations",
|
||||||
end: true,
|
end: true,
|
||||||
});
|
});
|
||||||
const accountEvents = useMatch({
|
const events = useMatch({ path: "/accounts/:accountId/events", end: true });
|
||||||
path: "/accounts/:accountId/events",
|
const income = useMatch({ path: "/accounts/:accountId/income", end: true });
|
||||||
end: true,
|
|
||||||
});
|
|
||||||
const accountIncome = useMatch({
|
|
||||||
path: "/accounts/:accountId/income",
|
|
||||||
end: true,
|
|
||||||
});
|
|
||||||
const account = useMatch({ path: "/accounts/:accountId", end: true });
|
const account = useMatch({ path: "/accounts/:accountId", end: true });
|
||||||
const accounts = useMatch({ path: "/accounts", end: true });
|
const accounts = useMatch({ path: "/accounts", end: true });
|
||||||
const settings = useMatch({ path: "/settings", end: true });
|
const settings = useMatch({ path: "/settings", end: true });
|
||||||
|
|
||||||
if (accountOperations)
|
if (operations)
|
||||||
return (
|
return (
|
||||||
<AccountBreadcrumbs
|
<AccountBreadcrumbs
|
||||||
accountId={accountOperations.params.accountId ?? ""}
|
accountId={operations.params.accountId ?? ""}
|
||||||
events={false}
|
section="operations"
|
||||||
income={false}
|
|
||||||
operations
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
if (accountEvents)
|
if (events)
|
||||||
return (
|
return (
|
||||||
<AccountBreadcrumbs
|
<AccountBreadcrumbs
|
||||||
accountId={accountEvents.params.accountId ?? ""}
|
accountId={events.params.accountId ?? ""}
|
||||||
events
|
section="events"
|
||||||
income={false}
|
|
||||||
operations={false}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
if (accountIncome)
|
if (income)
|
||||||
return (
|
return (
|
||||||
<AccountBreadcrumbs
|
<AccountBreadcrumbs
|
||||||
accountId={accountIncome.params.accountId ?? ""}
|
accountId={income.params.accountId ?? ""}
|
||||||
events={false}
|
section="income"
|
||||||
income
|
|
||||||
operations={false}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
if (account)
|
if (account)
|
||||||
return (
|
return <AccountBreadcrumbs accountId={account.params.accountId ?? ""} />;
|
||||||
<AccountBreadcrumbs
|
|
||||||
accountId={account.params.accountId ?? ""}
|
|
||||||
events={false}
|
|
||||||
income={false}
|
|
||||||
operations={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
if (accounts)
|
if (accounts)
|
||||||
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />;
|
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />;
|
||||||
if (settings)
|
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 { renderHook, waitFor } from "@testing-library/react";
|
||||||
import type { FC, PropsWithChildren } from "react";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { configureAuthClient } from "@/shared/api";
|
import { configureAuthClient } from "@/shared/api";
|
||||||
|
import { createQueryWrapper } from "@/test/query";
|
||||||
import {
|
import {
|
||||||
brokerageOperationsQueryParams,
|
brokerageOperationsQueryParams,
|
||||||
useBrokerageOperations,
|
useBrokerageOperations,
|
||||||
} from "./useBrokerageOperations";
|
} from "./useBrokerageOperations";
|
||||||
|
|
||||||
describe("useBrokerageOperations", () => {
|
describe("useBrokerageOperations", () => {
|
||||||
afterEach(() => configureAuthClient(null));
|
|
||||||
|
|
||||||
it("normalizes query parameters and loads a page without retries", async () => {
|
it("normalizes query parameters and loads a page without retries", async () => {
|
||||||
configureAuthClient({
|
configureAuthClient({
|
||||||
accessToken: () => "token",
|
accessToken: () => "token",
|
||||||
@@ -24,20 +20,9 @@ describe("useBrokerageOperations", () => {
|
|||||||
cursor: "next",
|
cursor: "next",
|
||||||
}),
|
}),
|
||||||
).toEqual({ categories: ["income", "tax"], 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(
|
const { result } = renderHook(
|
||||||
() => useBrokerageOperations("account-1", { limit: 5 }),
|
() => useBrokerageOperations("account-1", { limit: 5 }),
|
||||||
{ wrapper },
|
{ wrapper: createQueryWrapper().wrapper },
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
|
import {
|
||||||
import type { paths } from "@/shared/api/generated/schema";
|
apiQueryClient,
|
||||||
|
type paths,
|
||||||
|
useAuthenticatedQuery,
|
||||||
|
} from "@/shared/api";
|
||||||
|
|
||||||
type OperationsEndpoint =
|
type OperationsEndpoint =
|
||||||
paths["/api/v1/brokerage-accounts/{accountId}/operations"]["get"];
|
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(
|
function useBrokerageOperations(
|
||||||
accountId: string | undefined,
|
accountId: string | undefined,
|
||||||
query: BrokerageOperationsQuery = {},
|
query: BrokerageOperationsQuery = {},
|
||||||
) {
|
) {
|
||||||
const params = brokerageOperationsQueryParams(query);
|
|
||||||
return useAuthenticatedQuery({
|
return useAuthenticatedQuery({
|
||||||
...apiQueryClient.queryOptions(
|
...brokerageOperationsQueryOptions(accountId, query),
|
||||||
"get",
|
|
||||||
"/api/v1/brokerage-accounts/{accountId}/operations",
|
|
||||||
{ params: { path: { accountId: accountId ?? "" }, query: params } },
|
|
||||||
),
|
|
||||||
enabled: Boolean(accountId),
|
enabled: Boolean(accountId),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
retry: false,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
type BrokerageOperation,
|
type BrokerageOperation,
|
||||||
|
brokerageOperationsQueryOptions,
|
||||||
brokerageOperationsQueryParams,
|
brokerageOperationsQueryParams,
|
||||||
type OperationCategory,
|
type OperationCategory,
|
||||||
operationCategories,
|
operationCategories,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export {
|
export {
|
||||||
type BrokerageOperation,
|
type BrokerageOperation,
|
||||||
|
brokerageOperationsQueryOptions,
|
||||||
type OperationCategory,
|
type OperationCategory,
|
||||||
operationCategories,
|
operationCategories,
|
||||||
useBrokerageOperations,
|
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 type { FC } from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import { formatSnapshotTime, instrumentLogoUrl } from "@/shared/lib";
|
||||||
formatMoney,
|
|
||||||
formatSnapshotTime,
|
|
||||||
instrumentLogoUrl,
|
|
||||||
} from "@/entities/portfolio";
|
|
||||||
import type { BrokerageOperation } from "../api/useBrokerageOperations";
|
import type { BrokerageOperation } from "../api/useBrokerageOperations";
|
||||||
|
import { paymentPresentation } from "../model/display";
|
||||||
import classes from "./LedgerEntryRow.module.css";
|
import classes from "./LedgerEntryRow.module.css";
|
||||||
|
|
||||||
type LedgerEntryRowProps = {
|
type LedgerEntryRowProps = {
|
||||||
operation: BrokerageOperation;
|
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 LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
|
||||||
const { operation } = props;
|
const { operation } = props;
|
||||||
const { t } = useTranslation();
|
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 };
|
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 { renderHook, waitFor } from "@testing-library/react";
|
||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import type { FC, PropsWithChildren } from "react";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { configureAuthClient } from "@/shared/api";
|
import { configureAuthClient } from "@/shared/api";
|
||||||
|
import { createQueryWrapper } from "@/test/query";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import { useBrokerageAccounts } from "./useBrokerageAccounts";
|
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", () => {
|
describe("useBrokerageAccounts", () => {
|
||||||
afterEach(() => configureAuthClient(null));
|
|
||||||
|
|
||||||
it("loads accounts through the authenticated query seam", async () => {
|
it("loads accounts through the authenticated query seam", async () => {
|
||||||
configureAuthClient({
|
configureAuthClient({
|
||||||
accessToken: () => "token",
|
accessToken: () => "token",
|
||||||
@@ -46,7 +27,7 @@ describe("useBrokerageAccounts", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(() => useBrokerageAccounts(), {
|
const { result } = renderHook(() => useBrokerageAccounts(), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createQueryWrapper().wrapper,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
@@ -83,7 +64,7 @@ describe("useBrokerageAccounts", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const { result } = renderHook(() => useBrokerageAccounts(), {
|
const { result } = renderHook(() => useBrokerageAccounts(), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createQueryWrapper().wrapper,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
|
import {
|
||||||
import type { paths } from "@/shared/api/generated/schema";
|
apiQueryClient,
|
||||||
|
type paths,
|
||||||
|
useAuthenticatedQuery,
|
||||||
|
} from "@/shared/api";
|
||||||
|
|
||||||
const brokerageAccountsQueryOptions = () =>
|
const brokerageAccountsQueryOptions = () =>
|
||||||
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts");
|
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts");
|
||||||
@@ -13,7 +16,6 @@ function useBrokerageAccounts() {
|
|||||||
return useAuthenticatedQuery({
|
return useAuthenticatedQuery({
|
||||||
...brokerageAccountsQueryOptions(),
|
...brokerageAccountsQueryOptions(),
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
retry: false,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,5 @@ export {
|
|||||||
accountTypeKey,
|
accountTypeKey,
|
||||||
type BrokerageAccountCard,
|
type BrokerageAccountCard,
|
||||||
brokerageAccountCards,
|
brokerageAccountCards,
|
||||||
errorCode,
|
|
||||||
} from "./model/display";
|
} from "./model/display";
|
||||||
export { BrokerageAccountList } from "./ui/BrokerageAccountList";
|
export { BrokerageAccountList } from "./ui/BrokerageAccountList";
|
||||||
|
|||||||
@@ -16,12 +16,6 @@ function accountTypeKey(type: BrokerageAccount["type"]) {
|
|||||||
return `accounts.types.${type}` as const;
|
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(
|
function brokerageAccountCards(
|
||||||
accounts: BrokerageAccount[],
|
accounts: BrokerageAccount[],
|
||||||
): BrokerageAccountCard[] {
|
): BrokerageAccountCard[] {
|
||||||
@@ -44,9 +38,4 @@ function brokerageAccountCards(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export { accountTypeKey, type BrokerageAccountCard, brokerageAccountCards };
|
||||||
accountTypeKey,
|
|
||||||
type BrokerageAccountCard,
|
|
||||||
brokerageAccountCards,
|
|
||||||
errorCode,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Card, Group, Stack, Text } from "@mantine/core";
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { formatMoney } from "@/entities/portfolio";
|
import { formatMoney } from "@/shared/lib";
|
||||||
import type { BrokerageAccountCard } from "../model/display";
|
import type { BrokerageAccountCard } from "../model/display";
|
||||||
import { accountTypeKey } from "../model/display";
|
import { accountTypeKey } from "../model/display";
|
||||||
import classes from "./BrokerageAccountList.module.css";
|
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 { renderHook, waitFor } from "@testing-library/react";
|
||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import type { FC, PropsWithChildren } from "react";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { configureAuthClient } from "@/shared/api";
|
import { configureAuthClient } from "@/shared/api";
|
||||||
|
import { createQueryWrapper } from "@/test/query";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import { useBrokerageEvents } from "./useBrokerageEvents";
|
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", () => {
|
describe("useBrokerageEvents", () => {
|
||||||
afterEach(() => configureAuthClient(null));
|
|
||||||
|
|
||||||
it("loads future cash events through the authenticated query seam", async () => {
|
it("loads future cash events through the authenticated query seam", async () => {
|
||||||
configureAuthClient({
|
configureAuthClient({
|
||||||
accessToken: () => "token",
|
accessToken: () => "token",
|
||||||
@@ -54,7 +35,7 @@ describe("useBrokerageEvents", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(() => useBrokerageEvents("account-1"), {
|
const { result } = renderHook(() => useBrokerageEvents("account-1"), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createQueryWrapper().wrapper,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
|
import {
|
||||||
import type { paths } from "@/shared/api/generated/schema";
|
apiQueryClient,
|
||||||
|
type paths,
|
||||||
|
useAuthenticatedQuery,
|
||||||
|
} from "@/shared/api";
|
||||||
|
|
||||||
type EventsEndpoint =
|
type EventsEndpoint =
|
||||||
paths["/api/v1/brokerage-accounts/{accountId}/events"]["get"];
|
paths["/api/v1/brokerage-accounts/{accountId}/events"]["get"];
|
||||||
@@ -15,7 +18,6 @@ function useBrokerageEvents(accountId: string | undefined) {
|
|||||||
),
|
),
|
||||||
enabled: Boolean(accountId),
|
enabled: Boolean(accountId),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
retry: false,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
|
import {
|
||||||
import type { paths } from "@/shared/api/generated/schema";
|
apiQueryClient,
|
||||||
|
type paths,
|
||||||
|
useAuthenticatedQuery,
|
||||||
|
} from "@/shared/api";
|
||||||
|
|
||||||
type IncomeSummary =
|
type IncomeSummary =
|
||||||
paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"];
|
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),
|
enabled: Boolean(accountId),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
retry: false,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,8 +38,6 @@ function useIncomeAnalytics(
|
|||||||
),
|
),
|
||||||
enabled: Boolean(accountId),
|
enabled: Boolean(accountId),
|
||||||
staleTime: 30_000,
|
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 { renderHook, waitFor } from "@testing-library/react";
|
||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import type { FC, PropsWithChildren } from "react";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import { configureAuthClient } from "@/shared/api";
|
import { configureAuthClient } from "@/shared/api";
|
||||||
|
import { createQueryWrapper } from "@/test/query";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import { usePortfolio } from "./usePortfolio";
|
import { usePortfolio } from "./usePortfolio";
|
||||||
|
|
||||||
describe("usePortfolio", () => {
|
describe("usePortfolio", () => {
|
||||||
afterEach(() => configureAuthClient(null));
|
|
||||||
|
|
||||||
it("uses the account endpoint with a 30 second cache and no retry", async () => {
|
it("uses the account endpoint with a 30 second cache and no retry", async () => {
|
||||||
configureAuthClient({
|
configureAuthClient({
|
||||||
accessToken: () => "token",
|
accessToken: () => "token",
|
||||||
refresh: async () => true,
|
refresh: async () => true,
|
||||||
clear: vi.fn(),
|
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"), {
|
const { result } = renderHook(() => usePortfolio("account-1"), {
|
||||||
wrapper,
|
wrapper: createQueryWrapper().wrapper,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
@@ -58,16 +43,9 @@ describe("usePortfolio", () => {
|
|||||||
: HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
|
: HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const client = new QueryClient();
|
const { result } = renderHook(() => usePortfolio("account-1"), {
|
||||||
const wrapper: FC<PropsWithChildren> = (props) => {
|
wrapper: createQueryWrapper().wrapper,
|
||||||
const { children } = props;
|
});
|
||||||
return (
|
|
||||||
<MantineProvider>
|
|
||||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
|
||||||
</MantineProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
const { result } = renderHook(() => usePortfolio("account-1"), { wrapper });
|
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
void result.current.refetch();
|
void result.current.refetch();
|
||||||
|
|||||||
@@ -1,24 +1,30 @@
|
|||||||
import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
|
import {
|
||||||
import type { paths } from "@/shared/api/generated/schema";
|
apiQueryClient,
|
||||||
|
type paths,
|
||||||
|
useAuthenticatedQuery,
|
||||||
|
} from "@/shared/api";
|
||||||
|
|
||||||
const portfolioQueryPolicy = {
|
const portfolioQueryPolicy = {
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
retry: false,
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type Portfolio =
|
type Portfolio =
|
||||||
paths["/api/v1/brokerage-accounts/{accountId}/portfolio"]["get"]["responses"][200]["content"]["application/json"];
|
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) {
|
function usePortfolio(accountId: string | undefined) {
|
||||||
return useAuthenticatedQuery({
|
return useAuthenticatedQuery({
|
||||||
...apiQueryClient.queryOptions(
|
...portfolioQueryOptions(accountId),
|
||||||
"get",
|
|
||||||
"/api/v1/brokerage-accounts/{accountId}/portfolio",
|
|
||||||
{ params: { path: { accountId: accountId ?? "" } } },
|
|
||||||
),
|
|
||||||
enabled: Boolean(accountId),
|
enabled: Boolean(accountId),
|
||||||
...portfolioQueryPolicy,
|
...portfolioQueryPolicy,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export { type Portfolio, usePortfolio };
|
export { type Portfolio, portfolioQueryOptions, usePortfolio };
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
export { type Portfolio, usePortfolio } from "./api/usePortfolio";
|
|
||||||
export {
|
export {
|
||||||
formatMoney,
|
type Portfolio,
|
||||||
formatShare,
|
portfolioQueryOptions,
|
||||||
formatSnapshotTime,
|
usePortfolio,
|
||||||
instrumentLogoUrl,
|
} from "./api/usePortfolio";
|
||||||
portfolioErrorCode,
|
export { formatShare } from "./model/display";
|
||||||
} from "./model/display";
|
|
||||||
|
|||||||
@@ -1,16 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { formatMoney, formatShare, instrumentLogoUrl } from "./display";
|
import { formatShare } from "./display";
|
||||||
|
|
||||||
describe("portfolio display helpers", () => {
|
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", () => {
|
it("formats a share as a display-only percentage", () => {
|
||||||
expect(
|
expect(
|
||||||
formatShare(
|
formatShare(
|
||||||
@@ -33,15 +24,4 @@ describe("portfolio display helpers", () => {
|
|||||||
),
|
),
|
||||||
).toBeNull();
|
).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];
|
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(
|
function formatShare(
|
||||||
numerator: Money | null,
|
numerator: Money | null,
|
||||||
denominator: Money | null,
|
denominator: Money | null,
|
||||||
@@ -32,33 +17,4 @@ function formatShare(
|
|||||||
return `${rounded} %`;
|
return `${rounded} %`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSnapshotTime(value: string): string {
|
export { formatShare };
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export { AuthProvider, useAuth } from "./model/auth";
|
export { AuthProvider, useAuth } from "./model/auth";
|
||||||
|
export { AuthPageLayout } from "./ui/AuthPageLayout";
|
||||||
export { LoginForm } from "./ui/LoginForm";
|
export { LoginForm } from "./ui/LoginForm";
|
||||||
export { RegisterForm } from "./ui/RegisterForm";
|
export { RegisterForm } from "./ui/RegisterForm";
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { apiClient, configureAuthClient } from "@/shared/api";
|
import { apiClient, configureAuthClient, type paths } from "@/shared/api";
|
||||||
import type { paths } from "@/shared/api/generated/schema";
|
|
||||||
|
|
||||||
type User =
|
type User =
|
||||||
paths["/api/v1/auth/me"]["get"]["responses"][200]["content"]["application/json"];
|
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 { MantineProvider } from "@mantine/core";
|
||||||
import {
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
cleanup,
|
|
||||||
fireEvent,
|
|
||||||
render,
|
|
||||||
screen,
|
|
||||||
waitFor,
|
|
||||||
} from "@testing-library/react";
|
|
||||||
import { MemoryRouter } from "react-router";
|
import { MemoryRouter } from "react-router";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { AuthForm } from "./AuthForm";
|
import { AuthForm } from "./AuthForm";
|
||||||
|
|
||||||
describe("AuthForm", () => {
|
describe("AuthForm", () => {
|
||||||
afterEach(cleanup);
|
|
||||||
|
|
||||||
it("does not submit registration when password confirmation differs", async () => {
|
it("does not submit registration when password confirmation differs", async () => {
|
||||||
const onSubmit = vi.fn();
|
const onSubmit = vi.fn();
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -5,52 +5,13 @@ import { useState } from "react";
|
|||||||
import { FormProvider, type Resolver, useForm } from "react-hook-form";
|
import { FormProvider, type Resolver, useForm } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
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 = {
|
type AuthFormProps = {
|
||||||
register?: true;
|
register?: true;
|
||||||
onSubmit: (email: string, password: string) => Promise<void>;
|
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>
|
<Stack>
|
||||||
<TextInput
|
<TextInput
|
||||||
label={t("auth.email")}
|
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 type { QueryClient } from "@tanstack/react-query";
|
||||||
import { apiQueryClient } from "@/shared/api";
|
import { apiQueryClient, type paths } from "@/shared/api";
|
||||||
import type { paths } from "@/shared/api/generated/schema";
|
|
||||||
|
|
||||||
type TbankConnectionStatus =
|
type TbankConnectionStatus =
|
||||||
paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"];
|
paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"];
|
||||||
|
|||||||
@@ -1,24 +1,12 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import {
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
cleanup,
|
|
||||||
fireEvent,
|
|
||||||
render,
|
|
||||||
screen,
|
|
||||||
waitFor,
|
|
||||||
} from "@testing-library/react";
|
|
||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { apiQueryClient } from "@/shared/api";
|
import { apiQueryClient, configureAuthClient } from "@/shared/api";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import { TbankConnectionSection } from "./TbankConnectionSection";
|
import { TbankConnectionSection } from "./TbankConnectionSection";
|
||||||
|
|
||||||
vi.mock("@/features/auth", () => ({
|
|
||||||
useAuth: () => ({ accessToken: "test-token" }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
afterEach(cleanup);
|
|
||||||
|
|
||||||
const brokerageAccountsQueryKey = apiQueryClient.queryOptions(
|
const brokerageAccountsQueryKey = apiQueryClient.queryOptions(
|
||||||
"get",
|
"get",
|
||||||
"/api/v1/brokerage-accounts",
|
"/api/v1/brokerage-accounts",
|
||||||
@@ -39,6 +27,11 @@ const unrelatedQueryKey = apiQueryClient.queryOptions(
|
|||||||
).queryKey;
|
).queryKey;
|
||||||
|
|
||||||
function renderSection(client: QueryClient) {
|
function renderSection(client: QueryClient) {
|
||||||
|
configureAuthClient({
|
||||||
|
accessToken: () => "test-token",
|
||||||
|
refresh: async () => true,
|
||||||
|
clear: vi.fn(),
|
||||||
|
});
|
||||||
return render(
|
return render(
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<QueryClientProvider client={client}>
|
<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 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 {
|
import {
|
||||||
TbankConnectionError,
|
TbankConnectionError,
|
||||||
TbankConnectionLoading,
|
TbankConnectionLoading,
|
||||||
} from "./TbankConnectionSection.view";
|
} from "./TbankConnectionSection.view";
|
||||||
import { useTbankConnectionViewModel } from "./TbankConnectionSection.vm";
|
import { useTbankConnectionViewModel } from "./TbankConnectionSection.vm";
|
||||||
|
import { TbankConnectionSectionLoaded } from "./TbankConnectionSectionLoaded";
|
||||||
type TbankConnectionSectionLoadedProps = {
|
|
||||||
status: TbankConnectionStatus;
|
|
||||||
};
|
|
||||||
|
|
||||||
function errorCode(error: unknown): string | undefined {
|
|
||||||
return typeof error === "object" && error !== null && "code" in error
|
|
||||||
? String(error.code)
|
|
||||||
: undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TbankConnectionSection: FC = () => {
|
const TbankConnectionSection: FC = () => {
|
||||||
const viewModel = useTbankConnectionViewModel();
|
const viewModel = useTbankConnectionViewModel();
|
||||||
@@ -40,119 +14,4 @@ const TbankConnectionSection: FC = () => {
|
|||||||
return <TbankConnectionSectionLoaded status={viewModel.status} />;
|
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 };
|
export { TbankConnectionSection };
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useAuthenticatedQuery } from "@/shared/api";
|
||||||
import { useAuth } from "@/features/auth";
|
|
||||||
import { tbankConnectionQueryOptions } from "../model/connection-boundary";
|
import { tbankConnectionQueryOptions } from "../model/connection-boundary";
|
||||||
|
|
||||||
function useTbankConnectionViewModel() {
|
function useTbankConnectionViewModel() {
|
||||||
const { accessToken } = useAuth();
|
const status = useAuthenticatedQuery({
|
||||||
const status = useQuery({
|
|
||||||
...tbankConnectionQueryOptions(),
|
...tbankConnectionQueryOptions(),
|
||||||
enabled: Boolean(accessToken),
|
|
||||||
});
|
});
|
||||||
if (status.isPending) return { state: "loading" as const };
|
if (status.isPending) return { state: "loading" as const };
|
||||||
if (status.isError)
|
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 type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useParams } from "react-router";
|
import { useParams } from "react-router";
|
||||||
import {
|
import { useBrokerageEvents } from "@/entities/brokerage-event";
|
||||||
type BrokerageEvent,
|
import { AccountEventsPageEmpty } from "./components/AccountEventsPageEmpty.view";
|
||||||
useBrokerageEvents,
|
import { AccountEventsPageError } from "./components/AccountEventsPageError.view";
|
||||||
} from "@/entities/brokerage-event";
|
import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view";
|
||||||
import { formatMoney } from "@/entities/portfolio";
|
import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view";
|
||||||
import { LoadingSkeleton, StateView } from "@/shared/ui";
|
|
||||||
|
|
||||||
type AccountEventsPageErrorProps = {
|
|
||||||
retry: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AccountEventsPageLoadedProps = {
|
|
||||||
events: BrokerageEvent[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type AccountEventRowProps = {
|
|
||||||
event: BrokerageEvent;
|
|
||||||
};
|
|
||||||
|
|
||||||
const AccountEventsPage: FC = () => {
|
const AccountEventsPage: FC = () => {
|
||||||
const { accountId } = useParams();
|
const { accountId } = useParams();
|
||||||
@@ -40,98 +17,4 @@ const AccountEventsPage: FC = () => {
|
|||||||
return <AccountEventsPageLoaded events={events.data.events} />;
|
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 };
|
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 { MantineProvider } from "@mantine/core";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import type { FC, PropsWithChildren } from "react";
|
|
||||||
import { MemoryRouter, Route, Routes } from "react-router";
|
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 { configureAuthClient } from "@/shared/api";
|
||||||
|
import { createQueryWrapper } from "@/test/query";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import { AccountIncomePage } from "./AccountIncomePage";
|
import { AccountIncomePage } from "./AccountIncomePage";
|
||||||
|
import { isDate, toIncomeDisplayModel } from "./AccountIncomePage.vm";
|
||||||
|
|
||||||
const analytics = {
|
const analytics = {
|
||||||
accountId: "account-1",
|
accountId: "account-1",
|
||||||
@@ -41,7 +42,7 @@ const analytics = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
entries: [entry()],
|
entries: [entry()],
|
||||||
};
|
} satisfies IncomeAnalytics;
|
||||||
|
|
||||||
function money(currency: string, value: string) {
|
function money(currency: string, value: string) {
|
||||||
return { currency, value };
|
return { currency, value };
|
||||||
@@ -65,7 +66,7 @@ function comparison(currency: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function entry() {
|
function entry(): IncomeAnalytics["entries"][number] {
|
||||||
return {
|
return {
|
||||||
id: "coupon-1",
|
id: "coupon-1",
|
||||||
occurredAt: "2026-08-01T12:00:00Z",
|
occurredAt: "2026-08-01T12:00:00Z",
|
||||||
@@ -91,36 +92,45 @@ function renderPage() {
|
|||||||
refresh: async () => true,
|
refresh: async () => true,
|
||||||
clear: vi.fn(),
|
clear: vi.fn(),
|
||||||
});
|
});
|
||||||
const Wrapper: FC<PropsWithChildren> = (props) => (
|
const { wrapper: QueryWrapper } = createQueryWrapper();
|
||||||
<MantineProvider>
|
|
||||||
<QueryClientProvider client={new QueryClient()}>
|
|
||||||
{props.children}
|
|
||||||
</QueryClientProvider>
|
|
||||||
</MantineProvider>
|
|
||||||
);
|
|
||||||
return render(
|
return render(
|
||||||
<Wrapper>
|
<MantineProvider>
|
||||||
<MemoryRouter
|
<QueryWrapper>
|
||||||
initialEntries={[
|
<MemoryRouter
|
||||||
"/accounts/account-1/income?from=2026-08-01&to=2026-08-27",
|
initialEntries={[
|
||||||
]}
|
"/accounts/account-1/income?from=2026-08-01&to=2026-08-27",
|
||||||
>
|
]}
|
||||||
<Routes>
|
>
|
||||||
<Route
|
<Routes>
|
||||||
path="/accounts/:accountId/income"
|
<Route
|
||||||
element={<AccountIncomePage />}
|
path="/accounts/:accountId/income"
|
||||||
/>
|
element={<AccountIncomePage />}
|
||||||
<Route path="/accounts/:accountId/events" element={<h1>Events</h1>} />
|
/>
|
||||||
</Routes>
|
<Route
|
||||||
</MemoryRouter>
|
path="/accounts/:accountId/events"
|
||||||
</Wrapper>,
|
element={<h1>Events</h1>}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryWrapper>
|
||||||
|
</MantineProvider>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("AccountIncomePage", () => {
|
describe("AccountIncomePage", () => {
|
||||||
afterEach(() => {
|
it("rejects malformed calendar dates without throwing", () => {
|
||||||
cleanup();
|
expect(isDate("2026-99-99")).toBe(false);
|
||||||
configureAuthClient(null);
|
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 () => {
|
it("renders realized analytics, drill-down, and the independent forecast", async () => {
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import {
|
|
||||||
AccountIncomePageError,
|
|
||||||
AccountIncomePageLoaded,
|
|
||||||
AccountIncomePageLoading,
|
|
||||||
} from "./AccountIncomePage.view";
|
|
||||||
import { useAccountIncomePageViewModel } from "./AccountIncomePage.vm";
|
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 AccountIncomePage: FC = () => {
|
||||||
const viewModel = useAccountIncomePageViewModel();
|
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[];
|
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 =
|
type AccountIncomePageViewModel =
|
||||||
| { state: "loading" }
|
| { state: "loading" }
|
||||||
| { state: "error"; retry: () => void; period: IncomePeriod }
|
| { state: "error"; retry: () => void; period: IncomePeriod }
|
||||||
| {
|
| {
|
||||||
state: "loaded";
|
state: "loaded";
|
||||||
analytics: IncomeAnalytics;
|
analytics: IncomeAnalytics;
|
||||||
|
display: IncomeDisplayModel;
|
||||||
forecast: IncomeForecast;
|
forecast: IncomeForecast;
|
||||||
form: ReturnType<typeof useForm<IncomeForm>>;
|
form: ReturnType<typeof useForm<IncomeForm>>;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
@@ -81,6 +95,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
|
|||||||
return {
|
return {
|
||||||
state: "loaded",
|
state: "loaded",
|
||||||
analytics: analytics.data,
|
analytics: analytics.data,
|
||||||
|
display: toIncomeDisplayModel(analytics.data),
|
||||||
forecast: toForecast(events),
|
forecast: toForecast(events),
|
||||||
form,
|
form,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
@@ -126,12 +141,59 @@ function validPeriod(period: IncomePeriod): period is IncomePeriod {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isDate(value: string): boolean {
|
function isDate(value: string): boolean {
|
||||||
|
if (!datePattern.test(value)) return false;
|
||||||
|
const parsed = new Date(`${value}T00:00:00Z`);
|
||||||
return (
|
return (
|
||||||
datePattern.test(value) &&
|
Number.isFinite(parsed.valueOf()) &&
|
||||||
new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value
|
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 {
|
function moscowDate(value: Date): string {
|
||||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||||
timeZone: "Europe/Moscow",
|
timeZone: "Europe/Moscow",
|
||||||
@@ -144,4 +206,9 @@ function moscowDate(value: Date): string {
|
|||||||
return `${part("year")}-${part("month")}-${part("day")}`;
|
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 { MantineProvider } from "@mantine/core";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import {
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
cleanup,
|
|
||||||
fireEvent,
|
|
||||||
render,
|
|
||||||
screen,
|
|
||||||
waitFor,
|
|
||||||
} from "@testing-library/react";
|
|
||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import { MemoryRouter, Route, Routes } from "react-router";
|
import { MemoryRouter, Route, Routes } from "react-router";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { BrokerageOperation } from "@/entities/broker-operation";
|
import {
|
||||||
|
type BrokerageOperation,
|
||||||
|
LedgerEntryRow,
|
||||||
|
} from "@/entities/broker-operation";
|
||||||
import { configureAuthClient } from "@/shared/api";
|
import { configureAuthClient } from "@/shared/api";
|
||||||
|
import { createTestQueryClient } from "@/test/query";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import { AccountOperationsPage, OperationRow } from "./AccountOperationsPage";
|
import { AccountOperationsPage } from "./AccountOperationsPage";
|
||||||
|
|
||||||
const operation: BrokerageOperation = {
|
const operation: BrokerageOperation = {
|
||||||
id: "operation-1",
|
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", () => {
|
it("marks cashless operations without rendering operation details", () => {
|
||||||
render(
|
render(
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<OperationRow operation={operation} />
|
<LedgerEntryRow operation={operation} />
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
</MantineProvider>,
|
</MantineProvider>,
|
||||||
);
|
);
|
||||||
@@ -65,7 +78,7 @@ describe("OperationRow", () => {
|
|||||||
render(
|
render(
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<OperationRow
|
<LedgerEntryRow
|
||||||
operation={{
|
operation={{
|
||||||
...operation,
|
...operation,
|
||||||
payment: { currency: "RUB", value: "15" },
|
payment: { currency: "RUB", value: "15" },
|
||||||
@@ -83,7 +96,7 @@ describe("OperationRow", () => {
|
|||||||
render(
|
render(
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<OperationRow
|
<LedgerEntryRow
|
||||||
operation={{
|
operation={{
|
||||||
...operation,
|
...operation,
|
||||||
type: "OPERATION_TYPE_COUPON",
|
type: "OPERATION_TYPE_COUPON",
|
||||||
@@ -103,7 +116,7 @@ describe("OperationRow", () => {
|
|||||||
render(
|
render(
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<OperationRow
|
<LedgerEntryRow
|
||||||
operation={{
|
operation={{
|
||||||
...operation,
|
...operation,
|
||||||
type: "OPERATION_TYPE_DIVIDEND",
|
type: "OPERATION_TYPE_DIVIDEND",
|
||||||
@@ -123,7 +136,7 @@ describe("OperationRow", () => {
|
|||||||
render(
|
render(
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<OperationRow
|
<LedgerEntryRow
|
||||||
operation={{
|
operation={{
|
||||||
...operation,
|
...operation,
|
||||||
type: "OPERATION_TYPE_SERVICE_FEE",
|
type: "OPERATION_TYPE_SERVICE_FEE",
|
||||||
@@ -143,7 +156,7 @@ describe("OperationRow", () => {
|
|||||||
render(
|
render(
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<OperationRow
|
<LedgerEntryRow
|
||||||
operation={{
|
operation={{
|
||||||
...operation,
|
...operation,
|
||||||
payment: { currency: "RUB", value: "0" },
|
payment: { currency: "RUB", value: "0" },
|
||||||
@@ -161,7 +174,7 @@ describe("OperationRow", () => {
|
|||||||
render(
|
render(
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<OperationRow operation={operation} />
|
<LedgerEntryRow operation={operation} />
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
</MantineProvider>,
|
</MantineProvider>,
|
||||||
);
|
);
|
||||||
@@ -171,8 +184,6 @@ describe("OperationRow", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("AccountOperationsPage", () => {
|
describe("AccountOperationsPage", () => {
|
||||||
afterEach(() => configureAuthClient(null));
|
|
||||||
|
|
||||||
it("resets cursor pagination when filters change", async () => {
|
it("resets cursor pagination when filters change", async () => {
|
||||||
configureTestAuth();
|
configureTestAuth();
|
||||||
const searches: string[] = [];
|
const searches: string[] = [];
|
||||||
@@ -192,20 +203,7 @@ describe("AccountOperationsPage", () => {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
render(
|
renderPage();
|
||||||
<QueryClientProvider client={new QueryClient()}>
|
|
||||||
<MantineProvider>
|
|
||||||
<MemoryRouter initialEntries={["/accounts/account-1/operations"]}>
|
|
||||||
<Routes>
|
|
||||||
<Route
|
|
||||||
path="/accounts/:accountId/operations"
|
|
||||||
element={<AccountOperationsPage />}
|
|
||||||
/>
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>
|
|
||||||
</MantineProvider>
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole("heading", { name: "Журнал операций" }),
|
await screen.findByRole("heading", { name: "Журнал операций" }),
|
||||||
@@ -241,20 +239,7 @@ describe("AccountOperationsPage", () => {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
render(
|
renderPage();
|
||||||
<QueryClientProvider client={new QueryClient()}>
|
|
||||||
<MantineProvider>
|
|
||||||
<MemoryRouter initialEntries={["/accounts/account-1/operations"]}>
|
|
||||||
<Routes>
|
|
||||||
<Route
|
|
||||||
path="/accounts/:accountId/operations"
|
|
||||||
element={<AccountOperationsPage />}
|
|
||||||
/>
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>
|
|
||||||
</MantineProvider>
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole("heading", { name: "Журнал операций" }),
|
await screen.findByRole("heading", { name: "Журнал операций" }),
|
||||||
@@ -287,20 +272,7 @@ describe("AccountOperationsPage", () => {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
render(
|
renderPage();
|
||||||
<QueryClientProvider client={new QueryClient()}>
|
|
||||||
<MantineProvider>
|
|
||||||
<MemoryRouter initialEntries={["/accounts/account-1/operations"]}>
|
|
||||||
<Routes>
|
|
||||||
<Route
|
|
||||||
path="/accounts/:accountId/operations"
|
|
||||||
element={<AccountOperationsPage />}
|
|
||||||
/>
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>
|
|
||||||
</MantineProvider>
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole("heading", { name: "Журнал операций" }),
|
await screen.findByRole("heading", { name: "Журнал операций" }),
|
||||||
@@ -323,20 +295,7 @@ describe("AccountOperationsPage", () => {
|
|||||||
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
|
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
render(
|
renderPage();
|
||||||
<QueryClientProvider client={new QueryClient()}>
|
|
||||||
<MantineProvider>
|
|
||||||
<MemoryRouter initialEntries={["/accounts/account-1/operations"]}>
|
|
||||||
<Routes>
|
|
||||||
<Route
|
|
||||||
path="/accounts/:accountId/operations"
|
|
||||||
element={<AccountOperationsPage />}
|
|
||||||
/>
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>
|
|
||||||
</MantineProvider>
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText("Не удалось загрузить журнал"),
|
await screen.findByText("Не удалось загрузить журнал"),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { LedgerEntryRow } from "@/entities/broker-operation";
|
|
||||||
import { useAccountOperationsPageVm } from "./AccountOperationsPage.vm";
|
import { useAccountOperationsPageVm } from "./AccountOperationsPage.vm";
|
||||||
import { AccountOperationsPageError } from "./components/AccountOperationsPageError.view";
|
import { AccountOperationsPageError } from "./components/AccountOperationsPageError.view";
|
||||||
import { AccountOperationsPageLoaded } from "./components/AccountOperationsPageLoaded.view";
|
import { AccountOperationsPageLoaded } from "./components/AccountOperationsPageLoaded.view";
|
||||||
@@ -19,4 +18,4 @@ const AccountOperationsPage: FC = () => {
|
|||||||
return <AccountOperationsPageLoaded {...viewModel} />;
|
return <AccountOperationsPageLoaded {...viewModel} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export { AccountOperationsPage, LedgerEntryRow as OperationRow };
|
export { AccountOperationsPage };
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { useState } from "react";
|
|||||||
import { useParams } from "react-router";
|
import { useParams } from "react-router";
|
||||||
import {
|
import {
|
||||||
type BrokerageOperation,
|
type BrokerageOperation,
|
||||||
|
brokerageOperationsQueryOptions,
|
||||||
type OperationCategory,
|
type OperationCategory,
|
||||||
useBrokerageOperations,
|
useBrokerageOperations,
|
||||||
} from "@/entities/broker-operation";
|
} from "@/entities/broker-operation";
|
||||||
import { apiQueryClient } from "@/shared/api";
|
|
||||||
|
|
||||||
type AccountOperationsPageViewModel =
|
type AccountOperationsPageViewModel =
|
||||||
| { state: "loading" }
|
| { state: "loading" }
|
||||||
@@ -51,11 +51,9 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
|
|||||||
...(cursor ? { cursor } : {}),
|
...(cursor ? { cursor } : {}),
|
||||||
limit: 100,
|
limit: 100,
|
||||||
});
|
});
|
||||||
const resetQueryKey = apiQueryClient.queryOptions(
|
const resetQueryKey = brokerageOperationsQueryOptions(accountId, {
|
||||||
"get",
|
limit: 100,
|
||||||
"/api/v1/brokerage-accounts/{accountId}/operations",
|
}).queryKey;
|
||||||
{ params: { path: { accountId: accountId ?? "" }, query: { limit: 100 } } },
|
|
||||||
).queryKey;
|
|
||||||
|
|
||||||
const resetCursors = () => setCursors([]);
|
const resetCursors = () => setCursors([]);
|
||||||
|
|
||||||
|
|||||||
@@ -32,61 +32,36 @@
|
|||||||
color: var(--mantine-color-red-6);
|
color: var(--mantine-color-red-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stockCard {
|
.expandedCard {
|
||||||
padding: var(--mantine-spacing-md);
|
padding: var(--mantine-spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stockMain {
|
.expandedMain {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
gap: var(--mantine-spacing-sm);
|
gap: var(--mantine-spacing-sm);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stockIdentity,
|
.expandedIdentity,
|
||||||
.stockValue {
|
.expandedValue {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stockIdentifiers {
|
.expandedIdentifiers {
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stockValue {
|
.expandedValue {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stockMeta,
|
.expandedMeta {
|
||||||
.bondMeta {
|
|
||||||
padding-top: var(--mantine-spacing-xs);
|
padding-top: var(--mantine-spacing-xs);
|
||||||
margin-top: var(--mantine-spacing-sm);
|
margin-top: var(--mantine-spacing-sm);
|
||||||
border-top: 1px solid var(--mantine-color-gray-2);
|
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 {
|
.mono {
|
||||||
font-family: var(--mantine-font-family-monospace);
|
font-family: var(--mantine-font-family-monospace);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { type 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 { HttpResponse, http } from "msw";
|
||||||
import { MemoryRouter, Route, Routes } from "react-router";
|
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 { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio";
|
||||||
import { configureAuthClient } from "@/shared/api";
|
import { configureAuthClient } from "@/shared/api";
|
||||||
|
import { createTestQueryClient } from "@/test/query";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import {
|
import { AccountPage } from "./AccountPage";
|
||||||
AccountPage,
|
|
||||||
AccountPageEmpty,
|
|
||||||
AccountPageError,
|
|
||||||
AccountPageLoaded,
|
|
||||||
AccountPageLoading,
|
|
||||||
} from "./AccountPage";
|
|
||||||
import type {
|
import type {
|
||||||
AccountPageRecentEvents,
|
AccountPageRecentEvents,
|
||||||
AccountPageRecentOperations,
|
AccountPageRecentOperations,
|
||||||
} from "./AccountPage.vm";
|
} from "./AccountPage.vm";
|
||||||
import { toAccountPagePositionSections } 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) {
|
function renderPage(ui: React.ReactNode) {
|
||||||
|
const queryClient = createTestQueryClient();
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={new QueryClient()}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<MemoryRouter>{ui}</MemoryRouter>
|
<MemoryRouter>{ui}</MemoryRouter>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
@@ -113,10 +113,6 @@ function emptyProps() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("AccountPage states", () => {
|
describe("AccountPage states", () => {
|
||||||
afterEach(() => {
|
|
||||||
configureAuthClient(null);
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
it("renders loading and empty states", () => {
|
it("renders loading and empty states", () => {
|
||||||
renderPage(<AccountPageLoading />);
|
renderPage(<AccountPageLoading />);
|
||||||
expect(screen.getByRole("status")).toBeInTheDocument();
|
expect(screen.getByRole("status")).toBeInTheDocument();
|
||||||
@@ -528,12 +524,12 @@ describe("AccountPage states", () => {
|
|||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const queryClient = new QueryClient();
|
const queryClient = createTestQueryClient();
|
||||||
renderAccountRoute(queryClient);
|
renderAccountRoute(queryClient);
|
||||||
|
|
||||||
await screen.findByText("Вторая");
|
await screen.findByText("Вторая");
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: ["get", "/api/v1/brokerage-accounts/{accountId}/portfolio"],
|
queryKey: portfolioQueryOptions("account-1").queryKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
@@ -563,12 +559,12 @@ describe("AccountPage states", () => {
|
|||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const queryClient = new QueryClient();
|
const queryClient = createTestQueryClient();
|
||||||
renderAccountRoute(queryClient);
|
renderAccountRoute(queryClient);
|
||||||
|
|
||||||
await screen.findByText("Вторая");
|
await screen.findByText("Вторая");
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: ["get", "/api/v1/brokerage-accounts/{accountId}/portfolio"],
|
queryKey: portfolioQueryOptions("account-1").queryKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => expect(requestCount).toBe(2));
|
await waitFor(() => expect(requestCount).toBe(2));
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import {
|
|
||||||
AccountPageEmpty,
|
|
||||||
AccountPageError,
|
|
||||||
AccountPageLoaded,
|
|
||||||
AccountPageLoading,
|
|
||||||
} from "./AccountPage.view";
|
|
||||||
import { useAccountPageViewModel } from "./AccountPage.vm";
|
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 AccountPage: FC = () => {
|
||||||
const viewModel = useAccountPageViewModel();
|
const viewModel = useAccountPageViewModel();
|
||||||
@@ -38,10 +36,4 @@ const AccountPage: FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export {
|
export { AccountPage };
|
||||||
AccountPage,
|
|
||||||
AccountPageEmpty,
|
|
||||||
AccountPageError,
|
|
||||||
AccountPageLoaded,
|
|
||||||
AccountPageLoading,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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 { MantineProvider } from "@mantine/core";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import { renderHook, waitFor } from "@testing-library/react";
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import type { FC, PropsWithChildren } from "react";
|
import type { FC, PropsWithChildren } from "react";
|
||||||
import { MemoryRouter, Route, Routes } from "react-router";
|
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 type { Portfolio } from "@/entities/portfolio";
|
||||||
import { configureAuthClient } from "@/shared/api";
|
import { configureAuthClient } from "@/shared/api";
|
||||||
|
import { createQueryWrapper } from "@/test/query";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import { useAccountPageViewModel } from "./AccountPage.vm";
|
import { useAccountPageViewModel } from "./AccountPage.vm";
|
||||||
|
|
||||||
@@ -63,21 +63,21 @@ function configureTestAuth() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const wrapper: FC<PropsWithChildren> = (props) => {
|
function createWrapper() {
|
||||||
const { children } = props;
|
const { wrapper: QueryWrapper } = createQueryWrapper();
|
||||||
|
const Wrapper: FC<PropsWithChildren> = (props) => (
|
||||||
return (
|
<QueryWrapper>
|
||||||
<QueryClientProvider client={new QueryClient()}>
|
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<MemoryRouter initialEntries={["/accounts/account-1"]}>
|
<MemoryRouter initialEntries={["/accounts/account-1"]}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/accounts/:accountId" element={children} />
|
<Route path="/accounts/:accountId" element={props.children} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
</QueryClientProvider>
|
</QueryWrapper>
|
||||||
);
|
);
|
||||||
};
|
return Wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
function mockLoadedAccountPage() {
|
function mockLoadedAccountPage() {
|
||||||
server.use(
|
server.use(
|
||||||
@@ -130,13 +130,13 @@ function mockLoadedAccountPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("useAccountPageViewModel", () => {
|
describe("useAccountPageViewModel", () => {
|
||||||
afterEach(() => configureAuthClient(null));
|
|
||||||
|
|
||||||
it("prepares grouped position sections with derived display data", async () => {
|
it("prepares grouped position sections with derived display data", async () => {
|
||||||
configureTestAuth();
|
configureTestAuth();
|
||||||
mockLoadedAccountPage();
|
mockLoadedAccountPage();
|
||||||
|
|
||||||
const { result } = renderHook(() => useAccountPageViewModel(), { wrapper });
|
const { result } = renderHook(() => useAccountPageViewModel(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.state).toBe("loaded"));
|
await waitFor(() => expect(result.current.state).toBe("loaded"));
|
||||||
const loaded = result.current as Extract<
|
const loaded = result.current as Extract<
|
||||||
@@ -146,7 +146,6 @@ describe("useAccountPageViewModel", () => {
|
|||||||
expect(loaded.positionSections).toMatchObject([
|
expect(loaded.positionSections).toMatchObject([
|
||||||
{
|
{
|
||||||
kind: "stocks",
|
kind: "stocks",
|
||||||
wide: true,
|
|
||||||
positions: [
|
positions: [
|
||||||
{
|
{
|
||||||
displayName: "Акция",
|
displayName: "Акция",
|
||||||
@@ -159,7 +158,6 @@ describe("useAccountPageViewModel", () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: "bonds",
|
kind: "bonds",
|
||||||
wide: true,
|
|
||||||
positions: [
|
positions: [
|
||||||
{
|
{
|
||||||
displayName: "Облигация",
|
displayName: "Облигация",
|
||||||
@@ -172,7 +170,6 @@ describe("useAccountPageViewModel", () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: "other",
|
kind: "other",
|
||||||
wide: false,
|
|
||||||
positions: [
|
positions: [
|
||||||
{
|
{
|
||||||
displayName: "Фонд",
|
displayName: "Фонд",
|
||||||
@@ -190,7 +187,9 @@ describe("useAccountPageViewModel", () => {
|
|||||||
configureTestAuth();
|
configureTestAuth();
|
||||||
mockLoadedAccountPage();
|
mockLoadedAccountPage();
|
||||||
|
|
||||||
const { result } = renderHook(() => useAccountPageViewModel(), { wrapper });
|
const { result } = renderHook(() => useAccountPageViewModel(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(
|
expect(
|
||||||
@@ -233,7 +232,9 @@ describe("useAccountPageViewModel", () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(() => useAccountPageViewModel(), { wrapper });
|
const { result } = renderHook(() => useAccountPageViewModel(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(result.current).toMatchObject({
|
expect(result.current).toMatchObject({
|
||||||
|
|||||||
@@ -12,13 +12,9 @@ import {
|
|||||||
} from "@/entities/brokerage-event";
|
} from "@/entities/brokerage-event";
|
||||||
import { type IncomeSummary, useIncomeSummary } from "@/entities/income";
|
import { type IncomeSummary, useIncomeSummary } from "@/entities/income";
|
||||||
import type { Portfolio } from "@/entities/portfolio";
|
import type { Portfolio } from "@/entities/portfolio";
|
||||||
import {
|
import { formatShare, usePortfolio } from "@/entities/portfolio";
|
||||||
formatMoney,
|
import { apiErrorCode } from "@/shared/api";
|
||||||
formatShare,
|
import { formatMoney, instrumentLogoUrl } from "@/shared/lib";
|
||||||
instrumentLogoUrl,
|
|
||||||
portfolioErrorCode,
|
|
||||||
usePortfolio,
|
|
||||||
} from "@/entities/portfolio";
|
|
||||||
|
|
||||||
type PositionSectionKind = "stocks" | "bonds" | "other";
|
type PositionSectionKind = "stocks" | "bonds" | "other";
|
||||||
type AccountPageReturnTone = "positive" | "negative";
|
type AccountPageReturnTone = "positive" | "negative";
|
||||||
@@ -32,7 +28,6 @@ type AccountPagePosition = Portfolio["positions"][number] & {
|
|||||||
|
|
||||||
type AccountPagePositionSection = {
|
type AccountPagePositionSection = {
|
||||||
kind: PositionSectionKind;
|
kind: PositionSectionKind;
|
||||||
wide: boolean;
|
|
||||||
positions: AccountPagePosition[];
|
positions: AccountPagePosition[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -108,7 +103,7 @@ function useAccountPageViewModel(): AccountPageViewModel {
|
|||||||
if (portfolio.isError)
|
if (portfolio.isError)
|
||||||
return {
|
return {
|
||||||
state: "error" as const,
|
state: "error" as const,
|
||||||
code: portfolioErrorCode(portfolio.error),
|
code: apiErrorCode(portfolio.error),
|
||||||
retry: () => portfolio.refetch(),
|
retry: () => portfolio.refetch(),
|
||||||
};
|
};
|
||||||
return { state: "loading" as const };
|
return { state: "loading" as const };
|
||||||
@@ -188,9 +183,9 @@ function toAccountPagePositionSections(
|
|||||||
(denominators.get(currency) ?? 0) + Number(position.currentValue.value),
|
(denominators.get(currency) ?? 0) + Number(position.currentValue.value),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const stocks = toSection("stocks", true, portfolio, denominators);
|
const stocks = toSection("stocks", portfolio, denominators);
|
||||||
const bonds = toSection("bonds", true, portfolio, denominators);
|
const bonds = toSection("bonds", portfolio, denominators);
|
||||||
const other = toSection("other", false, portfolio, denominators);
|
const other = toSection("other", portfolio, denominators);
|
||||||
return [stocks, bonds, other].filter(
|
return [stocks, bonds, other].filter(
|
||||||
(section) => section.positions.length > 0,
|
(section) => section.positions.length > 0,
|
||||||
);
|
);
|
||||||
@@ -198,28 +193,27 @@ function toAccountPagePositionSections(
|
|||||||
|
|
||||||
function toSection(
|
function toSection(
|
||||||
kind: PositionSectionKind,
|
kind: PositionSectionKind,
|
||||||
wide: boolean,
|
|
||||||
portfolio: Portfolio,
|
portfolio: Portfolio,
|
||||||
denominators: Map<string, number>,
|
denominators: Map<string, number>,
|
||||||
): AccountPagePositionSection {
|
): AccountPagePositionSection {
|
||||||
return {
|
return {
|
||||||
kind,
|
kind,
|
||||||
wide,
|
|
||||||
positions: portfolio.positions
|
positions: portfolio.positions
|
||||||
.filter((position) => {
|
.filter((position) => {
|
||||||
if (kind === "stocks") return position.type === "STOCK";
|
if (kind === "stocks") return position.type === "STOCK";
|
||||||
if (kind === "bonds") return position.type === "BOND";
|
if (kind === "bonds") return position.type === "BOND";
|
||||||
return position.type !== "STOCK" && position.type !== "BOND";
|
return position.type !== "STOCK" && position.type !== "BOND";
|
||||||
})
|
})
|
||||||
.map((position) => toPosition(position, denominators, wide)),
|
.map((position) => toPosition(position, denominators, kind)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toPosition(
|
function toPosition(
|
||||||
position: Portfolio["positions"][number],
|
position: Portfolio["positions"][number],
|
||||||
denominators: Map<string, number>,
|
denominators: Map<string, number>,
|
||||||
wide: boolean,
|
kind: PositionSectionKind,
|
||||||
): AccountPagePosition {
|
): AccountPagePosition {
|
||||||
|
const expanded = kind !== "other";
|
||||||
const returnValue = position.absoluteReturn
|
const returnValue = position.absoluteReturn
|
||||||
? Number(position.absoluteReturn.value)
|
? Number(position.absoluteReturn.value)
|
||||||
: null;
|
: null;
|
||||||
@@ -238,10 +232,10 @@ function toPosition(
|
|||||||
})
|
})
|
||||||
: null,
|
: null,
|
||||||
returnText: position.absoluteReturn
|
returnText: position.absoluteReturn
|
||||||
? `${wide && returnValue !== null && returnValue > 0 ? "+" : ""}${formatMoney(position.absoluteReturn)}`
|
? `${expanded && returnValue !== null && returnValue > 0 ? "+" : ""}${formatMoney(position.absoluteReturn)}`
|
||||||
: null,
|
: null,
|
||||||
returnTone,
|
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 { Paper, Text } from "@mantine/core";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import type { Portfolio } from "@/entities/portfolio";
|
import type { Portfolio } from "@/entities/portfolio";
|
||||||
import { formatMoney } from "@/entities/portfolio";
|
import { formatMoney } from "@/shared/lib";
|
||||||
import classes from "../AccountPage.module.css";
|
import classes from "../AccountPage.module.css";
|
||||||
|
|
||||||
type CashBalancesProps = {
|
type CashBalancesProps = {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { FC } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import type { IncomeSummary as IncomeSummaryData } from "@/entities/income";
|
import type { IncomeSummary as IncomeSummaryData } from "@/entities/income";
|
||||||
import { formatMoney } from "@/entities/portfolio";
|
import { formatMoney } from "@/shared/lib";
|
||||||
import { StateView } from "@/shared/ui";
|
import { StateView } from "@/shared/ui";
|
||||||
|
|
||||||
type IncomeSummaryProps =
|
type IncomeSummaryProps =
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Paper, SimpleGrid, Text } from "@mantine/core";
|
import { Paper, SimpleGrid, Text } from "@mantine/core";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import type { Portfolio } from "@/entities/portfolio";
|
import type { Portfolio } from "@/entities/portfolio";
|
||||||
import { formatMoney } from "@/entities/portfolio";
|
import { formatMoney } from "@/shared/lib";
|
||||||
import classes from "../AccountPage.module.css";
|
import classes from "../AccountPage.module.css";
|
||||||
|
|
||||||
type PortfolioTotalsProps = {
|
type PortfolioTotalsProps = {
|
||||||
|
|||||||
@@ -1,44 +1,28 @@
|
|||||||
import { Card, Group, Stack, Text } from "@mantine/core";
|
import { Card, Group, Stack, Text } from "@mantine/core";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { formatMoney } from "@/entities/portfolio";
|
import { formatMoney } from "@/shared/lib";
|
||||||
import classes from "../AccountPage.module.css";
|
import classes from "../AccountPage.module.css";
|
||||||
import type { AccountPagePosition } from "../AccountPage.vm";
|
import type {
|
||||||
|
AccountPagePosition,
|
||||||
|
AccountPagePositionSection,
|
||||||
|
} from "../AccountPage.vm";
|
||||||
import { PositionLogo } from "./PositionLogo.view";
|
import { PositionLogo } from "./PositionLogo.view";
|
||||||
|
|
||||||
type PositionCardProps = {
|
type PositionCardProps = {
|
||||||
|
kind: AccountPagePositionSection["kind"];
|
||||||
position: AccountPagePosition;
|
position: AccountPagePosition;
|
||||||
wide?: boolean;
|
|
||||||
variant?: "stock" | "bond";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const PositionCard: FC<PositionCardProps> = (props) => {
|
const PositionCard: FC<PositionCardProps> = (props) => {
|
||||||
const { position, wide = false, variant } = props;
|
const { kind, position } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isExpanded = variant === "stock" || variant === "bond";
|
const isExpanded = kind !== "other";
|
||||||
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 returnClass = position.returnTone
|
const returnClass = position.returnTone
|
||||||
? classes[position.returnTone]
|
? classes[position.returnTone]
|
||||||
: undefined;
|
: undefined;
|
||||||
const returnText =
|
const returnText =
|
||||||
position.returnText && wide
|
position.returnText && isExpanded
|
||||||
? position.returnText
|
? position.returnText
|
||||||
: position.returnText
|
: position.returnText
|
||||||
? t("portfolio.return", {
|
? t("portfolio.return", {
|
||||||
@@ -46,7 +30,7 @@ const PositionCard: FC<PositionCardProps> = (props) => {
|
|||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
const identifier =
|
const identifier =
|
||||||
variant === "stock" ? (position.ticker ?? position.isin) : position.isin;
|
kind === "stocks" ? (position.ticker ?? position.isin) : position.isin;
|
||||||
const identifierText = [
|
const identifierText = [
|
||||||
t("portfolio.quantity", { value: position.quantity }),
|
t("portfolio.quantity", { value: position.quantity }),
|
||||||
identifier,
|
identifier,
|
||||||
@@ -68,22 +52,22 @@ const PositionCard: FC<PositionCardProps> = (props) => {
|
|||||||
|
|
||||||
if (isExpanded)
|
if (isExpanded)
|
||||||
return (
|
return (
|
||||||
<Card className={`${classes.tile} ${ec.card}`} withBorder>
|
<Card className={`${classes.tile} ${classes.expandedCard}`} withBorder>
|
||||||
<div className={ec.main}>
|
<div className={classes.expandedMain}>
|
||||||
<PositionLogo
|
<PositionLogo
|
||||||
key={position.logoSrc ?? "no-logo"}
|
key={position.logoSrc ?? "no-logo"}
|
||||||
src={position.logoSrc}
|
src={position.logoSrc}
|
||||||
label={position.displayName}
|
label={position.displayName}
|
||||||
/>
|
/>
|
||||||
<Stack gap={2} miw={0} className={ec.identity}>
|
<Stack gap={2} miw={0} className={classes.expandedIdentity}>
|
||||||
<Text fw={600} lineClamp={1}>
|
<Text fw={600} lineClamp={1}>
|
||||||
{position.displayName}
|
{position.displayName}
|
||||||
</Text>
|
</Text>
|
||||||
<Text c="dimmed" size="xs" className={ec.identifiers}>
|
<Text c="dimmed" size="xs" className={classes.expandedIdentifiers}>
|
||||||
{identifierText}
|
{identifierText}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack gap={2} align="flex-end" className={ec.value}>
|
<Stack gap={2} align="flex-end" className={classes.expandedValue}>
|
||||||
{position.currentValue ? (
|
{position.currentValue ? (
|
||||||
<Text fw={700} size="lg">
|
<Text fw={700} size="lg">
|
||||||
{formatMoney(position.currentValue)}
|
{formatMoney(position.currentValue)}
|
||||||
@@ -97,7 +81,7 @@ const PositionCard: FC<PositionCardProps> = (props) => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</div>
|
</div>
|
||||||
{secondaryText ? (
|
{secondaryText ? (
|
||||||
<Text c="dimmed" size="xs" className={ec.meta}>
|
<Text c="dimmed" size="xs" className={classes.expandedMeta}>
|
||||||
{secondaryText}
|
{secondaryText}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -106,13 +90,6 @@ const PositionCard: FC<PositionCardProps> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<Card className={classes.tile} withBorder>
|
<Card className={classes.tile} withBorder>
|
||||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
<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}>
|
<Stack gap={2} miw={0}>
|
||||||
<Text fw={700} lineClamp={1}>
|
<Text fw={700} lineClamp={1}>
|
||||||
{position.displayName}
|
{position.displayName}
|
||||||
|
|||||||
@@ -9,21 +9,15 @@ type PositionSectionProps = {
|
|||||||
|
|
||||||
const PositionSection: FC<PositionSectionProps> = (props) => {
|
const PositionSection: FC<PositionSectionProps> = (props) => {
|
||||||
const { section } = props;
|
const { section } = props;
|
||||||
|
const expanded = section.kind !== "other";
|
||||||
|
|
||||||
return (
|
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) => (
|
{section.positions.map((position) => (
|
||||||
<PositionCard
|
<PositionCard
|
||||||
|
kind={section.kind}
|
||||||
key={`${position.figi}-${position.displayName}-${position.quantity}`}
|
key={`${position.figi}-${position.displayName}-${position.quantity}`}
|
||||||
position={position}
|
position={position}
|
||||||
wide={section.wide}
|
|
||||||
variant={
|
|
||||||
section.kind === "stocks"
|
|
||||||
? "stock"
|
|
||||||
: section.kind === "bonds"
|
|
||||||
? "bond"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { FC } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import type { BrokerageEvent } from "@/entities/brokerage-event";
|
import type { BrokerageEvent } from "@/entities/brokerage-event";
|
||||||
import { formatMoney } from "@/entities/portfolio";
|
import { formatMoney } from "@/shared/lib";
|
||||||
import { LoadingSkeleton } from "@/shared/ui";
|
import { LoadingSkeleton } from "@/shared/ui";
|
||||||
import classes from "../AccountPage.module.css";
|
import classes from "../AccountPage.module.css";
|
||||||
import type { AccountPageRecentEvents } from "../AccountPage.vm";
|
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 {
|
export { AccountsPage } from "./ui/AccountsPage";
|
||||||
AccountsPage,
|
|
||||||
AccountsPageEmpty,
|
|
||||||
AccountsPageError,
|
|
||||||
AccountsPageLoading,
|
|
||||||
} from "./ui/AccountsPage";
|
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { type 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 { HttpResponse, http } from "msw";
|
||||||
import { MemoryRouter } from "react-router";
|
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 { brokerageAccountsQueryKey } from "@/entities/brokerage-account";
|
||||||
import { configureAuthClient } from "@/shared/api";
|
import { configureAuthClient } from "@/shared/api";
|
||||||
|
import { createTestQueryClient } from "@/test/query";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import { AccountsPage } from "./AccountsPage";
|
import { AccountsPage } from "./AccountsPage";
|
||||||
|
import { AccountsPageEmpty } from "./components/AccountsPageEmpty.view";
|
||||||
|
import { AccountsPageError } from "./components/AccountsPageError.view";
|
||||||
|
import { AccountsPageLoading } from "./components/AccountsPageLoading.view";
|
||||||
|
|
||||||
const account = {
|
const account = {
|
||||||
id: "account-1",
|
id: "account-1",
|
||||||
@@ -38,9 +42,36 @@ function renderPage(queryClient: QueryClient) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("AccountsPage", () => {
|
describe("AccountsPage", () => {
|
||||||
afterEach(() => {
|
it.each([
|
||||||
configureAuthClient(null);
|
["loading", AccountsPageLoading, "status"],
|
||||||
cleanup();
|
["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 () => {
|
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);
|
renderPage(queryClient);
|
||||||
|
|
||||||
await screen.findByText(account.name);
|
await screen.findByText(account.name);
|
||||||
@@ -86,7 +117,7 @@ describe("AccountsPage", () => {
|
|||||||
return HttpResponse.json([account]);
|
return HttpResponse.json([account]);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const queryClient = new QueryClient();
|
const queryClient = createTestQueryClient();
|
||||||
renderPage(queryClient);
|
renderPage(queryClient);
|
||||||
|
|
||||||
await screen.findByText(account.name);
|
await screen.findByText(account.name);
|
||||||
@@ -116,7 +147,7 @@ describe("AccountsPage", () => {
|
|||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const queryClient = new QueryClient();
|
const queryClient = createTestQueryClient();
|
||||||
renderPage(queryClient);
|
renderPage(queryClient);
|
||||||
|
|
||||||
await screen.findByText("Нет инвестиционных счетов");
|
await screen.findByText("Нет инвестиционных счетов");
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import {
|
|
||||||
AccountsPageEmpty,
|
|
||||||
AccountsPageError,
|
|
||||||
AccountsPageLoaded,
|
|
||||||
AccountsPageLoading,
|
|
||||||
} from "./AccountsPage.view";
|
|
||||||
import { useAccountsPageViewModel } from "./AccountsPage.vm";
|
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 AccountsPage: FC = () => {
|
||||||
const viewModel = useAccountsPageViewModel();
|
const viewModel = useAccountsPageViewModel();
|
||||||
@@ -29,9 +27,4 @@ const AccountsPage: FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export {
|
export { AccountsPage };
|
||||||
AccountsPage,
|
|
||||||
AccountsPageEmpty,
|
|
||||||
AccountsPageError,
|
|
||||||
AccountsPageLoading,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
brokerageAccountCards,
|
brokerageAccountCards,
|
||||||
errorCode,
|
|
||||||
useBrokerageAccounts,
|
useBrokerageAccounts,
|
||||||
} from "@/entities/brokerage-account";
|
} from "@/entities/brokerage-account";
|
||||||
|
import { apiErrorCode } from "@/shared/api";
|
||||||
|
|
||||||
function useAccountsPageViewModel() {
|
function useAccountsPageViewModel() {
|
||||||
const accounts = useBrokerageAccounts();
|
const accounts = useBrokerageAccounts();
|
||||||
@@ -11,7 +11,7 @@ function useAccountsPageViewModel() {
|
|||||||
if (accounts.isError)
|
if (accounts.isError)
|
||||||
return {
|
return {
|
||||||
state: "error" as const,
|
state: "error" as const,
|
||||||
code: errorCode(accounts.error),
|
code: apiErrorCode(accounts.error),
|
||||||
retry: () => accounts.refetch(),
|
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 type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { LoginForm } from "@/features/auth";
|
import { AuthPageLayout, LoginForm } from "@/features/auth";
|
||||||
|
|
||||||
const LoginPage: FC = () => {
|
const LoginPage: FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="auth-page">
|
<AuthPageLayout title={t("auth.login")}>
|
||||||
<Paper className="auth-card" p="xl" radius="lg" withBorder>
|
<LoginForm />
|
||||||
<Stack gap="lg">
|
</AuthPageLayout>
|
||||||
<Title order={1}>{t("auth.login")}</Title>
|
|
||||||
<LoginForm />
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Stack, Title } from "@mantine/core";
|
import { Button, Stack, Title } from "@mantine/core";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { Button, StateView } from "@/shared/ui";
|
import { StateView } from "@/shared/ui";
|
||||||
|
|
||||||
const NotFoundPage: FC = () => {
|
const NotFoundPage: FC = () => {
|
||||||
const { t } = useTranslation();
|
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