feat(account-operations): require explicit ledger filters
This commit is contained in:
Generated
+24
@@ -10,7 +10,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "5.7.1",
|
"@hookform/resolvers": "5.7.1",
|
||||||
"@mantine/core": "9.5.1",
|
"@mantine/core": "9.5.1",
|
||||||
|
"@mantine/dates": "^9.5.1",
|
||||||
"@tanstack/react-query": "5.101.4",
|
"@tanstack/react-query": "5.101.4",
|
||||||
|
"dayjs": "^1.11.23",
|
||||||
"i18next": "26.0.2",
|
"i18next": "26.0.2",
|
||||||
"openapi-fetch": "0.17.0",
|
"openapi-fetch": "0.17.0",
|
||||||
"openapi-react-query": "0.5.4",
|
"openapi-react-query": "0.5.4",
|
||||||
@@ -1380,6 +1382,22 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/@mantine/hooks": {
|
||||||
"version": "9.5.1",
|
"version": "9.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-9.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-9.5.1.tgz",
|
||||||
@@ -3516,6 +3534,12 @@
|
|||||||
"node": ">=20"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
|||||||
@@ -27,7 +27,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "5.7.1",
|
"@hookform/resolvers": "5.7.1",
|
||||||
"@mantine/core": "9.5.1",
|
"@mantine/core": "9.5.1",
|
||||||
|
"@mantine/dates": "9.5.1",
|
||||||
"@tanstack/react-query": "5.101.4",
|
"@tanstack/react-query": "5.101.4",
|
||||||
|
"dayjs": "1.11.23",
|
||||||
"i18next": "26.0.2",
|
"i18next": "26.0.2",
|
||||||
"openapi-fetch": "0.17.0",
|
"openapi-fetch": "0.17.0",
|
||||||
"openapi-react-query": "0.5.4",
|
"openapi-react-query": "0.5.4",
|
||||||
|
|||||||
+12
-9
@@ -1,4 +1,5 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { DatesProvider } from "@mantine/dates";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { BrowserRouter } from "react-router";
|
import { BrowserRouter } from "react-router";
|
||||||
@@ -13,15 +14,17 @@ const queryClient = new QueryClient({
|
|||||||
const App: FC = () => {
|
const App: FC = () => {
|
||||||
return (
|
return (
|
||||||
<MantineProvider theme={theme} forceColorScheme="light">
|
<MantineProvider theme={theme} forceColorScheme="light">
|
||||||
<QueryClientProvider client={queryClient}>
|
<DatesProvider settings={{ locale: "ru" }}>
|
||||||
<BrowserRouter>
|
<QueryClientProvider client={queryClient}>
|
||||||
<AuthProvider>
|
<BrowserRouter>
|
||||||
<RouteErrorBoundary>
|
<AuthProvider>
|
||||||
<AppRoutes />
|
<RouteErrorBoundary>
|
||||||
</RouteErrorBoundary>
|
<AppRoutes />
|
||||||
</AuthProvider>
|
</RouteErrorBoundary>
|
||||||
</BrowserRouter>
|
</AuthProvider>
|
||||||
</QueryClientProvider>
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</DatesProvider>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import "@mantine/core/styles.css";
|
import "@mantine/core/styles.css";
|
||||||
|
import "@mantine/dates/styles.css";
|
||||||
|
import "dayjs/locale/ru";
|
||||||
import "./app/styles.css";
|
import "./app/styles.css";
|
||||||
import "./shared/i18n";
|
import "./shared/i18n";
|
||||||
import { App } from "./app";
|
import { App } from "./app";
|
||||||
|
|||||||
@@ -215,11 +215,107 @@ describe("AccountOperationsPage", () => {
|
|||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
fireEvent.click(await screen.findByRole("button", { name: "Далее" }));
|
fireEvent.click(await screen.findByRole("button", { name: "Далее" }));
|
||||||
await waitFor(() => expect(searches.at(-1)).toContain("cursor=next-1"));
|
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"));
|
await waitFor(() => expect(searches.at(-1)).toContain("categories=tax"));
|
||||||
expect(searches.at(-1)).not.toContain("cursor=");
|
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(
|
||||||
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<MantineProvider>
|
||||||
|
<MemoryRouter initialEntries={["/accounts/account-1/operations"]}>
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/accounts/:accountId/operations"
|
||||||
|
element={<AccountOperationsPage />}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</MantineProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<MantineProvider>
|
||||||
|
<MemoryRouter initialEntries={["/accounts/account-1/operations"]}>
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/accounts/:accountId/operations"
|
||||||
|
element={<AccountOperationsPage />}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</MantineProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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 () => {
|
it("renders an error state", async () => {
|
||||||
configureTestAuth();
|
configureTestAuth();
|
||||||
server.use(
|
server.use(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useParams } from "react-router";
|
import { useParams } from "react-router";
|
||||||
import {
|
import {
|
||||||
@@ -5,40 +6,58 @@ import {
|
|||||||
type OperationCategory,
|
type OperationCategory,
|
||||||
useBrokerageOperations,
|
useBrokerageOperations,
|
||||||
} from "@/entities/broker-operation";
|
} from "@/entities/broker-operation";
|
||||||
|
import { apiQueryClient } from "@/shared/api";
|
||||||
|
|
||||||
type AccountOperationsPageViewModel =
|
type AccountOperationsPageViewModel =
|
||||||
| { state: "loading" }
|
| { state: "loading" }
|
||||||
| { state: "error"; retry: () => void }
|
| { state: "error"; retry: () => void }
|
||||||
| {
|
| {
|
||||||
state: "loaded";
|
state: "loaded";
|
||||||
from: string;
|
draftFrom: string | null;
|
||||||
to: string;
|
draftTo: string | null;
|
||||||
categories: OperationCategory[];
|
draftCategories: OperationCategory[];
|
||||||
items: BrokerageOperation[];
|
items: BrokerageOperation[];
|
||||||
hasNext: boolean;
|
hasNext: boolean;
|
||||||
hasPrevious: boolean;
|
hasPrevious: boolean;
|
||||||
onFromChange: (value: string) => void;
|
canApply: boolean;
|
||||||
onToChange: (value: string) => void;
|
onDraftFromChange: (value: string | null) => void;
|
||||||
onCategoriesChange: (value: OperationCategory[]) => void;
|
onDraftToChange: (value: string | null) => void;
|
||||||
|
onDraftCategoriesChange: (value: OperationCategory[]) => void;
|
||||||
|
onApply: () => void;
|
||||||
|
onReset: () => void;
|
||||||
onNext: () => void;
|
onNext: () => void;
|
||||||
onPrevious: () => void;
|
onPrevious: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
|
const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
|
||||||
const { accountId } = useParams();
|
const { accountId } = useParams();
|
||||||
const [from, setFrom] = useState("");
|
const queryClient = useQueryClient();
|
||||||
const [to, setTo] = useState("");
|
const [appliedFrom, setAppliedFrom] = useState<string | null>(null);
|
||||||
const [categories, setCategories] = useState<OperationCategory[]>([]);
|
const [appliedTo, setAppliedTo] = useState<string | null>(null);
|
||||||
|
const [appliedCategories, setAppliedCategories] = useState<
|
||||||
|
OperationCategory[]
|
||||||
|
>([]);
|
||||||
|
const [draftFrom, setDraftFrom] = useState<string | null>(null);
|
||||||
|
const [draftTo, setDraftTo] = useState<string | null>(null);
|
||||||
|
const [draftCategories, setDraftCategories] = useState<OperationCategory[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
const [cursors, setCursors] = useState<string[]>([]);
|
const [cursors, setCursors] = useState<string[]>([]);
|
||||||
const cursor = cursors.at(-1);
|
const cursor = cursors.at(-1);
|
||||||
const operations = useBrokerageOperations(accountId, {
|
const operations = useBrokerageOperations(accountId, {
|
||||||
from: dateStart(from),
|
from: dateStart(appliedFrom),
|
||||||
to: dateEnd(to),
|
to: dateEnd(appliedTo),
|
||||||
...(categories.length ? { categories } : {}),
|
...(appliedCategories.length ? { categories: appliedCategories } : {}),
|
||||||
...(cursor ? { cursor } : {}),
|
...(cursor ? { cursor } : {}),
|
||||||
limit: 100,
|
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.isLoading) return { state: "loading" };
|
||||||
if (operations.isError)
|
if (operations.isError)
|
||||||
@@ -46,25 +65,38 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
|
|||||||
if (!operations.data) return { state: "loading" };
|
if (!operations.data) return { state: "loading" };
|
||||||
const nextCursor = operations.data.nextCursor;
|
const nextCursor = operations.data.nextCursor;
|
||||||
|
|
||||||
|
const isDirty =
|
||||||
|
draftFrom !== appliedFrom ||
|
||||||
|
draftTo !== appliedTo ||
|
||||||
|
!sameCategories(draftCategories, appliedCategories);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state: "loaded",
|
state: "loaded",
|
||||||
from,
|
draftFrom,
|
||||||
to,
|
draftTo,
|
||||||
categories,
|
draftCategories,
|
||||||
items: operations.data.items,
|
items: operations.data.items,
|
||||||
hasNext: Boolean(operations.data.hasNext && nextCursor),
|
hasNext: Boolean(operations.data.hasNext && nextCursor),
|
||||||
hasPrevious: Boolean(cursors.length),
|
hasPrevious: Boolean(cursors.length),
|
||||||
onFromChange: (value) => {
|
canApply: isDirty,
|
||||||
setFrom(value);
|
onDraftFromChange: setDraftFrom,
|
||||||
reset();
|
onDraftToChange: setDraftTo,
|
||||||
|
onDraftCategoriesChange: setDraftCategories,
|
||||||
|
onApply: () => {
|
||||||
|
setAppliedFrom(draftFrom);
|
||||||
|
setAppliedTo(draftTo);
|
||||||
|
setAppliedCategories(draftCategories);
|
||||||
|
resetCursors();
|
||||||
},
|
},
|
||||||
onToChange: (value) => {
|
onReset: () => {
|
||||||
setTo(value);
|
setDraftFrom(null);
|
||||||
reset();
|
setDraftTo(null);
|
||||||
},
|
setDraftCategories([]);
|
||||||
onCategoriesChange: (value) => {
|
setAppliedFrom(null);
|
||||||
setCategories(value);
|
setAppliedTo(null);
|
||||||
reset();
|
setAppliedCategories([]);
|
||||||
|
resetCursors();
|
||||||
|
void queryClient.invalidateQueries({ queryKey: resetQueryKey });
|
||||||
},
|
},
|
||||||
onNext: () => {
|
onNext: () => {
|
||||||
if (nextCursor) setCursors((value) => [...value, nextCursor]);
|
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;
|
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;
|
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 };
|
export { type AccountOperationsPageViewModel, useAccountOperationsPageVm };
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import {
|
import { Button, Group, MultiSelect, Stack, Title } from "@mantine/core";
|
||||||
Button,
|
import { DateInput } from "@mantine/dates";
|
||||||
Checkbox,
|
|
||||||
Group,
|
|
||||||
Stack,
|
|
||||||
TextInput,
|
|
||||||
Title,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -21,56 +15,92 @@ type AccountOperationsPageLoadedProps = Extract<
|
|||||||
{ state: "loaded" }
|
{ state: "loaded" }
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
const operationCategorySet = new Set<string>(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<AccountOperationsPageLoadedProps> = (
|
const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
|
||||||
props,
|
props,
|
||||||
) => {
|
) => {
|
||||||
const {
|
const {
|
||||||
categories,
|
canApply,
|
||||||
from,
|
draftCategories,
|
||||||
|
draftFrom,
|
||||||
|
draftTo,
|
||||||
hasNext,
|
hasNext,
|
||||||
hasPrevious,
|
hasPrevious,
|
||||||
items,
|
items,
|
||||||
onCategoriesChange,
|
onApply,
|
||||||
onFromChange,
|
onDraftCategoriesChange,
|
||||||
|
onDraftFromChange,
|
||||||
|
onDraftToChange,
|
||||||
onNext,
|
onNext,
|
||||||
onPrevious,
|
onPrevious,
|
||||||
onToChange,
|
onReset,
|
||||||
to,
|
|
||||||
} = props;
|
} = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const categoryData = operationCategories.map((category) => ({
|
||||||
|
value: category,
|
||||||
|
label: t(`ledger.category.${category}`),
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack maw={960} gap="lg">
|
<Stack maw={960} gap="lg">
|
||||||
<Title order={1}>{t("ledger.title")}</Title>
|
<Title order={1}>{t("ledger.title")}</Title>
|
||||||
<Group align="end">
|
<Group align="end">
|
||||||
<TextInput
|
<DateInput
|
||||||
label={t("ledger.from")}
|
label={t("ledger.from")}
|
||||||
type="date"
|
value={toDateValue(draftFrom)}
|
||||||
value={from}
|
onChange={(value) => onDraftFromChange(fromDateValue(value))}
|
||||||
onChange={(event) => onFromChange(event.currentTarget.value)}
|
maxDate={toDateValue(draftTo) ?? undefined}
|
||||||
|
valueFormat="DD.MM.YYYY"
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<DateInput
|
||||||
label={t("ledger.to")}
|
label={t("ledger.to")}
|
||||||
type="date"
|
value={toDateValue(draftTo)}
|
||||||
value={to}
|
onChange={(value) => onDraftToChange(fromDateValue(value))}
|
||||||
onChange={(event) => onToChange(event.currentTarget.value)}
|
minDate={toDateValue(draftFrom) ?? undefined}
|
||||||
|
valueFormat="DD.MM.YYYY"
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Checkbox.Group
|
<MultiSelect
|
||||||
label={t("ledger.categories")}
|
label={t("ledger.categories")}
|
||||||
value={categories}
|
placeholder={t("ledger.categoriesPlaceholder")}
|
||||||
onChange={(value) => onCategoriesChange(value as OperationCategory[])}
|
data={categoryData}
|
||||||
>
|
value={draftCategories}
|
||||||
<Group mt="xs">
|
onChange={(value) =>
|
||||||
{operationCategories.map((category) => (
|
onDraftCategoriesChange(fromMultiSelectValue(value))
|
||||||
<Checkbox
|
}
|
||||||
key={category}
|
clearable
|
||||||
value={category}
|
/>
|
||||||
label={t(`ledger.category.${category}`)}
|
<Group>
|
||||||
/>
|
<Button onClick={onApply} disabled={!canApply}>
|
||||||
))}
|
{t("ledger.apply")}
|
||||||
</Group>
|
</Button>
|
||||||
</Checkbox.Group>
|
<Button variant="default" onClick={onReset}>
|
||||||
|
{t("ledger.reset")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
{items.length ? (
|
{items.length ? (
|
||||||
items.map((operation) => (
|
items.map((operation) => (
|
||||||
<LedgerEntryRow key={operation.id} operation={operation} />
|
<LedgerEntryRow key={operation.id} operation={operation} />
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ const ru = {
|
|||||||
from: "С",
|
from: "С",
|
||||||
to: "По",
|
to: "По",
|
||||||
categories: "Категории",
|
categories: "Категории",
|
||||||
|
categoriesPlaceholder: "Выберите категории",
|
||||||
|
apply: "Применить",
|
||||||
|
reset: "Сбросить",
|
||||||
previous: "Назад",
|
previous: "Назад",
|
||||||
next: "Далее",
|
next: "Далее",
|
||||||
retry: "Повторить",
|
retry: "Повторить",
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
|
|||||||
afterEach(() => server.resetHandlers());
|
afterEach(() => server.resetHandlers());
|
||||||
afterAll(() => server.close());
|
afterAll(() => server.close());
|
||||||
|
|
||||||
|
class ResizeObserver {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.ResizeObserver = ResizeObserver;
|
||||||
|
|
||||||
Object.defineProperty(window, "matchMedia", {
|
Object.defineProperty(window, "matchMedia", {
|
||||||
writable: true,
|
writable: true,
|
||||||
value: (query: string) => ({
|
value: (query: string) => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user