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) => ({