feat(account-operations): require explicit ledger filters

This commit is contained in:
2026-08-27 14:54:34 +03:00
parent 3c459e66cc
commit 7d748a65d3
9 changed files with 278 additions and 74 deletions
+24
View File
@@ -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",
+2
View File
@@ -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",
+12 -9
View File
@@ -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 (
<MantineProvider theme={theme} forceColorScheme="light">
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<RouteErrorBoundary>
<AppRoutes />
</RouteErrorBoundary>
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
<DatesProvider settings={{ locale: "ru" }}>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<RouteErrorBoundary>
<AppRoutes />
</RouteErrorBoundary>
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
</DatesProvider>
</MantineProvider>
);
};
+2
View File
@@ -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";
@@ -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(
<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 () => {
configureTestAuth();
server.use(
@@ -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<OperationCategory[]>([]);
const queryClient = useQueryClient();
const [appliedFrom, setAppliedFrom] = useState<string | null>(null);
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 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 };
@@ -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<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> = (
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 (
<Stack maw={960} gap="lg">
<Title order={1}>{t("ledger.title")}</Title>
<Group align="end">
<TextInput
<DateInput
label={t("ledger.from")}
type="date"
value={from}
onChange={(event) => onFromChange(event.currentTarget.value)}
value={toDateValue(draftFrom)}
onChange={(value) => onDraftFromChange(fromDateValue(value))}
maxDate={toDateValue(draftTo) ?? undefined}
valueFormat="DD.MM.YYYY"
/>
<TextInput
<DateInput
label={t("ledger.to")}
type="date"
value={to}
onChange={(event) => onToChange(event.currentTarget.value)}
value={toDateValue(draftTo)}
onChange={(value) => onDraftToChange(fromDateValue(value))}
minDate={toDateValue(draftFrom) ?? undefined}
valueFormat="DD.MM.YYYY"
/>
</Group>
<Checkbox.Group
<MultiSelect
label={t("ledger.categories")}
value={categories}
onChange={(value) => onCategoriesChange(value as OperationCategory[])}
>
<Group mt="xs">
{operationCategories.map((category) => (
<Checkbox
key={category}
value={category}
label={t(`ledger.category.${category}`)}
/>
))}
</Group>
</Checkbox.Group>
placeholder={t("ledger.categoriesPlaceholder")}
data={categoryData}
value={draftCategories}
onChange={(value) =>
onDraftCategoriesChange(fromMultiSelectValue(value))
}
clearable
/>
<Group>
<Button onClick={onApply} disabled={!canApply}>
{t("ledger.apply")}
</Button>
<Button variant="default" onClick={onReset}>
{t("ledger.reset")}
</Button>
</Group>
{items.length ? (
items.map((operation) => (
<LedgerEntryRow key={operation.id} operation={operation} />
+3
View File
@@ -96,6 +96,9 @@ const ru = {
from: "С",
to: "По",
categories: "Категории",
categoriesPlaceholder: "Выберите категории",
apply: "Применить",
reset: "Сбросить",
previous: "Назад",
next: "Далее",
retry: "Повторить",
+8
View File
@@ -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) => ({