From ff4e88edbeb9de244c2a6094ab70d9e48a4768a7 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sat, 12 Sep 2026 18:34:41 +0300 Subject: [PATCH] feat: add manual bank card screens Add cards, operations, refunds, categories and monthly summaries with Mantine date controls. Derive banking types from runtime OpenAPI. Cover recovery and session isolation. --- .storybook/preview.tsx | 5 +- src/app/routes/AppRoutes.test.tsx | 36 + src/app/routes/AppRoutes.tsx | 24 + src/app/routes/DocumentTitle.tsx | 26 +- src/app/routes/RouteGuards.tsx | 10 +- src/app/ui/AppBreadcrumbs.tsx | 5 + src/app/ui/CardBreadcrumbs.tsx | 20 + src/entities/bank-card/api/banking.test.tsx | 384 ++++ src/entities/bank-card/api/mutations.ts | 111 + src/entities/bank-card/api/queries.ts | 117 + src/entities/bank-card/index.ts | 25 + .../bank-card/model/form-validation.test.ts | 56 + .../bank-card/model/form-validation.ts | 85 + .../bank-card/ui/BankCardForm.stories.tsx | 44 + .../bank-card/ui/BankCardForm.test.tsx | 122 + src/entities/bank-card/ui/BankCardForm.tsx | 144 ++ .../bank-card/ui/BankCardForm.view.tsx | 87 + src/entities/bank-card/ui/BankFeedback.tsx | 37 + .../bank-card/ui/BankForms.module.css | 6 + .../bank-card/ui/BankSummary.stories.tsx | 102 + .../bank-card/ui/BankSummary.view.tsx | 90 + .../bank-card/ui/CategoryManager.test.tsx | 74 + src/entities/bank-card/ui/CategoryManager.tsx | 215 ++ src/entities/bank-card/ui/useBankFormFocus.ts | 16 + src/features/auth/model/auth.test.tsx | 13 + .../ui/TbankConnectionSection.test.tsx | 20 + src/pages/card/index.ts | 1 + src/pages/card/ui/CardPage.module.css | 19 + src/pages/card/ui/CardPage.test.tsx | 231 ++ src/pages/card/ui/CardPage.tsx | 37 + src/pages/card/ui/CardPage.view.tsx | 226 ++ src/pages/card/ui/CardPage.vm.ts | 103 + src/pages/card/ui/DeleteOperation.test.tsx | 76 + src/pages/card/ui/DeleteOperation.tsx | 111 + src/pages/card/ui/OperationForm.module.css | 6 + src/pages/card/ui/OperationForm.test.tsx | 458 ++++ src/pages/card/ui/OperationForm.tsx | 417 ++++ src/pages/card/ui/OperationForm.types.ts | 14 + src/pages/card/ui/OperationForm.view.tsx | 198 ++ src/pages/cards/index.ts | 1 + src/pages/cards/ui/CardsPage.module.css | 10 + src/pages/cards/ui/CardsPage.test.tsx | 185 ++ src/pages/cards/ui/CardsPage.tsx | 19 + src/pages/cards/ui/CardsPage.view.tsx | 137 ++ src/pages/cards/ui/CardsPage.vm.ts | 41 + src/shared/api/generated/schema.d.ts | 1467 ++++++++++++ src/shared/api/spec/openapi.json | 2023 +++++++++++++++++ src/shared/i18n/locales/ru.ts | 137 ++ src/shared/ui/AppShell.module.css | 9 +- src/shared/ui/AppShell.tsx | 5 + 50 files changed, 7795 insertions(+), 10 deletions(-) create mode 100644 src/app/ui/CardBreadcrumbs.tsx create mode 100644 src/entities/bank-card/api/banking.test.tsx create mode 100644 src/entities/bank-card/api/mutations.ts create mode 100644 src/entities/bank-card/api/queries.ts create mode 100644 src/entities/bank-card/index.ts create mode 100644 src/entities/bank-card/model/form-validation.test.ts create mode 100644 src/entities/bank-card/model/form-validation.ts create mode 100644 src/entities/bank-card/ui/BankCardForm.stories.tsx create mode 100644 src/entities/bank-card/ui/BankCardForm.test.tsx create mode 100644 src/entities/bank-card/ui/BankCardForm.tsx create mode 100644 src/entities/bank-card/ui/BankCardForm.view.tsx create mode 100644 src/entities/bank-card/ui/BankFeedback.tsx create mode 100644 src/entities/bank-card/ui/BankForms.module.css create mode 100644 src/entities/bank-card/ui/BankSummary.stories.tsx create mode 100644 src/entities/bank-card/ui/BankSummary.view.tsx create mode 100644 src/entities/bank-card/ui/CategoryManager.test.tsx create mode 100644 src/entities/bank-card/ui/CategoryManager.tsx create mode 100644 src/entities/bank-card/ui/useBankFormFocus.ts create mode 100644 src/pages/card/index.ts create mode 100644 src/pages/card/ui/CardPage.module.css create mode 100644 src/pages/card/ui/CardPage.test.tsx create mode 100644 src/pages/card/ui/CardPage.tsx create mode 100644 src/pages/card/ui/CardPage.view.tsx create mode 100644 src/pages/card/ui/CardPage.vm.ts create mode 100644 src/pages/card/ui/DeleteOperation.test.tsx create mode 100644 src/pages/card/ui/DeleteOperation.tsx create mode 100644 src/pages/card/ui/OperationForm.module.css create mode 100644 src/pages/card/ui/OperationForm.test.tsx create mode 100644 src/pages/card/ui/OperationForm.tsx create mode 100644 src/pages/card/ui/OperationForm.types.ts create mode 100644 src/pages/card/ui/OperationForm.view.tsx create mode 100644 src/pages/cards/index.ts create mode 100644 src/pages/cards/ui/CardsPage.module.css create mode 100644 src/pages/cards/ui/CardsPage.test.tsx create mode 100644 src/pages/cards/ui/CardsPage.tsx create mode 100644 src/pages/cards/ui/CardsPage.view.tsx create mode 100644 src/pages/cards/ui/CardsPage.vm.ts diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index aac21cb..b5bfa08 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -4,6 +4,7 @@ import "@mantine/dates/styles.css"; import "dayjs/locale/ru"; import "../src/shared/i18n"; import { MantineProvider } from "@mantine/core"; +import { DatesProvider } from "@mantine/dates"; import type { Preview } from "@storybook/react-vite"; import { cssVariablesResolver, theme } from "@/shared/theme"; @@ -33,7 +34,9 @@ const preview: Preview = { forceColorScheme="light" colorSchemeManager={colorSchemeManager} > - + + + ), ], diff --git a/src/app/routes/AppRoutes.test.tsx b/src/app/routes/AppRoutes.test.tsx index 3760312..84fcedb 100644 --- a/src/app/routes/AppRoutes.test.tsx +++ b/src/app/routes/AppRoutes.test.tsx @@ -37,6 +37,7 @@ describe("AppRoutes", () => { ["/accounts/demo", "Вход"], ["/accounts/demo/operations", "Вход"], ["/settings", "Вход"], + ["/cards?month=2026-08", "Вход"], ["/unknown", "Страница не найдена"], ])("renders %s with its page title", async (path, title) => { renderRoutes(path); @@ -94,6 +95,41 @@ describe("AppRoutes", () => { }); }); + 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([])), + http.post("*/api/v1/auth/login", () => + HttpResponse.json({ + accessToken: "test", + user: { id: "user-1", email: "user@example.com" }, + }), + ), + http.get("*/api/v1/bank-cards", () => HttpResponse.json([])), + http.get("*/api/v1/bank-cards/summary", () => + HttpResponse.json({ + month: "2026-08", + cardId: null, + expenses: { currency: "RUB", value: "0" }, + refunds: { currency: "RUB", value: "0" }, + net: { currency: "RUB", value: "0" }, + categories: [], + }), + ), + ); + renderRoutes("/cards?month=2026-08"); + await screen.findByRole("heading", { 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: "Вход" })); + await screen.findByRole("heading", { name: "Добавьте первую карту" }); + expect(screen.getByLabelText("Месяц")).toHaveTextContent("August 2026"); + expect(document.title).toBe("Карты — WealthLedger"); + }); + it("lazy-loads the protected shell for an authenticated accounts route", async () => { server.use( http.post("*/api/v1/auth/refresh", () => diff --git a/src/app/routes/AppRoutes.tsx b/src/app/routes/AppRoutes.tsx index 44c156e..a6c5bc9 100644 --- a/src/app/routes/AppRoutes.tsx +++ b/src/app/routes/AppRoutes.tsx @@ -4,6 +4,14 @@ import { Navigate, Route, Routes } from "react-router"; import { LazyPage } from "./RouteFallback"; import { Protected, Public } from "./RouteGuards"; +const CardPage = lazy(async () => { + const { CardPage } = await import("@/pages/card"); + return { default: CardPage }; +}); +const CardsPage = lazy(async () => { + const { CardsPage } = await import("@/pages/cards"); + return { default: CardsPage }; +}); const BondPage = lazy(async () => { const { BondPage } = await import("@/pages/bond"); return { default: BondPage }; @@ -93,6 +101,22 @@ const AppRoutes: FC = () => ( } > + + + + } + /> + + + + } + /> = (props) => { return ; }; +const CardDocumentTitle: FC = (props) => { + const { t } = useTranslation(); + const { data } = useBankCard(props.identifier); + return ; +}; + 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"); + if (card) return ; if (account) return ( { return ( ); diff --git a/src/app/routes/RouteGuards.tsx b/src/app/routes/RouteGuards.tsx index 3fe0f8d..4f34f8c 100644 --- a/src/app/routes/RouteGuards.tsx +++ b/src/app/routes/RouteGuards.tsx @@ -22,8 +22,16 @@ const Protected: FC = (props) => { const Public: FC = (props) => { const { children } = props; const { user, ready } = useAuth(); + const location = useLocation(); + const requested = (location.state as { returnTo?: string } | null)?.returnTo; + const returnTo = + typeof requested === "string" && + requested.startsWith("/") && + !requested.startsWith("//") + ? requested + : "/accounts"; if (!ready) return ; - return user ? : children; + return user ? : children; }; export { Protected, Public }; diff --git a/src/app/ui/AppBreadcrumbs.tsx b/src/app/ui/AppBreadcrumbs.tsx index f9c8464..995c0ba 100644 --- a/src/app/ui/AppBreadcrumbs.tsx +++ b/src/app/ui/AppBreadcrumbs.tsx @@ -6,6 +6,7 @@ import { AccountBreadcrumbs } from "./AccountBreadcrumbs"; import { AccountScopeSwitch } from "./AccountScopeSwitch"; import { BondBreadcrumbs } from "./BondBreadcrumbs"; import { BreadcrumbTrail } from "./BreadcrumbTrail"; +import { CardBreadcrumbs } from "./CardBreadcrumbs"; import { StockBreadcrumbs } from "./StockBreadcrumbs"; const AppBreadcrumbs: FC = () => { @@ -17,6 +18,8 @@ const AppBreadcrumbs: FC = () => { const events = useMatch({ path: "/accounts/:accountId/events", end: true }); const income = useMatch({ path: "/accounts/:accountId/income", end: true }); const account = useMatch({ path: "/accounts/:accountId", end: true }); + const card = useMatch("/cards/:cardId"); + const cards = useMatch("/cards"); const accounts = useMatch({ path: "/accounts", end: true }); const settings = useMatch({ path: "/settings", end: true }); @@ -71,6 +74,8 @@ const AppBreadcrumbs: FC = () => { ); + if (card) return ; + if (cards) return ; if (accounts) return ; if (settings) diff --git a/src/app/ui/CardBreadcrumbs.tsx b/src/app/ui/CardBreadcrumbs.tsx new file mode 100644 index 0000000..9ea035d --- /dev/null +++ b/src/app/ui/CardBreadcrumbs.tsx @@ -0,0 +1,20 @@ +import type { FC } from "react"; +import { useTranslation } from "react-i18next"; +import { useBankCard } from "@/entities/bank-card"; +import { BreadcrumbTrail } from "./BreadcrumbTrail"; + +type CardBreadcrumbsProps = { identifier: string }; +const CardBreadcrumbs: FC = (props) => { + const { data } = useBankCard(props.identifier); + const { t } = useTranslation(); + return ( + + ); +}; + +export { CardBreadcrumbs }; diff --git a/src/entities/bank-card/api/banking.test.tsx b/src/entities/bank-card/api/banking.test.tsx new file mode 100644 index 0000000..0a180b7 --- /dev/null +++ b/src/entities/bank-card/api/banking.test.tsx @@ -0,0 +1,384 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { HttpResponse, http } from "msw"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { apiClient, configureAuthClient } from "@/shared/api"; +import { createQueryWrapper } from "@/test/query"; +import { server } from "@/test/server"; +import { useBankMutations } from "./mutations"; +import { + type BankCard, + type CreateBankOperation, + useBankCard, + useBankCards, + useBankCategories, + useBankExpenses, + useBankOperation, + useBankOperations, + useBankSummary, +} from "./queries"; + +const card: BankCard = { + id: "a", + name: "Daily", + bankName: null, + lastFour: null, + openingBalance: { currency: "RUB", value: "100" }, + openingDate: "2026-09-01", + balance: { currency: "RUB", value: "100" }, + archivedAt: null, + version: 1, +}; +const transfer: CreateBankOperation = { + id: "4e6caa65-dd52-4629-81e2-ce8a139a0612", + kind: "transfer", + cardId: "a", + targetCardId: "b", + amount: { currency: "RUB", value: "90071992547409.93" }, + date: "2026-09-12", +}; + +function authenticate() { + configureAuthClient({ + accessToken: () => "token", + refresh: async () => true, + clear: vi.fn(), + }); +} + +function readHandlers(counts: Map, categoryName = "Food") { + return [ + http.get("*/api/v1/bank-cards", ({ request }) => { + const key = new URL(request.url).pathname + new URL(request.url).search; + counts.set(key, (counts.get(key) ?? 0) + 1); + return HttpResponse.json([card]); + }), + http.get("*/api/v1/bank-cards/*", ({ request }) => { + const url = new URL(request.url); + const key = url.pathname + url.search; + counts.set(key, (counts.get(key) ?? 0) + 1); + if (url.pathname.endsWith("/categories")) + return HttpResponse.json([ + { id: "food", name: categoryName, isDefault: true, version: 1 }, + ]); + if (url.pathname.endsWith("/summary")) + return HttpResponse.json({ + month: url.searchParams.get("month"), + cardId: url.searchParams.get("cardId"), + expenses: { currency: "RUB", value: "0" }, + refunds: { currency: "RUB", value: "0" }, + net: { currency: "RUB", value: "0" }, + categories: [], + }); + if ( + url.pathname.endsWith("/operations") || + url.pathname.endsWith("/expenses") + ) + return HttpResponse.json({ + items: [], + page: 1, + total: 0, + hasNext: false, + }); + if (url.pathname.includes("/operations/")) + return HttpResponse.json({ ...transfer, version: 1 }); + return HttpResponse.json({ ...card, id: url.pathname.split("/").at(-1) }); + }), + ]; +} + +function useReadModels() { + const cards = useBankCards(true); + const a = useBankCard("a"); + const b = useBankCard("b"); + const categories = useBankCategories(); + const history = useBankOperations("a", { + month: "2026-09", + page: 2, + limit: 20, + }); + const expenses = useBankExpenses("a", { page: 1 }); + const september = useBankSummary("2026-09"); + const august = useBankSummary("2026-08", "b"); + const original = useBankOperation("original"); + return { + reads: [ + cards, + a, + b, + categories, + history, + expenses, + september, + august, + original, + ], + mutations: useBankMutations(), + }; +} + +describe("banking API", () => { + beforeEach(authenticate); + + it("uses session-aware banking endpoints and explicit month/page without T-Bank", async () => { + const counts = new Map(); + server.use(...readHandlers(counts)); + const { result } = renderHook(useReadModels, { + wrapper: createQueryWrapper().wrapper, + }); + await waitFor(() => + 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?month=2026-09&page=2&limit=20", + ); + expect([...counts.keys()]).toContain( + "/api/v1/bank-cards/summary?month=2026-08&cardId=b", + ); + expect(counts.size).toBe(9); + }); + + it("replays the exact UUID and large Money string once across shared auth refresh", async () => { + let token = "expired"; + const refresh = vi.fn(async () => { + token = "renewed"; + return true; + }); + configureAuthClient({ accessToken: () => token, refresh, clear: vi.fn() }); + const bodies: unknown[] = []; + const headers: (string | null)[] = []; + server.use( + http.post("*/api/v1/bank-cards/operations", async ({ request }) => { + bodies.push(await request.json()); + headers.push(request.headers.get("Authorization")); + return bodies.length === 1 + ? HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }) + : HttpResponse.json({ ...transfer, version: 1 }); + }), + ); + const { result } = renderHook(useBankMutations, { + wrapper: createQueryWrapper().wrapper, + }); + await act(() => result.current.createOperation.mutateAsync(transfer)); + expect(bodies).toEqual([transfer, transfer]); + expect(headers).toEqual(["Bearer expired", "Bearer renewed"]); + expect(refresh).toHaveBeenCalledTimes(1); + }); + + it("does not automatically retry a network failure and preserves the form-supplied intent on repeat", async () => { + const bodies: unknown[] = []; + server.use( + http.post("*/api/v1/bank-cards/operations", async ({ request }) => { + bodies.push(await request.json()); + return bodies.length === 1 + ? HttpResponse.error() + : HttpResponse.json({ ...transfer, version: 1 }); + }), + ); + const { result } = renderHook(useBankMutations, { + wrapper: createQueryWrapper().wrapper, + }); + await act(async () => { + await expect( + result.current.createOperation.mutateAsync(transfer), + ).rejects.toThrow(); + }); + expect(bodies).toEqual([transfer]); + await act(() => result.current.createOperation.mutateAsync(transfer)); + expect(bodies).toEqual([transfer, transfer]); + }); + + it("keeps the confirmed balance while a mutation is pending or fails, and on refetch failure", async () => { + server.use(...readHandlers(new Map())); + let finish: () => void = () => {}; + const pending = new Promise((resolve) => { + finish = resolve; + }); + server.use( + http.post("*/api/v1/bank-cards/operations", async () => { + await pending; + return HttpResponse.json({ code: "INTERNAL_ERROR" }, { status: 500 }); + }), + ); + const { result } = renderHook( + () => ({ query: useBankCard("a"), mutations: useBankMutations() }), + { wrapper: createQueryWrapper().wrapper }, + ); + await waitFor(() => expect(result.current.query.isSuccess).toBe(true)); + let saving: Promise; + act(() => { + saving = result.current.mutations.createOperation + .mutateAsync(transfer) + .catch(() => undefined); + }); + await waitFor(() => + expect(result.current.mutations.createOperation.isPending).toBe(true), + ); + expect(result.current.query.data?.balance.value).toBe("100"); + await act(async () => { + finish(); + await saving; + }); + expect(result.current.query.data?.balance.value).toBe("100"); + server.use( + http.get("*/api/v1/bank-cards/a", () => + HttpResponse.json({ code: "INTERNAL_ERROR" }, { status: 500 }), + ), + ); + await act(() => result.current.query.refetch()); + expect(result.current.query.isError).toBe(true); + 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 () => { + const counts = new Map(); + server.use( + ...readHandlers(counts), + http.post("*/api/v1/bank-cards/operations", () => + HttpResponse.json({ ...transfer, version: 1 }), + ), + ); + const { wrapper, queryClient } = createQueryWrapper(); + const brokerKey = ["get", "/api/v1/brokerage-accounts"]; + queryClient.setQueryData(brokerKey, []); + const { result } = renderHook(useReadModels, { wrapper }); + await waitFor(() => + expect(result.current.reads.every((read) => read.isSuccess)).toBe(true), + ); + await act(() => + result.current.mutations.createOperation.mutateAsync(transfer), + ); + expect([...counts.values()]).toEqual(Array(9).fill(2)); + expect(queryClient.getQueryState(brokerKey)?.isInvalidated).toBe(false); + }); + + it.each([ + "BANK_VERSION_CONFLICT", + "BANK_INTENT_CONFLICT", + "BANK_REFUND_LIMIT", + ])("fetches current state after %s", async (code) => { + let reads = 0; + server.use( + http.get("*/api/v1/bank-cards/a", () => + HttpResponse.json({ ...card, version: ++reads }), + ), + http.patch("*/api/v1/bank-cards/a", () => + HttpResponse.json({ code }, { status: 409 }), + ), + ); + const { result } = renderHook( + () => ({ card: useBankCard("a"), mutations: useBankMutations() }), + { wrapper: createQueryWrapper().wrapper }, + ); + await waitFor(() => expect(result.current.card.data?.version).toBe(1)); + await act(async () => { + await expect( + result.current.mutations.updateCard.mutateAsync({ + id: "a", + body: { name: "changed", version: 1 }, + }), + ).rejects.toEqual({ code }); + }); + await waitFor(() => expect(result.current.card.data?.version).toBe(2)); + }); + + it("sends edit/delete versions and refreshes category names without optimistic updates", async () => { + const requests: unknown[] = []; + let name = "Food"; + server.use( + http.get("*/api/v1/bank-cards/categories", () => + HttpResponse.json([{ id: "food", name, isDefault: true, version: 1 }]), + ), + http.patch("*/api/v1/bank-cards/categories/food", async ({ request }) => { + const body = await request.json(); + requests.push(body); + name = "Groceries"; + return HttpResponse.json({ + id: "food", + name, + isDefault: true, + version: 2, + }); + }), + http.patch( + "*/api/v1/bank-cards/operations/original", + async ({ request }) => { + requests.push(await request.json()); + return HttpResponse.json({ ...transfer, version: 2 }); + }, + ), + http.delete("*/api/v1/bank-cards/operations/original", ({ request }) => { + requests.push(new URL(request.url).searchParams.get("version")); + return new HttpResponse(null, { status: 204 }); + }), + ); + const { result } = renderHook( + () => ({ + categories: useBankCategories(), + mutations: useBankMutations(), + }), + { wrapper: createQueryWrapper().wrapper }, + ); + await waitFor(() => + expect(result.current.categories.data?.[0].name).toBe("Food"), + ); + await act(() => + result.current.mutations.updateCategory.mutateAsync({ + id: "food", + body: { name: "Groceries", version: 1 }, + }), + ); + await waitFor(() => + expect(result.current.categories.data?.[0].name).toBe("Groceries"), + ); + await act(() => + result.current.mutations.updateOperation.mutateAsync({ + id: "original", + body: { description: "corrected", version: 1 }, + }), + ); + await act(() => + result.current.mutations.deleteOperation.mutateAsync({ + id: "original", + version: 2, + }), + ); + expect(requests).toEqual([ + { name: "Groceries", version: 1 }, + { description: "corrected", version: 1 }, + "2", + ]); + }); + + it("disables reads after session removal and retains banking data across a T-Bank API mutation", async () => { + const counts = new Map(); + server.use( + ...readHandlers(counts), + http.put("*/api/v1/tbank-connection", () => + HttpResponse.json({ + connected: true, + connectedAt: "2026-09-12T00:00:00Z", + }), + ), + ); + const { wrapper, queryClient } = createQueryWrapper(); + const { result, rerender } = renderHook(() => useBankCard("a"), { + wrapper, + }); + await waitFor(() => expect(result.current.data?.balance.value).toBe("100")); + await apiClient.PUT("/api/v1/tbank-connection", { + body: { token: "synthetic" }, + }); + expect(result.current.data?.balance.value).toBe("100"); + act(() => { + configureAuthClient(null); + queryClient.clear(); + }); + rerender(); + await waitFor(() => expect(result.current.fetchStatus).toBe("idle")); + expect(result.current.data).toBeUndefined(); + expect(counts.get("/api/v1/bank-cards/a")).toBe(1); + }); +}); diff --git a/src/entities/bank-card/api/mutations.ts b/src/entities/bank-card/api/mutations.ts new file mode 100644 index 0000000..910320d --- /dev/null +++ b/src/entities/bank-card/api/mutations.ts @@ -0,0 +1,111 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { apiClient, apiErrorCode } from "@/shared/api"; +import type { + CreateBankCard, + CreateBankOperation, + CreateExpenseCategory, + UpdateBankCard, + UpdateBankOperation, + UpdateExpenseCategory, +} from "./queries"; + +async function mutationData( + request: Promise<{ data?: T; error?: unknown }>, +) { + const { data, error } = await request; + if (error) throw error; + return data; +} + +function useBankMutations() { + const queryClient = useQueryClient(); + const invalidate = () => + queryClient.invalidateQueries({ + predicate: ({ queryKey: [method, path] }) => + method === "get" && + typeof path === "string" && + (path === "/api/v1/bank-cards" || + path.startsWith("/api/v1/bank-cards/")), + }); + const options = { + retry: false, + onSuccess: invalidate, + onError: (error: unknown) => { + if ( + [ + "BANK_VERSION_CONFLICT", + "BANK_WRITE_CONFLICT", + "BANK_INTENT_CONFLICT", + "BANK_REFUND_LIMIT", + "BANK_DEPENDENT_REFUNDS", + ].includes(apiErrorCode(error) ?? "") + ) + return invalidate(); + }, + } as const; + const createCard = useMutation({ + ...options, + mutationFn: (body: CreateBankCard) => + mutationData(apiClient.POST("/api/v1/bank-cards", { body })), + }); + const updateCard = useMutation({ + ...options, + mutationFn: ({ id, body }: { id: string; body: UpdateBankCard }) => + mutationData( + apiClient.PATCH("/api/v1/bank-cards/{id}", { + params: { path: { id } }, + body, + }), + ), + }); + const createOperation = useMutation({ + ...options, + mutationFn: (body: CreateBankOperation) => + mutationData(apiClient.POST("/api/v1/bank-cards/operations", { body })), + }); + const updateOperation = useMutation({ + ...options, + mutationFn: ({ id, body }: { id: string; body: UpdateBankOperation }) => + mutationData( + apiClient.PATCH("/api/v1/bank-cards/operations/{id}", { + params: { path: { id } }, + body, + }), + ), + }); + const deleteOperation = useMutation({ + ...options, + mutationFn: ({ id, version }: { id: string; version: number }) => + mutationData( + apiClient.DELETE("/api/v1/bank-cards/operations/{id}", { + params: { path: { id }, query: { version } }, + }), + ), + }); + const createCategory = useMutation({ + ...options, + mutationFn: (body: CreateExpenseCategory) => + mutationData(apiClient.POST("/api/v1/bank-cards/categories", { body })), + }); + const updateCategory = useMutation({ + ...options, + mutationFn: ({ id, body }: { id: string; body: UpdateExpenseCategory }) => + mutationData( + apiClient.PATCH("/api/v1/bank-cards/categories/{id}", { + params: { path: { id } }, + body, + }), + ), + }); + return { + createCard, + updateCard, + createOperation, + updateOperation, + deleteOperation, + createCategory, + updateCategory, + }; +} + +export { useBankMutations }; diff --git a/src/entities/bank-card/api/queries.ts b/src/entities/bank-card/api/queries.ts new file mode 100644 index 0000000..d083f79 --- /dev/null +++ b/src/entities/bank-card/api/queries.ts @@ -0,0 +1,117 @@ +import { + apiQueryClient, + type paths, + useAuthenticatedQuery, +} 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 BankSummary = + paths["/api/v1/bank-cards/summary"]["get"]["responses"][200]["content"]["application/json"]; +type ExpenseCategory = + paths["/api/v1/bank-cards/categories"]["get"]["responses"][200]["content"]["application/json"][number]; +type CreateBankCard = + paths["/api/v1/bank-cards"]["post"]["requestBody"]["content"]["application/json"]; +type UpdateBankCard = + paths["/api/v1/bank-cards/{id}"]["patch"]["requestBody"]["content"]["application/json"]; +type CreateBankOperation = + paths["/api/v1/bank-cards/operations"]["post"]["requestBody"]["content"]["application/json"]; +type UpdateBankOperation = + paths["/api/v1/bank-cards/operations/{id}"]["patch"]["requestBody"]["content"]["application/json"]; +type CreateExpenseCategory = + paths["/api/v1/bank-cards/categories"]["post"]["requestBody"]["content"]["application/json"]; +type UpdateExpenseCategory = + paths["/api/v1/bank-cards/categories/{id}"]["patch"]["requestBody"]["content"]["application/json"]; + +function useBankCards(includeArchived = false) { + return useAuthenticatedQuery( + apiQueryClient.queryOptions("get", "/api/v1/bank-cards", { + params: { + query: { includeArchived: includeArchived ? "true" : "false" }, + }, + }), + ); +} + +function useBankCard(id: string | undefined) { + return useAuthenticatedQuery({ + ...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/{id}", { + params: { path: { id: id ?? "" } }, + }), + enabled: Boolean(id), + }); +} + +function useBankCategories() { + return useAuthenticatedQuery( + apiQueryClient.queryOptions("get", "/api/v1/bank-cards/categories"), + ); +} + +function useBankOperations( + id: string | undefined, + query: { month: string; page: number; limit?: number }, +) { + return useAuthenticatedQuery({ + ...apiQueryClient.queryOptions( + "get", + "/api/v1/bank-cards/{id}/operations", + { params: { path: { id: id ?? "" }, query } }, + ), + enabled: Boolean(id && query.month), + }); +} + +function useBankExpenses( + id: string | undefined, + query: { page: number; limit?: number }, +) { + return useAuthenticatedQuery({ + ...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/{id}/expenses", { + params: { path: { id: id ?? "" }, query }, + }), + enabled: Boolean(id), + }); +} + +function useBankSummary(month: string, cardId?: string) { + return useAuthenticatedQuery({ + ...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/summary", { + params: { query: { month, cardId } }, + }), + enabled: Boolean(month), + }); +} + +function useBankOperation(id: string | undefined) { + return useAuthenticatedQuery({ + ...apiQueryClient.queryOptions( + "get", + "/api/v1/bank-cards/operations/{id}", + { params: { path: { id: id ?? "" } } }, + ), + enabled: Boolean(id), + }); +} + +export { + type BankCard, + type BankOperation, + type BankSummary, + type CreateBankCard, + type CreateBankOperation, + type CreateExpenseCategory, + type ExpenseCategory, + type UpdateBankCard, + type UpdateBankOperation, + type UpdateExpenseCategory, + useBankCard, + useBankCards, + useBankCategories, + useBankExpenses, + useBankOperation, + useBankOperations, + useBankSummary, +}; diff --git a/src/entities/bank-card/index.ts b/src/entities/bank-card/index.ts new file mode 100644 index 0000000..cfb514d --- /dev/null +++ b/src/entities/bank-card/index.ts @@ -0,0 +1,25 @@ +export { useBankMutations } from "./api/mutations"; +export { + type BankCard, + type BankOperation, + useBankCard, + useBankCards, + useBankCategories, + useBankExpenses, + useBankOperation, + useBankOperations, + useBankSummary, +} from "./api/queries"; +export { + bankDateSchema, + bankMoneyValue, + bankMonth, + bankToday, + moneyCents, + positiveMoneySchema, +} from "./model/form-validation"; +export { BankCardForm } from "./ui/BankCardForm"; +export { BankFeedback } from "./ui/BankFeedback"; +export { BankSummaryView } from "./ui/BankSummary.view"; +export { CategoryManager } from "./ui/CategoryManager"; +export { useBankFormFocus } from "./ui/useBankFormFocus"; diff --git a/src/entities/bank-card/model/form-validation.test.ts b/src/entities/bank-card/model/form-validation.test.ts new file mode 100644 index 0000000..225e278 --- /dev/null +++ b/src/entities/bank-card/model/form-validation.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + bankCardSchema, + bankDateSchema, + bankMoneyValue, + bankMonth, + bankToday, + positiveMoneySchema, +} from "./form-validation"; + +afterEach(() => vi.useRealTimers()); +describe("bank form input", () => { + it("keeps input bounds exact without rounding and canonicalizes leading zeros", () => { + expect(positiveMoneySchema.safeParse("92233720368547758.07").success).toBe( + true, + ); + for (const invalid of [ + "92233720368547758.08", + "1.001", + "0", + "-1", + "abc", + "1e3", + ]) { + expect(positiveMoneySchema.safeParse(invalid).success).toBe(false); + } + expect(bankMoneyValue(" -00010,20 ")).toBe("-10.20"); + const values = { + name: "A", + bankName: "", + lastFour: "", + openingBalance: "-92233720368547758.08", + openingDate: "2026-01-01", + }; + expect(bankCardSchema.safeParse(values).success).toBe(true); + expect( + bankCardSchema.safeParse({ + ...values, + openingBalance: "-92233720368547758.09", + }).success, + ).toBe(false); + expect( + bankCardSchema.safeParse({ ...values, lastFour: "123" }).success, + ).toBe(false); + }); + it("uses Moscow date and refuses nonexistent or future calendar days", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-31T21:30:00Z")); + expect(bankToday()).toBe("2026-09-01"); + expect(bankMonth(null)).toBe("2026-09"); + expect(bankMonth("2026-13")).toBe("2026-09"); + expect(bankDateSchema.safeParse("2026-09-01").success).toBe(true); + expect(bankDateSchema.safeParse("2026-09-02").success).toBe(false); + expect(bankDateSchema.safeParse("2026-02-30").success).toBe(false); + }); +}); diff --git a/src/entities/bank-card/model/form-validation.ts b/src/entities/bank-card/model/form-validation.ts new file mode 100644 index 0000000..490866f --- /dev/null +++ b/src/entities/bank-card/model/form-validation.ts @@ -0,0 +1,85 @@ +import { z } from "zod"; +import { validationMessage } from "@/shared/i18n"; + +function bankToday() { + return new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Moscow", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(new Date()); +} +function bankMonth(value: string | null) { + return value && /^\d{4}-(0[1-9]|1[0-2])$/.test(value) + ? value + : bankToday().slice(0, 7); +} +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{1,2})?$/.test(normalized)) return null; + const [whole, fraction = ""] = normalized.replace("-", "").split("."); + return ( + BigInt(`${whole}${fraction.padEnd(2, "0")}`) * + (normalized.startsWith("-") ? -1n : 1n) + ); +} +const moneySchema = z.string().refine((value) => { + const cents = moneyCents(value); + return ( + cents !== null && + cents >= -9223372036854775808n && + cents <= 9223372036854775807n + ); +}, validationMessage("bank.validation.money")); +const positiveMoneySchema = moneySchema.refine((value) => { + const cents = moneyCents(value); + return cents === null || cents > 0n; +}, validationMessage("bank.validation.positive")); +const bankDateSchema = z + .string() + .refine( + (value) => + /^\d{4}-\d{2}-\d{2}$/.test(value) && + value >= "0001-01-01" && + !Number.isNaN(Date.parse(`${value}T00:00:00Z`)) && + new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value && + value <= bankToday(), + validationMessage("bank.validation.date"), + ); +const bankCardSchema = z.object({ + name: z + .string() + .trim() + .min(1, validationMessage("bank.validation.name")(undefined)) + .max(100, validationMessage("bank.validation.name")(undefined)), + bankName: z + .string() + .trim() + .max(100, validationMessage("bank.validation.bankName")(undefined)), + lastFour: z + .string() + .refine( + (value) => !value || /^\d{4}$/.test(value), + validationMessage("bank.validation.lastFour"), + ), + openingBalance: moneySchema, + openingDate: bankDateSchema, +}); +type BankCardValues = z.infer; + +export { + type BankCardValues, + bankCardSchema, + bankDateSchema, + bankMoneyValue, + bankMonth, + bankToday, + moneyCents, + positiveMoneySchema, +}; diff --git a/src/entities/bank-card/ui/BankCardForm.stories.tsx b/src/entities/bank-card/ui/BankCardForm.stories.tsx new file mode 100644 index 0000000..a5580c7 --- /dev/null +++ b/src/entities/bank-card/ui/BankCardForm.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FormProvider, useForm } from "react-hook-form"; +import type { BankCardValues } from "../model/form-validation"; +import { BankCardFields } from "./BankCardForm.view"; + +const meta = { + title: "Product/BankCards/CardFields", + component: BankCardFields, + tags: ["autodocs"], + args: { archived: false }, + decorators: [ + (Story) => { + const methods = useForm({ + defaultValues: { + name: "Учебная повседневная карта", + bankName: "Вымышленный банк", + lastFour: "1234", + openingBalance: "-92233720368547758.08", + openingDate: "2026-08-01", + }, + }); + return ( + + + + ); + }, + ], + parameters: { + docs: { + description: { + component: + "Поля ручной дебетовой карты на вымышленных данных. Сохранения и обращения к API нет. Архив оставляет описательные сведения доступными, а финансовые поля — только для чтения.", + }, + }, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const Active: Story = { name: "Независимая карта в рублях" }; +export const Archived: Story = { + name: "Финансовые поля архивной карты", + args: { archived: true }, +}; diff --git a/src/entities/bank-card/ui/BankCardForm.test.tsx b/src/entities/bank-card/ui/BankCardForm.test.tsx new file mode 100644 index 0000000..14ed802 --- /dev/null +++ b/src/entities/bank-card/ui/BankCardForm.test.tsx @@ -0,0 +1,122 @@ +import { MantineProvider } from "@mantine/core"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { HttpResponse, http } from "msw"; +import { describe, expect, it, vi } from "vitest"; +import { configureAuthClient } from "@/shared/api"; +import { createTestQueryClient } from "@/test/query"; +import { server } from "@/test/server"; +import type { BankCard } from "../api/queries"; +import { BankCardForm } from "./BankCardForm"; + +const card: BankCard = { + id: "a", + name: "Основная", + bankName: "Банк", + lastFour: "1234", + openingBalance: { currency: "RUB", value: "100" }, + balance: { currency: "RUB", value: "50" }, + openingDate: "2026-01-01", + archivedAt: null, + version: 1, +}; +function setup(value: BankCard) { + configureAuthClient({ + accessToken: () => "test", + refresh: async () => false, + clear: () => {}, + }); + server.use(http.get("*/api/v1/bank-cards/a", () => HttpResponse.json(value))); + const close = vi.fn(); + render( + + + + + , + ); + return close; +} +describe("BankCardForm", () => { + it("clears optional metadata on archive while omitting protected financial fields", async () => { + const close = setup({ ...card, archivedAt: "2026-09-01" }); + expect(screen.getByLabelText(/Начальный остаток/)).toHaveAttribute( + "readonly", + ); + expect(screen.getByLabelText(/Дата начала/)).toHaveAttribute("readonly"); + fireEvent.change(screen.getByLabelText(/Банк/), { target: { value: "" } }); + fireEvent.change(screen.getByLabelText(/Последние четыре/), { + target: { value: "" }, + }); + let body: unknown; + server.use( + http.patch("*/api/v1/bank-cards/a", async ({ request }) => { + body = await request.json(); + return HttpResponse.json(card); + }), + ); + fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); + await waitFor(() => expect(close).toHaveBeenCalled()); + expect(body).toEqual({ + version: 1, + name: "Основная", + bankName: null, + lastFour: null, + }); + }); + it("retains an invalid opening date draft and explains conflict with saved history", async () => { + const close = setup(card); + fireEvent.change(screen.getByLabelText(/Дата начала/), { + target: { value: "2026-08-01" }, + }); + server.use( + http.patch("*/api/v1/bank-cards/a", () => + HttpResponse.json( + { code: "BANK_OPENING_DATE_CONFLICT" }, + { status: 400 }, + ), + ), + ); + fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); + await screen.findByText( + "Дата начала противоречит сохранённой истории карты.", + ); + expect(screen.getByLabelText(/Дата начала/)).toHaveValue("2026-08-01"); + expect(close).not.toHaveBeenCalled(); + }); + it("keeps card edit version until explicit refresh adoption", async () => { + const close = setup(card); + let version = 1; + const bodies: Record[] = []; + server.use( + http.get("*/api/v1/bank-cards/a", () => + HttpResponse.json({ ...card, version }), + ), + http.patch("*/api/v1/bank-cards/a", async ({ request }) => { + const body = (await request.json()) as Record; + bodies.push(body); + version = 2; + return body.version === 1 + ? HttpResponse.json( + { code: "BANK_VERSION_CONFLICT" }, + { status: 409 }, + ) + : HttpResponse.json(card); + }), + ); + fireEvent.change(screen.getByLabelText(/Название карты/), { + target: { value: "Мой черновик" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); + await screen.findByText(/Запись уже изменена/); + fireEvent.click(screen.getByRole("button", { name: "Перечитать запись" })); + const adopt = await screen.findByRole("button", { + name: "Использовать актуальную версию 2", + }); + expect(screen.getByLabelText(/Название карты/)).toHaveValue("Мой черновик"); + fireEvent.click(adopt); + fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); + await waitFor(() => expect(close).toHaveBeenCalled()); + expect(bodies.map((b) => b.version)).toEqual([1, 2]); + }); +}); diff --git a/src/entities/bank-card/ui/BankCardForm.tsx b/src/entities/bank-card/ui/BankCardForm.tsx new file mode 100644 index 0000000..1c45e8e --- /dev/null +++ b/src/entities/bank-card/ui/BankCardForm.tsx @@ -0,0 +1,144 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button, Group, Modal, Stack, Text } from "@mantine/core"; +import { type FC, useState } from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { useBankMutations } from "../api/mutations"; +import { type BankCard, useBankCard } from "../api/queries"; +import { + type BankCardValues, + bankCardSchema, + bankMoneyValue, + bankToday, +} from "../model/form-validation"; +import { BankCardFields } from "./BankCardForm.view"; +import { BankFeedback } from "./BankFeedback"; +import classes from "./BankForms.module.css"; +import { useBankFormFocus } from "./useBankFormFocus"; + +type BankCardFormProps = { card?: BankCard; onClose: () => void }; +const BankCardForm: FC = (props) => { + useBankFormFocus(); + const { t } = useTranslation(); + const [intent] = useState(() => crypto.randomUUID()); + const [version, setVersion] = useState(props.card?.version ?? 1); + const [error, setError] = useState(); + const [latest, setLatest] = useState(); + const fresh = useBankCard(props.card?.id); + const mutation = useBankMutations(); + const methods = useForm({ + resolver: zodResolver(bankCardSchema), + defaultValues: { + name: props.card?.name ?? "", + bankName: props.card?.bankName ?? "", + lastFour: props.card?.lastFour ?? "", + openingBalance: props.card?.openingBalance.value ?? "0", + openingDate: props.card?.openingDate ?? bankToday(), + }, + }); + const pending = + methods.formState.isSubmitting || (fresh.isFetching && Boolean(error)); + return ( + !pending && props.onClose()} + title={t(props.card ? "bank.editCard" : "bank.addCard")} + closeButtonProps={{ "aria-label": t("bank.close"), disabled: pending }} + closeOnClickOutside={!pending} + closeOnEscape={!pending} + > + +
{ + setError(undefined); + try { + const metadata = { + name: values.name, + bankName: values.bankName || null, + lastFour: values.lastFour || null, + }; + const financial = { + openingBalance: { + currency: "RUB" as const, + value: bankMoneyValue(values.openingBalance), + }, + openingDate: values.openingDate, + }; + if (props.card) + await mutation.updateCard.mutateAsync({ + id: props.card.id, + body: { + ...metadata, + ...(!props.card.archivedAt ? financial : {}), + version, + }, + }); + else + await mutation.createCard.mutateAsync({ + id: intent, + ...metadata, + ...financial, + }); + props.onClose(); + } catch (failure) { + setError(failure); + } + })} + > +
+ + + {error ? ( + <> + + {props.card && ( + + )} + + ) : null} + {latest && ( + <> + + {t("bank.versionHint", { + details: `${latest.name} · ${latest.openingBalance.value} RUB · ${latest.openingDate}`, + })} + + + + )} + + + + + +
+
+
+
+ ); +}; + +export { BankCardForm }; diff --git a/src/entities/bank-card/ui/BankCardForm.view.tsx b/src/entities/bank-card/ui/BankCardForm.view.tsx new file mode 100644 index 0000000..e2be3c4 --- /dev/null +++ b/src/entities/bank-card/ui/BankCardForm.view.tsx @@ -0,0 +1,87 @@ +import { Stack, Text, TextInput } from "@mantine/core"; +import { DateInput } from "@mantine/dates"; +import type { FC } from "react"; +import { Controller, useFormContext } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { + type BankCardValues, + bankDateSchema, + bankToday, +} from "../model/form-validation"; + +type BankCardFieldsProps = { archived: boolean }; +const BankCardFields: FC = (props) => { + const { + control, + register, + formState: { errors }, + } = useFormContext(); + const { t } = useTranslation(); + return ( + + + + + + ( + + bankDateSchema.safeParse(value).success ? value : null + } + fixOnBlur={false} + onInput={(event) => field.onChange(event.currentTarget.value)} + onChange={(value) => field.onChange(value ?? "")} + onBlur={(event) => { + field.onChange(event.currentTarget.value); + field.onBlur(); + }} + maxDate={bankToday()} + popoverProps={{ + withinPortal: false, + middlewares: { shift: { crossAxis: true } }, + }} + readOnly={props.archived} + error={errors.openingDate?.message} + /> + )} + /> + + {t("bank.openingHint")} + + {props.archived && {t("bank.archiveHint")}} + + ); +}; + +export { BankCardFields }; diff --git a/src/entities/bank-card/ui/BankFeedback.tsx b/src/entities/bank-card/ui/BankFeedback.tsx new file mode 100644 index 0000000..0b2ef22 --- /dev/null +++ b/src/entities/bank-card/ui/BankFeedback.tsx @@ -0,0 +1,37 @@ +import { Alert, Button, Group, Text } from "@mantine/core"; +import type { FC } from "react"; +import { useTranslation } from "react-i18next"; +import { apiErrorCode } from "@/shared/api"; + +type BankFeedbackProps = { + error: unknown; + retry?: () => void; + cached?: boolean; + loadingError?: boolean; +}; +const BankFeedback: FC = (props) => { + const { t } = useTranslation(); + const code = apiErrorCode(props.error); + return ( + + + + {props.cached + ? t("bank.refreshError") + : t(`bank.errors.${code}` as "bank.errors.BANK_INVALID_INPUT", { + defaultValue: t( + props.loadingError ? "bank.error" : "bank.requestError", + ), + })} + + {props.retry && ( + + )} + + + ); +}; + +export { BankFeedback }; diff --git a/src/entities/bank-card/ui/BankForms.module.css b/src/entities/bank-card/ui/BankForms.module.css new file mode 100644 index 0000000..1f24583 --- /dev/null +++ b/src/entities/bank-card/ui/BankForms.module.css @@ -0,0 +1,6 @@ +.fieldset { + border: 0; + padding: 0; + margin: 0; + min-width: 0; +} diff --git a/src/entities/bank-card/ui/BankSummary.stories.tsx b/src/entities/bank-card/ui/BankSummary.stories.tsx new file mode 100644 index 0000000..3b9387e --- /dev/null +++ b/src/entities/bank-card/ui/BankSummary.stories.tsx @@ -0,0 +1,102 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { BankSummaryView } from "./BankSummary.view"; + +const money = (value: string) => ({ currency: "RUB", value }); +const meta = { + title: "Product/BankCards/Summary", + component: BankSummaryView, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: + "Месячные расходы ручного учёта. Вымышленные суммы, без API и сохранения. Возвраты учитываются отдельно; отрицательный итог не превращается в процентную диаграмму.", + }, + }, + }, + args: { + loading: false, + error: false, + fetching: false, + retry: () => {}, + data: { + month: "2026-09", + cardId: null, + expenses: money("8500"), + refunds: money("1000"), + net: money("7500"), + categories: [ + { + categoryId: "food", + categoryName: "Продукты", + expenses: money("8500"), + refunds: money("1000"), + net: money("7500"), + }, + ], + }, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const Loaded: Story = { name: "Расходы и возвраты" }; +export const RefundMonth: Story = { + name: "Возврат покупки прошлого месяца", + args: { + data: { + month: "2026-09", + cardId: null, + expenses: money("0"), + refunds: money("600"), + net: money("-600"), + categories: [ + { + categoryId: "shopping", + categoryName: + "Покупки с длинным пользовательским названием категории", + expenses: money("0"), + refunds: money("600"), + net: money("-600"), + }, + ], + }, + }, +}; +export const Empty: Story = { + name: "Известные нули", + args: { + data: { + month: "2026-09", + cardId: null, + expenses: money("0"), + refunds: money("0"), + net: money("0"), + categories: [], + }, + }, +}; +export const Loading: Story = { + name: "Начальная загрузка", + args: { data: undefined, loading: true }, +}; +export const Failed: Story = { + name: "Ошибка без данных", + args: { data: undefined, error: true }, +}; +export const CachedError: Story = { + name: "Сохранённые суммы при ошибке обновления", + args: { error: true }, +}; +export const LongAmounts: Story = { + name: "Точный итог длиннее предела отдельного ввода", + args: { + data: { + month: "2026-09", + cardId: null, + expenses: money("184467440737095516.14"), + refunds: money("0"), + net: money("184467440737095516.14"), + categories: [], + }, + }, +}; diff --git a/src/entities/bank-card/ui/BankSummary.view.tsx b/src/entities/bank-card/ui/BankSummary.view.tsx new file mode 100644 index 0000000..93f788b --- /dev/null +++ b/src/entities/bank-card/ui/BankSummary.view.tsx @@ -0,0 +1,90 @@ +import { + Button, + Group, + Paper, + SimpleGrid, + Skeleton, + Stack, + Text, + Title, +} from "@mantine/core"; +import type { FC } from "react"; +import { useTranslation } from "react-i18next"; +import { formatMoney } from "@/shared/lib"; +import { LoadingSkeleton } from "@/shared/ui"; +import type { BankSummary } from "../api/queries"; + +type BankSummaryViewProps = { + data?: BankSummary; + loading: boolean; + error: boolean; + fetching: boolean; + retry: () => void; + all?: boolean; +}; +const BankSummaryView: FC = (props) => { + const { t } = useTranslation(); + return ( + + + + {t(props.all ? "bank.allSummary" : "bank.summary")} + + {props.data ? ( + <> + + {(["expenses", "refunds", "net"] as const).map((key) => ( +
+ {t(`bank.${key}`)} + + {formatMoney(props.data?.[key] ?? null)} + +
+ ))} +
+ + {t("bank.summaryHint")} + + {props.data.categories.length ? ( + props.data.categories.map((category) => ( + + {category.categoryName} + + {(["expenses", "refunds", "net"] as const).map((key) => ( + + {t(`bank.${key}`)}: {formatMoney(category[key])} + + ))} + + + )) + ) : ( + {t("bank.noExpenses")} + )} + + ) : props.loading ? ( + + + + + + + + ) : null} + {props.error && ( + + {t(props.data ? "bank.refreshError" : "bank.error")} + + + )} + {props.data && props.fetching && ( + {t("bank.refreshing")} + )} +
+
+ ); +}; + +export { BankSummaryView }; diff --git a/src/entities/bank-card/ui/CategoryManager.test.tsx b/src/entities/bank-card/ui/CategoryManager.test.tsx new file mode 100644 index 0000000..4179ef3 --- /dev/null +++ b/src/entities/bank-card/ui/CategoryManager.test.tsx @@ -0,0 +1,74 @@ +import { MantineProvider } from "@mantine/core"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { HttpResponse, http } from "msw"; +import { describe, expect, it } from "vitest"; +import { configureAuthClient } from "@/shared/api"; +import { createTestQueryClient } from "@/test/query"; +import { server } from "@/test/server"; +import { CategoryManager } from "./CategoryManager"; + +describe("CategoryManager", () => { + it("rejects duplicate case-insensitive and too long names then renames default identity", async () => { + configureAuthClient({ + accessToken: () => "test", + refresh: async () => false, + clear: () => {}, + }); + let category = { + id: "default", + name: "Другое", + isDefault: true, + version: 1, + }; + let creates = 0; + let body: unknown; + server.use( + http.get("*/api/v1/bank-cards/categories", () => + HttpResponse.json([category]), + ), + http.post("*/api/v1/bank-cards/categories", () => { + creates++; + return HttpResponse.json({}); + }), + http.patch( + "*/api/v1/bank-cards/categories/default", + async ({ request }) => { + body = await request.json(); + category = { ...category, name: "Остальное", version: 2 }; + return HttpResponse.json(category); + }, + ), + ); + render( + + + {}} /> + + , + ); + await screen.findByText("Другое"); + fireEvent.change(screen.getByLabelText(/Название категории/), { + target: { value: " другое " }, + }); + fireEvent.click(screen.getByRole("button", { name: "Добавить категорию" })); + await screen.findByText("Категория с таким названием уже есть."); + fireEvent.change(screen.getByLabelText(/Название категории/), { + target: { value: "a".repeat(81) }, + }); + fireEvent.click(screen.getByRole("button", { name: "Добавить категорию" })); + await screen.findByText("Введите название от 1 до 80 символов."); + expect(creates).toBe(0); + fireEvent.click(screen.getByRole("button", { name: "Переименовать" })); + fireEvent.input(screen.getByLabelText(/Название категории/), { + target: { value: "Остальное" }, + }); + fireEvent.blur(screen.getByLabelText(/Название категории/)); + fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); + await screen.findByText("Остальное"); + await waitFor(() => + expect(body).toEqual({ name: "Остальное", version: 1 }), + ); + expect(screen.getByText("По умолчанию")).toBeInTheDocument(); + }); +}); diff --git a/src/entities/bank-card/ui/CategoryManager.tsx b/src/entities/bank-card/ui/CategoryManager.tsx new file mode 100644 index 0000000..2801f18 --- /dev/null +++ b/src/entities/bank-card/ui/CategoryManager.tsx @@ -0,0 +1,215 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Badge, + Button, + Group, + Modal, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { type FC, useState } from "react"; +import { + Controller, + FormProvider, + useForm, + useFormContext, +} from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { z } from "zod"; +import { validationMessage } from "@/shared/i18n"; +import { useBankMutations } from "../api/mutations"; +import { type ExpenseCategory, useBankCategories } from "../api/queries"; +import { BankFeedback } from "./BankFeedback"; +import classes from "./BankForms.module.css"; +import { useBankFormFocus } from "./useBankFormFocus"; + +type CategoryManagerProps = { + onClose: () => void; + onCreated?: (id: string) => void; +}; +type CategoryValues = { name: string }; +const CategoryFields: FC = () => { + const { t } = useTranslation(); + const { + control, + formState: { errors }, + } = useFormContext(); + return ( + ( + + )} + /> + ); +}; +const CategoryManager: FC = (props) => { + useBankFormFocus(); + const { t } = useTranslation(); + const categories = useBankCategories(); + const mutation = useBankMutations(); + const [editing, setEditing] = useState(); + const [error, setError] = useState(); + const [latest, setLatest] = useState(); + const methods = useForm({ + resolver: zodResolver( + z.object({ + name: z + .string() + .trim() + .refine( + (value) => value.length > 0 && value.length <= 80, + validationMessage("bank.validation.category"), + ) + .refine( + (value) => + !categories.data?.some( + (c) => + c.id !== editing?.id && + c.name.toLocaleLowerCase("ru-RU") === + value.toLocaleLowerCase("ru-RU"), + ), + validationMessage("bank.validation.duplicate"), + ), + }), + ), + defaultValues: { name: "" }, + }); + const pending = methods.formState.isSubmitting; + return ( + !pending && props.onClose()} + title={t("bank.categories")} + closeOnClickOutside={!pending} + closeOnEscape={!pending} + closeButtonProps={{ "aria-label": t("bank.close"), disabled: pending }} + > + +
{ + setError(undefined); + try { + if (editing) + await mutation.updateCategory.mutateAsync({ + id: editing.id, + body: { name: values.name, version: editing.version }, + }); + else { + const result = await mutation.createCategory.mutateAsync({ + name: values.name, + }); + if (result) props.onCreated?.(result.id); + } + setEditing(undefined); + methods.reset({ name: "" }); + } catch (failure) { + setError(failure); + } + })} + > +
+ + {categories.data?.map((category) => ( + + + {category.name} + {category.isDefault && ( + {t("bank.defaultCategory")} + )} + + + + ))} + {categories.isLoading && ( + {t("bank.loading")} + )} + {categories.isError && ( + void categories.refetch()} + /> + )} + + {error ? ( + <> + + {editing && ( + + )} + + ) : null} + {latest && ( + <> + {t("bank.versionHint", { details: latest.name })} + + + )} + + + {editing && ( + + )} + + + +
+
+
+
+ ); +}; + +export { CategoryManager }; diff --git a/src/entities/bank-card/ui/useBankFormFocus.ts b/src/entities/bank-card/ui/useBankFormFocus.ts new file mode 100644 index 0000000..85a0845 --- /dev/null +++ b/src/entities/bank-card/ui/useBankFormFocus.ts @@ -0,0 +1,16 @@ +import { useEffect, useState } from "react"; + +function useBankFormFocus() { + const [trigger] = useState(() => document.activeElement); + useEffect( + () => () => { + queueMicrotask(() => { + if (trigger instanceof HTMLElement && trigger.isConnected) + trigger.focus(); + }); + }, + [trigger], + ); +} + +export { useBankFormFocus }; diff --git a/src/features/auth/model/auth.test.tsx b/src/features/auth/model/auth.test.tsx index 67233ed..ecc997d 100644 --- a/src/features/auth/model/auth.test.tsx +++ b/src/features/auth/model/auth.test.tsx @@ -8,6 +8,10 @@ import { createTestQueryClient } from "@/test/query"; import { server } from "@/test/server"; import { AuthProvider, useAuth } from "./auth"; +const bankCardsKey = apiQueryClient.queryOptions("get", "/api/v1/bank-cards", { + params: { query: { includeArchived: "false" } }, +}).queryKey; + describe("AuthProvider", () => { it("authorizes requests after restoring the initial session", async () => { let authorization: string | null = null; @@ -54,6 +58,7 @@ describe("AuthProvider", () => { server.use(http.post("*/api/v1/auth/refresh", () => HttpResponse.error())); const queryClient = createTestQueryClient(); queryClient.setQueryData(["cached-query"], { value: "stale" }); + queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]); const wrapper: FC = (props) => { const { children } = props; @@ -70,6 +75,7 @@ describe("AuthProvider", () => { expect(result.current.user).toBeNull(); expect(result.current.accessToken).toBeNull(); expect(queryClient.getQueryData(["cached-query"])).toBeUndefined(); + expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined(); }); it("keeps the original protected 401 when serialized refresh has a transport error", async () => { @@ -90,6 +96,7 @@ describe("AuthProvider", () => { ); const queryClient = createTestQueryClient(); queryClient.setQueryData(["cached-query"], { value: "stale" }); + queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]); const wrapper: FC = (props) => { const { children } = props; @@ -110,6 +117,7 @@ describe("AuthProvider", () => { await waitFor(() => expect(result.current.user).toBeNull()); expect(result.current.accessToken).toBeNull(); expect(queryClient.getQueryData(["cached-query"])).toBeUndefined(); + expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined(); }); it("clears connection status and financial cache independently on logout", async () => { @@ -136,6 +144,7 @@ describe("AuthProvider", () => { connectedAt: "2026-08-14T00:00:00Z", }); queryClient.setQueryData(portfolioKey, { source: "current-connection" }); + queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]); const wrapper: FC = (props) => { const { children } = props; return ( @@ -147,11 +156,15 @@ describe("AuthProvider", () => { const { result } = renderHook(() => useAuth(), { wrapper }); await waitFor(() => expect(result.current.accessToken).toBe("test-token")); + expect(queryClient.getQueryData(bankCardsKey)).toEqual([ + { id: "bank-card-1" }, + ]); await act(() => result.current.logout()); expect(result.current.user).toBeNull(); expect(result.current.accessToken).toBeNull(); expect(queryClient.getQueryData(statusKey)).toBeUndefined(); expect(queryClient.getQueryData(portfolioKey)).toBeUndefined(); + expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined(); }); }); diff --git a/src/features/tbank-connection/ui/TbankConnectionSection.test.tsx b/src/features/tbank-connection/ui/TbankConnectionSection.test.tsx index 71c4f3c..be680e1 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection.test.tsx +++ b/src/features/tbank-connection/ui/TbankConnectionSection.test.tsx @@ -32,6 +32,13 @@ const bondQueryKey = apiQueryClient.queryOptions( "/api/v1/bonds/{uid}/position", { params: { path: { uid: "e6123145-9665-43e0-8413-cd61b8aa9b13" } } }, ).queryKey; +const bankCardsQueryKey = apiQueryClient.queryOptions( + "get", + "/api/v1/bank-cards", + { + params: { query: { includeArchived: "false" } }, + }, +).queryKey; const unrelatedQueryKey = apiQueryClient.queryOptions( "get", "/api/v1/auth/me", @@ -66,6 +73,7 @@ function seededClient() { client.setQueryData(stockQueryKey, { source: "previous-connection" }); client.setQueryData(bondQueryKey, { source: "previous-connection" }); client.setQueryData(unrelatedQueryKey, { id: "user-1" }); + client.setQueryData(bankCardsQueryKey, [{ id: "bank-card-1" }]); return client; } @@ -86,6 +94,10 @@ describe("TbankConnectionSection", () => { ).toBeInTheDocument(), ); expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined(); + expect(client.getQueryData(bankCardsQueryKey)).toEqual([ + { id: "bank-card-1" }, + ]); + expect(client.getQueryState(bankCardsQueryKey)?.isInvalidated).toBe(false); expect(client.getQueryData(portfolioQueryKey)).toBeUndefined(); expect(client.getQueryData(stockQueryKey)).toBeUndefined(); expect(client.getQueryData(bondQueryKey)).toBeUndefined(); @@ -114,6 +126,10 @@ describe("TbankConnectionSection", () => { expect(client.getQueryData(portfolioQueryKey)).toBeUndefined(), ); expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined(); + expect(client.getQueryData(bankCardsQueryKey)).toEqual([ + { id: "bank-card-1" }, + ]); + expect(client.getQueryState(bankCardsQueryKey)?.isInvalidated).toBe(false); }); it("commits disconnected status and resets financial queries after deleting", async () => { @@ -139,6 +155,10 @@ describe("TbankConnectionSection", () => { ).toBeInTheDocument(), ); expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined(); + expect(client.getQueryData(bankCardsQueryKey)).toEqual([ + { id: "bank-card-1" }, + ]); + expect(client.getQueryState(bankCardsQueryKey)?.isInvalidated).toBe(false); expect(client.getQueryData(portfolioQueryKey)).toBeUndefined(); expect(client.getQueryData(stockQueryKey)).toBeUndefined(); expect(client.getQueryData(bondQueryKey)).toBeUndefined(); diff --git a/src/pages/card/index.ts b/src/pages/card/index.ts new file mode 100644 index 0000000..7d3a361 --- /dev/null +++ b/src/pages/card/index.ts @@ -0,0 +1 @@ +export { CardPage } from "./ui/CardPage"; diff --git a/src/pages/card/ui/CardPage.module.css b/src/pages/card/ui/CardPage.module.css new file mode 100644 index 0000000..0484ac2 --- /dev/null +++ b/src/pages/card/ui/CardPage.module.css @@ -0,0 +1,19 @@ +.wrap { + overflow-wrap: anywhere; + min-width: 0; +} +.operation { + min-width: 0; +} +.operation p { + white-space: normal; + overflow-wrap: anywhere; +} +.operation > div > div { + min-width: 0; +} +@media (min-width: 36em) { + .operation > div:first-child { + grid-template-columns: auto minmax(0, 1fr) minmax(0, 40%); + } +} diff --git a/src/pages/card/ui/CardPage.test.tsx b/src/pages/card/ui/CardPage.test.tsx new file mode 100644 index 0000000..b01d5ef --- /dev/null +++ b/src/pages/card/ui/CardPage.test.tsx @@ -0,0 +1,231 @@ +import { MantineProvider } from "@mantine/core"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import { HttpResponse, http } from "msw"; +import { MemoryRouter, Route, Routes } from "react-router"; +import { describe, expect, it } from "vitest"; +import { configureAuthClient } from "@/shared/api"; +import { createTestQueryClient } from "@/test/query"; +import { server } from "@/test/server"; +import { CardPage } from "./CardPage"; + +const money = (value: string) => ({ currency: "RUB", value }); +const card = { + id: "a", + name: "Повседневная", + bankName: null, + lastFour: null, + balance: money("10000"), + openingBalance: money("10000"), + openingDate: "2026-01-01", + archivedAt: null, + version: 1, +}; +const operation = { + id: "transfer", + kind: "transfer", + cardId: "a", + cardName: "Повседневная", + targetCardId: "b", + targetCardName: "Запасная", + categoryId: null, + categoryName: null, + originalExpenseId: null, + amount: money("50.01"), + availableRefund: null, + date: "2026-08-03", + description: null, + version: 1, + createdAt: "2026-08-03T00:00:00Z", +}; +function setup( + options: { + missing?: boolean; + summaryError?: boolean; + cardsError?: boolean; + } = {}, +) { + configureAuthClient({ + accessToken: () => "test", + refresh: async () => false, + clear: () => {}, + }); + const requests: string[] = []; + server.use( + http.get("*/api/v1/bank-cards/a", () => + options.missing + ? HttpResponse.json({ code: "BANK_NOT_FOUND" }, { status: 404 }) + : HttpResponse.json(card), + ), + http.get("*/api/v1/bank-cards", () => + options.cardsError + ? HttpResponse.error() + : HttpResponse.json([card, { ...card, id: "b", name: "Запасная" }]), + ), + http.get("*/api/v1/bank-cards/a/operations", ({ request }) => { + requests.push(new URL(request.url).search); + return HttpResponse.json({ + items: [operation], + page: 1, + total: 2, + hasNext: true, + }); + }), + http.get("*/api/v1/bank-cards/summary", () => + options.summaryError + ? HttpResponse.error() + : HttpResponse.json({ + month: "2026-08", + cardId: "a", + expenses: money("0"), + refunds: money("50"), + net: money("-50"), + categories: [], + }), + ), + ); + const client = createTestQueryClient(); + render( + + + + + } /> + + + + , + ); + return { requests, client }; +} +describe("CardPage", () => { + it("shares month with history, pages the journal and keeps current balance", async () => { + const { requests } = setup(); + await screen.findByRole("heading", { name: "Повседневная" }); + expect( + await screen.findByText("Перевод на «Запасная»"), + ).toBeInTheDocument(); + expect(screen.getByText("−50,01 ₽")).toBeInTheDocument(); + expect(screen.getByText("-50 ₽")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Далее" })); + await waitFor(() => + expect(requests.some((v) => v.includes("page=2"))).toBe(true), + ); + fireEvent.click(screen.getByLabelText("Месяц")); + fireEvent.click(await screen.findByRole("button", { name: "Jul" })); + await waitFor(() => + expect( + requests.some( + (v) => v.includes("month=2026-07") && v.includes("page=1"), + ), + ).toBe(true), + ); + expect(screen.getByText(/10\s000 ₽/)).toBeInTheDocument(); + }); + it("keeps card and journal when summary fails", async () => { + setup({ summaryError: true }); + await screen.findByRole("heading", { name: "Повседневная" }); + await screen.findByRole("alert"); + expect( + await screen.findByText("Перевод на «Запасная»"), + ).toBeInTheDocument(); + expect(screen.queryByText("0 ₽")).not.toBeInTheDocument(); + }); + it("keeps cached card and history after refetch failure", async () => { + const { client } = setup(); + await screen.findByText("Перевод на «Запасная»"); + server.use( + http.get("*/api/v1/bank-cards/a", () => HttpResponse.error()), + http.get("*/api/v1/bank-cards/a/operations", () => HttpResponse.error()), + ); + await client.invalidateQueries(); + await screen.findAllByRole("alert"); + expect( + screen.getByRole("heading", { name: "Повседневная" }), + ).toBeInTheDocument(); + expect(screen.getByText("Перевод на «Запасная»")).toBeInTheDocument(); + }); + it("handles inaccessible card neutrally", async () => { + setup({ missing: true }); + await screen.findByRole("heading", { name: "Карта не найдена" }); + expect(screen.getByRole("link", { name: "К картам" })).toHaveAttribute( + "href", + "/cards", + ); + expect(screen.queryByText(/10\s000 ₽/)).not.toBeInTheDocument(); + }); + it("archives without deleting history and restores financial actions", async () => { + setup(); + await screen.findByText("Перевод на «Запасная»"); + let current = card; + const bodies: unknown[] = []; + server.use( + http.get("*/api/v1/bank-cards/a", () => HttpResponse.json(current)), + http.get("*/api/v1/bank-cards", () => + HttpResponse.json([current, { ...card, id: "b", name: "Запасная" }]), + ), + http.patch("*/api/v1/bank-cards/a", async ({ request }) => { + const body = (await request.json()) as { + version: number; + archived: boolean; + }; + bodies.push(body); + current = { + ...current, + version: current.version + 1, + archivedAt: body.archived ? "2026-09-01" : null, + } as typeof card; + return HttpResponse.json(current); + }), + ); + fireEvent.click(screen.getByRole("button", { name: "Убрать в архив" })); + await screen.findByRole("button", { name: "Вернуть из архива" }); + expect( + screen.getByRole("button", { name: "Добавить операцию" }), + ).toBeDisabled(); + expect(screen.getByRole("button", { name: "Изменить" })).toBeDisabled(); + expect(screen.getByText("Перевод на «Запасная»")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Вернуть из архива" })); + await screen.findByRole("button", { name: "Убрать в архив" }); + expect( + screen.getByRole("button", { name: "Добавить операцию" }), + ).toBeEnabled(); + expect(bodies).toEqual([ + { version: 1, archived: true }, + { version: 2, archived: false }, + ]); + }); + it("recovers the prerequisite card list while preserving the visible balance and journal", async () => { + setup({ cardsError: true }); + await screen.findByText("Перевод на «Запасная»"); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Не удалось загрузить данные карт.", + ); + expect( + screen.getByRole("button", { name: "Добавить операцию" }), + ).toBeDisabled(); + server.use( + http.get("*/api/v1/bank-cards", () => + HttpResponse.json([card, { ...card, id: "b", name: "Запасная" }]), + ), + ); + fireEvent.click( + within(screen.getByRole("alert")).getByRole("button", { + name: "Повторить", + }), + ); + await waitFor(() => + expect( + screen.getByRole("button", { name: "Добавить операцию" }), + ).toBeEnabled(), + ); + expect(screen.getByText("Перевод на «Запасная»")).toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/card/ui/CardPage.tsx b/src/pages/card/ui/CardPage.tsx new file mode 100644 index 0000000..64d95b2 --- /dev/null +++ b/src/pages/card/ui/CardPage.tsx @@ -0,0 +1,37 @@ +import type { FC } from "react"; +import { BankCardForm } from "@/entities/bank-card"; +import { CardPageView } from "./CardPage.view"; +import { useCardPageVm } from "./CardPage.vm"; +import { DeleteOperation } from "./DeleteOperation"; +import { OperationForm } from "./OperationForm"; + +const CardPage: FC = () => { + const vm = useCardPageVm(); + return ( + <> + + {vm.editingCard && vm.card.data && ( + vm.setEditingCard(false)} + /> + )} + {vm.operation && vm.card.data && vm.cards.data && ( + vm.setOperation(null)} + /> + )} + {vm.deleting && ( + vm.setDeleting(null)} + /> + )} + + ); +}; + +export { CardPage }; diff --git a/src/pages/card/ui/CardPage.view.tsx b/src/pages/card/ui/CardPage.view.tsx new file mode 100644 index 0000000..f56ddd1 --- /dev/null +++ b/src/pages/card/ui/CardPage.view.tsx @@ -0,0 +1,226 @@ +import { + Anchor, + Badge, + Button, + Group, + Paper, + Skeleton, + Stack, + Text, + Title, +} from "@mantine/core"; +import { MonthPickerInput } from "@mantine/dates"; +import type { FC } from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; +import { BankFeedback, BankSummaryView } from "@/entities/bank-card"; +import { formatMoney } from "@/shared/lib"; +import { EntryCard, LoadingSkeleton, StateView } from "@/shared/ui"; +import classes from "./CardPage.module.css"; +import type { CardPageVm } from "./CardPage.vm"; + +type CardPageViewProps = { vm: CardPageVm }; +const CardPageView: FC = (props) => { + const { vm } = props; + const { t } = useTranslation(); + const card = vm.card.data; + if (!card) { + if (vm.card.isLoading) + return ( + + + + + + + + + ); + return ( + + + {!vm.notFound && ( + + )} + + } + /> + ); + } + return ( + + + + + + {card.name} + + {card.archivedAt && ( + {t("bank.archived")} + )} + + + {card.bankName} + {card.lastFour ? ` · •• ${card.lastFour}` : ""} + + + {formatMoney(card.balance)} + + {t("bank.manualBalance")} + + + + + + {vm.archiveError ? ( + void vm.card.refetch()} + /> + ) : null} + {card.archivedAt && {t("bank.archiveHint")}} + + + {vm.card.isError && ( + + {t("bank.refreshError")} + + + )} + {vm.card.isFetching && {t("bank.refreshing")}} + {vm.cards.isError && ( + void vm.cards.refetch()} + /> + )} + { + if (value) vm.setMonth(value.slice(0, 7)); + }} + popoverProps={{ middlewares: { shift: { crossAxis: true } } }} + /> + void vm.summary.refetch()} + /> + {t("bank.operations")} + {vm.rows ? ( + vm.rows.length ? ( + vm.rows.map((row) => ( +
+ + {t("bank.card")} + + ) : null + } + /> + + + + +
+ )) + ) : ( + {t("bank.noOperations")} + ) + ) : vm.history.isLoading ? ( + + + + + + + ) : null} + {vm.history.isError && ( + + {t(vm.rows ? "bank.refreshError" : "bank.error")} + + + )} + {vm.history.data && ( + + + {t("bank.page", { page: vm.page })} + + + )} +
+ ); +}; + +export { CardPageView }; diff --git a/src/pages/card/ui/CardPage.vm.ts b/src/pages/card/ui/CardPage.vm.ts new file mode 100644 index 0000000..b654330 --- /dev/null +++ b/src/pages/card/ui/CardPage.vm.ts @@ -0,0 +1,103 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useParams, useSearchParams } from "react-router"; +import { + type BankOperation, + bankMonth, + useBankCard, + useBankCards, + useBankMutations, + useBankOperations, + useBankSummary, +} from "@/entities/bank-card"; +import { apiErrorCode } from "@/shared/api"; +import { formatMoney } from "@/shared/lib"; + +function useCardPageVm() { + const { cardId } = useParams(); + const [params, setParams] = useSearchParams(); + const { t } = useTranslation(); + const month = bankMonth(params.get("month")); + const rawPage = Number(params.get("page") ?? 1); + const page = Number.isSafeInteger(rawPage) && rawPage > 0 ? rawPage : 1; + const card = useBankCard(cardId); + const { updateCard } = useBankMutations(); + const [archiveError, setArchiveError] = useState(); + const cards = useBankCards(true); + const history = useBankOperations(cardId, { month, page }); + const summary = useBankSummary(month, cardId); + const [editingCard, setEditingCard] = useState(false); + const [operation, setOperation] = useState( + null, + ); + const [deleting, setDeleting] = useState(null); + const rows = history.data?.items.map((item) => { + const outgoing = + item.kind === "expense" || + (item.kind === "transfer" && item.cardId === cardId); + return { + item, + title: + item.kind === "transfer" + ? t(outgoing ? "bank.transferTo" : "bank.transferFrom", { + name: outgoing ? item.targetCardName : item.cardName, + }) + : t(`bank.kinds.${item.kind}`), + metadata: [item.date, item.categoryName, item.description] + .filter(Boolean) + .join(" · "), + value: `${outgoing ? "−" : "+"}${formatMoney(item.amount)}`, + tone: outgoing ? ("negative" as const) : ("positive" as const), + otherCard: + item.kind === "transfer" + ? outgoing + ? item.targetCardId + : item.cardId + : null, + editable: + Boolean(cards.data) && + !cards.data?.some( + (c) => + (c.id === item.cardId || c.id === item.targetCardId) && + c.archivedAt, + ), + }; + }); + return { + card, + cards, + archiveError, + archivePending: updateCard.isPending, + toggleArchive: async () => { + if (!card.data) return; + setArchiveError(undefined); + try { + await updateCard.mutateAsync({ + id: card.data.id, + body: { version: card.data.version, archived: !card.data.archivedAt }, + }); + } catch (failure) { + setArchiveError(failure); + } + }, + history, + summary, + month, + page, + rows, + editingCard, + setEditingCard, + operation, + setOperation, + deleting, + setDeleting, + notFound: apiErrorCode(card.error) === "BANK_NOT_FOUND", + setMonth: (value: string) => { + if (value) setParams({ month: bankMonth(value) }); + }, + setPage: (value: number) => setParams({ month, page: String(value) }), + }; +} +type CardPageVm = ReturnType; + +export { type CardPageVm, useCardPageVm }; diff --git a/src/pages/card/ui/DeleteOperation.test.tsx b/src/pages/card/ui/DeleteOperation.test.tsx new file mode 100644 index 0000000..914bb96 --- /dev/null +++ b/src/pages/card/ui/DeleteOperation.test.tsx @@ -0,0 +1,76 @@ +import { MantineProvider } from "@mantine/core"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { HttpResponse, http } from "msw"; +import { describe, expect, it, vi } from "vitest"; +import type { BankOperation } from "@/entities/bank-card"; +import { configureAuthClient } from "@/shared/api"; +import { createTestQueryClient } from "@/test/query"; +import { server } from "@/test/server"; +import { DeleteOperation } from "./DeleteOperation"; + +const transfer: BankOperation = { + id: "transfer", + kind: "transfer", + cardId: "a", + cardName: "Основная", + targetCardId: "b", + targetCardName: "Запасная", + categoryId: null, + categoryName: null, + originalExpenseId: null, + amount: { currency: "RUB", value: "100" }, + availableRefund: null, + date: "2026-08-01", + description: null, + version: 1, + createdAt: "2026-08-01T00:00:00Z", +}; +describe("DeleteOperation", () => { + it("names both sides and requires a deliberate fresh version after conflict", async () => { + configureAuthClient({ + accessToken: () => "test", + refresh: async () => false, + clear: () => {}, + }); + const versions: string[] = []; + server.use( + http.get("*/api/v1/bank-cards/operations/transfer", () => + HttpResponse.json({ ...transfer, version: 2 }), + ), + http.delete("*/api/v1/bank-cards/operations/transfer", ({ request }) => { + const version = new URL(request.url).searchParams.get("version") ?? ""; + versions.push(version); + return version === "1" + ? HttpResponse.json( + { code: "BANK_VERSION_CONFLICT" }, + { status: 409 }, + ) + : new HttpResponse(null, { status: 204 }); + }), + ); + const close = vi.fn(); + render( + + + + + , + ); + expect(screen.getByText(/«Основная» → «Запасная»/)).toHaveTextContent( + "Обе стороны перевода будут удалены.", + ); + fireEvent.click(screen.getByRole("button", { name: "Удалить" })); + await screen.findByText(/Запись уже изменена/); + expect(close).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Перечитать запись" })); + fireEvent.click( + await screen.findByRole("button", { + name: "Использовать актуальную версию 2", + }), + ); + fireEvent.click(screen.getByRole("button", { name: "Удалить" })); + await waitFor(() => expect(close).toHaveBeenCalled()); + expect(versions).toEqual(["1", "2"]); + }); +}); diff --git a/src/pages/card/ui/DeleteOperation.tsx b/src/pages/card/ui/DeleteOperation.tsx new file mode 100644 index 0000000..d880f74 --- /dev/null +++ b/src/pages/card/ui/DeleteOperation.tsx @@ -0,0 +1,111 @@ +import { Button, Group, Modal, Stack, Text } from "@mantine/core"; +import { type FC, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + BankFeedback, + type BankOperation, + useBankFormFocus, + useBankMutations, + useBankOperation, +} from "@/entities/bank-card"; +import { formatMoney } from "@/shared/lib"; + +type DeleteOperationProps = { operation: BankOperation; onClose: () => void }; +const DeleteOperation: FC = (props) => { + useBankFormFocus(); + const { t } = useTranslation(); + const [record, setRecord] = useState(props.operation); + const [latest, setLatest] = useState(); + const [error, setError] = useState(); + const query = useBankOperation(record.id); + const { deleteOperation } = useBankMutations(); + const pending = + deleteOperation.isPending || (query.isFetching && Boolean(error)); + return ( + !pending && props.onClose()} + title={t("bank.deleteTitle")} + closeOnEscape={!pending} + closeOnClickOutside={!pending} + closeButtonProps={{ "aria-label": t("bank.close"), disabled: pending }} + > + + + {record.kind === "transfer" + ? t("bank.deleteTransfer", { + amount: formatMoney(record.amount), + source: record.cardName, + target: record.targetCardName, + }) + : t("bank.deleteHint", { + kind: t(`bank.kinds.${record.kind}`), + amount: formatMoney(record.amount), + card: record.cardName, + })} + + {error ? ( + <> + + + + ) : null} + {latest && ( + <> + + {t("bank.versionHint", { + details: `${latest.date} · ${formatMoney(latest.amount)} · ${latest.description ?? ""}`, + })} + + + + )} + + + + + + + ); +}; + +export { DeleteOperation }; diff --git a/src/pages/card/ui/OperationForm.module.css b/src/pages/card/ui/OperationForm.module.css new file mode 100644 index 0000000..fef62f4 --- /dev/null +++ b/src/pages/card/ui/OperationForm.module.css @@ -0,0 +1,6 @@ +.fieldset { + border: 0; + margin: 0; + padding: 0; + min-width: 0; +} diff --git a/src/pages/card/ui/OperationForm.test.tsx b/src/pages/card/ui/OperationForm.test.tsx new file mode 100644 index 0000000..9f319fc --- /dev/null +++ b/src/pages/card/ui/OperationForm.test.tsx @@ -0,0 +1,458 @@ +import { MantineProvider } from "@mantine/core"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import { HttpResponse, http } from "msw"; +import { describe, expect, it, vi } from "vitest"; +import type { BankCard, BankOperation } from "@/entities/bank-card"; +import { configureAuthClient } from "@/shared/api"; +import { createTestQueryClient } from "@/test/query"; +import { server } from "@/test/server"; +import { OperationForm } from "./OperationForm"; + +const money = (value: string) => ({ currency: "RUB", value }); +const card: BankCard = { + id: "a", + name: "Повседневная", + bankName: null, + lastFour: null, + balance: money("0"), + openingBalance: money("0"), + openingDate: "2026-01-01", + archivedAt: null, + version: 1, +}; +function setup(operation?: BankOperation) { + configureAuthClient({ + accessToken: () => "test", + refresh: async () => false, + clear: () => {}, + }); + server.use( + http.get("*/api/v1/bank-cards/categories", () => + HttpResponse.json([ + { id: "default", name: "Остальное", isDefault: true, version: 2 }, + ]), + ), + ); + const close = vi.fn(); + render( + + + + + , + ); + return close; +} +async function select(label: string, value: string | RegExp) { + fireEvent.click(screen.getByRole("combobox", { name: new RegExp(label) })); + fireEvent.click(await screen.findByRole("option", { name: value })); +} +describe("OperationForm", () => { + it("uses renamed default category and exact maximum positive money despite zero balance", async () => { + const close = setup(); + await waitFor(() => + expect(screen.getByRole("combobox", { name: /Категория/ })).toHaveValue( + "Остальное", + ), + ); + let body: unknown; + server.use( + http.post("*/api/v1/bank-cards/operations", async ({ request }) => { + body = await request.json(); + return HttpResponse.json({}, { status: 201 }); + }), + ); + fireEvent.change(screen.getByLabelText(/Сумма/), { + target: { value: "92233720368547758,07" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); + await waitFor(() => expect(close).toHaveBeenCalled()); + expect(body).toMatchObject({ + categoryId: "default", + amount: money("92233720368547758.07"), + }); + }); + it("checks both transfer opening dates, excludes archive and reuses intent after a lost response", async () => { + setup(); + await select("Вид операции", "Перевод"); + await select("Карта-получатель", "Запасная"); + fireEvent.change(screen.getByLabelText(/Сумма/), { + target: { value: "0" }, + }); + fireEvent.change(screen.getByLabelText(/Дата операции/), { + target: { value: "2026-07-31" }, + }); + const bodies: unknown[] = []; + server.use( + http.post("*/api/v1/bank-cards/operations", async ({ request }) => { + bodies.push(await request.json()); + return bodies.length === 1 + ? HttpResponse.error() + : HttpResponse.json({}, { status: 201 }); + }), + ); + fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); + expect( + await screen.findByText("Сумма операции должна быть больше нуля."), + ).toBeInTheDocument(); + expect(screen.getByText(/Дата не может быть раньше/)).toBeInTheDocument(); + expect(bodies).toHaveLength(0); + fireEvent.change(screen.getByLabelText(/Сумма/), { + target: { value: "150,01" }, + }); + fireEvent.change(screen.getByLabelText(/Дата операции/), { + target: { value: "2026-08-02" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); + await screen.findByText(/Не удалось подтвердить сохранение/); + expect(screen.getByLabelText(/Сумма/)).toHaveValue("150,01"); + fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); + await waitFor(() => expect(bodies).toHaveLength(2)); + expect(bodies[0]).toEqual(bodies[1]); + expect(bodies[0]).toMatchObject({ + kind: "transfer", + cardId: "a", + targetCardId: "b", + amount: money("150.01"), + }); + }); + it("topup omits categories and preserves entered description", async () => { + setup(); + await select("Вид операции", "Пополнение"); + expect( + screen.queryByRole("combobox", { name: /Категория/ }), + ).not.toBeInTheDocument(); + let body: unknown; + server.use( + http.post("*/api/v1/bank-cards/operations", async ({ request }) => { + body = await request.json(); + return HttpResponse.json({}, { status: 201 }); + }), + ); + fireEvent.change(screen.getByLabelText(/Сумма/), { + target: { value: "0.10" }, + }); + fireEvent.change(screen.getByLabelText(/Описание/), { + target: { value: "Внесение" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); + await waitFor(() => expect(body).toBeDefined()); + expect(body).toMatchObject({ + kind: "topup", + amount: money("0.10"), + description: "Внесение", + }); + expect(body).not.toHaveProperty("categoryId"); + }); + it("finds a previous-month purchase across pages, checks remaining refund and retains the complete-refund intent", async () => { + const purchase: BankOperation = { + id: "purchase", + kind: "expense", + cardId: "a", + cardName: card.name, + targetCardId: null, + targetCardName: null, + categoryId: "default", + categoryName: "Остальное", + originalExpenseId: null, + amount: money("500"), + availableRefund: money("500"), + date: "2026-07-01", + description: "Прошлая покупка", + version: 1, + createdAt: "2026-07-01T00:00:00Z", + }; + server.use( + http.get("*/api/v1/bank-cards/a/expenses", ({ request }) => { + return HttpResponse.json({ + items: + new URL(request.url).searchParams.get("page") === "2" + ? [purchase] + : [], + page: 1, + total: 1, + hasNext: new URL(request.url).searchParams.get("page") !== "2", + }); + }), + http.get("*/api/v1/bank-cards/operations/purchase", () => + HttpResponse.json(purchase), + ), + ); + const close = setup(); + await select("Вид операции", "Возврат"); + await waitFor(() => + expect(screen.getByRole("button", { name: "Далее" })).toBeEnabled(), + ); + fireEvent.click(screen.getByRole("button", { name: "Далее" })); + await waitFor(() => + expect(screen.getByRole("button", { name: "Назад" })).toBeEnabled(), + ); + await select("Исходная покупка", /Прошлая покупка/); + await screen.findByText("Доступно к возврату: 500 ₽"); + fireEvent.change(screen.getByLabelText(/Сумма/), { + target: { value: "500.01" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); + await screen.findByText("Сумма превышает доступный возврат."); + const bodies: unknown[] = []; + server.use( + http.post("*/api/v1/bank-cards/operations", async ({ request }) => { + bodies.push(await request.json()); + purchase.availableRefund = money("0"); + return bodies.length === 1 + ? HttpResponse.error() + : HttpResponse.json({}, { status: 201 }); + }), + ); + fireEvent.change(screen.getByLabelText(/Сумма/), { + target: { value: "500" }, + }); + fireEvent.change(screen.getByLabelText(/Дата операции/), { + target: { value: "2026-08-01" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); + await screen.findByText(/Не удалось подтвердить сохранение/); + fireEvent.click(screen.getByRole("button", { name: "Перечитать запись" })); + await screen.findByText("Доступно к возврату: 0 ₽"); + fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); + await waitFor(() => expect(close).toHaveBeenCalled()); + expect(bodies[0]).toEqual(bodies[1]); + expect(bodies[0]).toMatchObject({ + kind: "refund", + originalExpenseId: "purchase", + date: "2026-08-01", + }); + }); + it("edits a full refund with its own amount excluded from the limit and immutable purchase", async () => { + const refund: BankOperation = { + id: "refund", + kind: "refund", + cardId: "a", + cardName: card.name, + targetCardId: null, + targetCardName: null, + categoryId: "default", + categoryName: "Остальное", + originalExpenseId: "purchase", + amount: money("500"), + availableRefund: money("500"), + date: "2026-08-01", + description: "", + version: 2, + createdAt: "2026-08-01T00:00:00Z", + }; + const purchase = { + ...refund, + id: "purchase", + kind: "expense", + date: "2026-07-01", + availableRefund: money("0"), + }; + server.use( + http.get("*/api/v1/bank-cards/a/expenses", () => + HttpResponse.json({ + items: [purchase], + page: 1, + total: 1, + hasNext: false, + }), + ), + http.get("*/api/v1/bank-cards/operations/purchase", () => + HttpResponse.json(purchase), + ), + http.get("*/api/v1/bank-cards/operations/refund", () => + HttpResponse.json(refund), + ), + ); + const close = setup(refund); + await screen.findByText("Доступно к возврату: 500 ₽"); + expect( + screen.getByRole("combobox", { name: /Исходная покупка/ }), + ).toBeDisabled(); + expect( + screen.getByRole("combobox", { name: /Вид операции/ }), + ).toBeDisabled(); + fireEvent.change(screen.getByLabelText(/Описание/), { + target: { value: "Полностью возвращено" }, + }); + let body: unknown; + server.use( + http.patch( + "*/api/v1/bank-cards/operations/refund", + async ({ request }) => { + body = await request.json(); + return HttpResponse.json(refund); + }, + ), + ); + await waitFor(() => + expect(screen.getByRole("button", { name: "Сохранить" })).toBeEnabled(), + ); + fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); + await waitFor(() => expect(close).toHaveBeenCalled()); + expect(body).toMatchObject({ + version: 2, + amount: money("500"), + originalExpenseId: "purchase", + description: "Полностью возвращено", + }); + }); + + it("keeps edit draft and stale version until explicit adoption after conflict", async () => { + const operation: BankOperation = { + id: "expense", + kind: "expense", + cardId: "a", + cardName: card.name, + targetCardId: null, + targetCardName: null, + categoryId: "default", + categoryName: "Остальное", + originalExpenseId: null, + amount: money("100"), + availableRefund: money("100"), + date: "2026-08-01", + description: "Первое", + version: 1, + createdAt: "2026-08-01T00:00:00Z", + }; + let readVersion = 1; + const bodies: Record[] = []; + server.use( + http.get("*/api/v1/bank-cards/operations/expense", () => + HttpResponse.json({ + ...operation, + version: readVersion, + description: readVersion === 1 ? "Первое" : "Другое исправление", + }), + ), + http.patch( + "*/api/v1/bank-cards/operations/expense", + async ({ request }) => { + const body = (await request.json()) as Record; + bodies.push(body); + readVersion = 2; + return body.version === 1 + ? HttpResponse.json( + { code: "BANK_VERSION_CONFLICT" }, + { status: 409 }, + ) + : HttpResponse.json({ ...operation, ...body }); + }, + ), + ); + const close = setup(operation); + fireEvent.change(screen.getByLabelText(/Описание/), { + target: { value: "Мой черновик" }, + }); + fireEvent.change(screen.getByLabelText(/Сумма/), { + target: { value: "150" }, + }); + await waitFor(() => + expect(screen.getByRole("button", { name: "Сохранить" })).toBeEnabled(), + ); + fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); + await screen.findByText(/Запись уже изменена/); + expect(screen.getByLabelText(/Описание/)).toHaveValue("Мой черновик"); + fireEvent.click(screen.getByRole("button", { name: "Перечитать запись" })); + await screen.findByRole("button", { + name: "Использовать актуальную версию 2", + }); + expect(screen.getByLabelText(/Сумма/)).toHaveValue("150"); + fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); + await waitFor(() => expect(bodies).toHaveLength(2)); + expect(bodies[1].version).toBe(1); + fireEvent.click( + screen.getByRole("button", { name: "Использовать актуальную версию 2" }), + ); + fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); + await waitFor(() => expect(close).toHaveBeenCalled()); + expect(bodies[2]).toMatchObject({ + version: 2, + description: "Мой черновик", + amount: money("150"), + }); + }); + it("creates a category inside expense without losing operation fields", async () => { + setup(); + fireEvent.change(screen.getByLabelText(/Сумма/), { + target: { value: "001.20" }, + }); + fireEvent.change(screen.getByLabelText(/Описание/), { + target: { value: "Черновик расхода" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Добавить категорию" })); + const dialog = screen.getByRole("dialog", { name: "Категории" }); + fireEvent.change(within(dialog).getByLabelText(/Название категории/), { + target: { value: " кафе " }, + }); + let created = false; + server.use( + http.post("*/api/v1/bank-cards/categories", () => { + created = true; + return HttpResponse.json( + { id: "new", name: "кафе", version: 1, isDefault: false }, + { status: 201 }, + ); + }), + http.get("*/api/v1/bank-cards/categories", () => + HttpResponse.json([ + { id: "default", name: "Остальное", isDefault: true, version: 2 }, + ...(created + ? [{ id: "new", name: "кафе", isDefault: false, version: 1 }] + : []), + ]), + ), + ); + fireEvent.click( + within(dialog).getByRole("button", { name: "Добавить категорию" }), + ); + await waitFor(() => + expect( + screen.queryByRole("dialog", { name: "Категории" }), + ).not.toBeInTheDocument(), + ); + expect(screen.getByLabelText(/Сумма/)).toHaveValue("001.20"); + expect(screen.getByLabelText(/Описание/)).toHaveValue("Черновик расхода"); + await waitFor(() => + expect(screen.getByRole("combobox", { name: /Категория/ })).toHaveValue( + "кафе", + ), + ); + let body: unknown; + server.use( + http.post("*/api/v1/bank-cards/operations", async ({ request }) => { + body = await request.json(); + return HttpResponse.json({}, { status: 201 }); + }), + ); + fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); + await waitFor(() => expect(body).toBeDefined()); + expect(body).toMatchObject({ + categoryId: "new", + description: "Черновик расхода", + amount: money("1.20"), + }); + }); +}); diff --git a/src/pages/card/ui/OperationForm.tsx b/src/pages/card/ui/OperationForm.tsx new file mode 100644 index 0000000..0e2ceef --- /dev/null +++ b/src/pages/card/ui/OperationForm.tsx @@ -0,0 +1,417 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button, Group, Modal, Stack, Text } from "@mantine/core"; +import { type FC, useState } from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { z } from "zod"; +import { + type BankCard, + BankFeedback, + type BankOperation, + bankDateSchema, + bankMoneyValue, + bankToday, + CategoryManager, + moneyCents, + positiveMoneySchema, + useBankCategories, + useBankExpenses, + useBankFormFocus, + useBankMutations, + useBankOperation, +} from "@/entities/bank-card"; +import { validationMessage } from "@/shared/i18n"; +import { formatMoney } from "@/shared/lib"; +import classes from "./OperationForm.module.css"; +import type { OperationValues } from "./OperationForm.types"; +import { OperationFields, RefundFields } from "./OperationForm.view"; + +const operationSchema = z.object({ + kind: z.enum(["expense", "topup", "transfer", "refund"]), + cardId: z.string(), + targetCardId: z.string(), + categoryId: z.string(), + originalExpenseId: z.string(), + amount: positiveMoneySchema, + date: bankDateSchema, + description: z + .string() + .max(500, validationMessage("bank.validation.description")(undefined)), +}); + +type OperationFormProps = { + card: BankCard; + cards: BankCard[]; + operation?: BankOperation; + onClose: () => void; +}; +const OperationForm: FC = (props) => { + useBankFormFocus(); + const { t } = useTranslation(); + const [categoriesOpen, setCategoriesOpen] = useState(false); + const [version, setVersion] = useState(props.operation?.version ?? 1); + const [latest, setLatest] = useState(); + const [sourcePage, setSourcePage] = useState(1); + const [sourceId, setSourceId] = useState( + props.operation?.originalExpenseId ?? "", + ); + const sourceQuery = useBankOperation(sourceId || undefined); + const currentQuery = useBankOperation(props.operation?.id); + const [refundLimit, setRefundLimit] = useState( + props.operation?.availableRefund, + ); + const [intent] = useState(() => crypto.randomUUID()); + const [submittedValues, setSubmittedValues] = useState(); + const [error, setError] = useState(); + const categories = useBankCategories(); + const mutation = useBankMutations(); + const methods = useForm({ + resolver: zodResolver( + operationSchema.superRefine((values, ctx) => { + // Repeated intent reaches server idempotency even after its source changed. + if ( + submittedValues && + JSON.stringify(values) === JSON.stringify(submittedValues) + ) + return; + const source = props.cards.find((c) => c.id === values.cardId); + const target = props.cards.find((c) => c.id === values.targetCardId); + if (!source || source.archivedAt) + ctx.addIssue({ + code: "custom", + path: ["cardId"], + ...validationMessage("bank.validation.required")(undefined), + }); + if ( + values.kind === "transfer" && + (!target || target.archivedAt || target.id === values.cardId) + ) + ctx.addIssue({ + code: "custom", + path: ["targetCardId"], + ...validationMessage("bank.validation.destination")(undefined), + }); + if ( + (source && values.date < source.openingDate) || + (values.kind === "transfer" && + target && + values.date < target.openingDate) + ) + ctx.addIssue({ + code: "custom", + path: ["date"], + ...validationMessage("bank.validation.start")(undefined), + }); + if (values.kind === "refund") { + const expense = sourceQuery.data; + if (!expense || expense.cardId !== values.cardId) + ctx.addIssue({ + code: "custom", + path: ["originalExpenseId"], + ...validationMessage("bank.validation.required")(undefined), + }); + if (expense && values.date < expense.date) + ctx.addIssue({ + code: "custom", + path: ["date"], + ...validationMessage("bank.validation.start")(undefined), + }); + const limit = props.operation + ? refundLimit + : expense?.availableRefund; + const amount = moneyCents(values.amount); + if ( + limit && + amount !== null && + amount > (moneyCents(limit.value) ?? 0n) + ) + ctx.addIssue({ + code: "custom", + path: ["amount"], + ...validationMessage("bank.validation.refund")(undefined), + }); + } + if ( + values.kind === "expense" && + !categories.data?.some( + (c) => + c.id === + (values.categoryId || + categories.data?.find((c) => c.isDefault)?.id), + ) + ) + ctx.addIssue({ + code: "custom", + path: ["categoryId"], + ...validationMessage("bank.validation.required")(undefined), + }); + }), + ), + defaultValues: { + kind: props.operation?.kind ?? "expense", + cardId: props.operation?.cardId ?? props.card.id, + targetCardId: props.operation?.targetCardId ?? "", + categoryId: props.operation?.categoryId ?? "", + originalExpenseId: props.operation?.originalExpenseId ?? "", + amount: props.operation?.amount.value ?? "", + date: props.operation?.date ?? bankToday(), + description: props.operation?.description ?? "", + }, + }); + const kind = methods.watch("kind"); + const expenses = useBankExpenses( + kind === "refund" ? props.card.id : undefined, + { page: sourcePage }, + ); + const source = methods.watch("cardId"); + const pending = methods.formState.isSubmitting; + const options = props.cards + .filter((c) => !c.archivedAt) + .map((c) => ({ value: c.id, label: c.name })); + return ( + <> + !pending && !categoriesOpen && props.onClose()} + title={t(props.operation ? "bank.editOperation" : "bank.addOperation")} + closeOnEscape={!pending && !categoriesOpen} + closeOnClickOutside={!pending && !categoriesOpen} + closeButtonProps={{ "aria-label": t("bank.close"), disabled: pending }} + > + +
{ + setSubmittedValues(values); + setError(undefined); + try { + const body = { + kind: values.kind, + cardId: values.cardId, + ...(values.kind === "transfer" + ? { targetCardId: values.targetCardId } + : {}), + ...(values.kind === "expense" + ? { + categoryId: + values.categoryId || + categories.data?.find((c) => c.isDefault)?.id, + } + : {}), + ...(values.kind === "refund" + ? { originalExpenseId: values.originalExpenseId } + : {}), + amount: { + currency: "RUB" as const, + value: bankMoneyValue(values.amount), + }, + date: values.date, + description: values.description, + }; + if (props.operation) + await mutation.updateOperation.mutateAsync({ + id: props.operation.id, + body: { ...body, version }, + }); + else + await mutation.createOperation.mutateAsync({ + id: intent, + ...body, + }); + props.onClose(); + } catch (failure) { + setError(failure); + } + })} + > +
+ + c.value !== source)} + categories={ + categories.data?.map((c) => ({ + value: c.id, + label: c.name, + })) ?? [] + } + defaultCategory={ + categories.data?.find((c) => c.isDefault)?.id ?? "" + } + /> + {kind === "refund" && ( + <> + ({ + value: item.id, + label: t("bank.expenseChoice", { + date: item.date, + name: item.description || item.categoryName, + amount: formatMoney(item.amount), + available: formatMoney(item.availableRefund), + }), + })) ?? [] + } + selected={ + sourceQuery.data + ? { + value: sourceQuery.data.id, + label: t("bank.expenseChoice", { + date: sourceQuery.data.date, + name: + sourceQuery.data.description || + sourceQuery.data.categoryName, + amount: formatMoney(sourceQuery.data.amount), + available: formatMoney( + sourceQuery.data.availableRefund, + ), + }), + } + : undefined + } + onSelect={setSourceId} + limit={ + props.operation + ? refundLimit + : sourceQuery.data?.availableRefund + } + /> + {expenses.isLoading && ( + {t("bank.loadingHistory")} + )} + {expenses.data && + !expenses.data.items.length && + !props.operation && {t("bank.noPurchases")}} + {!props.operation && ( + + + {t("bank.page", { page: sourcePage })} + + + )} + {expenses.isError && ( + void expenses.refetch()} + /> + )}{" "} + {sourceQuery.isError && ( + void sourceQuery.refetch()} + /> + )} + + )} + {kind === "expense" && ( + + )} + {categories.isError && ( + void categories.refetch()} + /> + )} + {error ? ( + <> + + {(props.operation || sourceId) && ( + + )} + + ) : null} + {latest && ( + <> + + {t("bank.versionHint", { + details: `${latest.date} · ${formatMoney(latest.amount)} · ${latest.description ?? ""}`, + })} + + + + )} + + + + + +
+
+
+
+ {categoriesOpen && ( + setCategoriesOpen(false)} + onCreated={(id) => { + methods.setValue("categoryId", id); + setCategoriesOpen(false); + }} + /> + )} + + ); +}; + +export { OperationForm }; diff --git a/src/pages/card/ui/OperationForm.types.ts b/src/pages/card/ui/OperationForm.types.ts new file mode 100644 index 0000000..9dca01f --- /dev/null +++ b/src/pages/card/ui/OperationForm.types.ts @@ -0,0 +1,14 @@ +import type { BankOperation } from "@/entities/bank-card"; + +type OperationValues = { + kind: BankOperation["kind"]; + cardId: string; + targetCardId: string; + categoryId: string; + originalExpenseId: string; + amount: string; + date: string; + description: string; +}; + +export type { OperationValues }; diff --git a/src/pages/card/ui/OperationForm.view.tsx b/src/pages/card/ui/OperationForm.view.tsx new file mode 100644 index 0000000..388fe1d --- /dev/null +++ b/src/pages/card/ui/OperationForm.view.tsx @@ -0,0 +1,198 @@ +import { Select, Stack, Text, Textarea, TextInput } from "@mantine/core"; +import { DateInput } from "@mantine/dates"; +import type { FC } from "react"; +import { Controller, useFormContext } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { + type BankOperation, + bankDateSchema, + bankToday, +} from "@/entities/bank-card"; +import { formatMoney } from "@/shared/lib"; +import type { OperationValues } from "./OperationForm.types"; + +type Choice = { value: string; label: string }; +type OperationFieldsProps = { + editing: boolean; + refundCardId: string; + kind: OperationValues["kind"]; + cards: Choice[]; + targets: Choice[]; + categories: Choice[]; + defaultCategory: string; +}; +const OperationFields: FC = (props) => { + const { t } = useTranslation(); + const { + control, + register, + setValue, + formState: { errors, isSubmitting }, + } = useFormContext(); + return ( + + ( + + )} + /> + {props.kind === "transfer" && ( + ( + + )} + /> + )} +