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": { "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",
+2
View File
@@ -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
View File
@@ -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>
); );
}; };
+2
View File
@@ -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} />
+3
View File
@@ -96,6 +96,9 @@ const ru = {
from: "С", from: "С",
to: "По", to: "По",
categories: "Категории", categories: "Категории",
categoriesPlaceholder: "Выберите категории",
apply: "Применить",
reset: "Сбросить",
previous: "Назад", previous: "Назад",
next: "Далее", next: "Далее",
retry: "Повторить", retry: "Повторить",
+8
View File
@@ -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) => ({