From c97f62bd983d6b5327132f4ec42e767118720913 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sun, 6 Sep 2026 18:20:05 +0300 Subject: [PATCH] refactor: align frontend ownership boundaries Close audit gaps across API drift, query policy, UI, and tests. --- README.md | 7 +- dependency-cruiser.cjs | 1 + docs/API-LAYER.md | 4 +- docs/CONVENTIONS.md | 19 +- docs/UI-PATTERN.md | 11 +- package.json | 3 +- scripts/check-openapi.mjs | 40 ++ src/app/routes/AppRoutes.test.tsx | 113 ++--- src/app/routes/AppRoutes.tsx | 270 ++++-------- src/app/routes/RouteErrorBoundary.test.tsx | 5 +- src/app/routes/RouteFallback.tsx | 68 +++ src/app/routes/RouteGuards.tsx | 29 ++ src/app/styles.css | 71 +--- src/app/ui/AccountBreadcrumbs.tsx | 28 ++ src/app/ui/App.tsx | 4 +- src/app/ui/AppBreadcrumbs.module.css | 13 + src/app/ui/AppBreadcrumbs.test.tsx | 3 +- src/app/ui/AppBreadcrumbs.tsx | 129 +----- src/app/ui/BreadcrumbTrail.tsx | 36 ++ .../api/useBrokerageOperations.test.tsx | 21 +- .../api/useBrokerageOperations.ts | 32 +- src/entities/broker-operation/index.ts | 1 + .../broker-operation/model/display.ts | 53 +++ .../broker-operation/ui/LedgerEntryRow.tsx | 56 +-- .../api/useBrokerageAccounts.test.tsx | 27 +- .../api/useBrokerageAccounts.ts | 8 +- src/entities/brokerage-account/index.ts | 1 - .../brokerage-account/model/display.ts | 13 +- .../ui/BrokerageAccountList.tsx | 2 +- .../api/useBrokerageEvents.test.tsx | 25 +- .../brokerage-event/api/useBrokerageEvents.ts | 8 +- src/entities/income/api/useIncome.ts | 10 +- .../portfolio/api/usePortfolio.test.tsx | 34 +- src/entities/portfolio/api/usePortfolio.ts | 24 +- src/entities/portfolio/index.ts | 12 +- src/entities/portfolio/model/display.test.ts | 22 +- src/entities/portfolio/model/display.ts | 46 +-- src/features/auth/index.ts | 1 + src/features/auth/model/auth.tsx | 3 +- src/features/auth/model/validation.ts | 48 +++ src/features/auth/ui/AuthForm.module.css | 4 + src/features/auth/ui/AuthForm.test.tsx | 12 +- src/features/auth/ui/AuthForm.tsx | 53 +-- .../auth/ui/AuthPageLayout.module.css | 12 + src/features/auth/ui/AuthPageLayout.tsx | 21 + .../model/connection-boundary.ts | 3 +- .../ui/TbankConnectionSection.test.tsx | 23 +- .../ui/TbankConnectionSection.tsx | 143 +------ .../ui/TbankConnectionSection.vm.ts | 7 +- .../ui/TbankConnectionSectionLoaded.tsx | 139 +++++++ .../account-events/ui/AccountEventsPage.tsx | 127 +----- .../ui/components/AccountEventRow.view.tsx | 44 ++ .../AccountEventsPageEmpty.view.tsx | 17 + .../AccountEventsPageError.view.tsx | 20 + .../AccountEventsPageLoaded.view.tsx | 25 ++ .../AccountEventsPageLoading.view.tsx | 19 + .../ui/AccountIncomePage.test.tsx | 72 ++-- .../account-income/ui/AccountIncomePage.tsx | 8 +- .../ui/AccountIncomePage.view.tsx | 388 ------------------ .../account-income/ui/AccountIncomePage.vm.ts | 73 +++- .../AccountIncomePageError.view.tsx | 21 + .../AccountIncomePageLoaded.view.tsx | 68 +++ .../AccountIncomePageLoading.view.tsx | 17 + .../ui/components/IncomeBreakdown.view.tsx | 43 ++ .../ui/components/IncomeComparisons.view.tsx | 72 ++++ .../ui/components/IncomeForecast.view.tsx | 68 +++ .../ui/components/IncomeSummary.view.tsx | 26 ++ .../ui/components/IncomeTrend.view.tsx | 107 +++++ .../ui/AccountOperationsPage.test.tsx | 115 ++---- .../ui/AccountOperationsPage.tsx | 3 +- .../ui/AccountOperationsPage.vm.ts | 10 +- src/pages/account/ui/AccountPage.module.css | 39 +- src/pages/account/ui/AccountPage.test.tsx | 36 +- src/pages/account/ui/AccountPage.tsx | 18 +- src/pages/account/ui/AccountPage.view.tsx | 244 ----------- src/pages/account/ui/AccountPage.vm.test.tsx | 37 +- src/pages/account/ui/AccountPage.vm.ts | 30 +- .../ui/components/AccountPageEmpty.view.tsx | 48 +++ .../ui/components/AccountPageError.view.tsx | 57 +++ .../ui/components/AccountPageLoaded.view.tsx | 117 ++++++ .../ui/components/AccountPageLoading.view.tsx | 19 + .../ui/components/CashBalances.view.tsx | 2 +- .../ui/components/IncomeSummary.view.tsx | 2 +- .../ui/components/PortfolioTotals.view.tsx | 2 +- .../ui/components/PositionCard.view.tsx | 55 +-- .../ui/components/PositionSection.view.tsx | 12 +- .../ui/components/RecentEvents.view.tsx | 2 +- src/pages/account/ui/components/index.ts | 8 - src/pages/accounts/index.ts | 7 +- src/pages/accounts/ui/AccountsPage.test.tsx | 49 ++- src/pages/accounts/ui/AccountsPage.tsx | 17 +- src/pages/accounts/ui/AccountsPage.view.tsx | 175 -------- src/pages/accounts/ui/AccountsPage.vm.ts | 4 +- .../ui/components/AccountsPageEmpty.view.tsx | 23 ++ .../ui/components/AccountsPageError.view.tsx | 59 +++ .../ui/components/AccountsPageLoaded.view.tsx | 29 ++ .../components/AccountsPageLoading.view.tsx | 18 + .../components/AccountsRefreshState.view.tsx | 39 ++ src/pages/login/ui/LoginPage.tsx | 14 +- src/pages/not-found/ui/NotFoundPage.tsx | 4 +- src/pages/register/ui/RegisterPage.tsx | 14 +- src/shared/api/authenticated-query.test.tsx | 25 +- src/shared/api/client.test.ts | 4 +- src/shared/api/generated/schema.d.ts | 2 +- src/shared/api/index.ts | 2 + src/shared/api/problem-details.ts | 7 + src/shared/api/spec/openapi.json | 4 +- src/shared/lib/display.test.ts | 24 ++ src/shared/lib/display.ts | 35 ++ src/shared/lib/index.ts | 5 + src/shared/ui/AppShell.module.css | 33 ++ src/shared/ui/AppShell.tsx | 11 +- src/shared/ui/StateView.test.tsx | 6 +- src/shared/ui/StateView.tsx | 4 +- src/shared/ui/index.ts | 2 +- src/test/handlers.ts | 8 + src/test/query.tsx | 23 ++ src/test/setup.ts | 8 +- 118 files changed, 2190 insertions(+), 2254 deletions(-) create mode 100644 scripts/check-openapi.mjs create mode 100644 src/app/routes/RouteFallback.tsx create mode 100644 src/app/routes/RouteGuards.tsx create mode 100644 src/app/ui/AccountBreadcrumbs.tsx create mode 100644 src/app/ui/AppBreadcrumbs.module.css create mode 100644 src/app/ui/BreadcrumbTrail.tsx create mode 100644 src/entities/broker-operation/model/display.ts create mode 100644 src/features/auth/model/validation.ts create mode 100644 src/features/auth/ui/AuthForm.module.css create mode 100644 src/features/auth/ui/AuthPageLayout.module.css create mode 100644 src/features/auth/ui/AuthPageLayout.tsx create mode 100644 src/features/tbank-connection/ui/TbankConnectionSectionLoaded.tsx create mode 100644 src/pages/account-events/ui/components/AccountEventRow.view.tsx create mode 100644 src/pages/account-events/ui/components/AccountEventsPageEmpty.view.tsx create mode 100644 src/pages/account-events/ui/components/AccountEventsPageError.view.tsx create mode 100644 src/pages/account-events/ui/components/AccountEventsPageLoaded.view.tsx create mode 100644 src/pages/account-events/ui/components/AccountEventsPageLoading.view.tsx delete mode 100644 src/pages/account-income/ui/AccountIncomePage.view.tsx create mode 100644 src/pages/account-income/ui/components/AccountIncomePageError.view.tsx create mode 100644 src/pages/account-income/ui/components/AccountIncomePageLoaded.view.tsx create mode 100644 src/pages/account-income/ui/components/AccountIncomePageLoading.view.tsx create mode 100644 src/pages/account-income/ui/components/IncomeBreakdown.view.tsx create mode 100644 src/pages/account-income/ui/components/IncomeComparisons.view.tsx create mode 100644 src/pages/account-income/ui/components/IncomeForecast.view.tsx create mode 100644 src/pages/account-income/ui/components/IncomeSummary.view.tsx create mode 100644 src/pages/account-income/ui/components/IncomeTrend.view.tsx delete mode 100644 src/pages/account/ui/AccountPage.view.tsx create mode 100644 src/pages/account/ui/components/AccountPageEmpty.view.tsx create mode 100644 src/pages/account/ui/components/AccountPageError.view.tsx create mode 100644 src/pages/account/ui/components/AccountPageLoaded.view.tsx create mode 100644 src/pages/account/ui/components/AccountPageLoading.view.tsx delete mode 100644 src/pages/account/ui/components/index.ts delete mode 100644 src/pages/accounts/ui/AccountsPage.view.tsx create mode 100644 src/pages/accounts/ui/components/AccountsPageEmpty.view.tsx create mode 100644 src/pages/accounts/ui/components/AccountsPageError.view.tsx create mode 100644 src/pages/accounts/ui/components/AccountsPageLoaded.view.tsx create mode 100644 src/pages/accounts/ui/components/AccountsPageLoading.view.tsx create mode 100644 src/pages/accounts/ui/components/AccountsRefreshState.view.tsx create mode 100644 src/shared/api/problem-details.ts create mode 100644 src/shared/lib/display.test.ts create mode 100644 src/shared/lib/display.ts create mode 100644 src/shared/lib/index.ts create mode 100644 src/shared/ui/AppShell.module.css create mode 100644 src/test/query.tsx diff --git a/README.md b/README.md index 67bfd69..ab3f45e 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,13 @@ npm run dev ## Проверка ```bash +nvm use --silent npm run verify ``` -Команда сначала отклоняет runtime вне Node 22, затем проверяет repository -hygiene, strict TypeScript, Biome, один coverage-backed запуск Vitest, Knip, -dependency rules и production build. Если backend-контракт изменился, +Команда проверяет, что generated OpenAPI types соответствуют committed snapshot, +затем запускает strict TypeScript, Biome, один coverage-backed запуск Vitest, +Knip, dependency rules и production build. Если backend-контракт изменился, обновите `src/shared/api/spec/openapi.json` копированием backend OpenAPI document и затем выполните `npm run openapi:generate`. diff --git a/dependency-cruiser.cjs b/dependency-cruiser.cjs index 3e11c2e..a87fcea 100644 --- a/dependency-cruiser.cjs +++ b/dependency-cruiser.cjs @@ -45,5 +45,6 @@ module.exports = { options: { doNotFollow: { path: "node_modules" }, tsConfig: { fileName: "tsconfig.app.json" }, + tsPreCompilationDeps: true, }, }; diff --git a/docs/API-LAYER.md b/docs/API-LAYER.md index 863c1f2..c152cec 100644 --- a/docs/API-LAYER.md +++ b/docs/API-LAYER.md @@ -2,7 +2,9 @@ OpenAPI backend является источником истины. После изменения backend-контракта committed snapshot `src/shared/api/spec/openapi.json` обновляют простым копированием опубликованного документа backend, а затем регенерируют `src/shared/api/generated/schema.d.ts` командой `npm run openapi:generate`. Оба файла committed; generated файл не редактируется вручную. -`npm run openapi:generate` использует committed snapshot offline. `verify` никогда не требует backend и не пытается автоматически синхронизировать OpenAPI artifacts. +`npm run openapi:generate` использует committed snapshot offline. `verify` +повторно генерирует types во временную директорию и сравнивает их с committed +`schema.d.ts`; backend для этого не требуется, а tracked files gate не меняет. Все HTTP-вызовы используют `apiClient` и `apiQueryClient` из `src/shared/api`. Прямой `fetch` в features, pages и entities не используется. Query options берутся из `apiQueryClient`, а сохранённые cache keys и policies задаются вызывающим slice. diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index e60d973..58ec1fa 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -9,9 +9,8 @@ - Публичный импорт слайса идёт через его `index.ts`. - Тесты лежат рядом с исходником как `*.test.tsx`. - Зависимости используют exact versions; не добавлять semver ranges. -- Главный `*.view.tsx` страницы не содержит несколько state-компонентов: - каждый state/section — отдельный `*.view.tsx` в `components/` (см. File roles). -- Полный локальный gate: `npm run verify`. Pre-commit запускает staged formatting, OpenAPI check и FSD dependency checks; commit message должен следовать Conventional Commits. +- Полный локальный gate: `npm run verify`. Pre-commit запускает staged formatting + и FSD dependency checks; commit message должен следовать Conventional Commits. ## React component style @@ -39,11 +38,9 @@ Mandatory review conventions: - `Component.types.ts` - props и локальные типы. - `Component.module.css` - локальные стили. -State-компоненты страницы не колоцируются в одном `*.view.tsx`. Каждый -top-level state (loading, error, empty, loaded) и каждый именуемый section -выносится в отдельный `*.view.tsx` в подпапку `components/`. Имя файла -отражает state/section: `AccountOperationsPageLoaded.view.tsx`, -`AccountOperationsPageLoading.view.tsx`, `AccountOperationsPageError.view.tsx`. -Контейнер (`AccountOperationsPage.tsx`) импортирует их из `./components` и -выбирает нужный по `viewModel.state`. Нельзя держать несколько state components -внутри одного файла view — это mandatory review convention. +Самостоятельный top-level state или именуемый section обычно выносится в +соседний `*.view.tsx`, когда у него есть отдельная responsibility, reuse либо +change cadence или он скрывает principal component. Небольшой tightly-coupled +renderer остаётся рядом с parent. Предпочтительный flat pattern — +`components/AccountOperationsPageLoaded.view.tsx` без отдельной директории, +barrel, test и stylesheet для каждого компонента. diff --git a/docs/UI-PATTERN.md b/docs/UI-PATTERN.md index 3c1d72d..bcdf6aa 100644 --- a/docs/UI-PATTERN.md +++ b/docs/UI-PATTERN.md @@ -27,12 +27,11 @@ image state. Простое форматирование одного перед существующий formatter остаётся presentation concern; вычисления из всей коллекции остаются во VM. -View files дробятся по смысловой нагрузке, а не только по количеству строк. -Каждый top-level state и каждый именуемый section страницы — отдельный -`*.view.tsx` в `components/`, а не накопление внутри одного большого -`*.view.tsx`. Контейнер импортирует state views из `./components` и выбирает -нужный по `viewModel.state`. Держать loading/error/empty/loaded components в -одном файле view нельзя. +View files дробятся по ownership и смысловой нагрузке, а не по количеству строк. +Самостоятельные top-level states и sections выносятся в flat `components/*.view.tsx`, +если это делает principal component и границы изменений яснее. Маленькие +tightly-coupled renderers остаются рядом с parent; механическое правило «один +component — один file» и line-count gate не используются. Known API errors, retry и stale snapshot остаются local views, а непойманные route errors обрабатывает общий route-keyed ErrorBoundary. diff --git a/package.json b/package.json index e39d177..4bd544c 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,10 @@ "test:run": "vitest run", "test:coverage": "vitest run --coverage", "openapi:generate": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts", + "openapi:check": "node scripts/check-openapi.mjs", "knip": "knip", "deps": "depcruise src --config dependency-cruiser.cjs --output-type err-long", - "verify": "npm run typecheck && npm run lint && npm run test:coverage && npm run knip && npm run deps && npm run build", + "verify": "npm run openapi:check && npm run typecheck && npm run lint && npm run test:coverage && npm run knip && npm run deps && npm run build", "prepare": "husky", "preview": "vite preview" }, diff --git a/scripts/check-openapi.mjs b/scripts/check-openapi.mjs new file mode 100644 index 0000000..28a3c97 --- /dev/null +++ b/scripts/check-openapi.mjs @@ -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; diff --git a/src/app/routes/AppRoutes.test.tsx b/src/app/routes/AppRoutes.test.tsx index 0d99aab..e3594cd 100644 --- a/src/app/routes/AppRoutes.test.tsx +++ b/src/app/routes/AppRoutes.test.tsx @@ -1,25 +1,20 @@ import { MantineProvider } from "@mantine/core"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { HttpResponse, http } from "msw"; import { MemoryRouter } from "react-router"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { AuthProvider } from "@/features/auth"; -import { - AccountsPageEmpty, - AccountsPageError, - AccountsPageLoading, -} from "@/pages/accounts"; +import { createTestQueryClient } from "@/test/query"; import { server } from "@/test/server"; import { theme } from "../theme"; import { AppRoutes } from "./AppRoutes"; describe("AppRoutes", () => { - afterEach(cleanup); function renderRoutes(path: string) { return render( - + @@ -50,37 +45,6 @@ describe("AppRoutes", () => { ); }); - it("renders the T-Bank settings action for a missing connection", () => { - render( - - - - - , - ); - - 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( - - - - - , - ); - - screen.getByRole("button", { name: "Повторить" }).click(); - expect(retry).toHaveBeenCalledOnce(); - expect( - screen.getByRole("link", { name: "Открыть настройки T-Bank" }), - ).toHaveAttribute("href", "/settings"); - }); - it("redirects anonymous users from accounts", async () => { renderRoutes("/accounts"); @@ -91,6 +55,40 @@ describe("AppRoutes", () => { ); }); + it("logs in through the public form and reaches the protected landing", async () => { + let credentials: unknown; + server.use( + http.post("*/api/v1/auth/login", async ({ request }) => { + credentials = await request.json(); + return HttpResponse.json({ + accessToken: "login-token", + user: { id: "user-1", email: "user@example.com" }, + }); + }), + ); + renderRoutes("/accounts"); + + await screen.findByRole("heading", { level: 1, name: "Вход" }); + fireEvent.change(screen.getByLabelText("Email"), { + target: { value: " User@Example.com " }, + }); + fireEvent.change(screen.getByLabelText("Пароль"), { + target: { value: "a secure password" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Вход" })); + + expect( + await screen.findByRole("heading", { + level: 2, + name: "Нет инвестиционных счетов", + }), + ).toBeInTheDocument(); + expect(credentials).toEqual({ + email: "user@example.com", + password: "a secure password", + }); + }); + it("lazy-loads the protected shell for an authenticated accounts route", async () => { server.use( http.post("*/api/v1/auth/refresh", () => @@ -114,37 +112,4 @@ describe("AppRoutes", () => { screen.getByRole("navigation", { name: "WealthLedger" }), ).toBeInTheDocument(); }); - - it.each([ - ["loading", AccountsPageLoading, "status"], - ["error", AccountsPageError, "alert"], - ["empty", AccountsPageEmpty, undefined], - ] as const)("renders only the Accounts %s state", (_name, State, role) => { - render( - - - , - ); - - 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, - ); - }); }); diff --git a/src/app/routes/AppRoutes.tsx b/src/app/routes/AppRoutes.tsx index 8b2b8bb..ac35922 100644 --- a/src/app/routes/AppRoutes.tsx +++ b/src/app/routes/AppRoutes.tsx @@ -1,9 +1,8 @@ -import { Container, Group, Skeleton, Stack } from "@mantine/core"; -import type { FC, PropsWithChildren } from "react"; -import { lazy, Suspense } from "react"; -import { useTranslation } from "react-i18next"; -import { Navigate, Route, Routes, useLocation } from "react-router"; -import { useAuth } from "@/features/auth"; +import type { FC } from "react"; +import { lazy } from "react"; +import { Navigate, Route, Routes } from "react-router"; +import { LazyPage } from "./RouteFallback"; +import { Protected, Public } from "./RouteGuards"; const AccountPage = lazy(async () => { const { AccountPage } = await import("@/pages/account"); @@ -46,187 +45,96 @@ const ProtectedLayout = lazy(async () => { return { default: ProtectedLayout }; }); -type RouteSkeletonVariant = "auth" | "content" | "shell"; - -type LazyPageProps = PropsWithChildren<{ - variant: RouteSkeletonVariant; -}>; - -const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => { - const { variant } = props; - const { t } = useTranslation(); - - if (variant === "auth") - return ( - - - - - - - - - ); - - if (variant === "shell") - return ( - - - - - - - - - - - - - - ); - - return ( - - - - - ); -}; - -const LazyPage: FC = (props) => { - const { children, variant } = props; - - return ( - }> - {children} - - ); -}; - -const Protected: FC = (props) => { - const { children } = props; - const { user, ready } = useAuth(); - const location = useLocation(); - if (!ready) return ; - return user ? ( - children - ) : ( - ( + + } /> + + + + + + } /> - ); -}; - -const Public: FC = (props) => { - const { children } = props; - const { user, ready } = useAuth(); - if (!ready) return ; - return user ? : children; -}; - -const AppRoutes: FC = () => { - return ( - - } /> + + + + + + } + /> + + + + + + } + > - - - - - } - /> - - - - - - } - /> - - - - - - } - > - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - + } /> - - ); -}; + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + + } + /> + +); export { AppRoutes }; diff --git a/src/app/routes/RouteErrorBoundary.test.tsx b/src/app/routes/RouteErrorBoundary.test.tsx index bdcc7e4..0acd840 100644 --- a/src/app/routes/RouteErrorBoundary.test.tsx +++ b/src/app/routes/RouteErrorBoundary.test.tsx @@ -1,8 +1,8 @@ import { MantineProvider } from "@mantine/core"; -import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import type { FC } from "react"; import { MemoryRouter, useLocation, useNavigate } from "react-router"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { RouteErrorBoundary } from "./RouteErrorBoundary"; let authenticated = false; @@ -52,7 +52,6 @@ function renderBoundary(path: string) { } describe("RouteErrorBoundary", () => { - afterEach(cleanup); it("offers login after a public route error without error details", () => { authenticated = false; renderBoundary("/login"); diff --git a/src/app/routes/RouteFallback.tsx b/src/app/routes/RouteFallback.tsx new file mode 100644 index 0000000..9d90965 --- /dev/null +++ b/src/app/routes/RouteFallback.tsx @@ -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 ( + + + + + + + + + ); + + if (variant === "shell") + return ( + + + + + + + + + + + + + + ); + + return ( + + + + + ); +}; + +const LazyPage: FC = (props) => { + const { children, variant } = props; + return ( + }> + {children} + + ); +}; + +export { LazyPage, RouteSkeleton }; diff --git a/src/app/routes/RouteGuards.tsx b/src/app/routes/RouteGuards.tsx new file mode 100644 index 0000000..3fe0f8d --- /dev/null +++ b/src/app/routes/RouteGuards.tsx @@ -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 = (props) => { + const { children } = props; + const { user, ready } = useAuth(); + const location = useLocation(); + if (!ready) return ; + return user ? ( + children + ) : ( + + ); +}; + +const Public: FC = (props) => { + const { children } = props; + const { user, ready } = useAuth(); + if (!ready) return ; + return user ? : children; +}; + +export { Protected, Public }; diff --git a/src/app/styles.css b/src/app/styles.css index c554bd3..d520723 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -1,79 +1,12 @@ -:root { - --ft-color-bg-page: #e8ecf1; - --ft-color-bg-app: #f5f7fa; - --ft-color-bg-surface: #ffffff; - --ft-color-bg-muted: #f1f5f9; - --ft-color-text-primary: #1e293b; - --ft-color-text-secondary: #64748b; - --ft-color-border: #e2e8f0; - --ft-color-accent: #2563eb; - --ft-color-danger: #ef4444; - --ft-radius-card: 12px; - --ft-shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08); -} - * { box-sizing: border-box; } body { margin: 0; - background: var(--ft-color-bg-page); - color: var(--ft-color-text-primary); + background: var(--mantine-color-gray-2); + color: var(--mantine-color-dark-7); } a { color: inherit; text-decoration: none; } -.app-header { - align-items: center; - display: flex; - padding: 0 1rem; -} -.bottom-nav { - display: none; -} -[data-depth] { - margin-bottom: 1rem; -} -[data-depth] a { - color: var(--ft-color-accent); -} -.auth-page { - align-items: center; - display: flex; - justify-content: center; - min-height: 100dvh; - padding: 1.5rem; -} -.auth-card { - box-shadow: 0 12px 32px rgba(30, 41, 59, 0.1); - width: min(100%, 26rem); -} -.auth-card fieldset { - border: 0; - padding: 0; -} -@media (max-width: 47.99375em) { - .bottom-nav { - background: var(--ft-color-bg-surface); - border-top: 1px solid var(--ft-color-border); - bottom: 0; - display: flex; - justify-content: space-around; - left: 0; - padding: 0.75rem; - position: fixed; - right: 0; - z-index: 200; - } - .bottom-nav .active { - color: var(--ft-color-accent); - font-weight: 600; - } - [data-depth="3"] > :nth-child(-n + 2) { - display: none; - } - .mantine-AppShell-main { - padding-bottom: 5rem; - } -} diff --git a/src/app/ui/AccountBreadcrumbs.tsx b/src/app/ui/AccountBreadcrumbs.tsx new file mode 100644 index 0000000..e96b09a --- /dev/null +++ b/src/app/ui/AccountBreadcrumbs.tsx @@ -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 = (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 ; +}; + +export { AccountBreadcrumbs }; diff --git a/src/app/ui/App.tsx b/src/app/ui/App.tsx index 4819c6d..652f94d 100644 --- a/src/app/ui/App.tsx +++ b/src/app/ui/App.tsx @@ -8,7 +8,9 @@ import { theme } from "@/app/theme"; import { AuthProvider } from "@/features/auth"; const queryClient = new QueryClient({ - defaultOptions: { queries: { refetchOnWindowFocus: false } }, + defaultOptions: { + queries: { refetchOnWindowFocus: false, retry: false }, + }, }); const App: FC = () => { diff --git a/src/app/ui/AppBreadcrumbs.module.css b/src/app/ui/AppBreadcrumbs.module.css new file mode 100644 index 0000000..6439e50 --- /dev/null +++ b/src/app/ui/AppBreadcrumbs.module.css @@ -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; + } +} diff --git a/src/app/ui/AppBreadcrumbs.test.tsx b/src/app/ui/AppBreadcrumbs.test.tsx index 5628e70..aaa8c46 100644 --- a/src/app/ui/AppBreadcrumbs.test.tsx +++ b/src/app/ui/AppBreadcrumbs.test.tsx @@ -1,5 +1,5 @@ import { MantineProvider } from "@mantine/core"; -import { cleanup, render, screen } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router"; import { afterEach, describe, expect, it, vi } from "vitest"; import { AppBreadcrumbs } from "./AppBreadcrumbs"; @@ -26,7 +26,6 @@ function renderBreadcrumbs( describe("AppBreadcrumbs", () => { afterEach(() => { - cleanup(); vi.clearAllMocks(); }); diff --git a/src/app/ui/AppBreadcrumbs.tsx b/src/app/ui/AppBreadcrumbs.tsx index 9d32afb..c361f24 100644 --- a/src/app/ui/AppBreadcrumbs.tsx +++ b/src/app/ui/AppBreadcrumbs.tsx @@ -1,141 +1,44 @@ -import { Breadcrumbs } from "@mantine/core"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; -import { Link, useMatch } from "react-router"; -import { useBrokerageAccounts } from "@/entities/brokerage-account"; - -type Crumb = { label: string; to?: string }; - -type BreadcrumbTrailProps = { - items: Crumb[]; -}; - -type AccountBreadcrumbsProps = { - accountId: string; - events: boolean; - income: boolean; - operations: boolean; -}; - -const BreadcrumbTrail: FC = (props) => { - const { items } = props; - const { t } = useTranslation(); - - return ( - - ); -}; - -const AccountBreadcrumbs: FC = (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 ( - - ); -}; +import { useMatch } from "react-router"; +import { AccountBreadcrumbs } from "./AccountBreadcrumbs"; +import { BreadcrumbTrail } from "./BreadcrumbTrail"; const AppBreadcrumbs: FC = () => { const { t } = useTranslation(); - const accountOperations = useMatch({ + const operations = useMatch({ path: "/accounts/:accountId/operations", end: true, }); - const accountEvents = useMatch({ - path: "/accounts/:accountId/events", - end: true, - }); - const accountIncome = useMatch({ - path: "/accounts/:accountId/income", - end: true, - }); + const events = useMatch({ path: "/accounts/:accountId/events", end: true }); + const income = useMatch({ path: "/accounts/:accountId/income", end: true }); const account = useMatch({ path: "/accounts/:accountId", end: true }); const accounts = useMatch({ path: "/accounts", end: true }); const settings = useMatch({ path: "/settings", end: true }); - if (accountOperations) + if (operations) return ( ); - if (accountEvents) + if (events) return ( ); - if (accountIncome) + if (income) return ( ); if (account) - return ( - - ); + return ; if (accounts) return ; if (settings) diff --git a/src/app/ui/BreadcrumbTrail.tsx b/src/app/ui/BreadcrumbTrail.tsx new file mode 100644 index 0000000..c5571c8 --- /dev/null +++ b/src/app/ui/BreadcrumbTrail.tsx @@ -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 = (props) => { + const { items } = props; + const { t } = useTranslation(); + return ( + + ); +}; + +export { BreadcrumbTrail }; diff --git a/src/entities/broker-operation/api/useBrokerageOperations.test.tsx b/src/entities/broker-operation/api/useBrokerageOperations.test.tsx index 7c97966..800ecf8 100644 --- a/src/entities/broker-operation/api/useBrokerageOperations.test.tsx +++ b/src/entities/broker-operation/api/useBrokerageOperations.test.tsx @@ -1,17 +1,13 @@ -import { MantineProvider } from "@mantine/core"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { renderHook, waitFor } from "@testing-library/react"; -import type { FC, PropsWithChildren } from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { configureAuthClient } from "@/shared/api"; +import { createQueryWrapper } from "@/test/query"; import { brokerageOperationsQueryParams, useBrokerageOperations, } from "./useBrokerageOperations"; describe("useBrokerageOperations", () => { - afterEach(() => configureAuthClient(null)); - it("normalizes query parameters and loads a page without retries", async () => { configureAuthClient({ accessToken: () => "token", @@ -24,20 +20,9 @@ describe("useBrokerageOperations", () => { cursor: "next", }), ).toEqual({ categories: ["income", "tax"], cursor: "next" }); - const client = new QueryClient(); - const wrapper: FC = (props) => { - const { children } = props; - - return ( - - {children} - - ); - }; - const { result } = renderHook( () => useBrokerageOperations("account-1", { limit: 5 }), - { wrapper }, + { wrapper: createQueryWrapper().wrapper }, ); await waitFor(() => expect(result.current.isSuccess).toBe(true)); diff --git a/src/entities/broker-operation/api/useBrokerageOperations.ts b/src/entities/broker-operation/api/useBrokerageOperations.ts index 3c7f602..b02bafa 100644 --- a/src/entities/broker-operation/api/useBrokerageOperations.ts +++ b/src/entities/broker-operation/api/useBrokerageOperations.ts @@ -1,5 +1,8 @@ -import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api"; -import type { paths } from "@/shared/api/generated/schema"; +import { + apiQueryClient, + type paths, + useAuthenticatedQuery, +} from "@/shared/api"; type OperationsEndpoint = paths["/api/v1/brokerage-accounts/{accountId}/operations"]["get"]; @@ -32,25 +35,36 @@ function brokerageOperationsQueryParams(query: BrokerageOperationsQuery) { }; } +function brokerageOperationsQueryOptions( + accountId: string | undefined, + query: BrokerageOperationsQuery = {}, +) { + return apiQueryClient.queryOptions( + "get", + "/api/v1/brokerage-accounts/{accountId}/operations", + { + params: { + path: { accountId: accountId ?? "" }, + query: brokerageOperationsQueryParams(query), + }, + }, + ); +} + function useBrokerageOperations( accountId: string | undefined, query: BrokerageOperationsQuery = {}, ) { - const params = brokerageOperationsQueryParams(query); return useAuthenticatedQuery({ - ...apiQueryClient.queryOptions( - "get", - "/api/v1/brokerage-accounts/{accountId}/operations", - { params: { path: { accountId: accountId ?? "" }, query: params } }, - ), + ...brokerageOperationsQueryOptions(accountId, query), enabled: Boolean(accountId), staleTime: 30_000, - retry: false, }); } export { type BrokerageOperation, + brokerageOperationsQueryOptions, brokerageOperationsQueryParams, type OperationCategory, operationCategories, diff --git a/src/entities/broker-operation/index.ts b/src/entities/broker-operation/index.ts index cfe67cb..f293b53 100644 --- a/src/entities/broker-operation/index.ts +++ b/src/entities/broker-operation/index.ts @@ -1,5 +1,6 @@ export { type BrokerageOperation, + brokerageOperationsQueryOptions, type OperationCategory, operationCategories, useBrokerageOperations, diff --git a/src/entities/broker-operation/model/display.ts b/src/entities/broker-operation/model/display.ts new file mode 100644 index 0000000..28f93ee --- /dev/null +++ b/src/entities/broker-operation/model/display.ts @@ -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 }; diff --git a/src/entities/broker-operation/ui/LedgerEntryRow.tsx b/src/entities/broker-operation/ui/LedgerEntryRow.tsx index f1637e8..e83fcbb 100644 --- a/src/entities/broker-operation/ui/LedgerEntryRow.tsx +++ b/src/entities/broker-operation/ui/LedgerEntryRow.tsx @@ -2,32 +2,15 @@ import { Avatar, Paper, Stack, Text } from "@mantine/core"; import type { FC } from "react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { - formatMoney, - formatSnapshotTime, - instrumentLogoUrl, -} from "@/entities/portfolio"; +import { formatSnapshotTime, instrumentLogoUrl } from "@/shared/lib"; import type { BrokerageOperation } from "../api/useBrokerageOperations"; +import { paymentPresentation } from "../model/display"; import classes from "./LedgerEntryRow.module.css"; type LedgerEntryRowProps = { operation: BrokerageOperation; }; -type PaymentPresentation = { - amount: string; - label: string; - tone: "positive" | "negative" | "neutral"; -}; - -function entryLabelKey(operation: BrokerageOperation): string { - if (operation.category === "income") - return operation.type === "OPERATION_TYPE_COUPON" - ? "ledger.entryLabel.income.coupon" - : "ledger.entryLabel.income.dividend"; - return `ledger.category.${operation.category}`; -} - const LedgerEntryRow: FC = (props) => { const { operation } = props; const { t } = useTranslation(); @@ -72,39 +55,4 @@ const LedgerEntryRow: FC = (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 }; diff --git a/src/entities/brokerage-account/api/useBrokerageAccounts.test.tsx b/src/entities/brokerage-account/api/useBrokerageAccounts.test.tsx index 8c52410..047d1f5 100644 --- a/src/entities/brokerage-account/api/useBrokerageAccounts.test.tsx +++ b/src/entities/brokerage-account/api/useBrokerageAccounts.test.tsx @@ -1,31 +1,12 @@ -import { MantineProvider } from "@mantine/core"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { renderHook, waitFor } from "@testing-library/react"; import { HttpResponse, http } from "msw"; -import type { FC, PropsWithChildren } from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { configureAuthClient } from "@/shared/api"; +import { createQueryWrapper } from "@/test/query"; import { server } from "@/test/server"; import { useBrokerageAccounts } from "./useBrokerageAccounts"; -function createWrapper() { - const client = new QueryClient(); - const TestWrapper: FC = (props) => { - const { children } = props; - - return ( - - {children} - - ); - }; - - return TestWrapper; -} - describe("useBrokerageAccounts", () => { - afterEach(() => configureAuthClient(null)); - it("loads accounts through the authenticated query seam", async () => { configureAuthClient({ accessToken: () => "token", @@ -46,7 +27,7 @@ describe("useBrokerageAccounts", () => { ); const { result } = renderHook(() => useBrokerageAccounts(), { - wrapper: createWrapper(), + wrapper: createQueryWrapper().wrapper, }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); @@ -83,7 +64,7 @@ describe("useBrokerageAccounts", () => { }), ); const { result } = renderHook(() => useBrokerageAccounts(), { - wrapper: createWrapper(), + wrapper: createQueryWrapper().wrapper, }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); diff --git a/src/entities/brokerage-account/api/useBrokerageAccounts.ts b/src/entities/brokerage-account/api/useBrokerageAccounts.ts index 5136089..83ac0cd 100644 --- a/src/entities/brokerage-account/api/useBrokerageAccounts.ts +++ b/src/entities/brokerage-account/api/useBrokerageAccounts.ts @@ -1,5 +1,8 @@ -import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api"; -import type { paths } from "@/shared/api/generated/schema"; +import { + apiQueryClient, + type paths, + useAuthenticatedQuery, +} from "@/shared/api"; const brokerageAccountsQueryOptions = () => apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"); @@ -13,7 +16,6 @@ function useBrokerageAccounts() { return useAuthenticatedQuery({ ...brokerageAccountsQueryOptions(), staleTime: 60_000, - retry: false, }); } diff --git a/src/entities/brokerage-account/index.ts b/src/entities/brokerage-account/index.ts index fb1163e..a6bf808 100644 --- a/src/entities/brokerage-account/index.ts +++ b/src/entities/brokerage-account/index.ts @@ -7,6 +7,5 @@ export { accountTypeKey, type BrokerageAccountCard, brokerageAccountCards, - errorCode, } from "./model/display"; export { BrokerageAccountList } from "./ui/BrokerageAccountList"; diff --git a/src/entities/brokerage-account/model/display.ts b/src/entities/brokerage-account/model/display.ts index 61c45aa..c0b72e2 100644 --- a/src/entities/brokerage-account/model/display.ts +++ b/src/entities/brokerage-account/model/display.ts @@ -16,12 +16,6 @@ function accountTypeKey(type: BrokerageAccount["type"]) { return `accounts.types.${type}` as const; } -function errorCode(error: unknown): string | undefined { - return typeof error === "object" && error !== null && "code" in error - ? String(error.code) - : undefined; -} - function brokerageAccountCards( accounts: BrokerageAccount[], ): BrokerageAccountCard[] { @@ -44,9 +38,4 @@ function brokerageAccountCards( }); } -export { - accountTypeKey, - type BrokerageAccountCard, - brokerageAccountCards, - errorCode, -}; +export { accountTypeKey, type BrokerageAccountCard, brokerageAccountCards }; diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList.tsx index abfb332..18ff8cc 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList.tsx +++ b/src/entities/brokerage-account/ui/BrokerageAccountList.tsx @@ -2,7 +2,7 @@ import { Card, Group, Stack, Text } from "@mantine/core"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; -import { formatMoney } from "@/entities/portfolio"; +import { formatMoney } from "@/shared/lib"; import type { BrokerageAccountCard } from "../model/display"; import { accountTypeKey } from "../model/display"; import classes from "./BrokerageAccountList.module.css"; diff --git a/src/entities/brokerage-event/api/useBrokerageEvents.test.tsx b/src/entities/brokerage-event/api/useBrokerageEvents.test.tsx index 6ba2bd3..76f74cc 100644 --- a/src/entities/brokerage-event/api/useBrokerageEvents.test.tsx +++ b/src/entities/brokerage-event/api/useBrokerageEvents.test.tsx @@ -1,31 +1,12 @@ -import { MantineProvider } from "@mantine/core"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { renderHook, waitFor } from "@testing-library/react"; import { HttpResponse, http } from "msw"; -import type { FC, PropsWithChildren } from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { configureAuthClient } from "@/shared/api"; +import { createQueryWrapper } from "@/test/query"; import { server } from "@/test/server"; import { useBrokerageEvents } from "./useBrokerageEvents"; -function createWrapper() { - const client = new QueryClient(); - const TestWrapper: FC = (props) => { - const { children } = props; - - return ( - - {children} - - ); - }; - - return TestWrapper; -} - describe("useBrokerageEvents", () => { - afterEach(() => configureAuthClient(null)); - it("loads future cash events through the authenticated query seam", async () => { configureAuthClient({ accessToken: () => "token", @@ -54,7 +35,7 @@ describe("useBrokerageEvents", () => { ); const { result } = renderHook(() => useBrokerageEvents("account-1"), { - wrapper: createWrapper(), + wrapper: createQueryWrapper().wrapper, }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); diff --git a/src/entities/brokerage-event/api/useBrokerageEvents.ts b/src/entities/brokerage-event/api/useBrokerageEvents.ts index dca2d0d..d7c74be 100644 --- a/src/entities/brokerage-event/api/useBrokerageEvents.ts +++ b/src/entities/brokerage-event/api/useBrokerageEvents.ts @@ -1,5 +1,8 @@ -import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api"; -import type { paths } from "@/shared/api/generated/schema"; +import { + apiQueryClient, + type paths, + useAuthenticatedQuery, +} from "@/shared/api"; type EventsEndpoint = paths["/api/v1/brokerage-accounts/{accountId}/events"]["get"]; @@ -15,7 +18,6 @@ function useBrokerageEvents(accountId: string | undefined) { ), enabled: Boolean(accountId), staleTime: 30_000, - retry: false, }); } diff --git a/src/entities/income/api/useIncome.ts b/src/entities/income/api/useIncome.ts index 89d2599..628303c 100644 --- a/src/entities/income/api/useIncome.ts +++ b/src/entities/income/api/useIncome.ts @@ -1,5 +1,8 @@ -import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api"; -import type { paths } from "@/shared/api/generated/schema"; +import { + apiQueryClient, + type paths, + useAuthenticatedQuery, +} from "@/shared/api"; type IncomeSummary = paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"]; @@ -15,7 +18,6 @@ function useIncomeSummary(accountId: string | undefined) { ), enabled: Boolean(accountId), staleTime: 30_000, - retry: false, }); } @@ -36,8 +38,6 @@ function useIncomeAnalytics( ), enabled: Boolean(accountId), staleTime: 30_000, - retry: false, - refetchOnWindowFocus: false, }); } diff --git a/src/entities/portfolio/api/usePortfolio.test.tsx b/src/entities/portfolio/api/usePortfolio.test.tsx index f625713..4a62312 100644 --- a/src/entities/portfolio/api/usePortfolio.test.tsx +++ b/src/entities/portfolio/api/usePortfolio.test.tsx @@ -1,36 +1,21 @@ -import { MantineProvider } from "@mantine/core"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { renderHook, waitFor } from "@testing-library/react"; import { HttpResponse, http } from "msw"; -import type { FC, PropsWithChildren } from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { configureAuthClient } from "@/shared/api"; +import { createQueryWrapper } from "@/test/query"; import { server } from "@/test/server"; import { usePortfolio } from "./usePortfolio"; describe("usePortfolio", () => { - afterEach(() => configureAuthClient(null)); - it("uses the account endpoint with a 30 second cache and no retry", async () => { configureAuthClient({ accessToken: () => "token", refresh: async () => true, clear: vi.fn(), }); - const client = new QueryClient(); - const wrapper: FC = (props) => { - const { children } = props; - - return ( - - {children} - - ); - }; - const { result } = renderHook(() => usePortfolio("account-1"), { - wrapper, + wrapper: createQueryWrapper().wrapper, }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); @@ -58,16 +43,9 @@ describe("usePortfolio", () => { : HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }); }), ); - const client = new QueryClient(); - const wrapper: FC = (props) => { - const { children } = props; - return ( - - {children} - - ); - }; - const { result } = renderHook(() => usePortfolio("account-1"), { wrapper }); + const { result } = renderHook(() => usePortfolio("account-1"), { + wrapper: createQueryWrapper().wrapper, + }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); void result.current.refetch(); diff --git a/src/entities/portfolio/api/usePortfolio.ts b/src/entities/portfolio/api/usePortfolio.ts index 3ee5468..9326702 100644 --- a/src/entities/portfolio/api/usePortfolio.ts +++ b/src/entities/portfolio/api/usePortfolio.ts @@ -1,24 +1,30 @@ -import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api"; -import type { paths } from "@/shared/api/generated/schema"; +import { + apiQueryClient, + type paths, + useAuthenticatedQuery, +} from "@/shared/api"; const portfolioQueryPolicy = { staleTime: 30_000, - retry: false, } as const; type Portfolio = paths["/api/v1/brokerage-accounts/{accountId}/portfolio"]["get"]["responses"][200]["content"]["application/json"]; +function portfolioQueryOptions(accountId: string | undefined) { + return apiQueryClient.queryOptions( + "get", + "/api/v1/brokerage-accounts/{accountId}/portfolio", + { params: { path: { accountId: accountId ?? "" } } }, + ); +} + function usePortfolio(accountId: string | undefined) { return useAuthenticatedQuery({ - ...apiQueryClient.queryOptions( - "get", - "/api/v1/brokerage-accounts/{accountId}/portfolio", - { params: { path: { accountId: accountId ?? "" } } }, - ), + ...portfolioQueryOptions(accountId), enabled: Boolean(accountId), ...portfolioQueryPolicy, }); } -export { type Portfolio, usePortfolio }; +export { type Portfolio, portfolioQueryOptions, usePortfolio }; diff --git a/src/entities/portfolio/index.ts b/src/entities/portfolio/index.ts index cbadc85..2b30d50 100644 --- a/src/entities/portfolio/index.ts +++ b/src/entities/portfolio/index.ts @@ -1,8 +1,6 @@ -export { type Portfolio, usePortfolio } from "./api/usePortfolio"; export { - formatMoney, - formatShare, - formatSnapshotTime, - instrumentLogoUrl, - portfolioErrorCode, -} from "./model/display"; + type Portfolio, + portfolioQueryOptions, + usePortfolio, +} from "./api/usePortfolio"; +export { formatShare } from "./model/display"; diff --git a/src/entities/portfolio/model/display.test.ts b/src/entities/portfolio/model/display.test.ts index f1dd98b..045c4cf 100644 --- a/src/entities/portfolio/model/display.test.ts +++ b/src/entities/portfolio/model/display.test.ts @@ -1,16 +1,7 @@ import { describe, expect, it } from "vitest"; -import { formatMoney, formatShare, instrumentLogoUrl } from "./display"; +import { formatShare } from "./display"; describe("portfolio display helpers", () => { - it("formats decimal strings without financial arithmetic", () => { - expect(formatMoney({ value: "-1234567.89", currency: "RUB" })).toBe( - "-1 234 567,89 ₽", - ); - expect(formatMoney({ value: "10", currency: "RUB" })).toBe("10 ₽"); - expect(formatMoney({ value: "0.5", currency: "USD" })).toBe("0,5 USD"); - expect(formatMoney(null)).toBe("—"); - }); - it("formats a share as a display-only percentage", () => { expect( formatShare( @@ -33,15 +24,4 @@ describe("portfolio display helpers", () => { ), ).toBeNull(); }); - - it("builds the brand logo url from an upstream file name", () => { - expect(instrumentLogoUrl("sber3.png")).toBe( - "https://invest-brands.cdn-tinkoff.ru/sber3x160.png", - ); - expect(instrumentLogoUrl(" RU000A0JPNM1.png ")).toBe( - "https://invest-brands.cdn-tinkoff.ru/RU000A0JPNM1x160.png", - ); - expect(instrumentLogoUrl(null)).toBeNull(); - expect(instrumentLogoUrl(" ")).toBeNull(); - }); }); diff --git a/src/entities/portfolio/model/display.ts b/src/entities/portfolio/model/display.ts index f246cf2..e76240a 100644 --- a/src/entities/portfolio/model/display.ts +++ b/src/entities/portfolio/model/display.ts @@ -2,21 +2,6 @@ import type { Portfolio } from "../api/usePortfolio"; type Money = Portfolio["totalsByCurrency"][number]; -function formatDecimal(value: string): string { - const [integer, fraction] = value.split("."); - const sign = integer.startsWith("-") ? "-" : ""; - const grouped = (sign ? integer.slice(1) : integer).replace( - /\B(?=(\d{3})+(?!\d))/g, - " ", - ); - return `${sign}${grouped}${fraction ? `,${fraction}` : ""}`; -} - -function formatMoney(money: Money | null): string { - if (!money) return "—"; - return `${formatDecimal(money.value)} ${money.currency === "RUB" ? "₽" : money.currency}`; -} - function formatShare( numerator: Money | null, denominator: Money | null, @@ -32,33 +17,4 @@ function formatShare( return `${rounded} %`; } -function formatSnapshotTime(value: string): string { - return new Intl.DateTimeFormat("ru-RU", { - dateStyle: "medium", - timeStyle: "short", - timeZone: "UTC", - }).format(new Date(value)); -} - -function portfolioErrorCode(error: unknown): string | undefined { - return typeof error === "object" && error !== null && "code" in error - ? String(error.code) - : undefined; -} - -const logoSize = 160; - -function instrumentLogoUrl(logoName: string | null | undefined): string | null { - const stem = logoName?.trim().replace(/\.png$/i, ""); - return stem - ? `https://invest-brands.cdn-tinkoff.ru/${stem}x${logoSize}.png` - : null; -} - -export { - formatMoney, - formatShare, - formatSnapshotTime, - instrumentLogoUrl, - portfolioErrorCode, -}; +export { formatShare }; diff --git a/src/features/auth/index.ts b/src/features/auth/index.ts index 0539f5e..0c25e28 100644 --- a/src/features/auth/index.ts +++ b/src/features/auth/index.ts @@ -1,3 +1,4 @@ export { AuthProvider, useAuth } from "./model/auth"; +export { AuthPageLayout } from "./ui/AuthPageLayout"; export { LoginForm } from "./ui/LoginForm"; export { RegisterForm } from "./ui/RegisterForm"; diff --git a/src/features/auth/model/auth.tsx b/src/features/auth/model/auth.tsx index d2f4234..6bd700c 100644 --- a/src/features/auth/model/auth.tsx +++ b/src/features/auth/model/auth.tsx @@ -8,8 +8,7 @@ import { useRef, useState, } from "react"; -import { apiClient, configureAuthClient } from "@/shared/api"; -import type { paths } from "@/shared/api/generated/schema"; +import { apiClient, configureAuthClient, type paths } from "@/shared/api"; type User = paths["/api/v1/auth/me"]["get"]["responses"][200]["content"]["application/json"]; diff --git a/src/features/auth/model/validation.ts b/src/features/auth/model/validation.ts new file mode 100644 index 0000000..5725da0 --- /dev/null +++ b/src/features/auth/model/validation.ts @@ -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; + +export { type AuthFormValues, loginSchema, registerSchema }; diff --git a/src/features/auth/ui/AuthForm.module.css b/src/features/auth/ui/AuthForm.module.css new file mode 100644 index 0000000..659c819 --- /dev/null +++ b/src/features/auth/ui/AuthForm.module.css @@ -0,0 +1,4 @@ +.fieldset { + border: 0; + padding: 0; +} diff --git a/src/features/auth/ui/AuthForm.test.tsx b/src/features/auth/ui/AuthForm.test.tsx index 1e99403..76d8ff1 100644 --- a/src/features/auth/ui/AuthForm.test.tsx +++ b/src/features/auth/ui/AuthForm.test.tsx @@ -1,18 +1,10 @@ import { MantineProvider } from "@mantine/core"; -import { - cleanup, - fireEvent, - render, - screen, - waitFor, -} from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { AuthForm } from "./AuthForm"; describe("AuthForm", () => { - afterEach(cleanup); - it("does not submit registration when password confirmation differs", async () => { const onSubmit = vi.fn(); render( diff --git a/src/features/auth/ui/AuthForm.tsx b/src/features/auth/ui/AuthForm.tsx index 681c4b0..43f1ad2 100644 --- a/src/features/auth/ui/AuthForm.tsx +++ b/src/features/auth/ui/AuthForm.tsx @@ -5,52 +5,13 @@ import { useState } from "react"; import { FormProvider, type Resolver, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; -import { z } from "zod"; +import { + type AuthFormValues, + loginSchema, + registerSchema, +} from "../model/validation"; +import classes from "./AuthForm.module.css"; -import { validationMessage } from "@/shared/i18n"; - -function isEmailCharacterInvalid(character: string): boolean { - return ( - /\s/.test(character) || - character.charCodeAt(0) < 32 || - character.charCodeAt(0) === 127 - ); -} - -const email = z - .string() - .trim() - .toLowerCase() - .max(254) - .refine( - (value) => - value.split("@").length === 2 && - value.split("@").every(Boolean) && - !value.split("").some(isEmailCharacterInvalid), - validationMessage("auth.validation.invalidEmail"), - ); -const password = z - .string() - .refine( - (value) => - new TextEncoder().encode(value).length >= 12 && - new TextEncoder().encode(value).length <= 72, - validationMessage("auth.validation.invalidPasswordLength"), - ); - -const loginSchema = z.object({ - email, - password, - passwordConfirmation: z.string().optional(), -}); -const registerSchema = loginSchema.refine( - (value) => value.password === value.passwordConfirmation, - (value) => ({ - path: ["passwordConfirmation"], - ...validationMessage("auth.validation.passwordsDoNotMatch")(value), - }), -); -type AuthFormValues = z.input; type AuthFormProps = { register?: true; onSubmit: (email: string, password: string) => Promise; @@ -79,7 +40,7 @@ const AuthForm: FC = (props) => { } })} > -
+
; + +const AuthPageLayout: FC = (props) => { + const { children, title } = props; + return ( +
+ + + {title} + {children} + + +
+ ); +}; + +export { AuthPageLayout }; diff --git a/src/features/tbank-connection/model/connection-boundary.ts b/src/features/tbank-connection/model/connection-boundary.ts index 405116f..a0401a8 100644 --- a/src/features/tbank-connection/model/connection-boundary.ts +++ b/src/features/tbank-connection/model/connection-boundary.ts @@ -1,6 +1,5 @@ import type { QueryClient } from "@tanstack/react-query"; -import { apiQueryClient } from "@/shared/api"; -import type { paths } from "@/shared/api/generated/schema"; +import { apiQueryClient, type paths } from "@/shared/api"; type TbankConnectionStatus = paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"]; diff --git a/src/features/tbank-connection/ui/TbankConnectionSection.test.tsx b/src/features/tbank-connection/ui/TbankConnectionSection.test.tsx index 742a0dc..9712736 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection.test.tsx +++ b/src/features/tbank-connection/ui/TbankConnectionSection.test.tsx @@ -1,24 +1,12 @@ import { MantineProvider } from "@mantine/core"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { - cleanup, - fireEvent, - render, - screen, - waitFor, -} from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { HttpResponse, http } from "msw"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { apiQueryClient } from "@/shared/api"; +import { describe, expect, it, vi } from "vitest"; +import { apiQueryClient, configureAuthClient } from "@/shared/api"; import { server } from "@/test/server"; import { TbankConnectionSection } from "./TbankConnectionSection"; -vi.mock("@/features/auth", () => ({ - useAuth: () => ({ accessToken: "test-token" }), -})); - -afterEach(cleanup); - const brokerageAccountsQueryKey = apiQueryClient.queryOptions( "get", "/api/v1/brokerage-accounts", @@ -39,6 +27,11 @@ const unrelatedQueryKey = apiQueryClient.queryOptions( ).queryKey; function renderSection(client: QueryClient) { + configureAuthClient({ + accessToken: () => "test-token", + refresh: async () => true, + clear: vi.fn(), + }); return render( diff --git a/src/features/tbank-connection/ui/TbankConnectionSection.tsx b/src/features/tbank-connection/ui/TbankConnectionSection.tsx index aa335cb..165e3f1 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection.tsx +++ b/src/features/tbank-connection/ui/TbankConnectionSection.tsx @@ -1,36 +1,10 @@ -import { - Alert, - Button, - Modal, - PasswordInput, - Stack, - Text, -} from "@mantine/core"; -import { useQueryClient } from "@tanstack/react-query"; import type { FC } from "react"; -import { useState } from "react"; -import { useForm } from "react-hook-form"; -import { useTranslation } from "react-i18next"; -import { apiQueryClient } from "@/shared/api"; -import { - commitTbankConnectionBoundary, - type TbankConnectionStatus, -} from "../model/connection-boundary"; import { TbankConnectionError, TbankConnectionLoading, } from "./TbankConnectionSection.view"; import { useTbankConnectionViewModel } from "./TbankConnectionSection.vm"; - -type TbankConnectionSectionLoadedProps = { - status: TbankConnectionStatus; -}; - -function errorCode(error: unknown): string | undefined { - return typeof error === "object" && error !== null && "code" in error - ? String(error.code) - : undefined; -} +import { TbankConnectionSectionLoaded } from "./TbankConnectionSectionLoaded"; const TbankConnectionSection: FC = () => { const viewModel = useTbankConnectionViewModel(); @@ -40,119 +14,4 @@ const TbankConnectionSection: FC = () => { return ; }; -const TbankConnectionSectionLoaded: FC = ( - props, -) => { - const { status } = props; - const { t } = useTranslation(); - const queryClient = useQueryClient(); - const [deleteOpened, setDeleteOpened] = useState(false); - const [requestError, setRequestError] = useState(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 ( - - {t("tbank.title")} - - {connected - ? t("tbank.connected", { - date: new Date(String(status.connectedAt)).toLocaleDateString( - "ru-RU", - ), - }) - : t("tbank.disconnected")} - - - {t("tbank.hint")} - -
{ - setRequestError(null); - update.mutate({ body: { token } }); - })} - > -
- - - {requestError && ( - - {t( - requestError === "INVALID_TBANK_TOKEN" - ? "tbank.invalidToken" - : requestError === "TBANK_UNAVAILABLE" - ? "tbank.unavailable" - : "tbank.requestError", - )} - - )} - - {connected && ( - - )} - -
-
- !pending && setDeleteOpened(false)} - title={t("tbank.deleteTitle")} - closeOnClickOutside={!pending} - closeOnEscape={!pending} - > - - {t("tbank.deleteText")} - - - -
- ); -}; - export { TbankConnectionSection }; diff --git a/src/features/tbank-connection/ui/TbankConnectionSection.vm.ts b/src/features/tbank-connection/ui/TbankConnectionSection.vm.ts index 5df9190..9855647 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection.vm.ts +++ b/src/features/tbank-connection/ui/TbankConnectionSection.vm.ts @@ -1,12 +1,9 @@ -import { useQuery } from "@tanstack/react-query"; -import { useAuth } from "@/features/auth"; +import { useAuthenticatedQuery } from "@/shared/api"; import { tbankConnectionQueryOptions } from "../model/connection-boundary"; function useTbankConnectionViewModel() { - const { accessToken } = useAuth(); - const status = useQuery({ + const status = useAuthenticatedQuery({ ...tbankConnectionQueryOptions(), - enabled: Boolean(accessToken), }); if (status.isPending) return { state: "loading" as const }; if (status.isError) diff --git a/src/features/tbank-connection/ui/TbankConnectionSectionLoaded.tsx b/src/features/tbank-connection/ui/TbankConnectionSectionLoaded.tsx new file mode 100644 index 0000000..13e0b41 --- /dev/null +++ b/src/features/tbank-connection/ui/TbankConnectionSectionLoaded.tsx @@ -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 = ( + props, +) => { + const { status } = props; + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const [deleteOpened, setDeleteOpened] = useState(false); + const [requestError, setRequestError] = useState(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 ( + + {t("tbank.title")} + + {connected + ? t("tbank.connected", { + date: new Date(status.connectedAt ?? "").toLocaleDateString( + "ru-RU", + ), + }) + : t("tbank.disconnected")} + + + {t("tbank.hint")} + +
{ + setRequestError(null); + update.mutate({ body: { token } }); + })} + > +
+ + + {requestError && ( + + {t( + requestError === "INVALID_TBANK_TOKEN" + ? "tbank.invalidToken" + : requestError === "TBANK_UNAVAILABLE" + ? "tbank.unavailable" + : "tbank.requestError", + )} + + )} + + {connected && ( + + )} + +
+
+ !pending && setDeleteOpened(false)} + title={t("tbank.deleteTitle")} + closeOnClickOutside={!pending} + closeOnEscape={!pending} + > + + {t("tbank.deleteText")} + + + +
+ ); +}; + +export { TbankConnectionSectionLoaded }; diff --git a/src/pages/account-events/ui/AccountEventsPage.tsx b/src/pages/account-events/ui/AccountEventsPage.tsx index e848ce6..abdbeb0 100644 --- a/src/pages/account-events/ui/AccountEventsPage.tsx +++ b/src/pages/account-events/ui/AccountEventsPage.tsx @@ -1,33 +1,10 @@ -import { - Button, - Card, - Group, - Skeleton, - Stack, - Text, - Title, -} from "@mantine/core"; import type { FC } from "react"; -import { useTranslation } from "react-i18next"; import { useParams } from "react-router"; -import { - type BrokerageEvent, - useBrokerageEvents, -} from "@/entities/brokerage-event"; -import { formatMoney } from "@/entities/portfolio"; -import { LoadingSkeleton, StateView } from "@/shared/ui"; - -type AccountEventsPageErrorProps = { - retry: () => void; -}; - -type AccountEventsPageLoadedProps = { - events: BrokerageEvent[]; -}; - -type AccountEventRowProps = { - event: BrokerageEvent; -}; +import { useBrokerageEvents } from "@/entities/brokerage-event"; +import { AccountEventsPageEmpty } from "./components/AccountEventsPageEmpty.view"; +import { AccountEventsPageError } from "./components/AccountEventsPageError.view"; +import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view"; +import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view"; const AccountEventsPage: FC = () => { const { accountId } = useParams(); @@ -40,98 +17,4 @@ const AccountEventsPage: FC = () => { return ; }; -const AccountEventsPageLoading: FC = () => { - const { t } = useTranslation(); - - return ( - - - - {[0, 1, 2, 3].map((index) => ( - - ))} - - ); -}; - -const AccountEventsPageError: FC = (props) => { - const { retry } = props; - const { t } = useTranslation(); - - return ( - {t("events.retry")}} - /> - ); -}; - -const AccountEventsPageEmpty: FC = () => { - const { t } = useTranslation(); - - return ( - - {t("events.title")} - {t("events.hint")} - - - ); -}; - -const AccountEventsPageLoaded: FC = (props) => { - const { events } = props; - const { t } = useTranslation(); - - return ( - - {t("events.title")} - {t("events.hint")} - {events.map((event) => ( - - ))} - - ); -}; - -const AccountEventRow: FC = (props) => { - const { event } = props; - const { t } = useTranslation(); - - return ( - - - - {event.displayName} - - {formatCalendarDate(event.date)} ·{" "} - {t(`events.dateKind.${event.dateKind}`)} - - - - - {event.projectedAmount - ? formatMoney(event.projectedAmount) - : t("events.noAmount")} - - - {t(`events.type.${event.type}`)} - - - - - ); -}; - -function formatCalendarDate(date: string) { - return new Intl.DateTimeFormat("ru-RU", { - dateStyle: "medium", - timeZone: "UTC", - }).format(new Date(`${date}T00:00:00Z`)); -} - export { AccountEventsPage }; diff --git a/src/pages/account-events/ui/components/AccountEventRow.view.tsx b/src/pages/account-events/ui/components/AccountEventRow.view.tsx new file mode 100644 index 0000000..185a770 --- /dev/null +++ b/src/pages/account-events/ui/components/AccountEventRow.view.tsx @@ -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 = (props) => { + const { event } = props; + const { t } = useTranslation(); + return ( + + + + {event.displayName} + + {formatCalendarDate(event.date)} ·{" "} + {t(`events.dateKind.${event.dateKind}`)} + + + + + {event.projectedAmount + ? formatMoney(event.projectedAmount) + : t("events.noAmount")} + + + {t(`events.type.${event.type}`)} + + + + + ); +}; + +function formatCalendarDate(date: string) { + return new Intl.DateTimeFormat("ru-RU", { + dateStyle: "medium", + timeZone: "UTC", + }).format(new Date(`${date}T00:00:00Z`)); +} + +export { AccountEventRow }; diff --git a/src/pages/account-events/ui/components/AccountEventsPageEmpty.view.tsx b/src/pages/account-events/ui/components/AccountEventsPageEmpty.view.tsx new file mode 100644 index 0000000..394cbf6 --- /dev/null +++ b/src/pages/account-events/ui/components/AccountEventsPageEmpty.view.tsx @@ -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 ( + + {t("events.title")} + {t("events.hint")} + + + ); +}; + +export { AccountEventsPageEmpty }; diff --git a/src/pages/account-events/ui/components/AccountEventsPageError.view.tsx b/src/pages/account-events/ui/components/AccountEventsPageError.view.tsx new file mode 100644 index 0000000..ae03f49 --- /dev/null +++ b/src/pages/account-events/ui/components/AccountEventsPageError.view.tsx @@ -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 = (props) => { + const { t } = useTranslation(); + return ( + {t("events.retry")}} + /> + ); +}; + +export { AccountEventsPageError }; diff --git a/src/pages/account-events/ui/components/AccountEventsPageLoaded.view.tsx b/src/pages/account-events/ui/components/AccountEventsPageLoaded.view.tsx new file mode 100644 index 0000000..963b4d8 --- /dev/null +++ b/src/pages/account-events/ui/components/AccountEventsPageLoaded.view.tsx @@ -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 = (props) => { + const { t } = useTranslation(); + return ( + + {t("events.title")} + {t("events.hint")} + {props.events.map((event) => ( + + ))} + + ); +}; + +export { AccountEventsPageLoaded }; diff --git a/src/pages/account-events/ui/components/AccountEventsPageLoading.view.tsx b/src/pages/account-events/ui/components/AccountEventsPageLoading.view.tsx new file mode 100644 index 0000000..1f91713 --- /dev/null +++ b/src/pages/account-events/ui/components/AccountEventsPageLoading.view.tsx @@ -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 ( + + + + {[0, 1, 2, 3].map((index) => ( + + ))} + + ); +}; + +export { AccountEventsPageLoading }; diff --git a/src/pages/account-income/ui/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage.test.tsx index e31ce97..d610875 100644 --- a/src/pages/account-income/ui/AccountIncomePage.test.tsx +++ b/src/pages/account-income/ui/AccountIncomePage.test.tsx @@ -1,13 +1,14 @@ import { MantineProvider } from "@mantine/core"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import { HttpResponse, http } from "msw"; -import type { FC, PropsWithChildren } from "react"; import { MemoryRouter, Route, Routes } from "react-router"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import type { IncomeAnalytics } from "@/entities/income"; import { configureAuthClient } from "@/shared/api"; +import { createQueryWrapper } from "@/test/query"; import { server } from "@/test/server"; import { AccountIncomePage } from "./AccountIncomePage"; +import { isDate, toIncomeDisplayModel } from "./AccountIncomePage.vm"; const analytics = { accountId: "account-1", @@ -41,7 +42,7 @@ const analytics = { }, ], entries: [entry()], -}; +} satisfies IncomeAnalytics; function money(currency: string, value: string) { return { currency, value }; @@ -65,7 +66,7 @@ function comparison(currency: string) { }; } -function entry() { +function entry(): IncomeAnalytics["entries"][number] { return { id: "coupon-1", occurredAt: "2026-08-01T12:00:00Z", @@ -91,36 +92,45 @@ function renderPage() { refresh: async () => true, clear: vi.fn(), }); - const Wrapper: FC = (props) => ( - - - {props.children} - - - ); + const { wrapper: QueryWrapper } = createQueryWrapper(); return render( - - - - } - /> - Events} /> - - - , + + + + + } + /> + Events} + /> + + + + , ); } describe("AccountIncomePage", () => { - afterEach(() => { - cleanup(); - configureAuthClient(null); + it("rejects malformed calendar dates without throwing", () => { + expect(isDate("2026-99-99")).toBe(false); + expect(isDate("2026-02-29")).toBe(false); + expect(isDate("2024-02-29")).toBe(true); + }); + + it("groups income entries once in the view model", () => { + const display = toIncomeDisplayModel(analytics); + + expect(display.instruments[0].entries).toEqual([analytics.entries[0]]); + expect(display.trend[0].buckets[0].total).toEqual( + analytics.buckets[0].totalsByCurrency[0], + ); }); it("renders realized analytics, drill-down, and the independent forecast", async () => { diff --git a/src/pages/account-income/ui/AccountIncomePage.tsx b/src/pages/account-income/ui/AccountIncomePage.tsx index 4c94a76..62e1722 100644 --- a/src/pages/account-income/ui/AccountIncomePage.tsx +++ b/src/pages/account-income/ui/AccountIncomePage.tsx @@ -1,10 +1,8 @@ import type { FC } from "react"; -import { - AccountIncomePageError, - AccountIncomePageLoaded, - AccountIncomePageLoading, -} from "./AccountIncomePage.view"; import { useAccountIncomePageViewModel } from "./AccountIncomePage.vm"; +import { AccountIncomePageError } from "./components/AccountIncomePageError.view"; +import { AccountIncomePageLoaded } from "./components/AccountIncomePageLoaded.view"; +import { AccountIncomePageLoading } from "./components/AccountIncomePageLoading.view"; const AccountIncomePage: FC = () => { const viewModel = useAccountIncomePageViewModel(); diff --git a/src/pages/account-income/ui/AccountIncomePage.view.tsx b/src/pages/account-income/ui/AccountIncomePage.view.tsx deleted file mode 100644 index f9a923b..0000000 --- a/src/pages/account-income/ui/AccountIncomePage.view.tsx +++ /dev/null @@ -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; -type ErrorProps = Extract; - -const AccountIncomePageLoading: FC = () => { - const { t } = useTranslation(); - return ( - - - - - - ); -}; - -const AccountIncomePageError: FC = (props) => { - const { t } = useTranslation(); - return ( - {t("income.retry")}} - /> - ); -}; - -const AccountIncomePageLoaded: FC = (props) => { - const { analytics, form, forecast, onSubmit, onReset } = props; - const { t } = useTranslation(); - return ( - - {t("income.title")} -
- - - - - - -
- - {analytics.period.from} — {analytics.period.to} - - {analytics.totalsByCurrency.length ? ( - - ) : ( - - )} - {t("income.taxBoundary")} - - - - -
- ); -}; - -const IncomeSummary: FC<{ totals: IncomeAnalytics["totalsByCurrency"] }> = ( - props, -) => { - const { t } = useTranslation(); - return ( - - {props.totals.map((total) => ( - - {formatMoney(total.total)} - - {t("income.coupon")}: {formatMoney(total.coupon)} ·{" "} - {t("income.dividend")}: {formatMoney(total.dividend)} - - - ))} - - ); -}; - -const IncomeComparisons: FC<{ analytics: IncomeAnalytics }> = (props) => { - const { t } = useTranslation(); - return ( - - {t("income.comparisons")} - - {props.analytics.yearComparison ? ( - - ) : ( - {t("income.yearUnavailable")} - )} - - ); -}; - -const Comparison: FC<{ - title: string; - values: IncomeAnalytics["previous"]["totalsByCurrency"]; -}> = (props) => { - const { t } = useTranslation(); - return ( - - {props.title} - {props.values.map((value) => ( - - {value.currency} - - {t("income.selected")}: {t("income.coupon")}{" "} - {formatMoney(value.selected.coupon)} · {t("income.dividend")}{" "} - {formatMoney(value.selected.dividend)} · {t("income.total")}{" "} - {formatMoney(value.selected.total)} - - - {t("income.compared")}: {t("income.coupon")}{" "} - {formatMoney(value.compared.coupon)} · {t("income.dividend")}{" "} - {formatMoney(value.compared.dividend)} · {t("income.total")}{" "} - {formatMoney(value.compared.total)} - - - {t("income.difference")}: {t("income.coupon")}{" "} - {formatMoney(value.difference.coupon)} · {t("income.dividend")}{" "} - {formatMoney(value.difference.dividend)} · {t("income.total")}{" "} - {formatMoney(value.difference.total)} - - - ))} - - ); -}; - -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 ( - - {t("income.trend")} - {currencies.length > 1 ? ( - - ) : null} - - - {buckets.map((bucket) => ( - - {bucket.period.from} — {bucket.period.to}: {t("income.coupon")}{" "} - {formatMoney(bucket.total?.coupon ?? null)}, {t("income.dividend")}{" "} - {formatMoney(bucket.total?.dividend ?? null)} - - ))} - - - ); -}; - -const IncomeBreakdown: FC<{ analytics: IncomeAnalytics }> = (props) => { - const { t } = useTranslation(); - return ( - - {t("income.instruments")} - - {props.analytics.instruments.map((instrument) => ( - - - {instrument.displayName} · {formatMoney(instrument.total)} ·{" "} - {instrument.receiptCount} - - - - {t("income.coupon")}: {formatMoney(instrument.coupon)} ·{" "} - {t("income.dividend")}: {formatMoney(instrument.dividend)} - - - {props.analytics.entries - .filter((entry) => entryKey(entry) === instrument.key) - .map((entry) => ( - - ))} - - - - ))} - - - ); -}; - -const IncomeForecast: FC<{ forecast: LoadedProps["forecast"] }> = (props) => { - const { t } = useTranslation(); - if (props.forecast.state === "loading") - return {t("income.forecastLoading")}; - if (props.forecast.state === "error") - return ( - {t("income.retry")} - } - /> - ); - if (props.forecast.state === "empty") - return ( - - ); - if (props.forecast.state !== "loaded") return null; - return ( - - {t("income.forecastTitle")} - {t("income.forecastWarning")} - {props.forecast.totals.map((total) => ( - - {formatMoney(total)} - - ))} - {props.forecast.events.map((event) => ( - - ))} - - - ); -}; - -const ForecastEvent: FC<{ event: BrokerageEvent }> = (props) => ( - - {props.event.displayName} - - {props.event.date} · {formatMoney(props.event.projectedAmount)} - - -); - -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, -}; diff --git a/src/pages/account-income/ui/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage.vm.ts index 842e188..c9d1cad 100644 --- a/src/pages/account-income/ui/AccountIncomePage.vm.ts +++ b/src/pages/account-income/ui/AccountIncomePage.vm.ts @@ -20,12 +20,26 @@ type IncomeForecast = events: BrokerageEvent[]; }; +type IncomeDisplayModel = { + trend: { + currency: string; + buckets: { + period: IncomeAnalytics["buckets"][number]["period"]; + total: IncomeAnalytics["totalsByCurrency"][number]; + }[]; + }[]; + instruments: (IncomeAnalytics["instruments"][number] & { + entries: IncomeAnalytics["entries"]; + })[]; +}; + type AccountIncomePageViewModel = | { state: "loading" } | { state: "error"; retry: () => void; period: IncomePeriod } | { state: "loaded"; analytics: IncomeAnalytics; + display: IncomeDisplayModel; forecast: IncomeForecast; form: ReturnType>; onReset: () => void; @@ -81,6 +95,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => { return { state: "loaded", analytics: analytics.data, + display: toIncomeDisplayModel(analytics.data), forecast: toForecast(events), form, onSubmit, @@ -126,12 +141,59 @@ function validPeriod(period: IncomePeriod): period is IncomePeriod { } function isDate(value: string): boolean { + if (!datePattern.test(value)) return false; + const parsed = new Date(`${value}T00:00:00Z`); return ( - datePattern.test(value) && - new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value + Number.isFinite(parsed.valueOf()) && + parsed.toISOString().slice(0, 10) === value ); } +function toIncomeDisplayModel(analytics: IncomeAnalytics): IncomeDisplayModel { + const entriesByInstrument = new Map(); + for (const entry of analytics.entries) { + const key = incomeEntryKey(entry); + entriesByInstrument.set(key, [ + ...(entriesByInstrument.get(key) ?? []), + entry, + ]); + } + + return { + trend: analytics.totalsByCurrency.map(({ currency }) => ({ + currency, + buckets: analytics.buckets.map((bucket) => ({ + period: bucket.period, + total: + bucket.totalsByCurrency.find((item) => item.currency === currency) ?? + zeroSummary(currency), + })), + })), + instruments: analytics.instruments.map((instrument) => ({ + ...instrument, + entries: entriesByInstrument.get(instrument.key) ?? [], + })), + }; +} + +function incomeEntryKey(entry: IncomeAnalytics["entries"][number]): string { + const base = entry.instrumentUid + ? `uid:${entry.instrumentUid}` + : entry.figi + ? `figi:${entry.figi}` + : entry.ticker || entry.instrumentType + ? `ticker:${entry.ticker ?? ""}:${entry.instrumentType ?? ""}` + : `operation:${entry.id}`; + return `${base}:${entry.payment?.currency ?? ""}`; +} + +function zeroSummary( + currency: string, +): IncomeAnalytics["totalsByCurrency"][number] { + const zero = { currency, value: "0" }; + return { currency, coupon: zero, dividend: zero, total: zero }; +} + function moscowDate(value: Date): string { const parts = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Moscow", @@ -144,4 +206,9 @@ function moscowDate(value: Date): string { return `${part("year")}-${part("month")}-${part("day")}`; } -export { type AccountIncomePageViewModel, useAccountIncomePageViewModel }; +export { + type AccountIncomePageViewModel, + isDate, + toIncomeDisplayModel, + useAccountIncomePageViewModel, +}; diff --git a/src/pages/account-income/ui/components/AccountIncomePageError.view.tsx b/src/pages/account-income/ui/components/AccountIncomePageError.view.tsx new file mode 100644 index 0000000..3bf8094 --- /dev/null +++ b/src/pages/account-income/ui/components/AccountIncomePageError.view.tsx @@ -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; + +const AccountIncomePageError: FC = (props) => { + const { t } = useTranslation(); + return ( + {t("income.retry")}} + /> + ); +}; + +export { AccountIncomePageError }; diff --git a/src/pages/account-income/ui/components/AccountIncomePageLoaded.view.tsx b/src/pages/account-income/ui/components/AccountIncomePageLoaded.view.tsx new file mode 100644 index 0000000..26ac1bd --- /dev/null +++ b/src/pages/account-income/ui/components/AccountIncomePageLoaded.view.tsx @@ -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; + +const AccountIncomePageLoaded: FC = (props) => { + const { analytics, display, form, forecast, onSubmit, onReset } = props; + const { t } = useTranslation(); + return ( + + {t("income.title")} +
+ + + + + + +
+ + {analytics.period.from} — {analytics.period.to} + + {analytics.totalsByCurrency.length ? ( + + ) : ( + + )} + {t("income.taxBoundary")} + + + + +
+ ); +}; + +export { AccountIncomePageLoaded }; diff --git a/src/pages/account-income/ui/components/AccountIncomePageLoading.view.tsx b/src/pages/account-income/ui/components/AccountIncomePageLoading.view.tsx new file mode 100644 index 0000000..3dd5dec --- /dev/null +++ b/src/pages/account-income/ui/components/AccountIncomePageLoading.view.tsx @@ -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 ( + + + + + + ); +}; + +export { AccountIncomePageLoading }; diff --git a/src/pages/account-income/ui/components/IncomeBreakdown.view.tsx b/src/pages/account-income/ui/components/IncomeBreakdown.view.tsx new file mode 100644 index 0000000..0655079 --- /dev/null +++ b/src/pages/account-income/ui/components/IncomeBreakdown.view.tsx @@ -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; +type IncomeBreakdownProps = { + instruments: LoadedProps["display"]["instruments"]; +}; + +const IncomeBreakdown: FC = (props) => { + const { t } = useTranslation(); + return ( + + {t("income.instruments")} + + {props.instruments.map((instrument) => ( + + + {instrument.displayName} · {formatMoney(instrument.total)} ·{" "} + {instrument.receiptCount} + + + + {t("income.coupon")}: {formatMoney(instrument.coupon)} ·{" "} + {t("income.dividend")}: {formatMoney(instrument.dividend)} + + + {instrument.entries.map((entry) => ( + + ))} + + + + ))} + + + ); +}; + +export { IncomeBreakdown }; diff --git a/src/pages/account-income/ui/components/IncomeComparisons.view.tsx b/src/pages/account-income/ui/components/IncomeComparisons.view.tsx new file mode 100644 index 0000000..b776fbd --- /dev/null +++ b/src/pages/account-income/ui/components/IncomeComparisons.view.tsx @@ -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 = (props) => { + const { t } = useTranslation(); + return ( + + {t("income.comparisons")} + + {props.analytics.yearComparison ? ( + + ) : ( + {t("income.yearUnavailable")} + )} + + ); +}; + +const Comparison: FC = (props) => { + const { t } = useTranslation(); + return ( + + {props.title} + {props.values.map((value) => ( + + {value.currency} + + {t("income.selected")}: {t("income.coupon")}{" "} + {formatMoney(value.selected.coupon)} · {t("income.dividend")}{" "} + {formatMoney(value.selected.dividend)} · {t("income.total")}{" "} + {formatMoney(value.selected.total)} + + + {t("income.compared")}: {t("income.coupon")}{" "} + {formatMoney(value.compared.coupon)} · {t("income.dividend")}{" "} + {formatMoney(value.compared.dividend)} · {t("income.total")}{" "} + {formatMoney(value.compared.total)} + + + {t("income.difference")}: {t("income.coupon")}{" "} + {formatMoney(value.difference.coupon)} · {t("income.dividend")}{" "} + {formatMoney(value.difference.dividend)} · {t("income.total")}{" "} + {formatMoney(value.difference.total)} + + + ))} + + ); +}; + +export { IncomeComparisons }; diff --git a/src/pages/account-income/ui/components/IncomeForecast.view.tsx b/src/pages/account-income/ui/components/IncomeForecast.view.tsx new file mode 100644 index 0000000..91b0c29 --- /dev/null +++ b/src/pages/account-income/ui/components/IncomeForecast.view.tsx @@ -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; +type IncomeForecastProps = { forecast: LoadedProps["forecast"] }; +type ForecastEventProps = { event: BrokerageEvent }; + +const IncomeForecast: FC = (props) => { + const { t } = useTranslation(); + if (props.forecast.state === "loading") + return {t("income.forecastLoading")}; + if (props.forecast.state === "error") + return ( + {t("income.retry")} + } + /> + ); + if (props.forecast.state === "empty") + return ( + + ); + if (props.forecast.state !== "loaded") return null; + return ( + + {t("income.forecastTitle")} + {t("income.forecastWarning")} + {props.forecast.totals.map((total) => ( + + {formatMoney(total)} + + ))} + {props.forecast.events.map((event) => ( + + ))} + + + ); +}; + +const ForecastEvent: FC = (props) => ( + + {props.event.displayName} + + {props.event.date} · {formatMoney(props.event.projectedAmount)} + + +); + +export { IncomeForecast }; diff --git a/src/pages/account-income/ui/components/IncomeSummary.view.tsx b/src/pages/account-income/ui/components/IncomeSummary.view.tsx new file mode 100644 index 0000000..102655a --- /dev/null +++ b/src/pages/account-income/ui/components/IncomeSummary.view.tsx @@ -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 = (props) => { + const { t } = useTranslation(); + return ( + + {props.totals.map((total) => ( + + {formatMoney(total.total)} + + {t("income.coupon")}: {formatMoney(total.coupon)} ·{" "} + {t("income.dividend")}: {formatMoney(total.dividend)} + + + ))} + + ); +}; + +export { IncomeSummary }; diff --git a/src/pages/account-income/ui/components/IncomeTrend.view.tsx b/src/pages/account-income/ui/components/IncomeTrend.view.tsx new file mode 100644 index 0000000..94ab216 --- /dev/null +++ b/src/pages/account-income/ui/components/IncomeTrend.view.tsx @@ -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; +type IncomeTrendProps = { trend: LoadedProps["display"]["trend"] }; + +const IncomeTrend: FC = (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 ( + + {t("income.trend")} + {currencies.length > 1 ? ( + + ) : null} + + + {buckets.map((bucket) => ( + + {bucket.period.from} — {bucket.period.to}: {t("income.coupon")}{" "} + {formatMoney(bucket.total.coupon)}, {t("income.dividend")}{" "} + {formatMoney(bucket.total.dividend)} + + ))} + + + ); +}; + +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 }; diff --git a/src/pages/account-operations/ui/AccountOperationsPage.test.tsx b/src/pages/account-operations/ui/AccountOperationsPage.test.tsx index a1daac9..13ecbeb 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage.test.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage.test.tsx @@ -1,19 +1,17 @@ import { MantineProvider } from "@mantine/core"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { - cleanup, - fireEvent, - render, - screen, - waitFor, -} from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { HttpResponse, http } from "msw"; import { MemoryRouter, Route, Routes } from "react-router"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { BrokerageOperation } from "@/entities/broker-operation"; +import { describe, expect, it, vi } from "vitest"; +import { + type BrokerageOperation, + LedgerEntryRow, +} from "@/entities/broker-operation"; import { configureAuthClient } from "@/shared/api"; +import { createTestQueryClient } from "@/test/query"; import { server } from "@/test/server"; -import { AccountOperationsPage, OperationRow } from "./AccountOperationsPage"; +import { AccountOperationsPage } from "./AccountOperationsPage"; const operation: BrokerageOperation = { id: "operation-1", @@ -41,14 +39,29 @@ function configureTestAuth() { }); } -afterEach(cleanup); +function renderPage() { + return render( + + + + + } + /> + + + + , + ); +} -describe("OperationRow", () => { +describe("LedgerEntryRow", () => { it("marks cashless operations without rendering operation details", () => { render( - + , ); @@ -65,7 +78,7 @@ describe("OperationRow", () => { render( - { render( - { render( - { render( - { render( - { render( - + , ); @@ -171,8 +184,6 @@ describe("OperationRow", () => { }); describe("AccountOperationsPage", () => { - afterEach(() => configureAuthClient(null)); - it("resets cursor pagination when filters change", async () => { configureTestAuth(); const searches: string[] = []; @@ -192,20 +203,7 @@ describe("AccountOperationsPage", () => { }, ), ); - render( - - - - - } - /> - - - - , - ); + renderPage(); expect( await screen.findByRole("heading", { name: "Журнал операций" }), @@ -241,20 +239,7 @@ describe("AccountOperationsPage", () => { }, ), ); - render( - - - - - } - /> - - - - , - ); + renderPage(); expect( await screen.findByRole("heading", { name: "Журнал операций" }), @@ -287,20 +272,7 @@ describe("AccountOperationsPage", () => { }, ), ); - render( - - - - - } - /> - - - - , - ); + renderPage(); expect( await screen.findByRole("heading", { name: "Журнал операций" }), @@ -323,20 +295,7 @@ describe("AccountOperationsPage", () => { HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }), ), ); - render( - - - - - } - /> - - - - , - ); + renderPage(); expect( await screen.findByText("Не удалось загрузить журнал"), ).toBeInTheDocument(); diff --git a/src/pages/account-operations/ui/AccountOperationsPage.tsx b/src/pages/account-operations/ui/AccountOperationsPage.tsx index 4df8ed0..2e05100 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage.tsx @@ -1,5 +1,4 @@ import type { FC } from "react"; -import { LedgerEntryRow } from "@/entities/broker-operation"; import { useAccountOperationsPageVm } from "./AccountOperationsPage.vm"; import { AccountOperationsPageError } from "./components/AccountOperationsPageError.view"; import { AccountOperationsPageLoaded } from "./components/AccountOperationsPageLoaded.view"; @@ -19,4 +18,4 @@ const AccountOperationsPage: FC = () => { return ; }; -export { AccountOperationsPage, LedgerEntryRow as OperationRow }; +export { AccountOperationsPage }; diff --git a/src/pages/account-operations/ui/AccountOperationsPage.vm.ts b/src/pages/account-operations/ui/AccountOperationsPage.vm.ts index 43434b2..1f255f6 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage.vm.ts +++ b/src/pages/account-operations/ui/AccountOperationsPage.vm.ts @@ -3,10 +3,10 @@ import { useState } from "react"; import { useParams } from "react-router"; import { type BrokerageOperation, + brokerageOperationsQueryOptions, type OperationCategory, useBrokerageOperations, } from "@/entities/broker-operation"; -import { apiQueryClient } from "@/shared/api"; type AccountOperationsPageViewModel = | { state: "loading" } @@ -51,11 +51,9 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { ...(cursor ? { cursor } : {}), limit: 100, }); - const resetQueryKey = apiQueryClient.queryOptions( - "get", - "/api/v1/brokerage-accounts/{accountId}/operations", - { params: { path: { accountId: accountId ?? "" }, query: { limit: 100 } } }, - ).queryKey; + const resetQueryKey = brokerageOperationsQueryOptions(accountId, { + limit: 100, + }).queryKey; const resetCursors = () => setCursors([]); diff --git a/src/pages/account/ui/AccountPage.module.css b/src/pages/account/ui/AccountPage.module.css index 38b459e..18aadb2 100644 --- a/src/pages/account/ui/AccountPage.module.css +++ b/src/pages/account/ui/AccountPage.module.css @@ -32,61 +32,36 @@ color: var(--mantine-color-red-6); } -.stockCard { +.expandedCard { padding: var(--mantine-spacing-md); } -.stockMain { +.expandedMain { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: var(--mantine-spacing-sm); align-items: center; } -.stockIdentity, -.stockValue { +.expandedIdentity, +.expandedValue { min-width: 0; } -.stockIdentifiers { +.expandedIdentifiers { overflow-wrap: anywhere; } -.stockValue { +.expandedValue { white-space: nowrap; } -.stockMeta, -.bondMeta { +.expandedMeta { padding-top: var(--mantine-spacing-xs); margin-top: var(--mantine-spacing-sm); border-top: 1px solid var(--mantine-color-gray-2); } -.bondCard { - padding: var(--mantine-spacing-md); -} - -.bondMain { - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - gap: var(--mantine-spacing-sm); - align-items: center; -} - -.bondIdentity, -.bondValue { - min-width: 0; -} - -.bondIdentifiers { - overflow-wrap: anywhere; -} - -.bondValue { - white-space: nowrap; -} - .mono { font-family: var(--mantine-font-family-monospace); } diff --git a/src/pages/account/ui/AccountPage.test.tsx b/src/pages/account/ui/AccountPage.test.tsx index 8464948..70ae459 100644 --- a/src/pages/account/ui/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage.test.tsx @@ -1,28 +1,28 @@ import { MantineProvider } from "@mantine/core"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { type QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; import { HttpResponse, http } from "msw"; import { MemoryRouter, Route, Routes } from "react-router"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { Portfolio } from "@/entities/portfolio"; +import { describe, expect, it, vi } from "vitest"; +import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio"; import { configureAuthClient } from "@/shared/api"; +import { createTestQueryClient } from "@/test/query"; import { server } from "@/test/server"; -import { - AccountPage, - AccountPageEmpty, - AccountPageError, - AccountPageLoaded, - AccountPageLoading, -} from "./AccountPage"; +import { AccountPage } from "./AccountPage"; import type { AccountPageRecentEvents, AccountPageRecentOperations, } from "./AccountPage.vm"; import { toAccountPagePositionSections } from "./AccountPage.vm"; +import { AccountPageEmpty } from "./components/AccountPageEmpty.view"; +import { AccountPageError } from "./components/AccountPageError.view"; +import { AccountPageLoaded } from "./components/AccountPageLoaded.view"; +import { AccountPageLoading } from "./components/AccountPageLoading.view"; function renderPage(ui: React.ReactNode) { + const queryClient = createTestQueryClient(); return render( - + {ui} @@ -113,10 +113,6 @@ function emptyProps() { } describe("AccountPage states", () => { - afterEach(() => { - configureAuthClient(null); - cleanup(); - }); it("renders loading and empty states", () => { renderPage(); expect(screen.getByRole("status")).toBeInTheDocument(); @@ -528,12 +524,12 @@ describe("AccountPage states", () => { ); }), ); - const queryClient = new QueryClient(); + const queryClient = createTestQueryClient(); renderAccountRoute(queryClient); await screen.findByText("Вторая"); void queryClient.invalidateQueries({ - queryKey: ["get", "/api/v1/brokerage-accounts/{accountId}/portfolio"], + queryKey: portfolioQueryOptions("account-1").queryKey, }); expect( @@ -563,12 +559,12 @@ describe("AccountPage states", () => { }); }), ); - const queryClient = new QueryClient(); + const queryClient = createTestQueryClient(); renderAccountRoute(queryClient); await screen.findByText("Вторая"); void queryClient.invalidateQueries({ - queryKey: ["get", "/api/v1/brokerage-accounts/{accountId}/portfolio"], + queryKey: portfolioQueryOptions("account-1").queryKey, }); await waitFor(() => expect(requestCount).toBe(2)); diff --git a/src/pages/account/ui/AccountPage.tsx b/src/pages/account/ui/AccountPage.tsx index d7189f1..ad7c0b3 100644 --- a/src/pages/account/ui/AccountPage.tsx +++ b/src/pages/account/ui/AccountPage.tsx @@ -1,11 +1,9 @@ import type { FC } from "react"; -import { - AccountPageEmpty, - AccountPageError, - AccountPageLoaded, - AccountPageLoading, -} from "./AccountPage.view"; import { useAccountPageViewModel } from "./AccountPage.vm"; +import { AccountPageEmpty } from "./components/AccountPageEmpty.view"; +import { AccountPageError } from "./components/AccountPageError.view"; +import { AccountPageLoaded } from "./components/AccountPageLoaded.view"; +import { AccountPageLoading } from "./components/AccountPageLoading.view"; const AccountPage: FC = () => { const viewModel = useAccountPageViewModel(); @@ -38,10 +36,4 @@ const AccountPage: FC = () => { ); }; -export { - AccountPage, - AccountPageEmpty, - AccountPageError, - AccountPageLoaded, - AccountPageLoading, -}; +export { AccountPage }; diff --git a/src/pages/account/ui/AccountPage.view.tsx b/src/pages/account/ui/AccountPage.view.tsx deleted file mode 100644 index 6d4a3e6..0000000 --- a/src/pages/account/ui/AccountPage.view.tsx +++ /dev/null @@ -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 ( - - - - {[0, 1, 2].map((index) => ( - - ))} - - ); -}; - -const AccountPageError: FC = (props) => { - const { code, retry } = props; - const { t } = useTranslation(); - if (code === "NOT_FOUND") - return ( - - {t("portfolio.back")} - - } - /> - ); - if (code === "TBANK_NOT_CONNECTED" || code === "INVALID_TBANK_TOKEN") - return ( - - {t("accounts.openSettings")} - - } - /> - ); - if (code === "TBANK_UNAVAILABLE") - return ( - {t("accounts.retry")}} - /> - ); - return ( - {t("accounts.retry")}} - /> - ); -}; - -const AccountPageEmpty: FC = (props) => { - const { - account, - refreshing = false, - refreshError = false, - retry, - recentOperations, - recentEvents, - incomeSummary, - } = props; - const { t } = useTranslation(); - - return ( - - - - - - - {incomeSummary ? : null} - - ); -}; - -const AccountPageLoaded: FC = (props) => { - const { - account, - portfolio, - positionSections, - refreshing = false, - refreshError = false, - retry, - recentOperations, - recentEvents, - incomeSummary, - } = props; - const { t } = useTranslation(); - - return ( - - - - {portfolio.isStale ? ( - {t("portfolio.staleWarning")} - ) : null} - - {t("portfolio.updatedAt", { - date: formatSnapshotTime(portfolio.lastSuccessfulAt), - })} - - - s.kind), - "cash", - "recentOperations", - "recentEvents", - "incomeSummary", - ]} - classNames={{ - control: classes.accordionControl, - label: classes.accordionLabel, - chevron: classes.accordionChevron, - panel: classes.accordionPanel, - }} - > - {positionSections.map((section) => ( - - - {t(positionSectionTitleKey(section.kind))} - - - - - - ))} - - {t("portfolio.cashBalances")} - - - - - - {t("ledger.recentTitle")} - - - - - - {t("events.recentTitle")} - - - - - - {t("income.summaryTitle")} - - {incomeSummary ? : null} - - - - - ); -}; - -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, -}; diff --git a/src/pages/account/ui/AccountPage.vm.test.tsx b/src/pages/account/ui/AccountPage.vm.test.tsx index aa76752..0949a85 100644 --- a/src/pages/account/ui/AccountPage.vm.test.tsx +++ b/src/pages/account/ui/AccountPage.vm.test.tsx @@ -1,12 +1,12 @@ import { MantineProvider } from "@mantine/core"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { renderHook, waitFor } from "@testing-library/react"; import { HttpResponse, http } from "msw"; import type { FC, PropsWithChildren } from "react"; import { MemoryRouter, Route, Routes } from "react-router"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { Portfolio } from "@/entities/portfolio"; import { configureAuthClient } from "@/shared/api"; +import { createQueryWrapper } from "@/test/query"; import { server } from "@/test/server"; import { useAccountPageViewModel } from "./AccountPage.vm"; @@ -63,21 +63,21 @@ function configureTestAuth() { }); } -const wrapper: FC = (props) => { - const { children } = props; - - return ( - +function createWrapper() { + const { wrapper: QueryWrapper } = createQueryWrapper(); + const Wrapper: FC = (props) => ( + - + - + ); -}; + return Wrapper; +} function mockLoadedAccountPage() { server.use( @@ -130,13 +130,13 @@ function mockLoadedAccountPage() { } describe("useAccountPageViewModel", () => { - afterEach(() => configureAuthClient(null)); - it("prepares grouped position sections with derived display data", async () => { configureTestAuth(); mockLoadedAccountPage(); - const { result } = renderHook(() => useAccountPageViewModel(), { wrapper }); + const { result } = renderHook(() => useAccountPageViewModel(), { + wrapper: createWrapper(), + }); await waitFor(() => expect(result.current.state).toBe("loaded")); const loaded = result.current as Extract< @@ -146,7 +146,6 @@ describe("useAccountPageViewModel", () => { expect(loaded.positionSections).toMatchObject([ { kind: "stocks", - wide: true, positions: [ { displayName: "Акция", @@ -159,7 +158,6 @@ describe("useAccountPageViewModel", () => { }, { kind: "bonds", - wide: true, positions: [ { displayName: "Облигация", @@ -172,7 +170,6 @@ describe("useAccountPageViewModel", () => { }, { kind: "other", - wide: false, positions: [ { displayName: "Фонд", @@ -190,7 +187,9 @@ describe("useAccountPageViewModel", () => { configureTestAuth(); mockLoadedAccountPage(); - const { result } = renderHook(() => useAccountPageViewModel(), { wrapper }); + const { result } = renderHook(() => useAccountPageViewModel(), { + wrapper: createWrapper(), + }); await waitFor(() => expect( @@ -233,7 +232,9 @@ describe("useAccountPageViewModel", () => { ), ); - const { result } = renderHook(() => useAccountPageViewModel(), { wrapper }); + const { result } = renderHook(() => useAccountPageViewModel(), { + wrapper: createWrapper(), + }); await waitFor(() => expect(result.current).toMatchObject({ diff --git a/src/pages/account/ui/AccountPage.vm.ts b/src/pages/account/ui/AccountPage.vm.ts index 88914b1..95d3fa1 100644 --- a/src/pages/account/ui/AccountPage.vm.ts +++ b/src/pages/account/ui/AccountPage.vm.ts @@ -12,13 +12,9 @@ import { } from "@/entities/brokerage-event"; import { type IncomeSummary, useIncomeSummary } from "@/entities/income"; import type { Portfolio } from "@/entities/portfolio"; -import { - formatMoney, - formatShare, - instrumentLogoUrl, - portfolioErrorCode, - usePortfolio, -} from "@/entities/portfolio"; +import { formatShare, usePortfolio } from "@/entities/portfolio"; +import { apiErrorCode } from "@/shared/api"; +import { formatMoney, instrumentLogoUrl } from "@/shared/lib"; type PositionSectionKind = "stocks" | "bonds" | "other"; type AccountPageReturnTone = "positive" | "negative"; @@ -32,7 +28,6 @@ type AccountPagePosition = Portfolio["positions"][number] & { type AccountPagePositionSection = { kind: PositionSectionKind; - wide: boolean; positions: AccountPagePosition[]; }; @@ -108,7 +103,7 @@ function useAccountPageViewModel(): AccountPageViewModel { if (portfolio.isError) return { state: "error" as const, - code: portfolioErrorCode(portfolio.error), + code: apiErrorCode(portfolio.error), retry: () => portfolio.refetch(), }; return { state: "loading" as const }; @@ -188,9 +183,9 @@ function toAccountPagePositionSections( (denominators.get(currency) ?? 0) + Number(position.currentValue.value), ); } - const stocks = toSection("stocks", true, portfolio, denominators); - const bonds = toSection("bonds", true, portfolio, denominators); - const other = toSection("other", false, portfolio, denominators); + const stocks = toSection("stocks", portfolio, denominators); + const bonds = toSection("bonds", portfolio, denominators); + const other = toSection("other", portfolio, denominators); return [stocks, bonds, other].filter( (section) => section.positions.length > 0, ); @@ -198,28 +193,27 @@ function toAccountPagePositionSections( function toSection( kind: PositionSectionKind, - wide: boolean, portfolio: Portfolio, denominators: Map, ): AccountPagePositionSection { return { kind, - wide, positions: portfolio.positions .filter((position) => { if (kind === "stocks") return position.type === "STOCK"; if (kind === "bonds") return position.type === "BOND"; return position.type !== "STOCK" && position.type !== "BOND"; }) - .map((position) => toPosition(position, denominators, wide)), + .map((position) => toPosition(position, denominators, kind)), }; } function toPosition( position: Portfolio["positions"][number], denominators: Map, - wide: boolean, + kind: PositionSectionKind, ): AccountPagePosition { + const expanded = kind !== "other"; const returnValue = position.absoluteReturn ? Number(position.absoluteReturn.value) : null; @@ -238,10 +232,10 @@ function toPosition( }) : null, returnText: position.absoluteReturn - ? `${wide && returnValue !== null && returnValue > 0 ? "+" : ""}${formatMoney(position.absoluteReturn)}` + ? `${expanded && returnValue !== null && returnValue > 0 ? "+" : ""}${formatMoney(position.absoluteReturn)}` : null, returnTone, - logoSrc: wide ? instrumentLogoUrl(position.logoName) : null, + logoSrc: expanded ? instrumentLogoUrl(position.logoName) : null, }; } diff --git a/src/pages/account/ui/components/AccountPageEmpty.view.tsx b/src/pages/account/ui/components/AccountPageEmpty.view.tsx new file mode 100644 index 0000000..e9b6aa7 --- /dev/null +++ b/src/pages/account/ui/components/AccountPageEmpty.view.tsx @@ -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 = (props) => { + const { t } = useTranslation(); + return ( + + + + + + + {props.incomeSummary ? : null} + + ); +}; + +export { AccountPageEmpty }; diff --git a/src/pages/account/ui/components/AccountPageError.view.tsx b/src/pages/account/ui/components/AccountPageError.view.tsx new file mode 100644 index 0000000..f10b739 --- /dev/null +++ b/src/pages/account/ui/components/AccountPageError.view.tsx @@ -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 = (props) => { + const { code, retry } = props; + const { t } = useTranslation(); + if (code === "NOT_FOUND") + return ( + + {t("portfolio.back")} + + } + /> + ); + if (code === "TBANK_NOT_CONNECTED" || code === "INVALID_TBANK_TOKEN") + return ( + + {t("accounts.openSettings")} + + } + /> + ); + if (code === "TBANK_UNAVAILABLE") + return ( + {t("accounts.retry")}} + /> + ); + return ( + {t("accounts.retry")}} + /> + ); +}; + +export { AccountPageError }; diff --git a/src/pages/account/ui/components/AccountPageLoaded.view.tsx b/src/pages/account/ui/components/AccountPageLoaded.view.tsx new file mode 100644 index 0000000..41d8e36 --- /dev/null +++ b/src/pages/account/ui/components/AccountPageLoaded.view.tsx @@ -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 = (props) => { + const { t } = useTranslation(); + return ( + + + + {props.portfolio.isStale ? ( + {t("portfolio.staleWarning")} + ) : null} + + {t("portfolio.updatedAt", { + date: formatSnapshotTime(props.portfolio.lastSuccessfulAt), + })} + + + section.kind), + "cash", + "recentOperations", + "recentEvents", + "incomeSummary", + ]} + classNames={{ + control: classes.accordionControl, + label: classes.accordionLabel, + chevron: classes.accordionChevron, + panel: classes.accordionPanel, + }} + > + {props.positionSections.map((section) => ( + + + {t(positionSectionTitleKey(section.kind))} + + + + + + ))} + + {t("portfolio.cashBalances")} + + + + + + {t("ledger.recentTitle")} + + + + + + {t("events.recentTitle")} + + + + + + {t("income.summaryTitle")} + + {props.incomeSummary ? ( + + ) : null} + + + + + ); +}; + +function positionSectionTitleKey(kind: AccountPagePositionSection["kind"]) { + if (kind === "stocks") return "portfolio.stocks"; + if (kind === "bonds") return "portfolio.bonds"; + return "portfolio.otherPositions"; +} + +export { AccountPageLoaded }; diff --git a/src/pages/account/ui/components/AccountPageLoading.view.tsx b/src/pages/account/ui/components/AccountPageLoading.view.tsx new file mode 100644 index 0000000..bb1ee89 --- /dev/null +++ b/src/pages/account/ui/components/AccountPageLoading.view.tsx @@ -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 ( + + + + {[0, 1, 2].map((index) => ( + + ))} + + ); +}; + +export { AccountPageLoading }; diff --git a/src/pages/account/ui/components/CashBalances.view.tsx b/src/pages/account/ui/components/CashBalances.view.tsx index 20a9da3..7b675c8 100644 --- a/src/pages/account/ui/components/CashBalances.view.tsx +++ b/src/pages/account/ui/components/CashBalances.view.tsx @@ -1,7 +1,7 @@ import { Paper, Text } from "@mantine/core"; import type { FC } from "react"; import type { Portfolio } from "@/entities/portfolio"; -import { formatMoney } from "@/entities/portfolio"; +import { formatMoney } from "@/shared/lib"; import classes from "../AccountPage.module.css"; type CashBalancesProps = { diff --git a/src/pages/account/ui/components/IncomeSummary.view.tsx b/src/pages/account/ui/components/IncomeSummary.view.tsx index e9ab16d..6b86d66 100644 --- a/src/pages/account/ui/components/IncomeSummary.view.tsx +++ b/src/pages/account/ui/components/IncomeSummary.view.tsx @@ -3,7 +3,7 @@ import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import type { IncomeSummary as IncomeSummaryData } from "@/entities/income"; -import { formatMoney } from "@/entities/portfolio"; +import { formatMoney } from "@/shared/lib"; import { StateView } from "@/shared/ui"; type IncomeSummaryProps = diff --git a/src/pages/account/ui/components/PortfolioTotals.view.tsx b/src/pages/account/ui/components/PortfolioTotals.view.tsx index ea71d59..e0f6b83 100644 --- a/src/pages/account/ui/components/PortfolioTotals.view.tsx +++ b/src/pages/account/ui/components/PortfolioTotals.view.tsx @@ -1,7 +1,7 @@ import { Paper, SimpleGrid, Text } from "@mantine/core"; import type { FC } from "react"; import type { Portfolio } from "@/entities/portfolio"; -import { formatMoney } from "@/entities/portfolio"; +import { formatMoney } from "@/shared/lib"; import classes from "../AccountPage.module.css"; type PortfolioTotalsProps = { diff --git a/src/pages/account/ui/components/PositionCard.view.tsx b/src/pages/account/ui/components/PositionCard.view.tsx index aada142..51bd82e 100644 --- a/src/pages/account/ui/components/PositionCard.view.tsx +++ b/src/pages/account/ui/components/PositionCard.view.tsx @@ -1,44 +1,28 @@ import { Card, Group, Stack, Text } from "@mantine/core"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; -import { formatMoney } from "@/entities/portfolio"; +import { formatMoney } from "@/shared/lib"; import classes from "../AccountPage.module.css"; -import type { AccountPagePosition } from "../AccountPage.vm"; +import type { + AccountPagePosition, + AccountPagePositionSection, +} from "../AccountPage.vm"; import { PositionLogo } from "./PositionLogo.view"; type PositionCardProps = { + kind: AccountPagePositionSection["kind"]; position: AccountPagePosition; - wide?: boolean; - variant?: "stock" | "bond"; }; const PositionCard: FC = (props) => { - const { position, wide = false, variant } = props; + const { kind, position } = props; const { t } = useTranslation(); - const isExpanded = variant === "stock" || variant === "bond"; - const ec = - variant === "bond" - ? { - card: classes.bondCard, - main: classes.bondMain, - identity: classes.bondIdentity, - identifiers: classes.bondIdentifiers, - value: classes.bondValue, - meta: classes.bondMeta, - } - : { - card: classes.stockCard, - main: classes.stockMain, - identity: classes.stockIdentity, - identifiers: classes.stockIdentifiers, - value: classes.stockValue, - meta: classes.stockMeta, - }; + const isExpanded = kind !== "other"; const returnClass = position.returnTone ? classes[position.returnTone] : undefined; const returnText = - position.returnText && wide + position.returnText && isExpanded ? position.returnText : position.returnText ? t("portfolio.return", { @@ -46,7 +30,7 @@ const PositionCard: FC = (props) => { }) : null; const identifier = - variant === "stock" ? (position.ticker ?? position.isin) : position.isin; + kind === "stocks" ? (position.ticker ?? position.isin) : position.isin; const identifierText = [ t("portfolio.quantity", { value: position.quantity }), identifier, @@ -68,22 +52,22 @@ const PositionCard: FC = (props) => { if (isExpanded) return ( - -
+ +
- + {position.displayName} - + {identifierText} - + {position.currentValue ? ( {formatMoney(position.currentValue)} @@ -97,7 +81,7 @@ const PositionCard: FC = (props) => {
{secondaryText ? ( - + {secondaryText} ) : null} @@ -106,13 +90,6 @@ const PositionCard: FC = (props) => { return ( - {wide ? ( - - ) : null} {position.displayName} diff --git a/src/pages/account/ui/components/PositionSection.view.tsx b/src/pages/account/ui/components/PositionSection.view.tsx index 765af70..4bbfd31 100644 --- a/src/pages/account/ui/components/PositionSection.view.tsx +++ b/src/pages/account/ui/components/PositionSection.view.tsx @@ -9,21 +9,15 @@ type PositionSectionProps = { const PositionSection: FC = (props) => { const { section } = props; + const expanded = section.kind !== "other"; return ( - + {section.positions.map((position) => ( ))} diff --git a/src/pages/account/ui/components/RecentEvents.view.tsx b/src/pages/account/ui/components/RecentEvents.view.tsx index adce91b..196b1bb 100644 --- a/src/pages/account/ui/components/RecentEvents.view.tsx +++ b/src/pages/account/ui/components/RecentEvents.view.tsx @@ -3,7 +3,7 @@ import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import type { BrokerageEvent } from "@/entities/brokerage-event"; -import { formatMoney } from "@/entities/portfolio"; +import { formatMoney } from "@/shared/lib"; import { LoadingSkeleton } from "@/shared/ui"; import classes from "../AccountPage.module.css"; import type { AccountPageRecentEvents } from "../AccountPage.vm"; diff --git a/src/pages/account/ui/components/index.ts b/src/pages/account/ui/components/index.ts deleted file mode 100644 index 831deba..0000000 --- a/src/pages/account/ui/components/index.ts +++ /dev/null @@ -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"; diff --git a/src/pages/accounts/index.ts b/src/pages/accounts/index.ts index 34364d8..2bb8099 100644 --- a/src/pages/accounts/index.ts +++ b/src/pages/accounts/index.ts @@ -1,6 +1 @@ -export { - AccountsPage, - AccountsPageEmpty, - AccountsPageError, - AccountsPageLoading, -} from "./ui/AccountsPage"; +export { AccountsPage } from "./ui/AccountsPage"; diff --git a/src/pages/accounts/ui/AccountsPage.test.tsx b/src/pages/accounts/ui/AccountsPage.test.tsx index a76525d..849de0c 100644 --- a/src/pages/accounts/ui/AccountsPage.test.tsx +++ b/src/pages/accounts/ui/AccountsPage.test.tsx @@ -1,13 +1,17 @@ import { MantineProvider } from "@mantine/core"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { type QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; import { HttpResponse, http } from "msw"; import { MemoryRouter } from "react-router"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { brokerageAccountsQueryKey } from "@/entities/brokerage-account"; import { configureAuthClient } from "@/shared/api"; +import { createTestQueryClient } from "@/test/query"; import { server } from "@/test/server"; import { AccountsPage } from "./AccountsPage"; +import { AccountsPageEmpty } from "./components/AccountsPageEmpty.view"; +import { AccountsPageError } from "./components/AccountsPageError.view"; +import { AccountsPageLoading } from "./components/AccountsPageLoading.view"; const account = { id: "account-1", @@ -38,9 +42,36 @@ function renderPage(queryClient: QueryClient) { } describe("AccountsPage", () => { - afterEach(() => { - configureAuthClient(null); - cleanup(); + it.each([ + ["loading", AccountsPageLoading, "status"], + ["empty", AccountsPageEmpty, undefined], + ] as const)("renders only the %s state", (_name, State, role) => { + render( + + + , + ); + 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( + + + + + , + ); + screen.getByRole("button", { name: "Повторить" }).click(); + expect(retry).toHaveBeenCalledOnce(); + expect( + screen.getByRole("link", { name: "Открыть настройки T-Bank" }), + ).toHaveAttribute("href", "/settings"); }); it("keeps the cached account list visible during a background refresh", async () => { @@ -55,7 +86,7 @@ describe("AccountsPage", () => { }); }), ); - const queryClient = new QueryClient(); + const queryClient = createTestQueryClient(); renderPage(queryClient); await screen.findByText(account.name); @@ -86,7 +117,7 @@ describe("AccountsPage", () => { return HttpResponse.json([account]); }), ); - const queryClient = new QueryClient(); + const queryClient = createTestQueryClient(); renderPage(queryClient); await screen.findByText(account.name); @@ -116,7 +147,7 @@ describe("AccountsPage", () => { ); }), ); - const queryClient = new QueryClient(); + const queryClient = createTestQueryClient(); renderPage(queryClient); await screen.findByText("Нет инвестиционных счетов"); diff --git a/src/pages/accounts/ui/AccountsPage.tsx b/src/pages/accounts/ui/AccountsPage.tsx index e8e859d..29e2294 100644 --- a/src/pages/accounts/ui/AccountsPage.tsx +++ b/src/pages/accounts/ui/AccountsPage.tsx @@ -1,11 +1,9 @@ import type { FC } from "react"; -import { - AccountsPageEmpty, - AccountsPageError, - AccountsPageLoaded, - AccountsPageLoading, -} from "./AccountsPage.view"; import { useAccountsPageViewModel } from "./AccountsPage.vm"; +import { AccountsPageEmpty } from "./components/AccountsPageEmpty.view"; +import { AccountsPageError } from "./components/AccountsPageError.view"; +import { AccountsPageLoaded } from "./components/AccountsPageLoaded.view"; +import { AccountsPageLoading } from "./components/AccountsPageLoading.view"; const AccountsPage: FC = () => { const viewModel = useAccountsPageViewModel(); @@ -29,9 +27,4 @@ const AccountsPage: FC = () => { ); }; -export { - AccountsPage, - AccountsPageEmpty, - AccountsPageError, - AccountsPageLoading, -}; +export { AccountsPage }; diff --git a/src/pages/accounts/ui/AccountsPage.view.tsx b/src/pages/accounts/ui/AccountsPage.view.tsx deleted file mode 100644 index aa400e3..0000000 --- a/src/pages/accounts/ui/AccountsPage.view.tsx +++ /dev/null @@ -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 ( - - - {[0, 1, 2].map((index) => ( - - ))} - - ); -}; - -const AccountsPageError: FC = (props) => { - const { code, retry } = props; - const { t } = useTranslation(); - const settingsAction = ( - - ); - if (code === "TBANK_NOT_CONNECTED") - return ( - - ); - if (code === "INVALID_TBANK_TOKEN") - return ( - - ); - if (code === "TBANK_UNAVAILABLE") - return ( - - - {settingsAction} - - } - /> - ); - return ( - {t("accounts.retry")}} - /> - ); -}; - -const AccountsPageEmpty: FC = (props) => { - const { refreshing = false, refreshError = false, retry } = props; - const { t } = useTranslation(); - - return ( - - - - - ); -}; - -const AccountsPageLoaded: FC = (props) => { - const { accounts, refreshing = false, refreshError = false, retry } = props; - const { t } = useTranslation(); - - return ( - - {t("accounts.title")} - - - - ); -}; - -const AccountsRefreshState: FC = (props) => { - const { refreshing, refreshError, retry } = props; - const { t } = useTranslation(); - if (!refreshing && !refreshError) return null; - - return ( - <> - {refreshing ? ( - - - - {t("accounts.refreshing")} - - - ) : null} - {refreshError ? ( - - - {t("accounts.refreshError")} - - - - ) : null} - - ); -}; - -export { - AccountsPageEmpty, - AccountsPageError, - AccountsPageLoaded, - AccountsPageLoading, -}; diff --git a/src/pages/accounts/ui/AccountsPage.vm.ts b/src/pages/accounts/ui/AccountsPage.vm.ts index d27243b..88ebe77 100644 --- a/src/pages/accounts/ui/AccountsPage.vm.ts +++ b/src/pages/accounts/ui/AccountsPage.vm.ts @@ -1,8 +1,8 @@ import { brokerageAccountCards, - errorCode, useBrokerageAccounts, } from "@/entities/brokerage-account"; +import { apiErrorCode } from "@/shared/api"; function useAccountsPageViewModel() { const accounts = useBrokerageAccounts(); @@ -11,7 +11,7 @@ function useAccountsPageViewModel() { if (accounts.isError) return { state: "error" as const, - code: errorCode(accounts.error), + code: apiErrorCode(accounts.error), retry: () => accounts.refetch(), }; } diff --git a/src/pages/accounts/ui/components/AccountsPageEmpty.view.tsx b/src/pages/accounts/ui/components/AccountsPageEmpty.view.tsx new file mode 100644 index 0000000..c7e63b8 --- /dev/null +++ b/src/pages/accounts/ui/components/AccountsPageEmpty.view.tsx @@ -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 = (props) => { + const { t } = useTranslation(); + return ( + + + + + ); +}; + +export { AccountsPageEmpty }; diff --git a/src/pages/accounts/ui/components/AccountsPageError.view.tsx b/src/pages/accounts/ui/components/AccountsPageError.view.tsx new file mode 100644 index 0000000..46faf78 --- /dev/null +++ b/src/pages/accounts/ui/components/AccountsPageError.view.tsx @@ -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 = (props) => { + const { code, retry } = props; + const { t } = useTranslation(); + const settingsAction = ( + + ); + if (code === "TBANK_NOT_CONNECTED") + return ( + + ); + if (code === "INVALID_TBANK_TOKEN") + return ( + + ); + if (code === "TBANK_UNAVAILABLE") + return ( + + + {settingsAction} + + } + /> + ); + return ( + {t("accounts.retry")}} + /> + ); +}; + +export { AccountsPageError }; diff --git a/src/pages/accounts/ui/components/AccountsPageLoaded.view.tsx b/src/pages/accounts/ui/components/AccountsPageLoaded.view.tsx new file mode 100644 index 0000000..ddc1801 --- /dev/null +++ b/src/pages/accounts/ui/components/AccountsPageLoaded.view.tsx @@ -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 = (props) => { + const { accounts, ...refresh } = props; + const { t } = useTranslation(); + return ( + + {t("accounts.title")} + + + + ); +}; + +export { AccountsPageLoaded }; diff --git a/src/pages/accounts/ui/components/AccountsPageLoading.view.tsx b/src/pages/accounts/ui/components/AccountsPageLoading.view.tsx new file mode 100644 index 0000000..9d76bf2 --- /dev/null +++ b/src/pages/accounts/ui/components/AccountsPageLoading.view.tsx @@ -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 ( + + + {[0, 1, 2].map((index) => ( + + ))} + + ); +}; + +export { AccountsPageLoading }; diff --git a/src/pages/accounts/ui/components/AccountsRefreshState.view.tsx b/src/pages/accounts/ui/components/AccountsRefreshState.view.tsx new file mode 100644 index 0000000..422c7ce --- /dev/null +++ b/src/pages/accounts/ui/components/AccountsRefreshState.view.tsx @@ -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 = (props) => { + const { refreshing = false, refreshError = false, retry } = props; + const { t } = useTranslation(); + if (!refreshing && !refreshError) return null; + return ( + <> + {refreshing ? ( + + + + {t("accounts.refreshing")} + + + ) : null} + {refreshError ? ( + + + {t("accounts.refreshError")} + + + + ) : null} + + ); +}; + +export { AccountsRefreshState, type AccountsRefreshStateProps }; diff --git a/src/pages/login/ui/LoginPage.tsx b/src/pages/login/ui/LoginPage.tsx index de0f87c..adb7753 100644 --- a/src/pages/login/ui/LoginPage.tsx +++ b/src/pages/login/ui/LoginPage.tsx @@ -1,20 +1,14 @@ -import { Paper, Stack, Title } from "@mantine/core"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; -import { LoginForm } from "@/features/auth"; +import { AuthPageLayout, LoginForm } from "@/features/auth"; const LoginPage: FC = () => { const { t } = useTranslation(); return ( -
- - - {t("auth.login")} - - - -
+ + + ); }; diff --git a/src/pages/not-found/ui/NotFoundPage.tsx b/src/pages/not-found/ui/NotFoundPage.tsx index 7d5d336..c6cafb8 100644 --- a/src/pages/not-found/ui/NotFoundPage.tsx +++ b/src/pages/not-found/ui/NotFoundPage.tsx @@ -1,8 +1,8 @@ -import { Stack, Title } from "@mantine/core"; +import { Button, Stack, Title } from "@mantine/core"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; -import { Button, StateView } from "@/shared/ui"; +import { StateView } from "@/shared/ui"; const NotFoundPage: FC = () => { const { t } = useTranslation(); diff --git a/src/pages/register/ui/RegisterPage.tsx b/src/pages/register/ui/RegisterPage.tsx index 2d0bef3..fb11abc 100644 --- a/src/pages/register/ui/RegisterPage.tsx +++ b/src/pages/register/ui/RegisterPage.tsx @@ -1,20 +1,14 @@ -import { Paper, Stack, Title } from "@mantine/core"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; -import { RegisterForm } from "@/features/auth"; +import { AuthPageLayout, RegisterForm } from "@/features/auth"; const RegisterPage: FC = () => { const { t } = useTranslation(); return ( -
- - - {t("auth.register")} - - - -
+ + + ); }; diff --git a/src/shared/api/authenticated-query.test.tsx b/src/shared/api/authenticated-query.test.tsx index 77b0a81..cfe8f65 100644 --- a/src/shared/api/authenticated-query.test.tsx +++ b/src/shared/api/authenticated-query.test.tsx @@ -1,31 +1,12 @@ -import { MantineProvider } from "@mantine/core"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { renderHook, waitFor } from "@testing-library/react"; import { HttpResponse, http } from "msw"; -import type { FC, PropsWithChildren } from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { createQueryWrapper } from "@/test/query"; import { server } from "@/test/server"; import { useAuthenticatedQuery } from "./authenticated-query"; import { apiQueryClient, configureAuthClient } from "./client"; -function createWrapper() { - const client = new QueryClient(); - const TestWrapper: FC = (props) => { - const { children } = props; - - return ( - - {children} - - ); - }; - - return TestWrapper; -} - describe("useAuthenticatedQuery", () => { - afterEach(() => configureAuthClient(null)); - it("keeps protected queries idle without a session and loads after session configuration", async () => { const requests = vi.fn(); server.use( @@ -47,7 +28,7 @@ describe("useAuthenticatedQuery", () => { useAuthenticatedQuery( apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"), ), - { wrapper: createWrapper() }, + { wrapper: createQueryWrapper().wrapper }, ); await new Promise((resolve) => setTimeout(resolve, 0)); diff --git a/src/shared/api/client.test.ts b/src/shared/api/client.test.ts index 6b99594..fa83613 100644 --- a/src/shared/api/client.test.ts +++ b/src/shared/api/client.test.ts @@ -1,11 +1,9 @@ import { HttpResponse, http } from "msw"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { server } from "@/test/server"; import { apiClient, configureAuthClient } from "./client"; describe("apiClient authentication", () => { - afterEach(() => configureAuthClient(null)); - it("refreshes once and retries an unauthorized protected request", async () => { let token = "expired"; const refresh = vi.fn(async () => { diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 469804f..f6161cd 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -232,7 +232,7 @@ export interface components { TbankConnectionStatusDto: { connected: boolean; /** Format: date-time */ - connectedAt: Record | null; + connectedAt: string | null; }; PutTbankConnectionDto: { token: string; diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts index 32ad258..9f8de66 100644 --- a/src/shared/api/index.ts +++ b/src/shared/api/index.ts @@ -1,2 +1,4 @@ export { useAuthenticatedQuery } from "./authenticated-query"; export { apiClient, apiQueryClient, configureAuthClient } from "./client"; +export type { paths } from "./generated/schema"; +export { apiErrorCode } from "./problem-details"; diff --git a/src/shared/api/problem-details.ts b/src/shared/api/problem-details.ts new file mode 100644 index 0000000..f32de0e --- /dev/null +++ b/src/shared/api/problem-details.ts @@ -0,0 +1,7 @@ +function apiErrorCode(error: unknown): string | undefined { + return typeof error === "object" && error !== null && "code" in error + ? String(error.code) + : undefined; +} + +export { apiErrorCode }; diff --git a/src/shared/api/spec/openapi.json b/src/shared/api/spec/openapi.json index 918fe85..49991ca 100644 --- a/src/shared/api/spec/openapi.json +++ b/src/shared/api/spec/openapi.json @@ -1275,7 +1275,7 @@ "type": "boolean" }, "connectedAt": { - "type": "object", + "type": "string", "nullable": true, "format": "date-time" } @@ -1960,4 +1960,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/shared/lib/display.test.ts b/src/shared/lib/display.test.ts new file mode 100644 index 0000000..01ae6b2 --- /dev/null +++ b/src/shared/lib/display.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { formatMoney, instrumentLogoUrl } from "./display"; + +describe("shared 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("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(); + }); +}); diff --git a/src/shared/lib/display.ts b/src/shared/lib/display.ts new file mode 100644 index 0000000..33f7b73 --- /dev/null +++ b/src/shared/lib/display.ts @@ -0,0 +1,35 @@ +type Money = { currency: string; value: string }; + +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 formatSnapshotTime(value: string): string { + return new Intl.DateTimeFormat("ru-RU", { + dateStyle: "medium", + timeStyle: "short", + timeZone: "UTC", + }).format(new Date(value)); +} + +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, formatSnapshotTime, instrumentLogoUrl }; diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts new file mode 100644 index 0000000..4363ab8 --- /dev/null +++ b/src/shared/lib/index.ts @@ -0,0 +1,5 @@ +export { + formatMoney, + formatSnapshotTime, + instrumentLogoUrl, +} from "./display"; diff --git a/src/shared/ui/AppShell.module.css b/src/shared/ui/AppShell.module.css new file mode 100644 index 0000000..af22541 --- /dev/null +++ b/src/shared/ui/AppShell.module.css @@ -0,0 +1,33 @@ +.header { + align-items: center; + display: flex; + padding: 0 var(--mantine-spacing-md); +} + +.bottomNav { + display: none; +} + +@media (max-width: 47.99375em) { + .bottomNav { + background: var(--mantine-color-white); + border-top: 1px solid var(--mantine-color-gray-3); + bottom: 0; + display: flex; + justify-content: space-around; + left: 0; + padding: var(--mantine-spacing-sm); + position: fixed; + right: 0; + z-index: 200; + } + + .active { + color: var(--mantine-primary-color-filled); + font-weight: 600; + } + + .main { + padding-bottom: 5rem; + } +} diff --git a/src/shared/ui/AppShell.tsx b/src/shared/ui/AppShell.tsx index d55ff6a..bfef47a 100644 --- a/src/shared/ui/AppShell.tsx +++ b/src/shared/ui/AppShell.tsx @@ -2,6 +2,7 @@ import { AppShell as MantineAppShell, NavLink, Text } from "@mantine/core"; import type { FC, ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Outlet, NavLink as RouterNavLink, useLocation } from "react-router"; +import classes from "./AppShell.module.css"; type AppShellProps = { contentAboveOutlet?: ReactNode; @@ -22,7 +23,7 @@ const AppShell: FC = (props) => { navbar={{ width: 220, breakpoint: "sm", collapsed: { mobile: true } }} padding="md" > - + {t("appName")} @@ -36,14 +37,16 @@ const AppShell: FC = (props) => { /> ))} - + {contentAboveOutlet} -