feat(cards): filter operations by date range
This commit is contained in:
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Vendored
+19
-5
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Вид операции",
|
||||
|
||||
Reference in New Issue
Block a user