feat: improve card operations pagination and scroll behavior
This commit is contained in:
@@ -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<string, number>();
|
||||
server.use(...readHandlers(counts));
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
<QueryClientProvider client={client}>
|
||||
<MantineProvider>
|
||||
<MantineProvider env="test">
|
||||
<MemoryRouter initialEntries={["/cards/a?month=2026-08"]}>
|
||||
<Routes>
|
||||
<Route path="/cards/:cardId" element={<CardPage />} />
|
||||
@@ -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<void>((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: "Повседневная" });
|
||||
|
||||
+189
-112
@@ -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<CardPageViewProps> = (props) => {
|
||||
const { vm } = props;
|
||||
const { t } = useTranslation();
|
||||
const card = vm.card.data;
|
||||
const paginationRef = useRef<HTMLDivElement>(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<CardPageViewProps> = (props) => {
|
||||
error={vm.summary.isError}
|
||||
retry={() => void vm.summary.refetch()}
|
||||
/>
|
||||
<Title order={2}>{t("bank.operations")}</Title>
|
||||
{vm.rows ? (
|
||||
vm.rows.length ? (
|
||||
vm.rows.map((row) => (
|
||||
<section
|
||||
key={row.item.id}
|
||||
className={classes.operation}
|
||||
aria-label={`${row.title} ${row.item.date}`}
|
||||
>
|
||||
<EntryCard
|
||||
title={row.title}
|
||||
metadata={row.metadata}
|
||||
color={row.color}
|
||||
initial={row.title[0]}
|
||||
icon={
|
||||
<BankOperationIcon
|
||||
kind={row.item.kind}
|
||||
categoryName={row.item.categoryName}
|
||||
/>
|
||||
}
|
||||
value={row.value}
|
||||
valueLabel={t(`bank.kinds.${row.item.kind}`)}
|
||||
tone={row.tone}
|
||||
details={
|
||||
row.otherCard ? (
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/cards/${row.otherCard}?month=${vm.month}`}
|
||||
>
|
||||
{t("bank.card")}
|
||||
</Anchor>
|
||||
) : null
|
||||
}
|
||||
actions={
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
disabled={!row.editable || Boolean(card.archivedAt)}
|
||||
size="sm"
|
||||
aria-label={t("bank.edit")}
|
||||
title={t("bank.edit")}
|
||||
onClick={() => vm.setOperation(row.item)}
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
<Group justify="space-between" align="end">
|
||||
<Title order={2}>{t("bank.operations")}</Title>
|
||||
<Select
|
||||
label={t("bank.pageSize")}
|
||||
value={vm.pageSize}
|
||||
onChange={vm.setPageSize}
|
||||
allowDeselect={false}
|
||||
w={180}
|
||||
data={[
|
||||
"5",
|
||||
"10",
|
||||
"20",
|
||||
"50",
|
||||
"100",
|
||||
{ value: "all", label: t("bank.allOperations") },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
<Stack
|
||||
key={`${vm.month}-${vm.pageSize}-${vm.history.data?.page ?? "loading"}`}
|
||||
gap="lg"
|
||||
className={classes.operations}
|
||||
aria-busy={vm.history.isFetching}
|
||||
data-pending={vm.history.isPlaceholderData || undefined}
|
||||
>
|
||||
{vm.rows ? (
|
||||
vm.rows.length ? (
|
||||
vm.rows.map((row) => (
|
||||
<section
|
||||
key={row.item.id}
|
||||
className={classes.operation}
|
||||
aria-label={`${row.title} ${row.item.date}`}
|
||||
>
|
||||
<EntryCard
|
||||
title={row.title}
|
||||
metadata={row.metadata}
|
||||
color={row.color}
|
||||
initial={row.title[0]}
|
||||
icon={
|
||||
<BankOperationIcon
|
||||
kind={row.item.kind}
|
||||
categoryName={row.item.categoryName}
|
||||
/>
|
||||
}
|
||||
value={row.value}
|
||||
valueLabel={t(`bank.kinds.${row.item.kind}`)}
|
||||
tone={row.tone}
|
||||
details={
|
||||
row.otherCard ? (
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/cards/${row.otherCard}?month=${vm.month}`}
|
||||
>
|
||||
<path d="m16 3 5 5-12 12-6 1 1-6L16 3Z M13 6l5 5" />
|
||||
</svg>
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
disabled={!row.editable || Boolean(card.archivedAt)}
|
||||
size="sm"
|
||||
aria-label={t("bank.delete")}
|
||||
title={t("bank.delete")}
|
||||
onClick={() => vm.setDeleting(row.item)}
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
{t("bank.card")}
|
||||
</Anchor>
|
||||
) : null
|
||||
}
|
||||
actions={
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
disabled={
|
||||
vm.history.isPlaceholderData ||
|
||||
!row.editable ||
|
||||
Boolean(card.archivedAt)
|
||||
}
|
||||
size="sm"
|
||||
aria-label={t("bank.edit")}
|
||||
title={t("bank.edit")}
|
||||
onClick={() => vm.setOperation(row.item)}
|
||||
>
|
||||
<path d="M3 6h18 M9 6V3h6v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7" />
|
||||
</svg>
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
))
|
||||
) : (
|
||||
<Text>{t("bank.noOperations")}</Text>
|
||||
)
|
||||
) : vm.history.isLoading ? (
|
||||
<LoadingSkeleton label={t("bank.loadingHistory")}>
|
||||
<Stack>
|
||||
<Skeleton h={90} />
|
||||
<Skeleton h={90} />
|
||||
</Stack>
|
||||
</LoadingSkeleton>
|
||||
) : null}
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m16 3 5 5-12 12-6 1 1-6L16 3Z M13 6l5 5" />
|
||||
</svg>
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
disabled={
|
||||
vm.history.isPlaceholderData ||
|
||||
!row.editable ||
|
||||
Boolean(card.archivedAt)
|
||||
}
|
||||
size="sm"
|
||||
aria-label={t("bank.delete")}
|
||||
title={t("bank.delete")}
|
||||
onClick={() => vm.setDeleting(row.item)}
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M3 6h18 M9 6V3h6v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7" />
|
||||
</svg>
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
))
|
||||
) : (
|
||||
<Text>{t("bank.noOperations")}</Text>
|
||||
)
|
||||
) : vm.history.isLoading ? (
|
||||
<LoadingSkeleton label={t("bank.loadingHistory")}>
|
||||
<Stack>
|
||||
<Skeleton h={90} />
|
||||
<Skeleton h={90} />
|
||||
</Stack>
|
||||
</LoadingSkeleton>
|
||||
) : null}
|
||||
</Stack>
|
||||
{vm.history.isError && (
|
||||
<Group role="alert">
|
||||
<Text>{t(vm.rows ? "bank.refreshError" : "bank.error")}</Text>
|
||||
@@ -232,23 +295,37 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
{vm.history.data && (
|
||||
<Group>
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={vm.page === 1 || vm.history.isFetching}
|
||||
onClick={() => vm.setPage(vm.page - 1)}
|
||||
>
|
||||
{t("bank.previous")}
|
||||
</Button>
|
||||
<Text>{t("bank.page", { page: vm.page })}</Text>
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={!vm.history.data.hasNext || vm.history.isFetching}
|
||||
onClick={() => vm.setPage(vm.page + 1)}
|
||||
>
|
||||
{t("bank.next")}
|
||||
</Button>
|
||||
{vm.history.data && vm.pageSize !== "all" && (
|
||||
<Group
|
||||
component="nav"
|
||||
ref={paginationRef}
|
||||
aria-label={t("bank.pagination")}
|
||||
justify="center"
|
||||
py="sm"
|
||||
>
|
||||
<Pagination
|
||||
total={vm.totalPages}
|
||||
value={vm.page}
|
||||
onChange={(page) => {
|
||||
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",
|
||||
),
|
||||
})}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -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<unknown>();
|
||||
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<BankOperation | "new" | null>(
|
||||
@@ -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<typeof useCardPageVm>;
|
||||
|
||||
@@ -96,6 +96,9 @@ const ru = {
|
||||
previous: "Назад",
|
||||
next: "Далее",
|
||||
page: "Страница {{page}}",
|
||||
pagination: "Страницы операций",
|
||||
pageSize: "Операций на странице",
|
||||
allOperations: "Все",
|
||||
transferTo: "Перевод на «{{name}}»",
|
||||
transferFrom: "Перевод с «{{name}}»",
|
||||
edit: "Изменить",
|
||||
|
||||
Reference in New Issue
Block a user