diff --git a/src/entities/bank-card/api/queries.ts b/src/entities/bank-card/api/queries.ts index 0a69d0a..8773936 100644 --- a/src/entities/bank-card/api/queries.ts +++ b/src/entities/bank-card/api/queries.ts @@ -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), }); } diff --git a/src/pages/card/ui/CardPage.test.tsx b/src/pages/card/ui/CardPage.test.tsx index 1341447..cb4581e 100644 --- a/src/pages/card/ui/CardPage.test.tsx +++ b/src/pages/card/ui/CardPage.test.tsx @@ -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( , ); - 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 () => { diff --git a/src/pages/card/ui/CardPage.view.tsx b/src/pages/card/ui/CardPage.view.tsx index fcb2248..74c0c9f 100644 --- a/src/pages/card/ui/CardPage.view.tsx +++ b/src/pages/card/ui/CardPage.view.tsx @@ -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 = (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 ( + { + setDraft({ scope, value: next }); + onChange(next); + }} + popoverProps={{ + returnFocus: true, + middlewares: { shift: { crossAxis: true } }, + }} + /> + ); +}; + type CardPageViewProps = { vm: CardPageVm }; const CardPageView: FC = (props) => { const { vm } = props; @@ -36,7 +69,7 @@ const CardPageView: FC = (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 = (props) => { retry={() => void vm.cards.refetch()} /> )} - { - if (value) vm.setMonth(value.slice(0, 7)); - }} - popoverProps={{ middlewares: { shift: { crossAxis: true } } }} - /> + = (props) => { /> = (props) => { row.otherCard ? ( {t("bank.card")} diff --git a/src/pages/card/ui/CardPage.vm.ts b/src/pages/card/ui/CardPage.vm.ts index 4c5ed9c..7b37a38 100644 --- a/src/pages/card/ui/CardPage.vm.ts +++ b/src/pages/card/ui/CardPage.vm.ts @@ -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(); 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( 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; diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 94d2d1b..7f76b34 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -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: { diff --git a/src/shared/api/spec/openapi.json b/src/shared/api/spec/openapi.json index c9e21f0..c3a3242 100644 --- a/src/shared/api/spec/openapi.json +++ b/src/shared/api/spec/openapi.json @@ -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", diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts index 356684b..1f80f19 100644 --- a/src/shared/i18n/locales/ru.ts +++ b/src/shared/i18n/locales/ru.ts @@ -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: "Вид операции",