diff --git a/src/entities/bank-card/api/banking.test.tsx b/src/entities/bank-card/api/banking.test.tsx index 39d3374..d801e9d 100644 --- a/src/entities/bank-card/api/banking.test.tsx +++ b/src/entities/bank-card/api/banking.test.tsx @@ -120,6 +120,45 @@ function useReadModels() { describe("banking API", () => { beforeEach(authenticate); + it("loads every page for all mode and fails without exposing partial results", async () => { + const pages: number[] = []; + let fail = false; + server.use( + http.get("*/api/v1/bank-cards/a/operations", ({ request }) => { + const url = new URL(request.url); + const page = Number(url.searchParams.get("page")); + pages.push(page); + expect(url.searchParams.get("limit")).toBe("100"); + expect(url.searchParams.get("month")).toBe("2026-09"); + if (fail && page === 2) + return HttpResponse.json({ code: "FAILED" }, { status: 500 }); + return HttpResponse.json({ + items: [{ ...transfer, id: String(page) }], + total: 2, + page, + hasNext: page === 1, + }); + }), + ); + const { result } = renderHook( + () => useBankOperations("a", { month: "2026-09", page: 1, limit: "all" }), + { wrapper: createQueryWrapper().wrapper }, + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(pages).toEqual([1, 2]); + expect(result.current.data?.items.map((item) => item.id)).toEqual([ + "1", + "2", + ]); + fail = true; + const failed = renderHook( + () => useBankOperations("a", { month: "2026-09", page: 1, limit: "all" }), + { wrapper: createQueryWrapper().wrapper }, + ); + await waitFor(() => expect(failed.result.current.isError).toBe(true)); + expect(failed.result.current.data).toBeUndefined(); + }); + it("uses session-aware banking endpoints and explicit month/page without T-Bank", async () => { const counts = new Map(); server.use(...readHandlers(counts)); diff --git a/src/entities/bank-card/api/queries.ts b/src/entities/bank-card/api/queries.ts index d083f79..a9dc83d 100644 --- a/src/entities/bank-card/api/queries.ts +++ b/src/entities/bank-card/api/queries.ts @@ -1,4 +1,5 @@ import { + apiClient, apiQueryClient, type paths, useAuthenticatedQuery, @@ -8,6 +9,8 @@ type BankCard = paths["/api/v1/bank-cards/{id}"]["get"]["responses"][200]["content"]["application/json"]; type BankOperation = paths["/api/v1/bank-cards/operations/{id}"]["get"]["responses"][200]["content"]["application/json"]; +type BankOperations = + paths["/api/v1/bank-cards/{id}/operations"]["get"]["responses"][200]["content"]["application/json"]; type BankSummary = paths["/api/v1/bank-cards/summary"]["get"]["responses"][200]["content"]["application/json"]; type ExpenseCategory = @@ -52,14 +55,54 @@ function useBankCategories() { function useBankOperations( id: string | undefined, - query: { month: string; page: number; limit?: number }, + query: { month: string; page: number; limit?: number | "all" }, ) { + const all = query.limit === "all"; + const params = { + path: { id: id ?? "" }, + query: { + ...query, + page: all ? 1 : query.page, + limit: query.limit === "all" ? 100 : query.limit, + }, + }; + const options = apiQueryClient.queryOptions( + "get", + "/api/v1/bank-cards/{id}/operations", + { params }, + ); return useAuthenticatedQuery({ - ...apiQueryClient.queryOptions( - "get", - "/api/v1/bank-cards/{id}/operations", - { params: { path: { id: id ?? "" }, query } }, - ), + queryKey: all ? ([...options.queryKey, "all"] as const) : options.queryKey, + placeholderData: ( + previousData: BankOperations | undefined, + previousQuery, + ) => { + const previous = previousQuery?.queryKey[2]?.params; + return !all && + previousQuery?.queryKey.length === 3 && + previous?.path?.id === id && + previous?.query?.month === query.month && + previous?.query?.limit === query.limit + ? previousData + : undefined; + }, + queryFn: async ({ signal }) => { + const items: BankOperation[] = []; + for (let page = params.query.page; ; page++) { + const { data, error } = await apiClient.GET( + "/api/v1/bank-cards/{id}/operations", + { + params: { ...params, query: { ...params.query, page } }, + signal, + }, + ); + if (error) throw error; + if (!data) throw new Error("Missing bank operations response"); + if (!all) return data; + items.push(...data.items); + if (!data.hasNext) return { ...data, items, page: 1, hasNext: false }; + } + }, enabled: Boolean(id && query.month), }); } diff --git a/src/pages/card/ui/CardPage.module.css b/src/pages/card/ui/CardPage.module.css index 0484ac2..d1fb713 100644 --- a/src/pages/card/ui/CardPage.module.css +++ b/src/pages/card/ui/CardPage.module.css @@ -17,3 +17,23 @@ grid-template-columns: auto minmax(0, 1fr) minmax(0, 40%); } } + +.operations[data-pending] { + opacity: 0.55; +} +@media (prefers-reduced-motion: no-preference) { + .operations { + animation: operations-appear 200ms ease-out; + transition: opacity 150ms ease; + } + @keyframes operations-appear { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } + } +} diff --git a/src/pages/card/ui/CardPage.test.tsx b/src/pages/card/ui/CardPage.test.tsx index 078b5ff..1341447 100644 --- a/src/pages/card/ui/CardPage.test.tsx +++ b/src/pages/card/ui/CardPage.test.tsx @@ -9,7 +9,7 @@ import { } from "@testing-library/react"; import { HttpResponse, http } from "msw"; import { MemoryRouter, Route, Routes } from "react-router"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { configureAuthClient } from "@/shared/api"; import { createTestQueryClient } from "@/test/query"; import { server } from "@/test/server"; @@ -71,10 +71,15 @@ function setup( http.get("*/api/v1/bank-cards/a/operations", ({ request }) => { requests.push(new URL(request.url).search); return HttpResponse.json({ - items: [operation], - page: 1, - total: 2, - hasNext: true, + items: [ + { + ...operation, + id: `operation-${new URL(request.url).searchParams.get("page")}`, + }, + ], + page: Number(new URL(request.url).searchParams.get("page")), + total: 30, + hasNext: Number(new URL(request.url).searchParams.get("page")) < 3, }); }), http.get("*/api/v1/bank-cards/summary", () => @@ -93,7 +98,7 @@ function setup( const client = createTestQueryClient(); render( - + } /> @@ -114,10 +119,22 @@ describe("CardPage", () => { expect(screen.getByText("03.08.2026")).toBeInTheDocument(); expect(screen.getByText("−50,01 ₽")).toBeInTheDocument(); expect(screen.getByText("-50 ₽")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled(); + expect(requests.every((value) => value.includes("limit=10"))).toBe(true); fireEvent.click(screen.getByRole("button", { name: "Далее" })); await waitFor(() => expect(requests.some((v) => v.includes("page=2"))).toBe(true), ); + const lastPage = await screen.findByRole("button", { name: "Страница 3" }); + await waitFor(() => expect(lastPage).toBeEnabled()); + fireEvent.click(lastPage); + await waitFor(() => expect(requests.at(-1)).toContain("page=3")); + await waitFor(() => + expect(screen.getByRole("button", { name: "Далее" })).toBeDisabled(), + ); + expect( + screen.getByRole("button", { name: "Страница 3" }), + ).toHaveTextContent("3"); fireEvent.click(screen.getByLabelText("Месяц")); fireEvent.click(await screen.findByRole("button", { name: "Jul" })); await waitFor(() => @@ -129,6 +146,89 @@ describe("CardPage", () => { ); expect(screen.getByText(/10\s000 ₽/)).toBeInTheDocument(); }); + it("keeps the journal in place while the next page loads", async () => { + const scroll = vi.spyOn(window, "scrollBy").mockImplementation(() => {}); + setup(); + await screen.findByText("Перевод на «Запасная»"); + const navigation = screen.getByRole("navigation", { + name: "Страницы операций", + }); + const measure = vi + .spyOn(navigation, "getBoundingClientRect") + .mockReturnValue({ top: 600 } as DOMRect); + let release = () => {}; + const pending = new Promise((resolve) => { + release = resolve; + }); + server.use( + http.get("*/api/v1/bank-cards/a/operations", async () => { + await pending; + return HttpResponse.json({ + items: [{ ...operation, id: "next", description: "Новая страница" }], + page: 2, + total: 30, + hasNext: true, + }); + }), + ); + fireEvent.click(screen.getByRole("button", { name: "Далее" })); + await waitFor(() => + expect(screen.getByRole("button", { name: "Далее" })).toBeDisabled(), + ); + expect(screen.getByText("Перевод на «Запасная»")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Изменить" })).toBeDisabled(); + expect( + screen.queryByLabelText("Загрузка операций"), + ).not.toBeInTheDocument(); + expect(scroll).not.toHaveBeenCalled(); + measure.mockReturnValue({ top: 200 } as DOMRect); + release(); + await screen.findByText(/Новая страница/); + const list = screen.getByText(/Новая страница/).closest("[aria-busy]"); + expect(list?.parentElement?.style.minHeight).toBe(""); + expect(scroll).toHaveBeenCalledExactlyOnceWith({ + top: -400, + behavior: "instant", + }); + measure.mockRestore(); + scroll.mockRestore(); + await waitFor(() => + expect(screen.getByRole("button", { name: "Далее" })).toBeEnabled(), + ); + }); + it("changes page size, resets the page and shows all monthly operations", async () => { + const { requests } = setup(); + await screen.findByText("Перевод на «Запасная»"); + fireEvent.click(screen.getByRole("button", { name: "Страница 3" })); + await waitFor(() => expect(requests.at(-1)).toContain("page=3")); + await waitFor(() => + expect(screen.getByRole("button", { name: "Страница 1" })).toBeEnabled(), + ); + fireEvent.click( + screen.getByRole("combobox", { name: "Операций на странице" }), + ); + fireEvent.click(await screen.findByRole("option", { name: "20" })); + await waitFor(() => { + expect(requests.at(-1)).toContain("limit=20"); + expect(requests.at(-1)).toContain("page=1"); + }); + await waitFor(() => + expect(screen.getByRole("button", { name: "Страница 1" })).toBeEnabled(), + ); + fireEvent.click( + screen.getByRole("combobox", { name: "Операций на странице" }), + ); + fireEvent.click(await screen.findByRole("option", { name: "Все" })); + await waitFor(() => + expect( + screen.queryByRole("navigation", { name: "Страницы операций" }), + ).not.toBeInTheDocument(), + ); + await waitFor(() => expect(requests.at(-1)).toContain("limit=100")); + await waitFor(() => + expect(screen.getAllByText("Перевод на «Запасная»")).toHaveLength(3), + ); + }); it("keeps card and journal when summary fails", async () => { setup({ summaryError: true }); await screen.findByRole("heading", { name: "Повседневная" }); diff --git a/src/pages/card/ui/CardPage.view.tsx b/src/pages/card/ui/CardPage.view.tsx index f7aa721..fcb2248 100644 --- a/src/pages/card/ui/CardPage.view.tsx +++ b/src/pages/card/ui/CardPage.view.tsx @@ -3,14 +3,16 @@ import { Anchor, Button, Group, + Pagination, Paper, + Select, Skeleton, Stack, Text, Title, } from "@mantine/core"; import { MonthPickerInput } from "@mantine/dates"; -import type { FC } from "react"; +import { type FC, useLayoutEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import { @@ -28,6 +30,34 @@ const CardPageView: FC = (props) => { const { vm } = props; const { t } = useTranslation(); const card = vm.card.data; + const paginationRef = useRef(null); + const pendingPage = useRef<{ + scope: string; + page: number; + top: number; + } | null>(null); + const scope = `${card?.id}-${vm.month}-${vm.pageSize}`; + useLayoutEffect(() => { + const pending = pendingPage.current; + if (!pending) return; + if (pending.scope !== scope || vm.history.isError) { + pendingPage.current = null; + return; + } + if (vm.history.isPlaceholderData || vm.history.data?.page !== pending.page) + return; + const pagination = paginationRef.current; + if (pagination) { + const delta = pagination.getBoundingClientRect().top - pending.top; + if (delta) window.scrollBy({ top: delta, behavior: "instant" }); + } + pendingPage.current = null; + }, [ + scope, + vm.history.data, + vm.history.isPlaceholderData, + vm.history.isError, + ]); if (!card) { if (vm.card.isLoading) return ( @@ -128,102 +158,135 @@ const CardPageView: FC = (props) => { error={vm.summary.isError} retry={() => void vm.summary.refetch()} /> - {t("bank.operations")} - {vm.rows ? ( - vm.rows.length ? ( - vm.rows.map((row) => ( -
- - } - value={row.value} - valueLabel={t(`bank.kinds.${row.item.kind}`)} - tone={row.tone} - details={ - row.otherCard ? ( - - {t("bank.card")} - - ) : null - } - actions={ - - vm.setOperation(row.item)} - > - + + {vm.rows ? ( + vm.rows.length ? ( + vm.rows.map((row) => ( +
+ + } + value={row.value} + valueLabel={t(`bank.kinds.${row.item.kind}`)} + tone={row.tone} + details={ + row.otherCard ? ( + - - - - vm.setDeleting(row.item)} - > - - - - } - /> -
- )) - ) : ( - {t("bank.noOperations")} - ) - ) : vm.history.isLoading ? ( - - - - - - - ) : null} + + + vm.setDeleting(row.item)} + > + + + + } + /> +
+ )) + ) : ( + {t("bank.noOperations")} + ) + ) : vm.history.isLoading ? ( + + + + + + + ) : null} + {vm.history.isError && ( {t(vm.rows ? "bank.refreshError" : "bank.error")} @@ -232,23 +295,37 @@ const CardPageView: FC = (props) => { )} - {vm.history.data && ( - - - {t("bank.page", { page: vm.page })} - + {vm.history.data && vm.pageSize !== "all" && ( + + { + if (paginationRef.current) + pendingPage.current = { + scope, + page, + top: paginationRef.current.getBoundingClientRect().top, + }; + vm.setPage(page); + }} + disabled={vm.history.isFetching} + siblings={0} + getItemProps={(page) => ({ + "aria-label": t("bank.page", { page }), + })} + getControlProps={(control) => ({ + "aria-label": t( + control === "previous" ? "bank.previous" : "bank.next", + ), + })} + /> )} diff --git a/src/pages/card/ui/CardPage.vm.ts b/src/pages/card/ui/CardPage.vm.ts index 65ea042..4c5ed9c 100644 --- a/src/pages/card/ui/CardPage.vm.ts +++ b/src/pages/card/ui/CardPage.vm.ts @@ -13,18 +13,29 @@ import { import { apiErrorCode } from "@/shared/api"; import { formatDate, formatMoney } from "@/shared/lib"; +const PAGE_SIZES = ["5", "10", "20", "50", "100", "all"]; + function useCardPageVm() { const { cardId } = useParams(); const [params, setParams] = useSearchParams(); const { t } = useTranslation(); const month = bankMonth(params.get("month")); + const requestedSize = params.get("limit") ?? "10"; + const pageSize = PAGE_SIZES.includes(requestedSize) ? requestedSize : "10"; const rawPage = Number(params.get("page") ?? 1); - const page = Number.isSafeInteger(rawPage) && rawPage > 0 ? rawPage : 1; + const page = + pageSize !== "all" && Number.isSafeInteger(rawPage) && rawPage > 0 + ? rawPage + : 1; const card = useBankCard(cardId); const { updateCard } = useBankMutations(); const [archiveError, setArchiveError] = useState(); const cards = useBankCards(true); - const history = useBankOperations(cardId, { month, page }); + const history = useBankOperations(cardId, { + month, + page, + limit: pageSize === "all" ? "all" : Number(pageSize), + }); const summary = useBankSummary(month, cardId); const [editingCard, setEditingCard] = useState(false); const [operation, setOperation] = useState( @@ -85,6 +96,11 @@ function useCardPageVm() { summary, month, page, + pageSize, + totalPages: + pageSize === "all" + ? 1 + : Math.max(1, Math.ceil((history.data?.total ?? 0) / Number(pageSize))), rows, editingCard, setEditingCard, @@ -94,9 +110,14 @@ function useCardPageVm() { setDeleting, notFound: apiErrorCode(card.error) === "BANK_NOT_FOUND", setMonth: (value: string) => { - if (value) setParams({ month: bankMonth(value) }); + if (value) setParams({ month: bankMonth(value), limit: pageSize }); }, - setPage: (value: number) => setParams({ month, page: String(value) }), + setPageSize: (value: string | null) => { + if (value && PAGE_SIZES.includes(value)) + setParams({ month, limit: value }); + }, + setPage: (value: number) => + setParams({ month, page: String(value), limit: pageSize }), }; } type CardPageVm = ReturnType; diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts index 3cbef58..c8e3442 100644 --- a/src/shared/i18n/locales/ru.ts +++ b/src/shared/i18n/locales/ru.ts @@ -96,6 +96,9 @@ const ru = { previous: "Назад", next: "Далее", page: "Страница {{page}}", + pagination: "Страницы операций", + pageSize: "Операций на странице", + allOperations: "Все", transferTo: "Перевод на «{{name}}»", transferFrom: "Перевод с «{{name}}»", edit: "Изменить",