diff --git a/package-lock.json b/package-lock.json index d106d1f..9d343f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,9 @@ "dependencies": { "@hookform/resolvers": "5.7.1", "@mantine/core": "9.5.1", + "@mantine/dates": "^9.5.1", "@tanstack/react-query": "5.101.4", + "dayjs": "^1.11.23", "i18next": "26.0.2", "openapi-fetch": "0.17.0", "openapi-react-query": "0.5.4", @@ -1380,6 +1382,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@mantine/dates": { + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-9.5.1.tgz", + "integrity": "sha512-1FTIgF8L6AKEb+Ql53zVyCCp4JxvQp/AUDts5T/JEI7U0CZGpzg+FHGtmNShLR4ONuOfzOV1gkgU8INbxVJjag==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1" + }, + "peerDependencies": { + "@mantine/core": "9.5.1", + "@mantine/hooks": "9.5.1", + "dayjs": ">=1.0.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + } + }, "node_modules/@mantine/hooks": { "version": "9.5.1", "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-9.5.1.tgz", @@ -3516,6 +3534,12 @@ "node": ">=20" } }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", diff --git a/package.json b/package.json index 6aa6524..e39d177 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,9 @@ "dependencies": { "@hookform/resolvers": "5.7.1", "@mantine/core": "9.5.1", + "@mantine/dates": "9.5.1", "@tanstack/react-query": "5.101.4", + "dayjs": "1.11.23", "i18next": "26.0.2", "openapi-fetch": "0.17.0", "openapi-react-query": "0.5.4", diff --git a/src/app/ui/App.tsx b/src/app/ui/App.tsx index 5aa149c..4819c6d 100644 --- a/src/app/ui/App.tsx +++ b/src/app/ui/App.tsx @@ -1,4 +1,5 @@ import { MantineProvider } from "@mantine/core"; +import { DatesProvider } from "@mantine/dates"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { FC } from "react"; import { BrowserRouter } from "react-router"; @@ -13,15 +14,17 @@ const queryClient = new QueryClient({ const App: FC = () => { return ( - - - - - - - - - + + + + + + + + + + + ); }; diff --git a/src/main.tsx b/src/main.tsx index 7241345..0fe96a6 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,8 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "@mantine/core/styles.css"; +import "@mantine/dates/styles.css"; +import "dayjs/locale/ru"; import "./app/styles.css"; import "./shared/i18n"; import { App } from "./app"; diff --git a/src/pages/account-operations/ui/AccountOperationsPage.test.tsx b/src/pages/account-operations/ui/AccountOperationsPage.test.tsx index bf6fa86..a1daac9 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage.test.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage.test.tsx @@ -215,11 +215,107 @@ describe("AccountOperationsPage", () => { ).not.toBeInTheDocument(); fireEvent.click(await screen.findByRole("button", { name: "Далее" })); await waitFor(() => expect(searches.at(-1)).toContain("cursor=next-1")); - fireEvent.click(screen.getByRole("checkbox", { name: "Налог" })); + fireEvent.click(screen.getByRole("combobox", { name: "Категории" })); + fireEvent.click(await screen.findByText("Налог")); + fireEvent.click(screen.getByRole("button", { name: "Применить" })); await waitFor(() => expect(searches.at(-1)).toContain("categories=tax")); expect(searches.at(-1)).not.toContain("cursor="); }); + it("does not refetch when draft changes without apply", async () => { + configureTestAuth(); + const searches: string[] = []; + server.use( + http.get( + "*/api/v1/brokerage-accounts/:accountId/operations", + ({ request }) => { + const url = new URL(request.url); + searches.push(url.search); + return HttpResponse.json({ + accountId: "account-1", + items: [operation], + nextCursor: null, + hasNext: false, + observedAt: "2026-08-14T00:00:00Z", + }); + }, + ), + ); + render( + + + + + } + /> + + + + , + ); + + expect( + await screen.findByRole("heading", { name: "Журнал операций" }), + ).toBeInTheDocument(); + await waitFor(() => expect(searches.length).toBeGreaterThan(0)); + const countBefore = searches.length; + fireEvent.click(screen.getByRole("combobox", { name: "Категории" })); + fireEvent.click(await screen.findByText("Налог")); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(searches.length).toBe(countBefore); + expect(searches.some((s) => s.includes("categories=tax"))).toBe(false); + }); + + it("reset clears filters and refetches unfiltered", async () => { + configureTestAuth(); + const searches: string[] = []; + server.use( + http.get( + "*/api/v1/brokerage-accounts/:accountId/operations", + ({ request }) => { + const url = new URL(request.url); + searches.push(url.search); + return HttpResponse.json({ + accountId: "account-1", + items: [operation], + nextCursor: null, + hasNext: false, + observedAt: "2026-08-14T00:00:00Z", + }); + }, + ), + ); + render( + + + + + } + /> + + + + , + ); + + expect( + await screen.findByRole("heading", { name: "Журнал операций" }), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole("combobox", { name: "Категории" })); + fireEvent.click(await screen.findByText("Налог")); + fireEvent.click(screen.getByRole("button", { name: "Применить" })); + await waitFor(() => + expect(searches.some((s) => s.includes("categories=tax"))).toBe(true), + ); + fireEvent.click(screen.getByRole("button", { name: "Сбросить" })); + await waitFor(() => expect(searches.at(-1)).not.toContain("categories=")); + expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled(); + }); + it("renders an error state", async () => { configureTestAuth(); server.use( diff --git a/src/pages/account-operations/ui/AccountOperationsPage.vm.ts b/src/pages/account-operations/ui/AccountOperationsPage.vm.ts index f8bd574..43434b2 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage.vm.ts +++ b/src/pages/account-operations/ui/AccountOperationsPage.vm.ts @@ -1,3 +1,4 @@ +import { useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { useParams } from "react-router"; import { @@ -5,40 +6,58 @@ import { type OperationCategory, useBrokerageOperations, } from "@/entities/broker-operation"; +import { apiQueryClient } from "@/shared/api"; type AccountOperationsPageViewModel = | { state: "loading" } | { state: "error"; retry: () => void } | { state: "loaded"; - from: string; - to: string; - categories: OperationCategory[]; + draftFrom: string | null; + draftTo: string | null; + draftCategories: OperationCategory[]; items: BrokerageOperation[]; hasNext: boolean; hasPrevious: boolean; - onFromChange: (value: string) => void; - onToChange: (value: string) => void; - onCategoriesChange: (value: OperationCategory[]) => void; + canApply: boolean; + onDraftFromChange: (value: string | null) => void; + onDraftToChange: (value: string | null) => void; + onDraftCategoriesChange: (value: OperationCategory[]) => void; + onApply: () => void; + onReset: () => void; onNext: () => void; onPrevious: () => void; }; const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { const { accountId } = useParams(); - const [from, setFrom] = useState(""); - const [to, setTo] = useState(""); - const [categories, setCategories] = useState([]); + const queryClient = useQueryClient(); + const [appliedFrom, setAppliedFrom] = useState(null); + const [appliedTo, setAppliedTo] = useState(null); + const [appliedCategories, setAppliedCategories] = useState< + OperationCategory[] + >([]); + const [draftFrom, setDraftFrom] = useState(null); + const [draftTo, setDraftTo] = useState(null); + const [draftCategories, setDraftCategories] = useState( + [], + ); const [cursors, setCursors] = useState([]); const cursor = cursors.at(-1); const operations = useBrokerageOperations(accountId, { - from: dateStart(from), - to: dateEnd(to), - ...(categories.length ? { categories } : {}), + from: dateStart(appliedFrom), + to: dateEnd(appliedTo), + ...(appliedCategories.length ? { categories: appliedCategories } : {}), ...(cursor ? { cursor } : {}), limit: 100, }); - const reset = () => setCursors([]); + const resetQueryKey = apiQueryClient.queryOptions( + "get", + "/api/v1/brokerage-accounts/{accountId}/operations", + { params: { path: { accountId: accountId ?? "" }, query: { limit: 100 } } }, + ).queryKey; + + const resetCursors = () => setCursors([]); if (operations.isLoading) return { state: "loading" }; if (operations.isError) @@ -46,25 +65,38 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { if (!operations.data) return { state: "loading" }; const nextCursor = operations.data.nextCursor; + const isDirty = + draftFrom !== appliedFrom || + draftTo !== appliedTo || + !sameCategories(draftCategories, appliedCategories); + return { state: "loaded", - from, - to, - categories, + draftFrom, + draftTo, + draftCategories, items: operations.data.items, hasNext: Boolean(operations.data.hasNext && nextCursor), hasPrevious: Boolean(cursors.length), - onFromChange: (value) => { - setFrom(value); - reset(); + canApply: isDirty, + onDraftFromChange: setDraftFrom, + onDraftToChange: setDraftTo, + onDraftCategoriesChange: setDraftCategories, + onApply: () => { + setAppliedFrom(draftFrom); + setAppliedTo(draftTo); + setAppliedCategories(draftCategories); + resetCursors(); }, - onToChange: (value) => { - setTo(value); - reset(); - }, - onCategoriesChange: (value) => { - setCategories(value); - reset(); + onReset: () => { + setDraftFrom(null); + setDraftTo(null); + setDraftCategories([]); + setAppliedFrom(null); + setAppliedTo(null); + setAppliedCategories([]); + resetCursors(); + void queryClient.invalidateQueries({ queryKey: resetQueryKey }); }, onNext: () => { if (nextCursor) setCursors((value) => [...value, nextCursor]); @@ -73,12 +105,16 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { }; }; -function dateStart(value: string) { +function dateStart(value: string | null) { return value ? `${value}T00:00:00.000Z` : undefined; } -function dateEnd(value: string) { +function dateEnd(value: string | null) { return value ? `${value}T23:59:59.999Z` : undefined; } +function sameCategories(a: OperationCategory[], b: OperationCategory[]) { + return a.length === b.length && a.every((value) => b.includes(value)); +} + export { type AccountOperationsPageViewModel, useAccountOperationsPageVm }; diff --git a/src/pages/account-operations/ui/components/AccountOperationsPageLoaded.view.tsx b/src/pages/account-operations/ui/components/AccountOperationsPageLoaded.view.tsx index f22ef1c..a5e8b5b 100644 --- a/src/pages/account-operations/ui/components/AccountOperationsPageLoaded.view.tsx +++ b/src/pages/account-operations/ui/components/AccountOperationsPageLoaded.view.tsx @@ -1,11 +1,5 @@ -import { - Button, - Checkbox, - Group, - Stack, - TextInput, - Title, -} from "@mantine/core"; +import { Button, Group, MultiSelect, Stack, Title } from "@mantine/core"; +import { DateInput } from "@mantine/dates"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { @@ -21,56 +15,92 @@ type AccountOperationsPageLoadedProps = Extract< { state: "loaded" } >; +const operationCategorySet = new Set(operationCategories); + +function toDateValue(value: string | null): Date | null { + if (!value) return null; + const [year, month, day] = value.split("-").map(Number); + return new Date(year, month - 1, day); +} + +function fromDateValue(value: Date | string | null): string | null { + if (!value) return null; + if (typeof value === "string") return value; + const year = value.getFullYear(); + const month = String(value.getMonth() + 1).padStart(2, "0"); + const day = String(value.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function fromMultiSelectValue(value: string[]) { + return value.filter((item): item is OperationCategory => + operationCategorySet.has(item), + ); +} + const AccountOperationsPageLoaded: FC = ( props, ) => { const { - categories, - from, + canApply, + draftCategories, + draftFrom, + draftTo, hasNext, hasPrevious, items, - onCategoriesChange, - onFromChange, + onApply, + onDraftCategoriesChange, + onDraftFromChange, + onDraftToChange, onNext, onPrevious, - onToChange, - to, + onReset, } = props; const { t } = useTranslation(); + const categoryData = operationCategories.map((category) => ({ + value: category, + label: t(`ledger.category.${category}`), + })); + return ( {t("ledger.title")} - onFromChange(event.currentTarget.value)} + value={toDateValue(draftFrom)} + onChange={(value) => onDraftFromChange(fromDateValue(value))} + maxDate={toDateValue(draftTo) ?? undefined} + valueFormat="DD.MM.YYYY" /> - onToChange(event.currentTarget.value)} + value={toDateValue(draftTo)} + onChange={(value) => onDraftToChange(fromDateValue(value))} + minDate={toDateValue(draftFrom) ?? undefined} + valueFormat="DD.MM.YYYY" /> - onCategoriesChange(value as OperationCategory[])} - > - - {operationCategories.map((category) => ( - - ))} - - + placeholder={t("ledger.categoriesPlaceholder")} + data={categoryData} + value={draftCategories} + onChange={(value) => + onDraftCategoriesChange(fromMultiSelectValue(value)) + } + clearable + /> + + + + {items.length ? ( items.map((operation) => ( diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts index 6dbea7b..6c10d2c 100644 --- a/src/shared/i18n/locales/ru.ts +++ b/src/shared/i18n/locales/ru.ts @@ -96,6 +96,9 @@ const ru = { from: "С", to: "По", categories: "Категории", + categoriesPlaceholder: "Выберите категории", + apply: "Применить", + reset: "Сбросить", previous: "Назад", next: "Далее", retry: "Повторить", diff --git a/src/test/setup.ts b/src/test/setup.ts index 47c26c9..5612ab9 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -7,6 +7,14 @@ beforeAll(() => server.listen({ onUnhandledRequest: "error" })); afterEach(() => server.resetHandlers()); afterAll(() => server.close()); +class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +window.ResizeObserver = ResizeObserver; + Object.defineProperty(window, "matchMedia", { writable: true, value: (query: string) => ({