From f9f0f6921e86f6c81510373c680b136e4580f386 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Tue, 29 Sep 2026 07:07:36 +0300 Subject: [PATCH] refactor: clarify frontend module ownership --- .ai/conventions/testing.md | 2 +- .ai/conventions/ui.md | 6 +- .../src/EntryCard/EntryCard.browser.test.tsx | 38 ++++ packages/ui/src/EntryCard/EntryCard.tsx | 4 +- src/app/model/index.ts | 1 + src/app/model/useCurrentRoute.ts | 23 +++ src/app/routes/AppRoutes/AppRoutes.test.tsx | 44 ++++- .../routes/DocumentTitle/DocumentTitle.tsx | 22 ++- src/app/routes/RouteFallback.tsx | 13 +- src/app/routes/RouteGuards.tsx | 13 +- src/app/ui/AppBreadcrumbs/AppBreadcrumbs.tsx | 25 ++- src/entities/bank-card/api/banking.test.tsx | 40 ++-- src/entities/bank-card/api/mutations.ts | 2 +- src/entities/bank-card/api/queries.ts | 61 +----- src/entities/bank-card/api/summary.test.ts | 4 +- src/entities/bank-card/api/types.ts | 48 +++++ src/entities/bank-card/index.ts | 8 +- .../bank-card/model/category.schema.ts | 2 +- .../bank-card/model/form-validation.test.ts | 9 +- .../bank-card/model/form-validation.ts | 27 +-- src/entities/bank-card/model/form.schema.ts | 3 +- src/entities/bank-card/model/money.ts | 26 +++ .../bank-card/model/operation-appearance.ts | 2 +- .../bank-card/model/operation-kinds.ts | 2 +- .../ui/BankCardFace/BankCardFace.tsx | 2 +- .../ui/BankCardForm/BankCardFields.vm.ts | 27 ++- .../ui/BankCardForm/BankCardForm.test.tsx | 2 +- .../ui/BankCardForm/BankCardForm.tsx | 3 +- .../ui/BankCardForm/BankCardForm.view.tsx | 14 +- .../ui/BankCardForm/BankCardForm.vm.ts | 7 +- .../ui/BankCardForm/useOpeningDateField.ts | 49 ----- .../ui/BankCardForm/usePaymentSystemField.ts | 27 --- .../ui/BankSummary/BankSummary.view.tsx | 2 +- .../ui/CategoryManager/CategoryManager.vm.ts | 4 +- src/entities/bank-card/ui/bankDateField.ts | 39 ++++ src/entities/bond/api/queries.ts | 6 + src/entities/bond/index.ts | 2 + .../broker-operation/ui/LedgerEntryRow.tsx | 2 + src/entities/brokerage-event/index.ts | 1 + .../brokerage-event/model/event-key.ts | 10 + .../BrokerageEventsPreview.tsx | 11 +- src/entities/stock/api/queries.ts | 6 + src/entities/stock/index.ts | 2 + src/features/auth/model/auth-context.ts | 1 + src/features/auth/model/auth.vm.ts | 4 + src/features/instrument-details/index.ts | 9 +- .../instrument-details/model/types.ts | 13 +- .../ui/InstrumentDetails.module.css | 9 +- .../ui/InstrumentLayout.view.tsx | 31 +++ .../InstrumentMetric.module.css | 5 + .../InstrumentMetric.view.tsx | 4 +- src/features/tbank-connection/index.ts | 4 + .../model/connection-boundary.ts | 25 +-- .../InvestmentRefreshState.stories.tsx | 52 +++++ .../InvestmentRefreshState.view.tsx} | 15 +- .../AccountEventsPage/AccountEventsPage.vm.ts | 29 +-- .../AccountEventsPageLoaded.view.tsx | 15 +- .../AccountIncomePage.module.css | 7 - .../components/IncomePayments.view.tsx | 8 +- .../AccountOperationsPage.vm.ts | 31 +-- src/pages/account/model/previews.ts | 144 ++++++++++++++ .../ui/AccountPage/AccountPage.test.tsx | 2 +- .../account/ui/AccountPage/AccountPage.vm.ts | 144 ++------------ .../components/AccountPageEmpty.view.tsx | 7 +- .../components/AccountPageLoaded.view.tsx | 11 +- .../PortfolioRefreshState.stories.tsx | 58 ------ .../PortfolioRefreshState.view.tsx | 55 ------ .../components/RecentOperations.view.tsx | 2 +- .../components/AccountsPageEmpty.view.tsx | 10 +- .../components/AccountsPageLoaded.view.tsx | 11 +- src/pages/bond/ui/BondPage/BondPage.view.tsx | 25 ++- .../BondPage/components/BondMetrics.view.tsx | 18 +- .../card/ui/CardPage/CardHistory.view.tsx | 126 ++++++++++++ src/pages/card/ui/CardPage/CardPage.view.tsx | 104 +--------- .../ui/CardPage/CardPagePresentation.vm.ts | 8 +- .../ui/OperationForm/OperationFields.vm.ts | 33 +--- .../OperationForm/OperationForm.stories.tsx | 10 +- .../ui/OperationForm/OperationForm.types.ts | 9 +- .../ui/OperationForm/OperationForm.view.tsx | 17 +- .../card/ui/OperationForm/OperationForm.vm.ts | 68 ++----- .../ui/OperationForm/useRefundPurchase.ts | 61 ++++++ .../ui/SettingsPage/SettingsPage.vm.ts | 10 +- .../stock/ui/StockPage/StockPage.view.tsx | 183 ++---------------- .../components/StockCompany.view.tsx | 120 ++++++++++++ .../StockPage/components/StockQuote.view.tsx | 98 ++++++++++ .../api/resource-query.test.tsx} | 26 ++- src/shared/api/resource-query.ts | 47 +++-- src/shared/lib/index.ts | 1 + src/shared/lib/useUrlPeriod.ts | 27 +++ src/test/storybook.test.tsx | 2 +- vitest.config.ts | 2 +- 91 files changed, 1292 insertions(+), 1050 deletions(-) create mode 100644 packages/ui/src/EntryCard/EntryCard.browser.test.tsx create mode 100644 src/app/model/index.ts create mode 100644 src/app/model/useCurrentRoute.ts create mode 100644 src/entities/bank-card/api/types.ts create mode 100644 src/entities/bank-card/model/money.ts delete mode 100644 src/entities/bank-card/ui/BankCardForm/useOpeningDateField.ts delete mode 100644 src/entities/bank-card/ui/BankCardForm/usePaymentSystemField.ts create mode 100644 src/entities/bank-card/ui/bankDateField.ts create mode 100644 src/entities/brokerage-event/model/event-key.ts create mode 100644 src/features/instrument-details/ui/InstrumentLayout.view.tsx create mode 100644 src/features/instrument-details/ui/InstrumentMetric/InstrumentMetric.module.css rename src/features/instrument-details/ui/{ => InstrumentMetric}/InstrumentMetric.view.tsx (80%) create mode 100644 src/features/tbank-connection/ui/InvestmentRefreshState/InvestmentRefreshState.stories.tsx rename src/{pages/accounts/ui/AccountsPage/components/AccountsRefreshState.view.tsx => features/tbank-connection/ui/InvestmentRefreshState/InvestmentRefreshState.view.tsx} (70%) create mode 100644 src/pages/account/model/previews.ts delete mode 100644 src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.stories.tsx delete mode 100644 src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.view.tsx create mode 100644 src/pages/card/ui/CardPage/CardHistory.view.tsx create mode 100644 src/pages/card/ui/OperationForm/useRefundPurchase.ts create mode 100644 src/pages/stock/ui/StockPage/components/StockCompany.view.tsx create mode 100644 src/pages/stock/ui/StockPage/components/StockQuote.view.tsx rename src/{entities/portfolio/api/resource-lifecycle.test.tsx => shared/api/resource-query.test.tsx} (87%) create mode 100644 src/shared/lib/useUrlPeriod.ts diff --git a/.ai/conventions/testing.md b/.ai/conventions/testing.md index da1e450..3e80393 100644 --- a/.ai/conventions/testing.md +++ b/.ai/conventions/testing.md @@ -47,7 +47,7 @@ throwing route, безопасная ссылка и reset после navigation Примеры: [AuthForm test](../../src/features/auth/ui/AuthForm/AuthForm.test.tsx), [client test](../../src/shared/api/client.test.ts), -[resource lifecycle test](../../src/entities/portfolio/api/resource-lifecycle.test.tsx). +[resource lifecycle test](../../src/shared/api/resource-query.test.tsx). Не копируй fixtures/сценарии целиком, если задача затрагивает один исход. ## Команды diff --git a/.ai/conventions/ui.md b/.ai/conventions/ui.md index 432d6ec..879c2f2 100644 --- a/.ai/conventions/ui.md +++ b/.ai/conventions/ui.md @@ -76,9 +76,9 @@ JSX props получают ссылки на функции, включая `onC Closure над состоянием, mutations и refs является действием VM, а не shared helper. VM собирает связные предметные hooks, когда внутри смешиваются независимые -сценарии. Например, `useOpeningDateField` владеет draft/date parsing и событиями -даты, `usePaymentSystemField` — выбором платёжной системы. Каждый такой hook -возвращает данные и действия своей ответственности, VM объединяет их для view. +сценарии. Например, `useRefundPurchase` владеет выбором исходной покупки, +её загрузкой и пагинацией. Общий адаптер `bankDateField` обслуживает поля даты; +простое подключение одного controller остаётся в VM формы. Не группируй несвязанные функции только по префиксу `on*` и не создавай passthrough hook над одним вызовом ради формального дробления. diff --git a/packages/ui/src/EntryCard/EntryCard.browser.test.tsx b/packages/ui/src/EntryCard/EntryCard.browser.test.tsx new file mode 100644 index 0000000..7238790 --- /dev/null +++ b/packages/ui/src/EntryCard/EntryCard.browser.test.tsx @@ -0,0 +1,38 @@ +import "@mantine/core/styles.css"; + +import "../styles.css"; + +import { MantineProvider } from "@mantine/core"; +// eslint-disable-next-line testing-library/no-manual-cleanup -- Vitest globals are disabled, so RTL cannot register its automatic afterEach cleanup. +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, expect, it } from "vitest"; + +import { cssVariablesResolver, theme } from "../theme"; +import { EntryCard } from "./EntryCard"; + +afterEach(cleanup); + +it("shows the full wrapped title without horizontal overflow", () => { + const title = "Дивидендная выплата с очень длинным описанием, которое должно переноситься и оставаться доступным полностью"; + + render( + + + , + ); + + const description = screen.getByText(title); + expect(description.clientHeight).toBeGreaterThan(0); + expect(description.scrollHeight) + .toBeLessThanOrEqual(description.clientHeight); + expect(document.documentElement.scrollWidth) + .toBeLessThanOrEqual(window.innerWidth); +}); diff --git a/packages/ui/src/EntryCard/EntryCard.tsx b/packages/ui/src/EntryCard/EntryCard.tsx index 8a63f82..973c8ef 100644 --- a/packages/ui/src/EntryCard/EntryCard.tsx +++ b/packages/ui/src/EntryCard/EntryCard.tsx @@ -28,6 +28,8 @@ const EntryCard: FC = (props) => { } : undefined; + const titleLineClamp = props.wrap ? undefined : 1; + const valueContainerClass = props.actions ? classes.valueFooter : undefined; const valueLabelClass = `${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`; @@ -49,7 +51,7 @@ const EntryCard: FC = (props) => { - + {props.title} diff --git a/src/app/model/index.ts b/src/app/model/index.ts new file mode 100644 index 0000000..9b1e264 --- /dev/null +++ b/src/app/model/index.ts @@ -0,0 +1 @@ +export { useCurrentRoute } from "./useCurrentRoute"; diff --git a/src/app/model/useCurrentRoute.ts b/src/app/model/useCurrentRoute.ts new file mode 100644 index 0000000..4204d82 --- /dev/null +++ b/src/app/model/useCurrentRoute.ts @@ -0,0 +1,23 @@ +import { useMatch } from "react-router"; + +function useCurrentRoute() { + const account = useMatch("/accounts/:accountId/:section?"); + + const section = (["events", "income", "operations"] as const) + .find(value => value === account?.params.section?.toLowerCase()); + + return { + account, + accounts: useMatch("/accounts"), + bond: useMatch("/bonds/:bondId"), + bonds: useMatch("/bonds"), + card: useMatch("/cards/:cardId"), + cards: useMatch("/cards"), + section, + settings: useMatch("/settings"), + stock: useMatch("/stocks/:stockId"), + stocks: useMatch("/stocks"), + }; +} + +export { useCurrentRoute }; diff --git a/src/app/routes/AppRoutes/AppRoutes.test.tsx b/src/app/routes/AppRoutes/AppRoutes.test.tsx index b3b3684..40990f6 100644 --- a/src/app/routes/AppRoutes/AppRoutes.test.tsx +++ b/src/app/routes/AppRoutes/AppRoutes.test.tsx @@ -1,9 +1,11 @@ +import type { ReactNode } from "react"; + import { MantineProvider } from "@mantine/core"; import { QueryClientProvider } from "@tanstack/react-query"; import { fireEvent, render, screen } from "@testing-library/react"; import { cssVariablesResolver, theme } from "@wealthledger/ui/theme"; import { http, HttpResponse } from "msw"; -import { type InitialEntry, MemoryRouter } from "react-router"; +import { type InitialEntry, Link, MemoryRouter } from "react-router"; import { describe, expect, it } from "vitest"; import { AuthProvider } from "@/features/auth"; @@ -12,10 +14,11 @@ import { server } from "@/test/server"; import { AppRoutes } from "./AppRoutes"; -function renderRoutes(path: InitialEntry) { +function renderRoutes(path: InitialEntry, controls?: ReactNode) { return render( + {controls} { }); }); + it.each([ + { isFailure: false, isNewDestination: false }, + { isFailure: true, isNewDestination: false }, + { isFailure: true, isNewDestination: true }, + ])("finishes logout safely: failure=$isFailure, new destination=$isNewDestination", async ({ isFailure, isNewDestination }) => { + const { promise: pendingLogout, resolve: finishLogout } + = Promise.withResolvers(); + + server.use( + http.post("*/api/v1/auth/refresh", () => HttpResponse.json({ + accessToken: "test-token", user: { email: "user@example.com", id: "user-1" }, + })), + http.post("*/api/v1/auth/logout", async () => { + await pendingLogout; + + return isFailure + ? HttpResponse.error() + : new HttpResponse(null, { status: 204 }); + }), + ); + renderRoutes("/settings", Открыть настройки); + fireEvent.click(await screen.findByRole("button", { name: "Выйти" })); + await screen.findByRole("heading", { level: 1, name: "Вход" }); + if (isNewDestination) fireEvent.click(screen.getByRole("link", { 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: "Вход" })); + finishLogout(); + + const destination = isNewDestination + ? { level: 1, name: "Настройки" } + : { level: 2, name: "Нет инвестиционных счетов" }; + + expect(await screen.findByRole("heading", destination)).toBeInTheDocument(); + expect(document.title).toBe(isNewDestination ? "Настройки — WealthLedger" : "Счета — WealthLedger"); + }); + it("returns after login to cards with the selected month without T-Bank", async () => { server.use( http.get("*/api/v1/bank-cards/categories", () => HttpResponse.json([])), diff --git a/src/app/routes/DocumentTitle/DocumentTitle.tsx b/src/app/routes/DocumentTitle/DocumentTitle.tsx index 2c3caa4..61d25f0 100644 --- a/src/app/routes/DocumentTitle/DocumentTitle.tsx +++ b/src/app/routes/DocumentTitle/DocumentTitle.tsx @@ -2,8 +2,8 @@ import type { FC } from "react"; import { useDocumentTitle } from "@mantine/hooks"; import { useTranslation } from "react-i18next"; -import { useMatch } from "react-router"; +import { useCurrentRoute } from "@/app/model"; import { useBankCard } from "@/entities/bank-card"; import { useBond } from "@/entities/bond"; import { useBrokerageAccounts } from "@/entities/brokerage-account"; @@ -82,14 +82,18 @@ const CardDocumentTitle: FC = (props) => { const ProtectedDocumentTitle: FC = () => { const { t } = useTranslation(); - const account = useMatch("/accounts/:accountId/:section?"); - const bond = useMatch("/bonds/:bondId"); - const stock = useMatch("/stocks/:stockId"); - const card = useMatch("/cards/:cardId"); - const cards = useMatch("/cards"); - const bonds = useMatch("/bonds"); - const stocks = useMatch("/stocks"); - const settings = useMatch("/settings"); + + const { + account, + bond, + bonds, + card, + cards, + settings, + stock, + stocks, + } = useCurrentRoute(); + if (card) return ; if (account) return ( diff --git a/src/app/routes/RouteFallback.tsx b/src/app/routes/RouteFallback.tsx index a2b71cf..20e3f9c 100644 --- a/src/app/routes/RouteFallback.tsx +++ b/src/app/routes/RouteFallback.tsx @@ -1,6 +1,7 @@ import type { FC, PropsWithChildren } from "react"; import { Container, Group, Skeleton, Stack } from "@mantine/core"; +import { LoadingSkeleton } from "@wealthledger/ui"; import { Suspense } from "react"; import { useTranslation } from "react-i18next"; @@ -19,11 +20,7 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => { if (variant === "auth") return ( - + @@ -31,7 +28,7 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => { - + ); @@ -62,11 +59,11 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => { ); return ( - + - + ); }; diff --git a/src/app/routes/RouteGuards.tsx b/src/app/routes/RouteGuards.tsx index 76c99cf..5bdff06 100644 --- a/src/app/routes/RouteGuards.tsx +++ b/src/app/routes/RouteGuards.tsx @@ -1,5 +1,4 @@ -import type { FC, PropsWithChildren } from "react"; - +import { type FC, type PropsWithChildren, useState } from "react"; import { Navigate, useLocation } from "react-router"; import { useAuth } from "@/features/auth"; @@ -8,16 +7,22 @@ import { RouteSkeleton } from "./RouteFallback"; const Protected: FC = (props) => { const { children } = props; - const { ready, user } = useAuth(); + const { ready, signedOut, user } = useAuth(); const location = useLocation(); + const [wasAuthenticated, setWasAuthenticated] = useState(false); + if (user && !wasAuthenticated) setWasAuthenticated(true); if (!ready) return ; + const loginState = signedOut && wasAuthenticated + ? undefined + : { returnTo: `${location.pathname}${location.search}` }; + return user ? <>{children} : ( ); diff --git a/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.tsx b/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.tsx index 51e9593..b21d084 100644 --- a/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.tsx +++ b/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.tsx @@ -2,7 +2,8 @@ import type { FC } from "react"; import { Stack } from "@mantine/core"; import { useTranslation } from "react-i18next"; -import { useMatch } from "react-router"; + +import { useCurrentRoute } from "@/app/model"; import { AccountBreadcrumbs } from "./AccountBreadcrumbs"; import { AccountScopeSwitch } from "./AccountScopeSwitch/AccountScopeSwitch"; @@ -14,19 +15,17 @@ import { StockBreadcrumbs } from "./StockBreadcrumbs"; const AppBreadcrumbs: FC = () => { const { t } = useTranslation(); - const account = useMatch("/accounts/:accountId/:section?"); + const { + account, + accounts, + bond, + card, + cards, + section, + settings, + stock, + } = useCurrentRoute(); - const section = (["events", "income", "operations"] as const).find( - value => value === account?.params.section?.toLowerCase(), - ); - - const card = useMatch("/cards/:cardId"); - const cards = useMatch("/cards"); - const accounts = useMatch({ end: true, path: "/accounts" }); - const settings = useMatch({ end: true, path: "/settings" }); - - const stock = useMatch({ end: true, path: "/stocks/:stockId" }); - const bond = useMatch({ end: true, path: "/bonds/:bondId" }); if (bond) return ; if (stock) return ; diff --git a/src/entities/bank-card/api/banking.test.tsx b/src/entities/bank-card/api/banking.test.tsx index e8935aa..2ec2666 100644 --- a/src/entities/bank-card/api/banking.test.tsx +++ b/src/entities/bank-card/api/banking.test.tsx @@ -6,6 +6,8 @@ import { apiClient, configureAuthClient } from "@/shared/api"; import { createQueryWrapper } from "@/test/query"; import { server } from "@/test/server"; +import type { BankCard, CreateBankOperation } from "./types"; + import { useCreateBankOperation, useDeleteBankOperation, @@ -14,8 +16,6 @@ import { useUpdateExpenseCategory, } from "./mutations"; import { - type BankCard, - type CreateBankOperation, useBankCard, useBankCards, useBankCategories, @@ -106,14 +106,14 @@ function useReadModels() { const categories = useBankCategories(); const history = useBankOperations("a", { - limit: 20, - month: "2026-09", - page: 2, + from: "2026-09-01", + limit: 20, page: 2, + to: "2026-09-30", }); const expenses = useBankExpenses("a", { page: 1 }); - const september = useBankSummary("2026-09"); - const august = useBankSummary("2026-08", "b"); + const september = useBankSummary({ from: "2026-09-01", to: "2026-09-30" }); + const august = useBankSummary({ from: "2026-08-01", to: "2026-08-31" }, "b"); const original = useBankOperation("original"); return { @@ -144,7 +144,8 @@ describe("banking API", () => { const page = Number(url.searchParams.get("page")); pages.push(page); expect(url.searchParams.get("limit")).toBe("100"); - expect(url.searchParams.get("month")).toBe("2026-09"); + expect(url.searchParams.get("from")).toBe("2026-09-01"); + expect(url.searchParams.get("to")).toBe("2026-09-30"); if (isFail && page === 2) return HttpResponse.json({ code: "FAILED" }, { status: 500 }); @@ -158,7 +159,7 @@ describe("banking API", () => { ); const { result } = renderHook( - () => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }), + () => useBankOperations("a", { from: "2026-09-01", limit: "all", page: 1, to: "2026-09-30" }), { wrapper: createQueryWrapper().wrapper }, ); @@ -171,7 +172,7 @@ describe("banking API", () => { isFail = true; const { result: failedResult } = renderHook( - () => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }), + () => useBankOperations("a", { from: "2026-09-01", limit: "all", page: 1, to: "2026-09-30" }), { wrapper: createQueryWrapper().wrapper }, ); @@ -179,7 +180,7 @@ describe("banking API", () => { expect(failedResult.current.data).toBeUndefined(); }); - it("uses session-aware banking endpoints and explicit month/page without T-Bank", async () => { + it("uses session-aware banking endpoints and explicit period/page without T-Bank", async () => { const counts = new Map(); server.use(...readHandlers(counts)); @@ -191,12 +192,15 @@ describe("banking API", () => { expect(result.current.reads.every(read => read.isSuccess)).toBe(true), ); expect(counts.keys()).toContain("/api/v1/bank-cards?includeArchived=true"); - expect(counts.keys()).toContain( - "/api/v1/bank-cards/a/operations?limit=20&month=2026-09&page=2", - ); - expect(counts.keys()).toContain( - "/api/v1/bank-cards/summary?cardId=b&month=2026-08", - ); + const urls = Array.from(counts.keys(), path => new URL(path, "http://localhost")); + const history = urls.find(url => url.pathname === "/api/v1/bank-cards/a/operations"); + const summary = urls.find(url => url.pathname === "/api/v1/bank-cards/summary" && url.searchParams.get("cardId") === "b"); + expect(Object.fromEntries(history?.searchParams ?? [])).toEqual({ + from: "2026-09-01", limit: "20", page: "2", to: "2026-09-30", + }); + expect(Object.fromEntries(summary?.searchParams ?? [])).toEqual({ + cardId: "b", from: "2026-08-01", to: "2026-08-31", + }); expect(counts.size).toBe(9); }); @@ -303,7 +307,7 @@ describe("banking API", () => { expect(result.current.query.data?.balance.value).toBe("100"); }); - it("refetches both cards, every month, categories, journal and source expenses after a transfer without invalidating brokerage", async () => { + it("refetches both cards, every period, categories, journal and source expenses after a transfer without invalidating brokerage", async () => { const counts = new Map(); server.use( ...readHandlers(counts), diff --git a/src/entities/bank-card/api/mutations.ts b/src/entities/bank-card/api/mutations.ts index dc169d5..2d46cc6 100644 --- a/src/entities/bank-card/api/mutations.ts +++ b/src/entities/bank-card/api/mutations.ts @@ -14,7 +14,7 @@ import type { UpdateBankCard, UpdateBankOperation, UpdateExpenseCategory, -} from "./queries"; +} from "./types"; async function mutationData( request: Promise<{ data?: T; error?: unknown }>, diff --git a/src/entities/bank-card/api/queries.ts b/src/entities/bank-card/api/queries.ts index 6576b5b..436bb84 100644 --- a/src/entities/bank-card/api/queries.ts +++ b/src/entities/bank-card/api/queries.ts @@ -1,44 +1,12 @@ import { apiClient, apiQueryClient, - type paths, useAuthenticatedQuery, } from "@/shared/api"; -import { moneyCents } from "../model/form-validation"; +import type { BankOperation, BankOperations, BankSummary } from "./types"; -type BankCard - = paths["/api/v1/bank-cards/{id}"]["get"]["responses"][200]["content"]["application/json"]; - -type BankOperation - = paths["/api/v1/bank-cards/operations/{id}"]["get"]["responses"][200]["content"]["application/json"]; - -type BankOperations - = paths["/api/v1/bank-cards/{id}/operations"]["get"]["responses"][200]["content"]["application/json"]; - -type BankSummary - = paths["/api/v1/bank-cards/summary"]["get"]["responses"][200]["content"]["application/json"]; - -type CreateBankCard - = paths["/api/v1/bank-cards"]["post"]["requestBody"]["content"]["application/json"]; - -type CreateBankOperation - = paths["/api/v1/bank-cards/operations"]["post"]["requestBody"]["content"]["application/json"]; - -type CreateExpenseCategory - = paths["/api/v1/bank-cards/categories"]["post"]["requestBody"]["content"]["application/json"]; - -type ExpenseCategory - = paths["/api/v1/bank-cards/categories"]["get"]["responses"][200]["content"]["application/json"][number]; - -type UpdateBankCard - = paths["/api/v1/bank-cards/{id}"]["patch"]["requestBody"]["content"]["application/json"]; - -type UpdateBankOperation - = paths["/api/v1/bank-cards/operations/{id}"]["patch"]["requestBody"]["content"]["application/json"]; - -type UpdateExpenseCategory - = paths["/api/v1/bank-cards/categories/{id}"]["patch"]["requestBody"]["content"]["application/json"]; +import { moneyCents } from "../model/money"; const OPERATIONS_PAGE_SIZE = 100; @@ -110,7 +78,6 @@ function useBankOperations( query: { from?: string; limit?: "all" | number; - month?: string; page: number; to?: string; }, @@ -134,7 +101,7 @@ function useBankOperations( // eslint-disable-next-line @tanstack/query/exhaustive-deps -- Generated options.queryKey already contains params; retain the shared cache identity. return useAuthenticatedQuery({ - enabled: Boolean(id && (query.month || (query.from && query.to))), + enabled: Boolean(id && query.from && query.to), placeholderData: ( previousData: BankOperations | undefined, previousQuery, @@ -144,7 +111,6 @@ function useBankOperations( return !isAll && previousQuery?.queryKey.length === options.queryKey.length && previous?.path.id === id - && previous?.query?.month === query.month && previous?.query?.from === query.from && previous?.query?.to === query.to && previous?.query?.limit === query.limit @@ -179,36 +145,19 @@ function useBankOperations( } function useBankSummary( - period: string - | { from: string; to: string }, + period: { from: string; to: string }, cardId?: string, ) { - const query - = typeof period === "string" - ? { cardId, month: period } - : { ...period, cardId }; - return useAuthenticatedQuery({ ...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/summary", { - params: { query }, + params: { query: { ...period, cardId } }, }), - enabled: Boolean(period), select: sortBankSummary, }); } export { - type BankCard, - type BankOperation, - type BankSummary, - type CreateBankCard, - type CreateBankOperation, - type CreateExpenseCategory, - type ExpenseCategory, sortBankSummary, - type UpdateBankCard, - type UpdateBankOperation, - type UpdateExpenseCategory, useBankCard, useBankCards, useBankCategories, diff --git a/src/entities/bank-card/api/summary.test.ts b/src/entities/bank-card/api/summary.test.ts index 2175a8c..100554c 100644 --- a/src/entities/bank-card/api/summary.test.ts +++ b/src/entities/bank-card/api/summary.test.ts @@ -1,6 +1,8 @@ import { expect, it } from "vitest"; -import { type BankSummary, sortBankSummary } from "./queries"; +import type { BankSummary } from "./types"; + +import { sortBankSummary } from "./queries"; const money = (value: string) => ({ currency: "RUB", value }); diff --git a/src/entities/bank-card/api/types.ts b/src/entities/bank-card/api/types.ts new file mode 100644 index 0000000..f4dc250 --- /dev/null +++ b/src/entities/bank-card/api/types.ts @@ -0,0 +1,48 @@ +import type { paths } from "@/shared/api"; + +type BankCard + = paths["/api/v1/bank-cards/{id}"]["get"]["responses"][200]["content"]["application/json"]; + +type BankOperation + = paths["/api/v1/bank-cards/operations/{id}"]["get"]["responses"][200]["content"]["application/json"]; + +type BankOperations + = paths["/api/v1/bank-cards/{id}/operations"]["get"]["responses"][200]["content"]["application/json"]; + +type BankSummary + = paths["/api/v1/bank-cards/summary"]["get"]["responses"][200]["content"]["application/json"]; + +type CreateBankCard + = paths["/api/v1/bank-cards"]["post"]["requestBody"]["content"]["application/json"]; + +type CreateBankOperation + = paths["/api/v1/bank-cards/operations"]["post"]["requestBody"]["content"]["application/json"]; + +type CreateExpenseCategory + = paths["/api/v1/bank-cards/categories"]["post"]["requestBody"]["content"]["application/json"]; + +type ExpenseCategory + = paths["/api/v1/bank-cards/categories"]["get"]["responses"][200]["content"]["application/json"][number]; + +type UpdateBankCard + = paths["/api/v1/bank-cards/{id}"]["patch"]["requestBody"]["content"]["application/json"]; + +type UpdateBankOperation + = paths["/api/v1/bank-cards/operations/{id}"]["patch"]["requestBody"]["content"]["application/json"]; + +type UpdateExpenseCategory + = paths["/api/v1/bank-cards/categories/{id}"]["patch"]["requestBody"]["content"]["application/json"]; + +export type { + BankCard, + BankOperation, + BankOperations, + BankSummary, + CreateBankCard, + CreateBankOperation, + CreateExpenseCategory, + ExpenseCategory, + UpdateBankCard, + UpdateBankOperation, + UpdateExpenseCategory, +}; diff --git a/src/entities/bank-card/index.ts b/src/entities/bank-card/index.ts index 9714f53..eec8d7d 100644 --- a/src/entities/bank-card/index.ts +++ b/src/entities/bank-card/index.ts @@ -5,8 +5,6 @@ export { useUpdateBankOperation, } from "./api/mutations"; export { - type BankCard, - type BankOperation, useBankCard, useBankCards, useBankCategories, @@ -15,14 +13,16 @@ export { useBankOperations, useBankSummary, } from "./api/queries"; -export { bankDateInputValue, bankMoneyValue, bankToday, moneyCents } from "./model/form-validation"; +export type { BankCard, BankOperation } from "./api/types"; +export { bankToday } from "./model/form-validation"; export { bankDateSchema, positiveMoneySchema } from "./model/form.schema"; - +export { bankMoneyValue, moneyCents } from "./model/money"; export { bankOperationAppearance } from "./model/operation-appearance"; export { bankOperationKinds } from "./model/operation-kinds"; export { bankPeriod } from "./model/period"; export { BankCardFace } from "./ui/BankCardFace/BankCardFace"; export { BankCardForm } from "./ui/BankCardForm/BankCardForm"; +export { bankDateField } from "./ui/bankDateField"; export { BankFeedback } from "./ui/BankFeedback"; export { BankPeriodPicker } from "./ui/BankPeriodPicker/BankPeriodPicker"; export { BankSummaryView } from "./ui/BankSummary/BankSummary.view"; diff --git a/src/entities/bank-card/model/category.schema.ts b/src/entities/bank-card/model/category.schema.ts index f485f91..eaae5a2 100644 --- a/src/entities/bank-card/model/category.schema.ts +++ b/src/entities/bank-card/model/category.schema.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { validationMessage } from "@/shared/i18n"; -import type { ExpenseCategory } from "../api/queries"; +import type { ExpenseCategory } from "../api/types"; const MAX_CATEGORY_NAME_LENGTH = 80; function categorySchema(categories?: ExpenseCategory[], editingId?: string) { diff --git a/src/entities/bank-card/model/form-validation.test.ts b/src/entities/bank-card/model/form-validation.test.ts index f34effb..1361bc0 100644 --- a/src/entities/bank-card/model/form-validation.test.ts +++ b/src/entities/bank-card/model/form-validation.test.ts @@ -1,7 +1,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { bankDateInputValue, bankMoneyValue, bankMonth, bankToday } from "./form-validation"; -import { bankCardSchema, bankDateSchema, positiveMoneySchema } from "./form.schema"; +import { bankDateInputValue, bankMonth, bankToday } from "./form-validation"; +import { + bankCardSchema, + bankDateSchema, + positiveMoneySchema, +} from "./form.schema"; +import { bankMoneyValue } from "./money"; afterEach(() => vi.useRealTimers()); describe("bank form input", () => { diff --git a/src/entities/bank-card/model/form-validation.ts b/src/entities/bank-card/model/form-validation.ts index 0b87a25..c9fd230 100644 --- a/src/entities/bank-card/model/form-validation.ts +++ b/src/entities/bank-card/model/form-validation.ts @@ -1,21 +1,11 @@ import { moscowDate } from "@/shared/lib"; -const DECIMAL_PART_COUNT = 2; -const CENTS_PRECISION = 2; - const ISO_MONTH_LENGTH = 7; function bankDateInputValue(value: string) { return value.replace(/^(\d{2})\.(\d{2})\.(\d{4})$/, "$3-$2-$1"); } -function bankMoneyValue(value: string) { - return value - .trim() - .replace(",", ".") - .replace(/^(-?)0+(?=\d)/, "$1"); -} - function bankMonth(value: null | string) { return value && value >= "0001-01" && /^\d{4}-(?:0[1-9]|1[0-2])$/.test(value) ? value @@ -26,19 +16,4 @@ function bankToday() { return moscowDate(new Date()); } -function moneyCents(value: string): bigint | null { - const normalized = value.trim().replace(",", "."); - if (!/^-?\d+$|^-?\d+\.\d{1,2}$/.test(normalized)) - return null; - - const [whole, fraction = ""] = normalized - .replace("-", "") - .split(".", DECIMAL_PART_COUNT); - - return ( - BigInt(`${whole}${fraction.padEnd(CENTS_PRECISION, "0")}`) - * (normalized.startsWith("-") ? -1n : 1n) - ); -} - -export { bankDateInputValue, bankMoneyValue, bankMonth, bankToday, moneyCents }; +export { bankDateInputValue, bankMonth, bankToday }; diff --git a/src/entities/bank-card/model/form.schema.ts b/src/entities/bank-card/model/form.schema.ts index 9051b90..c5aa609 100644 --- a/src/entities/bank-card/model/form.schema.ts +++ b/src/entities/bank-card/model/form.schema.ts @@ -3,7 +3,8 @@ import { z } from "zod"; import { validationMessage } from "@/shared/i18n"; import { calendarDate } from "@/shared/lib"; -import { bankToday, moneyCents } from "./form-validation"; +import { bankToday } from "./form-validation"; +import { moneyCents } from "./money"; const MAX_CARD_NAME_LENGTH = 100; const MIN_BALANCE_KOPECKS = -9_223_372_036_854_775_808n; const MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n; diff --git a/src/entities/bank-card/model/money.ts b/src/entities/bank-card/model/money.ts new file mode 100644 index 0000000..8b3db89 --- /dev/null +++ b/src/entities/bank-card/model/money.ts @@ -0,0 +1,26 @@ +const DECIMAL_PART_COUNT = 2; +const CENTS_PRECISION = 2; + +function bankMoneyValue(value: string) { + return value + .trim() + .replace(",", ".") + .replace(/^(-?)0+(?=\d)/, "$1"); +} + +function moneyCents(value: string): bigint | null { + const normalized = value.trim().replace(",", "."); + if (!/^-?\d+$|^-?\d+\.\d{1,2}$/.test(normalized)) + return null; + + const [whole, fraction = ""] = normalized + .replace("-", "") + .split(".", DECIMAL_PART_COUNT); + + return ( + BigInt(`${whole}${fraction.padEnd(CENTS_PRECISION, "0")}`) + * (normalized.startsWith("-") ? -1n : 1n) + ); +} + +export { bankMoneyValue, moneyCents }; diff --git a/src/entities/bank-card/model/operation-appearance.ts b/src/entities/bank-card/model/operation-appearance.ts index 177aa02..4940bb6 100644 --- a/src/entities/bank-card/model/operation-appearance.ts +++ b/src/entities/bank-card/model/operation-appearance.ts @@ -1,6 +1,6 @@ import type { OperationIconProps } from "@wealthledger/icons"; -import type { BankOperation } from "../api/queries"; +import type { BankOperation } from "../api/types"; const categoryIcons: { appearance: OperationIconProps; diff --git a/src/entities/bank-card/model/operation-kinds.ts b/src/entities/bank-card/model/operation-kinds.ts index e849652..bac041c 100644 --- a/src/entities/bank-card/model/operation-kinds.ts +++ b/src/entities/bank-card/model/operation-kinds.ts @@ -1,4 +1,4 @@ -import type { BankOperation } from "../api/queries"; +import type { BankOperation } from "../api/types"; const bankOperationKinds = [ "expense", diff --git a/src/entities/bank-card/ui/BankCardFace/BankCardFace.tsx b/src/entities/bank-card/ui/BankCardFace/BankCardFace.tsx index 58d3070..fca519b 100644 --- a/src/entities/bank-card/ui/BankCardFace/BankCardFace.tsx +++ b/src/entities/bank-card/ui/BankCardFace/BankCardFace.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; import { formatMoney } from "@/shared/lib"; -import type { BankCard } from "../../api/queries"; +import type { BankCard } from "../../api/types"; import classes from "./BankCardFace.module.css"; diff --git a/src/entities/bank-card/ui/BankCardForm/BankCardFields.vm.ts b/src/entities/bank-card/ui/BankCardForm/BankCardFields.vm.ts index 69ebd46..3238e5b 100644 --- a/src/entities/bank-card/ui/BankCardForm/BankCardFields.vm.ts +++ b/src/entities/bank-card/ui/BankCardForm/BankCardFields.vm.ts @@ -1,10 +1,9 @@ -import { useFormContext } from "react-hook-form"; +import { useController, useFormContext } from "react-hook-form"; import { useTranslation } from "react-i18next"; import type { BankCardValues } from "../../model/form.schema"; -import { useOpeningDateField } from "./useOpeningDateField"; -import { usePaymentSystemField } from "./usePaymentSystemField"; +import { bankDateField } from "../bankDateField"; function useBankCardFieldsVm() { const { @@ -14,10 +13,26 @@ function useBankCardFieldsVm() { } = useFormContext(); const { t } = useTranslation(); - const openingDate = useOpeningDateField(control); - const paymentSystem = usePaymentSystemField(control); + const { field: openingDateField } = useController({ control, name: "openingDate" }); + const { field: paymentSystemField } = useController({ control, name: "paymentSystem" }); - return { ...openingDate, ...paymentSystem, errors, register, t }; + const paymentSystems = ["mir", "visa", "mastercard"].map(value => ({ + label: t(`bank.paymentSystems.${value}`), + value, + })); + + const onPaymentSystemChange = (value: null | string) => + paymentSystemField.onChange(value ?? ""); + + return { + errors, + onPaymentSystemChange, + openingDate: { ...openingDateField, ...bankDateField(openingDateField) }, + paymentSystemField, + paymentSystems, + register, + t, + }; } export { useBankCardFieldsVm }; diff --git a/src/entities/bank-card/ui/BankCardForm/BankCardForm.test.tsx b/src/entities/bank-card/ui/BankCardForm/BankCardForm.test.tsx index 49611b1..b2cf032 100644 --- a/src/entities/bank-card/ui/BankCardForm/BankCardForm.test.tsx +++ b/src/entities/bank-card/ui/BankCardForm/BankCardForm.test.tsx @@ -8,7 +8,7 @@ import { configureAuthClient } from "@/shared/api"; import { createTestQueryClient } from "@/test/query"; import { server } from "@/test/server"; -import type { BankCard } from "../../api/queries"; +import type { BankCard } from "../../api/types"; import { BankCardForm } from "./BankCardForm"; diff --git a/src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx b/src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx index b1926fa..786b221 100644 --- a/src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx +++ b/src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx @@ -4,7 +4,8 @@ import { FormProvider } from "react-hook-form"; import { formatDate } from "@/shared/lib"; -import { type BankCard } from "../../api/queries"; +import type { BankCard } from "../../api/types"; + import { BankFeedback } from "../BankFeedback"; import { BankCardFields } from "./BankCardForm.view"; import { useBankCardFormVm } from "./BankCardForm.vm"; diff --git a/src/entities/bank-card/ui/BankCardForm/BankCardForm.view.tsx b/src/entities/bank-card/ui/BankCardForm/BankCardForm.view.tsx index d253e3e..fba5b35 100644 --- a/src/entities/bank-card/ui/BankCardForm/BankCardForm.view.tsx +++ b/src/entities/bank-card/ui/BankCardForm/BankCardForm.view.tsx @@ -10,14 +10,9 @@ type BankCardFieldsProps = { archived: boolean }; const BankCardFields: FC = (props) => { const { - dateParser, errors, - onOpeningDateBlur, - onOpeningDateChange, - onOpeningDateInput, onPaymentSystemChange, - openingDateField, - openingDateValue, + openingDate, paymentSystemField, paymentSystems, register, @@ -71,22 +66,17 @@ const BankCardFields: FC = (props) => { /> diff --git a/src/entities/bank-card/ui/BankCardForm/BankCardForm.vm.ts b/src/entities/bank-card/ui/BankCardForm/BankCardForm.vm.ts index 53bffc1..4add66a 100644 --- a/src/entities/bank-card/ui/BankCardForm/BankCardForm.vm.ts +++ b/src/entities/bank-card/ui/BankCardForm/BankCardForm.vm.ts @@ -3,10 +3,13 @@ import { useState } from "react"; import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; +import type { BankCard } from "../../api/types"; + import { useCreateBankCard, useUpdateBankCard } from "../../api/mutations"; -import { type BankCard, useBankCard } from "../../api/queries"; -import { bankMoneyValue, bankToday } from "../../model/form-validation"; +import { useBankCard } from "../../api/queries"; +import { bankToday } from "../../model/form-validation"; import { bankCardSchema, type BankCardValues } from "../../model/form.schema"; +import { bankMoneyValue } from "../../model/money"; import { useBankFormFocus } from "../useBankFormFocus"; type BankCardFormProps = { card?: BankCard; onClose: () => void }; diff --git a/src/entities/bank-card/ui/BankCardForm/useOpeningDateField.ts b/src/entities/bank-card/ui/BankCardForm/useOpeningDateField.ts deleted file mode 100644 index 12fbe8a..0000000 --- a/src/entities/bank-card/ui/BankCardForm/useOpeningDateField.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { DateInput } from "@mantine/dates"; -import type { ComponentProps } from "react"; -import type { Control } from "react-hook-form"; - -import { useController } from "react-hook-form"; - -import { bankDateInputValue } from "../../model/form-validation"; -import { type BankCardValues, bankDateSchema } from "../../model/form.schema"; - -function useOpeningDateField(control: Control) { - const { field: openingDateField } = useController({ - control, - name: "openingDate", - }); - - const dateParser: ComponentProps["dateParser"] = value => - bankDateSchema.safeParse(bankDateInputValue(value)).success - ? bankDateInputValue(value) - : null; - - const onOpeningDateBlur: ComponentProps["onBlur"] = ( - event, - ) => { - openingDateField.onChange(bankDateInputValue(event.currentTarget.value)); - openingDateField.onBlur(); - }; - - const onOpeningDateChange = (value: null | string) => - openingDateField.onChange(value ?? ""); - - const onOpeningDateInput: ComponentProps["onInput"] = event => - openingDateField.onChange(bankDateInputValue(event.currentTarget.value)); - - const openingDateValue = bankDateSchema.safeParse(openingDateField.value) - .success - ? openingDateField.value - : undefined; - - return { - dateParser, - onOpeningDateBlur, - onOpeningDateChange, - onOpeningDateInput, - openingDateField, - openingDateValue, - }; -} - -export { useOpeningDateField }; diff --git a/src/entities/bank-card/ui/BankCardForm/usePaymentSystemField.ts b/src/entities/bank-card/ui/BankCardForm/usePaymentSystemField.ts deleted file mode 100644 index fd5548f..0000000 --- a/src/entities/bank-card/ui/BankCardForm/usePaymentSystemField.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { Control } from "react-hook-form"; - -import { useController } from "react-hook-form"; -import { useTranslation } from "react-i18next"; - -import type { BankCardValues } from "../../model/form.schema"; - -function usePaymentSystemField(control: Control) { - const { t } = useTranslation(); - - const { field: paymentSystemField } = useController({ - control, - name: "paymentSystem", - }); - - const paymentSystems = ["mir", "visa", "mastercard"].map(value => ({ - label: t(`bank.paymentSystems.${value}`), - value, - })); - - const onPaymentSystemChange = (value: null | string) => - paymentSystemField.onChange(value ?? ""); - - return { onPaymentSystemChange, paymentSystemField, paymentSystems }; -} - -export { usePaymentSystemField }; diff --git a/src/entities/bank-card/ui/BankSummary/BankSummary.view.tsx b/src/entities/bank-card/ui/BankSummary/BankSummary.view.tsx index 45a6882..4bd575f 100644 --- a/src/entities/bank-card/ui/BankSummary/BankSummary.view.tsx +++ b/src/entities/bank-card/ui/BankSummary/BankSummary.view.tsx @@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next"; import { formatMoney } from "@/shared/lib"; -import type { BankSummary } from "../../api/queries"; +import type { BankSummary } from "../../api/types"; import { bankOperationAppearance } from "../../model/operation-appearance"; import classes from "./BankSummary.module.css"; diff --git a/src/entities/bank-card/ui/CategoryManager/CategoryManager.vm.ts b/src/entities/bank-card/ui/CategoryManager/CategoryManager.vm.ts index ffedbec..4a6ebe1 100644 --- a/src/entities/bank-card/ui/CategoryManager/CategoryManager.vm.ts +++ b/src/entities/bank-card/ui/CategoryManager/CategoryManager.vm.ts @@ -5,11 +5,13 @@ import { useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; +import type { ExpenseCategory } from "../../api/types"; + import { useCreateExpenseCategory, useUpdateExpenseCategory, } from "../../api/mutations"; -import { type ExpenseCategory, useBankCategories } from "../../api/queries"; +import { useBankCategories } from "../../api/queries"; import { categorySchema } from "../../model/category.schema"; import { useBankFormFocus } from "../useBankFormFocus"; diff --git a/src/entities/bank-card/ui/bankDateField.ts b/src/entities/bank-card/ui/bankDateField.ts new file mode 100644 index 0000000..983be81 --- /dev/null +++ b/src/entities/bank-card/ui/bankDateField.ts @@ -0,0 +1,39 @@ +import type { DateInput } from "@mantine/dates"; +import type { ComponentProps } from "react"; + +import { bankDateInputValue } from "../model/form-validation"; +import { bankDateSchema } from "../model/form.schema"; + +type DateField = { + onBlur: () => void; + onChange: (value: string) => void; + value: string; +}; + +function bankDateField(field: DateField) { + const dateParser: ComponentProps["dateParser"] = (value) => { + const parsed = bankDateInputValue(value); + + return bankDateSchema.safeParse(parsed).success ? parsed : null; + }; + + const onBlur: ComponentProps["onBlur"] = (event) => { + field.onChange(bankDateInputValue(event.currentTarget.value)); + field.onBlur(); + }; + + const onInput: ComponentProps["onInput"] = event => + field.onChange(bankDateInputValue(event.currentTarget.value)); + + return { + dateParser, + onBlur, + onChange: (value: null | string) => field.onChange(value ?? ""), + onInput, + value: bankDateSchema.safeParse(field.value).success + ? field.value + : undefined, + }; +} + +export { bankDateField }; diff --git a/src/entities/bond/api/queries.ts b/src/entities/bond/api/queries.ts index 4755d6c..6a78fa0 100644 --- a/src/entities/bond/api/queries.ts +++ b/src/entities/bond/api/queries.ts @@ -1,5 +1,9 @@ import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api"; +type BondCandles = NonNullable; + +type BondPosition = NonNullable; + const MIN_BOND_SEARCH_LENGTH = 2; type BondCoupons = NonNullable; @@ -101,8 +105,10 @@ function useBondSearch(query: string) { } export { + type BondCandles, type BondCoupons, type BondMetrics, + type BondPosition, MIN_BOND_SEARCH_LENGTH, useBond, useBondCandles, diff --git a/src/entities/bond/index.ts b/src/entities/bond/index.ts index dc839e1..3bba34f 100644 --- a/src/entities/bond/index.ts +++ b/src/entities/bond/index.ts @@ -1,6 +1,8 @@ export { + type BondCandles, type BondCoupons, type BondMetrics, + type BondPosition, MIN_BOND_SEARCH_LENGTH, useBond, useBondCandles, diff --git a/src/entities/broker-operation/ui/LedgerEntryRow.tsx b/src/entities/broker-operation/ui/LedgerEntryRow.tsx index 498154a..bd49a21 100644 --- a/src/entities/broker-operation/ui/LedgerEntryRow.tsx +++ b/src/entities/broker-operation/ui/LedgerEntryRow.tsx @@ -19,6 +19,7 @@ import { operationLabelKey, paymentPresentation } from "../model/display"; type LedgerEntryRowProps = { operation: BrokerageOperation; withDetails?: boolean; + wrap?: boolean; }; const LedgerEntryRow: FC = (props) => { @@ -84,6 +85,7 @@ const LedgerEntryRow: FC = (props) => { tone={payment.tone} value={payment.amount} valueLabel={t(payment.label)} + wrap={props.wrap} /> ); }; diff --git a/src/entities/brokerage-event/index.ts b/src/entities/brokerage-event/index.ts index f3b2e91..aab5711 100644 --- a/src/entities/brokerage-event/index.ts +++ b/src/entities/brokerage-event/index.ts @@ -2,6 +2,7 @@ export { type BrokerageEvent, useBrokerageEvents, } from "./api/useBrokerageEvents"; +export { brokerageEventKey } from "./model/event-key"; export { BrokerageEventCard } from "./ui/BrokerageEventCard"; export { BrokerageEventsPreview, diff --git a/src/entities/brokerage-event/model/event-key.ts b/src/entities/brokerage-event/model/event-key.ts new file mode 100644 index 0000000..1d460bd --- /dev/null +++ b/src/entities/brokerage-event/model/event-key.ts @@ -0,0 +1,10 @@ +import type { BrokerageEvent } from "../api/useBrokerageEvents"; + +function brokerageEventKey(event: BrokerageEvent) { + return JSON.stringify([ + event.account?.id, event.type, event.date, event.instrumentUid, + event.figi, event.ticker, event.logoName, + ]); +} + +export { brokerageEventKey }; diff --git a/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx b/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx index 013b024..80d87a0 100644 --- a/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx +++ b/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx @@ -9,6 +9,7 @@ import { formatSnapshotTime } from "@/shared/lib"; import type { BrokerageEvent } from "../../api/useBrokerageEvents"; +import { brokerageEventKey } from "../../model/event-key"; import { BrokerageEventCard } from "../BrokerageEventCard"; type BrokerageEventsPreviewProps = { @@ -66,15 +67,7 @@ const BrokerageEventsPreview: FC = (props) => { && events.items.map(event => ( ))} diff --git a/src/entities/stock/api/queries.ts b/src/entities/stock/api/queries.ts index 51e52e6..8afef1e 100644 --- a/src/entities/stock/api/queries.ts +++ b/src/entities/stock/api/queries.ts @@ -4,6 +4,10 @@ import { useResourceQuery, } from "@/shared/api"; +type StockCandles = NonNullable; + +type StockPosition = NonNullable; + const MIN_STOCK_SEARCH_LENGTH = 2; type StockDividends = NonNullable; @@ -104,7 +108,9 @@ function useStockSearch(query: string) { export { MIN_STOCK_SEARCH_LENGTH, + type StockCandles, type StockDividends, + type StockPosition, useStock, useStockCandles, useStockCompany, diff --git a/src/entities/stock/index.ts b/src/entities/stock/index.ts index d449a38..b349d42 100644 --- a/src/entities/stock/index.ts +++ b/src/entities/stock/index.ts @@ -1,6 +1,8 @@ export { MIN_STOCK_SEARCH_LENGTH, + type StockCandles, type StockDividends, + type StockPosition, useStock, useStockCandles, useStockCompany, diff --git a/src/features/auth/model/auth-context.ts b/src/features/auth/model/auth-context.ts index 1573a73..1a0ed08 100644 --- a/src/features/auth/model/auth-context.ts +++ b/src/features/auth/model/auth-context.ts @@ -7,6 +7,7 @@ type AuthContextValue = { logout: () => Promise; ready: boolean; register: (email: string, password: string) => Promise; + signedOut: boolean; user: undefined | User; }; diff --git a/src/features/auth/model/auth.vm.ts b/src/features/auth/model/auth.vm.ts index 4a6906e..191d160 100644 --- a/src/features/auth/model/auth.vm.ts +++ b/src/features/auth/model/auth.vm.ts @@ -31,6 +31,7 @@ function useAuthProviderVm() { const [user, setUser] = useState(undefined); const accessTokenRef = useRef(undefined); const [ready, setReady] = useState(false); + const [signedOut, setSignedOut] = useState(false); const generationRef = useRef(0); const userRef = useRef(undefined); @@ -57,6 +58,7 @@ function useAuthProviderVm() { const changedIdentity = userRef.current && userRef.current.id !== authentication.user.id; + setSignedOut(false); accessTokenRef.current = authentication.accessToken; if (changedIdentity) { @@ -136,6 +138,7 @@ function useAuthProviderVm() { } async function logout() { + setSignedOut(true); clearSession(); await serialize(async () => { await apiClient.POST("/api/v1/auth/logout"); @@ -159,6 +162,7 @@ function useAuthProviderVm() { ready, register: (email: string, password: string) => authenticate("/api/v1/auth/register", email, password), + signedOut, user, }; diff --git a/src/features/instrument-details/index.ts b/src/features/instrument-details/index.ts index 0058754..988741b 100644 --- a/src/features/instrument-details/index.ts +++ b/src/features/instrument-details/index.ts @@ -1,10 +1,15 @@ export { section, type SectionState } from "./model/section"; export { useInstrumentParameters } from "./model/useInstrumentParameters"; export { InstrumentChart } from "./ui/InstrumentChart/InstrumentChart.view"; -export { default as instrumentDetailsStyles } from "./ui/InstrumentDetails.module.css"; export { InstrumentEvents } from "./ui/InstrumentEvents.view"; export { InstrumentHeader } from "./ui/InstrumentHeader/InstrumentHeader.view"; -export { InstrumentMetric } from "./ui/InstrumentMetric.view"; +export { + InstrumentMetrics, + InstrumentPage, + InstrumentPrice, + InstrumentTerms, +} from "./ui/InstrumentLayout.view"; +export { InstrumentMetric } from "./ui/InstrumentMetric/InstrumentMetric.view"; export { InstrumentPageState } from "./ui/InstrumentPageState.view"; export { InstrumentPosition } from "./ui/InstrumentPosition.view"; export { InstrumentQuoteStatus } from "./ui/InstrumentQuoteStatus.view"; diff --git a/src/features/instrument-details/model/types.ts b/src/features/instrument-details/model/types.ts index bef92b4..acab9ad 100644 --- a/src/features/instrument-details/model/types.ts +++ b/src/features/instrument-details/model/types.ts @@ -1,10 +1,13 @@ +import type { BondCandles, BondPosition } from "@/entities/bond"; import type { BrokerageOperation } from "@/entities/broker-operation"; -import type { paths } from "@/shared/api"; +import type { StockCandles, StockPosition } from "@/entities/stock"; import type { SectionState } from "./section"; -type Candles = NonNullable< - paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"] +type Candles = Pick; type InstrumentChartProps = { priceUnit?: "percent"; vm: InstrumentChartVm }; @@ -33,8 +36,6 @@ type InstrumentPositionVm = { positionState: SectionState; }; -type Position = NonNullable< - paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"] ->; +type Position = BondPosition | StockPosition; export type { InstrumentChartProps, InstrumentEventsVm, InstrumentPositionVm }; diff --git a/src/features/instrument-details/ui/InstrumentDetails.module.css b/src/features/instrument-details/ui/InstrumentDetails.module.css index 46d3e94..9d86734 100644 --- a/src/features/instrument-details/ui/InstrumentDetails.module.css +++ b/src/features/instrument-details/ui/InstrumentDetails.module.css @@ -75,15 +75,14 @@ overflow-wrap: anywhere; } .metricsGrid { + --instrument-metric-padding: var(--mantine-spacing-sm); + --instrument-metric-background: var(--wl-color-subtle); + --instrument-metric-radius: var(--mantine-radius-md); display: grid; grid-template-columns: repeat(auto-fit, minmax(135px, 1fr)); gap: var(--mantine-spacing-lg); } -.metricsGrid > div { - padding: var(--mantine-spacing-sm); - background: var(--wl-color-subtle); - border-radius: var(--mantine-radius-md); -} + @media (max-width: 48em) { .metricsGrid { grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/src/features/instrument-details/ui/InstrumentLayout.view.tsx b/src/features/instrument-details/ui/InstrumentLayout.view.tsx new file mode 100644 index 0000000..c6d2b04 --- /dev/null +++ b/src/features/instrument-details/ui/InstrumentLayout.view.tsx @@ -0,0 +1,31 @@ +import type { FC, PropsWithChildren } from "react"; + +import { Stack, Text, type TextProps, Title } from "@mantine/core"; + +import classes from "./InstrumentDetails.module.css"; + +type InstrumentPriceProps = PropsWithChildren>; + +type InstrumentTermsProps = PropsWithChildren<{ title: string }>; + +const InstrumentPage: FC = props => ( + {props.children} +); + +const InstrumentPrice: FC = props => ( + {props.children} +); + +const InstrumentMetrics: FC = props => ( +
{props.children}
+); + +const InstrumentTerms: FC = props => ( +
+ {props.title} + + {props.children} +
+); + +export { InstrumentMetrics, InstrumentPage, InstrumentPrice, InstrumentTerms }; diff --git a/src/features/instrument-details/ui/InstrumentMetric/InstrumentMetric.module.css b/src/features/instrument-details/ui/InstrumentMetric/InstrumentMetric.module.css new file mode 100644 index 0000000..3d673f0 --- /dev/null +++ b/src/features/instrument-details/ui/InstrumentMetric/InstrumentMetric.module.css @@ -0,0 +1,5 @@ +.metric { + padding: var(--instrument-metric-padding, 0); + background: var(--instrument-metric-background, transparent); + border-radius: var(--instrument-metric-radius, 0); +} diff --git a/src/features/instrument-details/ui/InstrumentMetric.view.tsx b/src/features/instrument-details/ui/InstrumentMetric/InstrumentMetric.view.tsx similarity index 80% rename from src/features/instrument-details/ui/InstrumentMetric.view.tsx rename to src/features/instrument-details/ui/InstrumentMetric/InstrumentMetric.view.tsx index cda6358..c57e56f 100644 --- a/src/features/instrument-details/ui/InstrumentMetric.view.tsx +++ b/src/features/instrument-details/ui/InstrumentMetric/InstrumentMetric.view.tsx @@ -2,10 +2,12 @@ import type { FC } from "react"; import { Text } from "@mantine/core"; +import classes from "./InstrumentMetric.module.css"; + type InstrumentMetricProps = { label: string; value: string }; const InstrumentMetric: FC = props => ( -
+
{props.label} diff --git a/src/features/tbank-connection/index.ts b/src/features/tbank-connection/index.ts index 850e209..9b07524 100644 --- a/src/features/tbank-connection/index.ts +++ b/src/features/tbank-connection/index.ts @@ -1,2 +1,6 @@ export { useScreenSync } from "./model/screen-sync"; +export { + InvestmentRefreshState, + type InvestmentRefreshStateProps, +} from "./ui/InvestmentRefreshState/InvestmentRefreshState.view"; export { TbankConnectionSection } from "./ui/TbankConnectionSection/TbankConnectionSection"; diff --git a/src/features/tbank-connection/model/connection-boundary.ts b/src/features/tbank-connection/model/connection-boundary.ts index a25a7fe..445e65c 100644 --- a/src/features/tbank-connection/model/connection-boundary.ts +++ b/src/features/tbank-connection/model/connection-boundary.ts @@ -9,7 +9,14 @@ import { type TbankConnectionStatus = paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"]; -const brokerageAccountsPath = "/api/v1/brokerage-accounts"; +function isInvestmentPath(path: string) { + return [ + "/api/v1/brokerage-accounts", + "/api/v1/stocks", + "/api/v1/bonds", + "/api/v1/tbank-sync", + ].some(root => path === root || path.startsWith(`${root}/`)); +} const tbankConnectionQueryOptions = () => apiQueryClient.queryOptions("get", "/api/v1/tbank-connection"); @@ -20,13 +27,7 @@ function commitTbankConnectionBoundary( queryClient: QueryClient, status: TbankConnectionStatus, ) { - invalidateApiRequests( - path => - path.startsWith("/api/v1/brokerage-accounts") - || path.startsWith("/api/v1/stocks") - || path.startsWith("/api/v1/bonds") - || path.startsWith("/api/v1/tbank-sync"), - ); + invalidateApiRequests(isInvestmentPath); queryClient.setQueryData(tbankConnectionQueryKey, status); void queryClient.resetQueries({ predicate: ({ queryKey }) => { @@ -35,13 +36,7 @@ function commitTbankConnectionBoundary( return ( method === "get" && typeof path === "string" - && (path.startsWith("/api/v1/tbank-sync") - || path === "/api/v1/bonds" - || path.startsWith("/api/v1/bonds/") - || path === "/api/v1/stocks" - || path.startsWith("/api/v1/stocks/") - || path === brokerageAccountsPath - || path.startsWith(`${brokerageAccountsPath}/`)) + && isInvestmentPath(path) ); }, }); diff --git a/src/features/tbank-connection/ui/InvestmentRefreshState/InvestmentRefreshState.stories.tsx b/src/features/tbank-connection/ui/InvestmentRefreshState/InvestmentRefreshState.stories.tsx new file mode 100644 index 0000000..651044d --- /dev/null +++ b/src/features/tbank-connection/ui/InvestmentRefreshState/InvestmentRefreshState.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { Stack, Text } from "@mantine/core"; +import { fn } from "storybook/test"; + +import { InvestmentRefreshState } from "./InvestmentRefreshState.view"; + +const meta = { + args: { labelScope: "portfolio", refreshError: false, refreshing: false, retry: fn() }, + argTypes: { + refreshError: { control: "boolean" }, + refreshing: { control: "boolean" }, + retry: { control: false }, + }, + component: InvestmentRefreshState, + parameters: { + docs: { + description: { + component: + "Обновление уже показанных данных портфеля. Синтетические суммы остаются видимыми вместе с InvestmentRefreshState при refetch и его ошибке. Суммы синтетические; это presentation Client Cached Snapshot, без QueryClient и запроса API. Retry регистрирует локальное действие в Actions и не доказывает восстановление API.", + }, + }, + }, + render: arguments_ => ( + + 125 000,50 ₽ · 320 USD + + + + ), + tags: ["autodocs"], + title: "Product/InvestmentRefreshState", +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Idle: Story = { name: "Сохранённые данные" }; +export const Refreshing: Story = { + args: { refreshing: true }, + name: "Фоновое обновление", +}; +export const RefreshError: Story = { + args: { labelScope: "portfolio", refreshError: true }, + name: "Ошибка обновления с retry", +}; + +export const SourceFailure: Story = { + args: { labelScope: "portfolio", refreshError: true, sourceError: true }, + name: "Ошибка синхронизации сохраняет суммы", +}; diff --git a/src/pages/accounts/ui/AccountsPage/components/AccountsRefreshState.view.tsx b/src/features/tbank-connection/ui/InvestmentRefreshState/InvestmentRefreshState.view.tsx similarity index 70% rename from src/pages/accounts/ui/AccountsPage/components/AccountsRefreshState.view.tsx rename to src/features/tbank-connection/ui/InvestmentRefreshState/InvestmentRefreshState.view.tsx index 089a4ef..796d95a 100644 --- a/src/pages/accounts/ui/AccountsPage/components/AccountsRefreshState.view.tsx +++ b/src/features/tbank-connection/ui/InvestmentRefreshState/InvestmentRefreshState.view.tsx @@ -5,7 +5,8 @@ import { useTranslation } from "react-i18next"; import { formatSnapshotTime } from "@/shared/lib"; -type AccountsRefreshStateProps = { +type InvestmentRefreshStateProps = { + labelScope?: "accounts" | "portfolio"; lastSuccessfulAt?: null | string; refreshError?: boolean; refreshing?: boolean; @@ -13,8 +14,8 @@ type AccountsRefreshStateProps = { sourceError?: boolean; }; -const AccountsRefreshState: FC = (props) => { - const { refreshError = false, refreshing = false, retry } = props; +const InvestmentRefreshState: FC = (props) => { + const { labelScope = "accounts", refreshError = false, refreshing = false, retry } = props; const { t } = useTranslation(); return ( @@ -37,11 +38,11 @@ const AccountsRefreshState: FC = (props) => { {!!refreshing && ( - + - {t("accounts.refreshing")} + {t(`${labelScope}.refreshing`)} )} @@ -53,7 +54,7 @@ const AccountsRefreshState: FC = (props) => { {t( props.sourceError ? "investment.syncFailed" - : "accounts.refreshError", + : `${labelScope}.refreshError`, )} @@ -63,4 +64,4 @@ const AccountsRefreshState: FC = (props) => { ); }; -export { AccountsRefreshState, type AccountsRefreshStateProps }; +export { InvestmentRefreshState, type InvestmentRefreshStateProps }; diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts index 109f6b4..f9707e0 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts @@ -1,12 +1,12 @@ -import { useEffect, useState } from "react"; -import { useParams, useSearchParams } from "react-router"; +import { useState } from "react"; +import { useParams } from "react-router"; import { type BrokerageEvent, useBrokerageEvents, } from "@/entities/brokerage-event"; import { useScreenSync } from "@/features/tbank-connection"; -import { isValidPeriod } from "@/shared/lib"; +import { isValidPeriod, useUrlPeriod } from "@/shared/lib"; type AccountEventsPageViewModel = | { @@ -29,18 +29,12 @@ type AccountEventsPageViewModel function useAccountEventsPageViewModel(): AccountEventsPageViewModel { const { accountId = "" } = useParams(); - const [searchParameters, setSearchParameters] = useSearchParams(); - const urlFrom = searchParameters.get("from"); - const urlTo = searchParameters.get("to"); - const isValidUrlPeriod = isValidPeriod(urlFrom, urlTo); - const appliedFrom = isValidUrlPeriod - ? urlFrom - : null; - - const appliedTo = isValidUrlPeriod - ? urlTo - : null; + const { + from: appliedFrom, + setParameters: setSearchParameters, + to: appliedTo, + } = useUrlPeriod(); const [draftPeriod, setDraftPeriod] = useState<[ null | string, @@ -48,13 +42,6 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel { ]>([appliedFrom, appliedTo]); const [draftFrom, draftTo] = draftPeriod; - useEffect(() => { - if (isValidUrlPeriod) return; - const normalized = new URLSearchParams(searchParameters); - normalized.delete("from"); - normalized.delete("to"); - setSearchParameters(normalized, { replace: true }); - }, [isValidUrlPeriod, searchParameters, setSearchParameters]); const scope = JSON.stringify([accountId, appliedFrom, appliedTo]); const [previousScope, setPreviousScope] = useState(scope); diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx index 362a84f..649e9e1 100644 --- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx @@ -12,7 +12,10 @@ import { import { DatePickerInput } from "@mantine/dates"; import { useTranslation } from "react-i18next"; -import { BrokerageEventCard } from "@/entities/brokerage-event"; +import { + BrokerageEventCard, + brokerageEventKey, +} from "@/entities/brokerage-event"; import { formatSnapshotTime } from "@/shared/lib"; import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm"; @@ -33,15 +36,7 @@ const AccountEventsPageLoadedEvents: FC = ( {props.events.map(event => ( ))} diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.module.css b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.module.css index cf9807e..2e737bc 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.module.css +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.module.css @@ -22,13 +22,6 @@ min-width: 0; } -/* Keep the reused Ledger row's full text readable inside income cards. */ -.entries :global(.mantine-Text-root) { - -webkit-line-clamp: unset; - white-space: normal; - overflow-wrap: anywhere; -} - .comparison { container-type: inline-size; } diff --git a/src/pages/account-income/ui/AccountIncomePage/components/IncomePayments.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/IncomePayments.view.tsx index fd85150..ab7507b 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/IncomePayments.view.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/IncomePayments.view.tsx @@ -5,13 +5,11 @@ import { useTranslation } from "react-i18next"; import { LedgerEntryRow } from "@/entities/broker-operation"; -import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; +import type { IncomeDisplayModel } from "../../../model/display"; import classes from "../AccountIncomePage.module.css"; -type IncomePaymentsProps = { entries: LoadedProps["display"]["entries"] }; - -type LoadedProps = Extract; +type IncomePaymentsProps = { entries: IncomeDisplayModel["entries"] }; const IncomePayments: FC = (props) => { const { t } = useTranslation(); @@ -23,7 +21,7 @@ const IncomePayments: FC = (props) => { {props.entries.map(({ key, operation }) => (
  • - +
  • ))}
    diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts index 64a4f47..6b9f5fe 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts +++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts @@ -1,6 +1,6 @@ import { useQueryClient } from "@tanstack/react-query"; -import { useEffect, useState } from "react"; -import { useParams, useSearchParams } from "react-router"; +import { useState } from "react"; +import { useParams } from "react-router"; import { type BrokerageOperation, @@ -11,7 +11,7 @@ import { } from "@/entities/broker-operation"; import { useScreenSync } from "@/features/tbank-connection"; import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; -import { isValidPeriod } from "@/shared/lib"; +import { isValidPeriod, useUrlPeriod } from "@/shared/lib"; const operationCategorySet = new Set(operationCategories); @@ -44,18 +44,13 @@ type AccountOperationsPageViewModel const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { const { accountId } = useParams(); const queryClient = useQueryClient(); - const [searchParameters, setSearchParameters] = useSearchParams(); - const urlFrom = searchParameters.get("from"); - const urlTo = searchParameters.get("to"); - const isValidUrlPeriod = isValidPeriod(urlFrom, urlTo); - const appliedFrom = isValidUrlPeriod - ? urlFrom - : null; - - const appliedTo = isValidUrlPeriod - ? urlTo - : null; + const { + from: appliedFrom, + parameters: searchParameters, + setParameters: setSearchParameters, + to: appliedTo, + } = useUrlPeriod(); const appliedCategories = operationCategories.filter(category => (searchParameters.get("categories") ?? "").split(",").includes(category), @@ -71,14 +66,6 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { const [draftCategories, setDraftCategories] = useState(appliedCategories); - useEffect(() => { - if (isValidUrlPeriod) return; - const normalized = new URLSearchParams(searchParameters); - normalized.delete("from"); - normalized.delete("to"); - setSearchParameters(normalized, { replace: true }); - }, [isValidUrlPeriod, searchParameters, setSearchParameters]); - const firstPageQuery = { from: dateStart(appliedFrom), to: dateEnd(appliedTo), diff --git a/src/pages/account/model/previews.ts b/src/pages/account/model/previews.ts new file mode 100644 index 0000000..7390268 --- /dev/null +++ b/src/pages/account/model/previews.ts @@ -0,0 +1,144 @@ +import type { BrokerageOperation } from "@/entities/broker-operation"; +import type { + BrokerageEvent, + BrokerageEventsPreviewState, +} from "@/entities/brokerage-event"; +import type { IncomeSummary } from "@/entities/income"; +import type { InvestmentResourceMeta } from "@/shared/api"; + +const RECENT_EVENTS_LIMIT = 5; + +type AccountPageIncomeSummary + = | { + lastSuccessfulAt?: null | string; + refreshError?: boolean; + stale?: boolean; + state: "loaded"; + summary: IncomeSummary; + to: string; + } + | { retry: () => Promise | undefined; state: "error"; to: string } + | { + state: "empty" + | "loading" + | "partial"; + to: string; + }; + +type AccountPageRecentEvents = BrokerageEventsPreviewState; + +type AccountPageRecentOperations + = | { + items: BrokerageOperation[]; + lastSuccessfulAt?: null | string; + refreshError?: boolean; + stale?: boolean; + state: "loaded" | "partial"; + to: string; + } + | { + state: "empty" + | "error" + | "loading"; + to: string; + }; + +type PreviewResource = { + data?: T; + isError: boolean; + meta?: Pick; +}; + +function toIncomeSummary( + accountId: string | undefined, + income: PreviewResource, + retry: () => Promise, +): AccountPageIncomeSummary { + const to = `/accounts/${accountId}/income`; + if (!income.data && !income.isError && income.meta?.sync.state !== "failed") + return { state: "loading", to }; + if (income.meta?.availability === "partial") return { state: "partial", to }; + if (!income.data && (income.isError || income.meta?.sync.state === "failed")) + return { retry, state: "error", to }; + if (!income.data?.totalsByCurrency.length) return { state: "empty", to }; + + return { + lastSuccessfulAt: income.meta?.lastSuccessfulAt, + refreshError: income.isError || income.meta?.sync.state === "failed", + stale: income.meta?.freshness === "stale", + state: "loaded", + summary: income.data, + to, + }; +} + +function toRecentEvents( + accountId: string | undefined, + events: PreviewResource<{ events: BrokerageEvent[] }>, +): AccountPageRecentEvents { + const to = `/accounts/${accountId}/events`; + if (!events.data && !events.isError && events.meta?.sync.state !== "failed") + return { state: "loading", to }; + if (events.meta?.availability === "partial") + return { + items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [], + state: "partial", + to, + }; + if (!events.data && (events.isError || events.meta?.sync.state === "failed")) + return { state: "error", to }; + const items = events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? []; + if (items.length === 0) return { state: "empty", to }; + + return { + items, + lastSuccessfulAt: events.meta?.lastSuccessfulAt, + refreshError: events.isError || events.meta?.sync.state === "failed", + stale: events.meta?.freshness === "stale", + state: "loaded", + to, + }; +} + +function toRecentOperations( + accountId: string | undefined, + operations: PreviewResource<{ items: BrokerageOperation[] }>, +): AccountPageRecentOperations { + const to = `/accounts/${accountId}/operations`; + if ( + !operations.data + && !operations.isError + && operations.meta?.sync.state !== "failed" + ) + return { state: "loading", to }; + if (operations.meta?.availability === "partial") + return { items: operations.data?.items ?? [], state: "partial", to }; + if ( + !operations.data + && (operations.isError || operations.meta?.sync.state === "failed") + ) + return { state: "error", to }; + if (!operations.data?.items.length) return { state: "empty", to }; + + return { + items: operations.data.items, + lastSuccessfulAt: operations.meta?.lastSuccessfulAt, + refreshError: + operations.isError || operations.meta?.sync.state === "failed", + stale: operations.meta?.freshness === "stale", + state: "loaded", + to, + }; +} + +export { toIncomeSummary, toRecentEvents, toRecentOperations }; +export type { + AccountPageIncomeSummary, + AccountPageRecentEvents, + AccountPageRecentOperations, +}; diff --git a/src/pages/account/ui/AccountPage/AccountPage.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.test.tsx index ebe4aff..1a8a691 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.test.tsx @@ -22,7 +22,7 @@ import { server } from "@/test/server"; import type { AccountPageRecentEvents, AccountPageRecentOperations, -} from "./AccountPage.vm"; +} from "../../model/previews"; import { toAccountPagePositionSections } from "../../model/positions"; import { AccountPage } from "./AccountPage"; diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.ts b/src/pages/account/ui/AccountPage/AccountPage.vm.ts index 69fb200..77c0341 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.ts +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.ts @@ -1,6 +1,5 @@ import { useParams } from "react-router"; -import type { BrokerageOperation } from "@/entities/broker-operation"; import type { Portfolio } from "@/entities/portfolio"; import { useBrokerageOperations } from "@/entities/broker-operation"; @@ -9,17 +8,25 @@ import { useBrokerageAccounts, } from "@/entities/brokerage-account"; import { - type BrokerageEventsPreviewState, useBrokerageEvents, } from "@/entities/brokerage-event"; -import { type IncomeSummary, useIncomeSummary } from "@/entities/income"; +import { useIncomeSummary } from "@/entities/income"; import { usePortfolio } from "@/entities/portfolio"; import { useScreenSync } from "@/features/tbank-connection"; import { apiErrorCode } from "@/shared/api"; -import { type AccountPagePositionSection, toAccountPagePositionSections } from "../../model/positions"; - -const RECENT_EVENTS_LIMIT = 5; +import { + type AccountPagePositionSection, + toAccountPagePositionSections, +} from "../../model/positions"; +import { + type AccountPageIncomeSummary, + type AccountPageRecentEvents, + type AccountPageRecentOperations, + toIncomeSummary, + toRecentEvents, + toRecentOperations, +} from "../../model/previews"; type AccountPageContent = { account?: BrokerageAccount; @@ -38,131 +45,18 @@ type AccountPageEmptyViewModel = AccountPageContent & { state: "empty"; }; -type AccountPageIncomeSummary - = | { - lastSuccessfulAt?: null | string; - refreshError?: boolean; - stale?: boolean; - state: "loaded"; - summary: IncomeSummary; - to: string; - } - | { retry: () => Promise | undefined; state: "error"; to: string } - | { state: "empty" - | "loading" - | "partial"; to: string; }; - type AccountPageLoadedViewModel = AccountPageContent & { portfolio: Portfolio; positionSections: AccountPagePositionSection[]; state: "loaded"; }; -type AccountPageRecentEvents = BrokerageEventsPreviewState; - -type AccountPageRecentOperations - = | { - items: BrokerageOperation[]; - lastSuccessfulAt?: null | string; - refreshError?: boolean; - stale?: boolean; - state: "loaded" | "partial"; - to: string; - } - | { - state: "empty" - | "error" - | "loading"; - to: string; - }; - type AccountPageViewModel = | AccountPageEmptyViewModel | AccountPageLoadedViewModel | { code?: string; retry: () => Promise | undefined; state: "error" } | { state: "loading" }; -function toIncomeSummary( - accountId: string | undefined, - income: ReturnType, -): AccountPageIncomeSummary { - const to = `/accounts/${accountId}/income`; - if (!income.data && !income.isError && income.meta?.sync.state !== "failed") - return { state: "loading", to }; - if (income.meta?.availability === "partial") return { state: "partial", to }; - if (!income.data && (income.isError || income.meta?.sync.state === "failed")) - return { retry: () => income.refetch(), state: "error", to }; - if (!income.data?.totalsByCurrency.length) return { state: "empty", to }; - - return { - lastSuccessfulAt: income.meta?.lastSuccessfulAt, - refreshError: income.isError || income.meta?.sync.state === "failed", - stale: income.meta?.freshness === "stale", - state: "loaded", - summary: income.data, - to, - }; -} - -function toRecentEvents( - accountId: string | undefined, - events: ReturnType, -): AccountPageRecentEvents { - const to = `/accounts/${accountId}/events`; - if (!events.data && !events.isError && events.meta?.sync.state !== "failed") - return { state: "loading", to }; - if (events.meta?.availability === "partial") - return { - items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [], - state: "partial", - to, - }; - if (!events.data && (events.isError || events.meta?.sync.state === "failed")) - return { state: "error", to }; - const items = events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? []; - if (items.length === 0) return { state: "empty", to }; - - return { - items, - lastSuccessfulAt: events.meta?.lastSuccessfulAt, - refreshError: events.isError || events.meta?.sync.state === "failed", - stale: events.meta?.freshness === "stale", - state: "loaded", - to, - }; -} - -function toRecentOperations( - accountId: string | undefined, - operations: ReturnType, -): AccountPageRecentOperations { - const to = `/accounts/${accountId}/operations`; - if ( - !operations.data - && !operations.isError - && operations.meta?.sync.state !== "failed" - ) - return { state: "loading", to }; - if (operations.meta?.availability === "partial") - return { items: operations.data?.items ?? [], state: "partial", to }; - if ( - !operations.data - && (operations.isError || operations.meta?.sync.state === "failed") - ) - return { state: "error", to }; - if (!operations.data?.items.length) return { state: "empty", to }; - - return { - items: operations.data.items, - lastSuccessfulAt: operations.meta?.lastSuccessfulAt, - refreshError: - operations.isError || operations.meta?.sync.state === "failed", - stale: operations.meta?.freshness === "stale", - state: "loaded", - to, - }; -} - function useAccountPageViewModel(): AccountPageViewModel { const { accountId } = useParams(); const portfolio = usePortfolio(accountId); @@ -197,7 +91,10 @@ function useAccountPageViewModel(): AccountPageViewModel { const account = accounts.data?.find(item => item.id === accountId); const recentOperations = toRecentOperations(accountId, operations); const recentEvents = toRecentEvents(accountId, events); - const incomeSummary = toIncomeSummary(accountId, income); + + const incomeSummary = toIncomeSummary( + accountId, income, () => income.refetch(), + ); const refresh = { partial: portfolio.meta?.availability === "partial", @@ -235,9 +132,4 @@ function useAccountPageViewModel(): AccountPageViewModel { }; } -export { - type AccountPageIncomeSummary, - type AccountPageRecentEvents, - type AccountPageRecentOperations, - useAccountPageViewModel, -}; +export { useAccountPageViewModel }; diff --git a/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx index bcea919..1d3a28b 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx @@ -7,16 +7,16 @@ import { useTranslation } from "react-i18next"; import type { BrokerageAccount } from "@/entities/brokerage-account"; import { BrokerageEventsPreview } from "@/entities/brokerage-event"; +import { InvestmentRefreshState } from "@/features/tbank-connection"; import type { AccountPageIncomeSummary, AccountPageRecentEvents, AccountPageRecentOperations, -} from "../AccountPage.vm"; +} from "../../../model/previews"; import { AccountHeading } from "./AccountHeading.view"; import { IncomeSummary } from "./IncomeSummary.view"; -import { PortfolioRefreshState } from "./PortfolioRefreshState/PortfolioRefreshState.view"; import { PortfolioStructure } from "./PortfolioStructure/PortfolioStructure"; import { RecentOperations } from "./RecentOperations.view"; @@ -45,7 +45,8 @@ const AccountPageEmpty: FC = (props) => { - = (props) => { - ( - - - - - - ), - tags: ["autodocs"], - title: "Product/PortfolioRefreshState", -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -export const Idle: Story = { name: "Сохранённые данные" }; -export const Refreshing: Story = { - args: { refreshing: true }, - name: "Фоновое обновление", -}; -export const RefreshError: Story = { - args: { refreshError: true }, - name: "Ошибка обновления с retry", -}; - -export const SourceFailure: Story = { - args: { refreshError: true, sourceError: true }, - name: "Ошибка синхронизации сохраняет суммы", -}; diff --git a/src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.view.tsx b/src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.view.tsx deleted file mode 100644 index 1507457..0000000 --- a/src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.view.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import type { FC } from "react"; - -import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core"; -import { useTranslation } from "react-i18next"; - -type PortfolioRefreshStateProps = { - refreshError: boolean; - refreshing: boolean; - retry?: () => void; - sourceError?: boolean; -}; - -const PortfolioRefreshState: FC = (props) => { - const { refreshError, refreshing, retry } = props; - const { t } = useTranslation(); - - return ( - <> - - - {!!refreshing && ( - - - - - {t("portfolio.refreshing")} - - - )} - - {!!refreshError && ( - - - - {t( - props.sourceError - ? "investment.syncFailed" - : "portfolio.refreshError", - )} - - - - )} - - ); -}; - -export { PortfolioRefreshState }; diff --git a/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx b/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx index ff81b82..0ba28bc 100644 --- a/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx +++ b/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx @@ -8,7 +8,7 @@ import { Link } from "react-router"; import { LedgerEntryRow } from "@/entities/broker-operation"; import { formatSnapshotTime } from "@/shared/lib"; -import type { AccountPageRecentOperations } from "../AccountPage.vm"; +import type { AccountPageRecentOperations } from "../../../model/previews"; type RecentOperationsProps = { operations: AccountPageRecentOperations; diff --git a/src/pages/accounts/ui/AccountsPage/components/AccountsPageEmpty.view.tsx b/src/pages/accounts/ui/AccountsPage/components/AccountsPageEmpty.view.tsx index 3e007ab..a7908cf 100644 --- a/src/pages/accounts/ui/AccountsPage/components/AccountsPageEmpty.view.tsx +++ b/src/pages/accounts/ui/AccountsPage/components/AccountsPageEmpty.view.tsx @@ -5,16 +5,16 @@ import { StateView } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; import { - AccountsRefreshState, - type AccountsRefreshStateProps, -} from "./AccountsRefreshState.view"; + InvestmentRefreshState, + type InvestmentRefreshStateProps, +} from "@/features/tbank-connection"; -const AccountsPageEmpty: FC = (props) => { +const AccountsPageEmpty: FC = (props) => { const { t } = useTranslation(); return ( - + = (props) => { - + -
    - - )} - - {companyMetricsContent} - - - {t("stock.periodNote")} - - - )} - + @@ -229,20 +80,8 @@ const StockPageView: FC = (props) => { /> - + ); }; -function metricUnit( - metric: NonNullable["metrics"][number], - unknownCurrency: string, -) { - if (metric.unit === "money") - return metric.currency === "RUB" - ? "₽" - : (metric.currency ?? unknownCurrency); - - return metric.unit === "percent" ? "%" : "×"; -} - export { StockPageView }; diff --git a/src/pages/stock/ui/StockPage/components/StockCompany.view.tsx b/src/pages/stock/ui/StockPage/components/StockCompany.view.tsx new file mode 100644 index 0000000..df67069 --- /dev/null +++ b/src/pages/stock/ui/StockPage/components/StockCompany.view.tsx @@ -0,0 +1,120 @@ +import type { FC } from "react"; + +import { + Alert, + Button, + Group, + Stack, + Text, +} from "@mantine/core"; +import { useTranslation } from "react-i18next"; + +import { + InstrumentMetric, + InstrumentMetrics, + InstrumentSection as StockSection, +} from "@/features/instrument-details"; + +import type { StockPageVm } from "../StockPage.vm"; + +const compactMetricFormatter = new Intl.NumberFormat("ru-RU", { + maximumFractionDigits: 2, + notation: "compact", +}); + +const metricFormatter = new Intl.NumberFormat("ru-RU", { + maximumFractionDigits: 2, + notation: "standard", +}); + +type StockCompanyProps = Pick & { + stock: NonNullable; +}; + +const StockCompany: FC = (props) => { + const { stock } = props; + const { t } = useTranslation(); + let companyMetricsContent; + + if (props.company) { + if (props.company.metrics.length > 0) { + companyMetricsContent = ( + + {props.company.metrics.map((metric) => { + const label = [t(`stock.metrics.${metric.key}`), metric.period].filter(Boolean).join(" · "); + const formatter = metric.unit === "money" ? compactMetricFormatter : metricFormatter; + const value = `${formatter.format(metric.value)} ${metricUnit(metric, t("stock.currencyUnknown"))}`; + + return ( + + ); + })} + + ); + } + else { + companyMetricsContent = {t("stock.noFundamentals")}; + } + } + + return ( + + + {!!stock.sector && ( + + {t("stock.sector")}:{" "} + {t(`stock.sectors.${stock.sector}`, { + defaultValue: stock.sector, + })} + + )} + + {stock.lot !== null && {t("stock.lot", { value: stock.lot })}} + + + {!!props.company && ( + + {props.company.unavailable.length > 0 && ( + + + {t("stock.partialCompany")} + + + + + )} + + {companyMetricsContent} + + + {t("stock.periodNote")} + + + )} + + ); +}; + +function metricUnit( + metric: NonNullable["metrics"][number], + unknownCurrency: string, +) { + if (metric.unit === "money") + return metric.currency === "RUB" + ? "₽" + : (metric.currency ?? unknownCurrency); + + return metric.unit === "percent" ? "%" : "×"; +} + +export { StockCompany }; diff --git a/src/pages/stock/ui/StockPage/components/StockQuote.view.tsx b/src/pages/stock/ui/StockPage/components/StockQuote.view.tsx new file mode 100644 index 0000000..5c9a7b7 --- /dev/null +++ b/src/pages/stock/ui/StockPage/components/StockQuote.view.tsx @@ -0,0 +1,98 @@ +import type { FC } from "react"; + +import { + Group, + Skeleton, + Text, +} from "@mantine/core"; +import { LoadingSkeleton } from "@wealthledger/ui"; +import { useTranslation } from "react-i18next"; + +import { + InstrumentPrice, + InstrumentQuoteStatus, +} from "@/features/instrument-details"; +import { + formatDate, + formatDecimal, + formatMoney, +} from "@/shared/lib"; + +import type { StockPageVm } from "../StockPage.vm"; + +import classes from "../StockPage.module.css"; + +type StockQuoteProps = Pick; + +const StockQuote: FC = (props) => { + const { t } = useTranslation(); + + const appearanceClass = props.quote?.change?.value.startsWith("-") + ? classes.negative + : classes.positive; + + let quoteContent; + + if (props.quoteState.loading) { + quoteContent = ( + + + + ); + } + else { + quoteContent = ( +
    + + {formatMoney(props.quote?.price)} + + + {!!props.quote?.change && ( + + {props.quote.change.value.startsWith("-") ? "" : "+"} + + {formatMoney(props.quote.change)} + + {props.quote.changePercent === null + ? "" + : ` (${formatDecimal(props.quote.changePercent)} %)`} + + )} + + {props.quote?.previousCloseDate + ? ( + + {t("stock.changeBase", { + date: formatDate(props.quote.previousCloseDate), + })} + + ) + : ( + + {t("stock.noComparison")} + + )} + + + + + + {!!props.quote?.unavailable.length && ( + + {t("stock.partialQuote")} + + )} + + {!!props.quoteState.refreshing && ( + + {t("accounts.refreshing")} + + )} +
    + ); + } + + return quoteContent; +}; + +export { StockQuote }; diff --git a/src/entities/portfolio/api/resource-lifecycle.test.tsx b/src/shared/api/resource-query.test.tsx similarity index 87% rename from src/entities/portfolio/api/resource-lifecycle.test.tsx rename to src/shared/api/resource-query.test.tsx index 6dcea08..9ccbc2a 100644 --- a/src/entities/portfolio/api/resource-lifecycle.test.tsx +++ b/src/shared/api/resource-query.test.tsx @@ -7,11 +7,17 @@ import { createQueryWrapper } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; -import { - type Portfolio, - portfolioQueryOptions, - usePortfolio, -} from "./usePortfolio"; +import { apiQueryClient, type paths, useResourceQuery } from "./index"; + +type Portfolio = NonNullable; + +function resourceOptions(accountId: string) { + return apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts/{accountId}/portfolio", { params: { path: { accountId } } }); +} + +function usePrivateResource(accountId: string) { + return useResourceQuery({ ...resourceOptions(accountId), meta: { accessGroup: "brokerage" } }); +} const snapshot: Portfolio = { cashBalances: [], @@ -52,7 +58,7 @@ describe("persistent resource query lifecycle", () => { ), ); const { queryClient, wrapper } = createQueryWrapper(); - const { result } = renderHook(() => usePortfolio("a"), { wrapper }); + const { result } = renderHook(() => usePrivateResource("a"), { wrapper }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); await act(() => result.current.observe("new")); await waitFor(() => expect(result.current.meta?.stateVersion).toBe("new")); @@ -60,7 +66,7 @@ describe("persistent resource query lifecycle", () => { expect(modes).toEqual([null, "observe", "observe"]); expect(queryClient.getQueryCache().getAll()).toHaveLength(1); expect( - queryClient.getQueryData(portfolioQueryOptions("a").queryKey), + queryClient.getQueryData(resourceOptions("a").queryKey), ).toMatchObject({ data: snapshot, meta: { stateVersion: "new" } }); }); it("does not convert missing or failed source data to empty financial success", async () => { @@ -79,7 +85,7 @@ describe("persistent resource query lifecycle", () => { ), ); - const { result } = renderHook(() => usePortfolio("a"), { + const { result } = renderHook(() => usePrivateResource("a"), { wrapper: createQueryWrapper().wrapper, }); @@ -109,7 +115,7 @@ describe("persistent resource query lifecycle", () => { ), ); - const { result } = renderHook(() => usePortfolio("a"), { + const { result } = renderHook(() => usePrivateResource("a"), { wrapper: createQueryWrapper().wrapper, }); @@ -186,7 +192,7 @@ describe("persistent resource query lifecycle", () => { const { wrapper } = createQueryWrapper(); const { result } = renderHook( - () => ({ a: usePortfolio("a"), b: usePortfolio("b") }), + () => ({ a: usePrivateResource("a"), b: usePrivateResource("b") }), { wrapper }, ); diff --git a/src/shared/api/resource-query.ts b/src/shared/api/resource-query.ts index 653b6f6..6a84724 100644 --- a/src/shared/api/resource-query.ts +++ b/src/shared/api/resource-query.ts @@ -1,14 +1,13 @@ import { type QueryClient, type QueryFunctionContext, - type QueryKey, useQueryClient, type UseQueryOptions, type UseQueryResult, } from "@tanstack/react-query"; import { useEffect } from "react"; -import type { components } from "./generated/schema"; +import type { components, paths } from "./generated/schema"; import { useAuthenticatedQuery } from "./authenticated-query"; import { getAuthenticatedSessionSnapshot } from "./client"; @@ -23,6 +22,19 @@ type Resource = { meta: InvestmentResourceMeta; }; +type ResourcePath = { + [P in keyof paths]: paths[P]["get"] extends { + // eslint-disable-next-line @typescript-eslint/no-magic-numbers -- HTTP success response in the generated contract. + responses: { 200: { content: { "application/json": Resource } } }; + } ? P : never; +}[keyof paths]; + +type ResourceQueryKey = readonly [ + "get", + ResourcePath, + { params?: { path?: object; query?: { sync?: "observe" } } }?, +]; + function invalidateResourceAccess( client: QueryClient, group: string, @@ -58,6 +70,17 @@ function invalidateResourceAccess( }); } +// Keep endpoint and parameters intact. The generated queryFn accepts the +// optional sync mode even for a cache key originally created without init. +function observationKey(key: K): K { + const [method, path, init] = key; + + return [method, path, { + ...init, + params: { ...init?.params, query: { ...init?.params?.query, sync: "observe" } }, + }] as unknown as K; +} + function unwrapResourceResult( result: UseQueryResult, ) { @@ -86,7 +109,7 @@ function unwrapResourceResult( } // Observation changes transport mode, never the generated cache identity. -function useResourceQuery( +function useResourceQuery( options: UseQueryOptions, ) { const client = useQueryClient(); @@ -137,23 +160,7 @@ function useResourceQuery( return client.query({ ...options, queryFn: async (context: QueryFunctionContext) => { - const [method, path, input] = context.queryKey; - - const init = input as - undefined - | { params?: { query?: Record } }; - - const queryKey = [ - method, - path, - { - ...init, - params: { - ...init?.params, - query: { ...init?.params?.query, sync: "observe" }, - }, - }, - ] as unknown as K; + const queryKey = observationKey(context.queryKey); const response = await queryFunction({ ...context, queryKey }); diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts index a3e2668..9931999 100644 --- a/src/shared/lib/index.ts +++ b/src/shared/lib/index.ts @@ -8,3 +8,4 @@ export { formatSnapshotTime, instrumentLogoUrl, } from "./display"; +export { useUrlPeriod } from "./useUrlPeriod"; diff --git a/src/shared/lib/useUrlPeriod.ts b/src/shared/lib/useUrlPeriod.ts new file mode 100644 index 0000000..0129edd --- /dev/null +++ b/src/shared/lib/useUrlPeriod.ts @@ -0,0 +1,27 @@ +import { useEffect } from "react"; +import { useSearchParams } from "react-router"; + +import { isValidPeriod } from "./calendar"; + +function useUrlPeriod() { + const [parameters, setParameters] = useSearchParams(); + const from = parameters.get("from"); + const to = parameters.get("to"); + const isValid = isValidPeriod(from, to); + useEffect(() => { + if (isValid || (!parameters.has("from") && !parameters.has("to"))) return; + const normalized = new URLSearchParams(parameters); + normalized.delete("from"); + normalized.delete("to"); + setParameters(normalized, { replace: true }); + }, [isValid, parameters, setParameters]); + + return { + from: isValid ? from : null, + parameters, + setParameters, + to: isValid ? to : null, + }; +} + +export { useUrlPeriod }; diff --git a/src/test/storybook.test.tsx b/src/test/storybook.test.tsx index 9a39756..e5ba09e 100644 --- a/src/test/storybook.test.tsx +++ b/src/test/storybook.test.tsx @@ -8,9 +8,9 @@ import * as UiLoadingStories from "../../packages/ui/src/LoadingSkeleton/Loading import * as StateStories from "../../packages/ui/src/StateView/StateView.stories"; import * as AccountStories from "../entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories"; import * as EventStories from "../entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.stories"; +import * as RefreshStories from "../features/tbank-connection/ui/InvestmentRefreshState/InvestmentRefreshState.stories"; import * as IncomePeriodStories from "../pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.stories"; import * as LoadingStories from "../pages/account/ui/AccountPage/components/AccountPageLoading/AccountPageLoading.stories"; -import * as RefreshStories from "../pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.stories"; import * as PositionStories from "../pages/account/ui/AccountPage/components/PositionCard/PositionCard.stories"; import * as OperationStories from "../pages/card/ui/OperationForm/OperationForm.stories"; diff --git a/vitest.config.ts b/vitest.config.ts index 5bb7f95..f50277b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -59,7 +59,7 @@ export default defineConfig({ ], provider: playwright(), }, - include: ["src/**/*.browser.test.tsx"], + include: ["src/**/*.browser.test.tsx", "packages/*/src/**/*.browser.test.tsx"], name: "browser", }, },