feat(cards): filter operations by date range

This commit is contained in:
2026-09-12 21:52:13 +03:00
parent 8e58c016d2
commit 9bb385b9f0
7 changed files with 188 additions and 47 deletions
+20 -5
View File
@@ -57,7 +57,13 @@ function useBankCategories() {
function useBankOperations(
id: string | undefined,
query: { month: string; page: number; limit?: number | "all" },
query: {
month?: string;
from?: string;
to?: string;
page: number;
limit?: number | "all";
},
) {
const all = query.limit === "all";
const params = {
@@ -84,6 +90,8 @@ function useBankOperations(
previousQuery?.queryKey.length === 3 &&
previous?.path?.id === id &&
previous?.query?.month === query.month &&
previous?.query?.from === query.from &&
previous?.query?.to === query.to &&
previous?.query?.limit === query.limit
? previousData
: undefined;
@@ -105,7 +113,7 @@ function useBankOperations(
if (!data.hasNext) return { ...data, items, page: 1, hasNext: false };
}
},
enabled: Boolean(id && query.month),
enabled: Boolean(id && (query.month || (query.from && query.to))),
});
}
@@ -134,13 +142,20 @@ function sortBankSummary(data: BankSummary): BankSummary {
};
}
function useBankSummary(month: string, cardId?: string) {
function useBankSummary(
period: string | { from: string; to: string },
cardId?: string,
) {
const query =
typeof period === "string"
? { month: period, cardId }
: { ...period, cardId };
return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/summary", {
params: { query: { month, cardId } },
params: { query },
}),
select: sortBankSummary,
enabled: Boolean(month),
enabled: Boolean(period),
});
}
+28 -11
View File
@@ -57,6 +57,7 @@ function setup(
clear: () => {},
});
const requests: string[] = [];
const summaryRequests: string[] = [];
server.use(
http.get("*/api/v1/bank-cards/a", () =>
options.missing
@@ -82,8 +83,9 @@ function setup(
hasNext: Number(new URL(request.url).searchParams.get("page")) < 3,
});
}),
http.get("*/api/v1/bank-cards/summary", () =>
options.summaryError
http.get("*/api/v1/bank-cards/summary", ({ request }) => {
summaryRequests.push(new URL(request.url).search);
return options.summaryError
? HttpResponse.error()
: HttpResponse.json({
month: "2026-08",
@@ -92,8 +94,8 @@ function setup(
refunds: money("50"),
net: money("-50"),
categories: [],
}),
),
});
}),
);
const client = createTestQueryClient();
render(
@@ -107,18 +109,18 @@ function setup(
</MantineProvider>
</QueryClientProvider>,
);
return { requests, client };
return { requests, summaryRequests, client };
}
describe("CardPage", () => {
it("shares month with history, pages the journal and keeps current balance", async () => {
const { requests } = setup();
it("shares date range with history, pages the journal and keeps current balance", async () => {
const { requests, summaryRequests } = setup();
await screen.findByRole("heading", { name: "Повседневная" });
expect(
await screen.findByText("Перевод на «Запасная»"),
).toBeInTheDocument();
expect(screen.getByText("03.08.2026")).toBeInTheDocument();
expect(screen.getByText("−50,01 ₽")).toBeInTheDocument();
expect(screen.getByText("-50 ₽")).toBeInTheDocument();
expect(await screen.findByText("-50,00 ₽")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
expect(requests.every((value) => value.includes("limit=10"))).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
@@ -135,15 +137,30 @@ describe("CardPage", () => {
expect(
screen.getByRole("button", { name: "Страница 3" }),
).toHaveTextContent("3");
fireEvent.click(screen.getByLabelText("Месяц"));
fireEvent.click(await screen.findByRole("button", { name: "Jul" }));
fireEvent.click(screen.getByLabelText("Период"));
fireEvent.click(
await screen.findByRole("button", { name: "10 August 2026" }),
);
fireEvent.click(
await screen.findByRole("button", { name: "20 August 2026" }),
);
await waitFor(() =>
expect(
requests.some(
(v) => v.includes("month=2026-07") && v.includes("page=1"),
(v) =>
v.includes("from=2026-08-10") &&
v.includes("to=2026-08-20") &&
v.includes("page=1"),
),
).toBe(true),
);
await waitFor(() =>
expect(summaryRequests.at(-1)).toContain("from=2026-08-10&to=2026-08-20"),
);
expect(screen.getByRole("link", { name: "Карта" })).toHaveAttribute(
"href",
"/cards/b?from=2026-08-10&to=2026-08-20",
);
expect(screen.getByText(/10\s000 ₽/)).toBeInTheDocument();
});
it("keeps the journal in place while the next page loads", async () => {
+39 -13
View File
@@ -11,8 +11,8 @@ import {
Text,
Title,
} from "@mantine/core";
import { MonthPickerInput } from "@mantine/dates";
import { type FC, useLayoutEffect, useRef } from "react";
import { DatePickerInput } from "@mantine/dates";
import { type FC, useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import {
@@ -25,6 +25,39 @@ import { EntryCard, LoadingSkeleton, StateView } from "@/shared/ui";
import classes from "./CardPage.module.css";
import type { CardPageVm } from "./CardPage.vm";
type PeriodPickerProps = {
period: CardPageVm["period"];
onChange: CardPageVm["setPeriod"];
};
const PeriodPicker: FC<PeriodPickerProps> = (props) => {
const { period, onChange } = props;
const { t } = useTranslation();
const scope = `${period.from}-${period.to}`;
const [draft, setDraft] = useState<{
scope: string;
value: [string | null, string | null];
}>();
const value: [string | null, string | null] =
draft?.scope === scope ? draft.value : [period.from, period.to];
return (
<DatePickerInput
type="range"
allowSingleDateInRange
label={t("bank.period")}
valueFormat="DD.MM.YYYY"
value={value}
onChange={(next) => {
setDraft({ scope, value: next });
onChange(next);
}}
popoverProps={{
returnFocus: true,
middlewares: { shift: { crossAxis: true } },
}}
/>
);
};
type CardPageViewProps = { vm: CardPageVm };
const CardPageView: FC<CardPageViewProps> = (props) => {
const { vm } = props;
@@ -36,7 +69,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
page: number;
top: number;
} | null>(null);
const scope = `${card?.id}-${vm.month}-${vm.pageSize}`;
const scope = `${card?.id}-${vm.period.from}-${vm.period.to}-${vm.pageSize}`;
useLayoutEffect(() => {
const pending = pendingPage.current;
if (!pending) return;
@@ -143,14 +176,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
retry={() => void vm.cards.refetch()}
/>
)}
<MonthPickerInput
label={t("bank.month")}
value={`${vm.month}-01`}
onChange={(value) => {
if (value) vm.setMonth(value.slice(0, 7));
}}
popoverProps={{ middlewares: { shift: { crossAxis: true } } }}
/>
<PeriodPicker period={vm.period} onChange={vm.setPeriod} />
<BankSummaryView
data={vm.summary.data}
loading={vm.summary.isLoading}
@@ -177,7 +203,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
/>
</Group>
<Stack
key={`${vm.month}-${vm.pageSize}-${vm.history.data?.page ?? "loading"}`}
key={`${vm.period.from}-${vm.period.to}-${vm.pageSize}-${vm.history.data?.page ?? "loading"}`}
gap="lg"
className={classes.operations}
aria-busy={vm.history.isFetching}
@@ -209,7 +235,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
row.otherCard ? (
<Anchor
component={Link}
to={`/cards/${row.otherCard}?month=${vm.month}`}
to={`/cards/${row.otherCard}?from=${vm.period.from}&to=${vm.period.to}`}
>
{t("bank.card")}
</Anchor>
+26 -7
View File
@@ -1,6 +1,8 @@
import dayjs from "dayjs";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useParams, useSearchParams } from "react-router";
import { z } from "zod";
import {
type BankOperation,
bankMonth,
@@ -13,6 +15,8 @@ import {
import { apiErrorCode } from "@/shared/api";
import { formatDate, formatMoney } from "@/shared/lib";
const calendarDate = z.string().date();
const PAGE_SIZES = ["5", "10", "20", "50", "100", "all"];
function useCardPageVm() {
@@ -20,6 +24,20 @@ function useCardPageVm() {
const [params, setParams] = useSearchParams();
const { t } = useTranslation();
const month = bankMonth(params.get("month"));
const from = params.get("from");
const to = params.get("to");
const period =
from &&
to &&
calendarDate.safeParse(from).success &&
calendarDate.safeParse(to).success &&
from >= "0001-01-01" &&
from <= to
? { from, to }
: {
from: `${month}-01`,
to: dayjs(`${month}-01`).endOf("month").format("YYYY-MM-DD"),
};
const requestedSize = params.get("limit") ?? "10";
const pageSize = PAGE_SIZES.includes(requestedSize) ? requestedSize : "10";
const rawPage = Number(params.get("page") ?? 1);
@@ -32,11 +50,11 @@ function useCardPageVm() {
const [archiveError, setArchiveError] = useState<unknown>();
const cards = useBankCards(true);
const history = useBankOperations(cardId, {
month,
...period,
page,
limit: pageSize === "all" ? "all" : Number(pageSize),
});
const summary = useBankSummary(month, cardId);
const summary = useBankSummary(period, cardId);
const [editingCard, setEditingCard] = useState(false);
const [operation, setOperation] = useState<BankOperation | "new" | null>(
null,
@@ -94,7 +112,7 @@ function useCardPageVm() {
},
history,
summary,
month,
period,
page,
pageSize,
totalPages:
@@ -109,15 +127,16 @@ function useCardPageVm() {
deleting,
setDeleting,
notFound: apiErrorCode(card.error) === "BANK_NOT_FOUND",
setMonth: (value: string) => {
if (value) setParams({ month: bankMonth(value), limit: pageSize });
setPeriod: (value: [string | null, string | null]) => {
if (value[0] && value[1])
setParams({ from: value[0], to: value[1], limit: pageSize });
},
setPageSize: (value: string | null) => {
if (value && PAGE_SIZES.includes(value))
setParams({ month, limit: value });
setParams({ ...period, limit: value });
},
setPage: (value: number) =>
setParams({ month, page: String(value), limit: pageSize }),
setParams({ ...period, page: String(value), limit: pageSize }),
};
}
type CardPageVm = ReturnType<typeof useCardPageVm>;
+19 -5
View File
@@ -764,7 +764,11 @@ export interface components {
net: components["schemas"]["MoneyDto"];
};
BankSummaryDto: {
month: string;
month?: string;
/** Format: date */
from?: string;
/** Format: date */
to?: string;
cardId: string | null;
expenses: components["schemas"]["MoneyDto"];
refunds: components["schemas"]["MoneyDto"];
@@ -2109,8 +2113,13 @@ export interface operations {
};
BankCardsController_summary: {
parameters: {
query: {
month: string;
query?: {
/** @description Use month or both from and to, exclusively. */
month?: string;
/** @description Inclusive start date; requires to. */
from?: string;
/** @description Inclusive end date; requires from. */
to?: string;
cardId?: string;
};
header?: never;
@@ -2536,10 +2545,15 @@ export interface operations {
};
BankCardsController_history: {
parameters: {
query: {
query?: {
page?: number;
limit?: number;
month: string;
/** @description Use month or both from and to, exclusively. */
month?: string;
/** @description Inclusive start date; requires to. */
from?: string;
/** @description Inclusive end date; requires from. */
to?: string;
};
header?: never;
path: {
+52 -3
View File
@@ -914,13 +914,34 @@
"parameters": [
{
"name": "month",
"required": true,
"required": false,
"in": "query",
"description": "Use month or both from and to, exclusively.",
"schema": {
"pattern": "^[0-9]{4}-(0[1-9]|1[0-2])$",
"type": "string"
}
},
{
"name": "from",
"required": false,
"in": "query",
"description": "Inclusive start date; requires to.",
"schema": {
"format": "date",
"type": "string"
}
},
{
"name": "to",
"required": false,
"in": "query",
"description": "Inclusive end date; requires from.",
"schema": {
"format": "date",
"type": "string"
}
},
{
"name": "cardId",
"required": false,
@@ -1487,12 +1508,33 @@
},
{
"name": "month",
"required": true,
"required": false,
"in": "query",
"description": "Use month or both from and to, exclusively.",
"schema": {
"pattern": "^[0-9]{4}-(0[1-9]|1[0-2])$",
"type": "string"
}
},
{
"name": "from",
"required": false,
"in": "query",
"description": "Inclusive start date; requires to.",
"schema": {
"format": "date",
"type": "string"
}
},
{
"name": "to",
"required": false,
"in": "query",
"description": "Inclusive end date; requires from.",
"schema": {
"format": "date",
"type": "string"
}
}
],
"responses": {
@@ -4971,6 +5013,14 @@
"month": {
"type": "string"
},
"from": {
"type": "string",
"format": "date"
},
"to": {
"type": "string",
"format": "date"
},
"cardId": {
"type": "string",
"nullable": true
@@ -4992,7 +5042,6 @@
}
},
"required": [
"month",
"cardId",
"expenses",
"refunds",
+4 -3
View File
@@ -55,12 +55,13 @@ const ru = {
summary: "Расходы",
allSummary: "Расходы по всем картам",
month: "Месяц",
period: "Период",
expenses: "Потрачено",
refunds: "Возвращено",
net: "После возвратов",
summaryHint:
"Возвраты учитываются в месяце получения. Итог может быть отрицательным.",
noExpenses: "За этот месяц операций по расходам нет.",
"Возвраты учитываются по дате получения. Итог может быть отрицательным.",
noExpenses: "За этот период операций по расходам нет.",
categories: "Категории",
category: "Категория",
categoryName: "Название категории",
@@ -70,7 +71,7 @@ const ru = {
notFound: "Карта не найдена",
back: "К картам",
operations: "Операции",
noOperations: "Операций за месяц нет.",
noOperations: "Операций за период нет.",
addOperation: "Добавить операцию",
editOperation: "Изменить операцию",
kind: "Вид операции",