feat: improve card operations pagination and scroll behavior
This commit is contained in:
@@ -120,6 +120,45 @@ function useReadModels() {
|
|||||||
describe("banking API", () => {
|
describe("banking API", () => {
|
||||||
beforeEach(authenticate);
|
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 () => {
|
it("uses session-aware banking endpoints and explicit month/page without T-Bank", async () => {
|
||||||
const counts = new Map<string, number>();
|
const counts = new Map<string, number>();
|
||||||
server.use(...readHandlers(counts));
|
server.use(...readHandlers(counts));
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
apiClient,
|
||||||
apiQueryClient,
|
apiQueryClient,
|
||||||
type paths,
|
type paths,
|
||||||
useAuthenticatedQuery,
|
useAuthenticatedQuery,
|
||||||
@@ -8,6 +9,8 @@ type BankCard =
|
|||||||
paths["/api/v1/bank-cards/{id}"]["get"]["responses"][200]["content"]["application/json"];
|
paths["/api/v1/bank-cards/{id}"]["get"]["responses"][200]["content"]["application/json"];
|
||||||
type BankOperation =
|
type BankOperation =
|
||||||
paths["/api/v1/bank-cards/operations/{id}"]["get"]["responses"][200]["content"]["application/json"];
|
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 =
|
type BankSummary =
|
||||||
paths["/api/v1/bank-cards/summary"]["get"]["responses"][200]["content"]["application/json"];
|
paths["/api/v1/bank-cards/summary"]["get"]["responses"][200]["content"]["application/json"];
|
||||||
type ExpenseCategory =
|
type ExpenseCategory =
|
||||||
@@ -52,14 +55,54 @@ function useBankCategories() {
|
|||||||
|
|
||||||
function useBankOperations(
|
function useBankOperations(
|
||||||
id: string | undefined,
|
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({
|
return useAuthenticatedQuery({
|
||||||
...apiQueryClient.queryOptions(
|
queryKey: all ? ([...options.queryKey, "all"] as const) : options.queryKey,
|
||||||
"get",
|
placeholderData: (
|
||||||
"/api/v1/bank-cards/{id}/operations",
|
previousData: BankOperations | undefined,
|
||||||
{ params: { path: { id: id ?? "" }, query } },
|
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),
|
enabled: Boolean(id && query.month),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,3 +17,23 @@
|
|||||||
grid-template-columns: auto minmax(0, 1fr) minmax(0, 40%);
|
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";
|
} from "@testing-library/react";
|
||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import { MemoryRouter, Route, Routes } from "react-router";
|
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 { configureAuthClient } from "@/shared/api";
|
||||||
import { createTestQueryClient } from "@/test/query";
|
import { createTestQueryClient } from "@/test/query";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
@@ -71,10 +71,15 @@ function setup(
|
|||||||
http.get("*/api/v1/bank-cards/a/operations", ({ request }) => {
|
http.get("*/api/v1/bank-cards/a/operations", ({ request }) => {
|
||||||
requests.push(new URL(request.url).search);
|
requests.push(new URL(request.url).search);
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
items: [operation],
|
items: [
|
||||||
page: 1,
|
{
|
||||||
total: 2,
|
...operation,
|
||||||
hasNext: true,
|
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", () =>
|
http.get("*/api/v1/bank-cards/summary", () =>
|
||||||
@@ -93,7 +98,7 @@ function setup(
|
|||||||
const client = createTestQueryClient();
|
const client = createTestQueryClient();
|
||||||
render(
|
render(
|
||||||
<QueryClientProvider client={client}>
|
<QueryClientProvider client={client}>
|
||||||
<MantineProvider>
|
<MantineProvider env="test">
|
||||||
<MemoryRouter initialEntries={["/cards/a?month=2026-08"]}>
|
<MemoryRouter initialEntries={["/cards/a?month=2026-08"]}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/cards/:cardId" element={<CardPage />} />
|
<Route path="/cards/:cardId" element={<CardPage />} />
|
||||||
@@ -114,10 +119,22 @@ describe("CardPage", () => {
|
|||||||
expect(screen.getByText("03.08.2026")).toBeInTheDocument();
|
expect(screen.getByText("03.08.2026")).toBeInTheDocument();
|
||||||
expect(screen.getByText("−50,01 ₽")).toBeInTheDocument();
|
expect(screen.getByText("−50,01 ₽")).toBeInTheDocument();
|
||||||
expect(screen.getByText("-50 ₽")).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: "Далее" }));
|
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(requests.some((v) => v.includes("page=2"))).toBe(true),
|
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(screen.getByLabelText("Месяц"));
|
||||||
fireEvent.click(await screen.findByRole("button", { name: "Jul" }));
|
fireEvent.click(await screen.findByRole("button", { name: "Jul" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
@@ -129,6 +146,89 @@ describe("CardPage", () => {
|
|||||||
);
|
);
|
||||||
expect(screen.getByText(/10\s000 ₽/)).toBeInTheDocument();
|
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 () => {
|
it("keeps card and journal when summary fails", async () => {
|
||||||
setup({ summaryError: true });
|
setup({ summaryError: true });
|
||||||
await screen.findByRole("heading", { name: "Повседневная" });
|
await screen.findByRole("heading", { name: "Повседневная" });
|
||||||
|
|||||||
+189
-112
@@ -3,14 +3,16 @@ import {
|
|||||||
Anchor,
|
Anchor,
|
||||||
Button,
|
Button,
|
||||||
Group,
|
Group,
|
||||||
|
Pagination,
|
||||||
Paper,
|
Paper,
|
||||||
|
Select,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { MonthPickerInput } from "@mantine/dates";
|
import { MonthPickerInput } from "@mantine/dates";
|
||||||
import type { FC } from "react";
|
import { type FC, useLayoutEffect, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import {
|
import {
|
||||||
@@ -28,6 +30,34 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|
|||||||
const { vm } = props;
|
const { vm } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const card = vm.card.data;
|
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 (!card) {
|
||||||
if (vm.card.isLoading)
|
if (vm.card.isLoading)
|
||||||
return (
|
return (
|
||||||
@@ -128,102 +158,135 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|
|||||||
error={vm.summary.isError}
|
error={vm.summary.isError}
|
||||||
retry={() => void vm.summary.refetch()}
|
retry={() => void vm.summary.refetch()}
|
||||||
/>
|
/>
|
||||||
<Title order={2}>{t("bank.operations")}</Title>
|
<Group justify="space-between" align="end">
|
||||||
{vm.rows ? (
|
<Title order={2}>{t("bank.operations")}</Title>
|
||||||
vm.rows.length ? (
|
<Select
|
||||||
vm.rows.map((row) => (
|
label={t("bank.pageSize")}
|
||||||
<section
|
value={vm.pageSize}
|
||||||
key={row.item.id}
|
onChange={vm.setPageSize}
|
||||||
className={classes.operation}
|
allowDeselect={false}
|
||||||
aria-label={`${row.title} ${row.item.date}`}
|
w={180}
|
||||||
>
|
data={[
|
||||||
<EntryCard
|
"5",
|
||||||
title={row.title}
|
"10",
|
||||||
metadata={row.metadata}
|
"20",
|
||||||
color={row.color}
|
"50",
|
||||||
initial={row.title[0]}
|
"100",
|
||||||
icon={
|
{ value: "all", label: t("bank.allOperations") },
|
||||||
<BankOperationIcon
|
]}
|
||||||
kind={row.item.kind}
|
/>
|
||||||
categoryName={row.item.categoryName}
|
</Group>
|
||||||
/>
|
<Stack
|
||||||
}
|
key={`${vm.month}-${vm.pageSize}-${vm.history.data?.page ?? "loading"}`}
|
||||||
value={row.value}
|
gap="lg"
|
||||||
valueLabel={t(`bank.kinds.${row.item.kind}`)}
|
className={classes.operations}
|
||||||
tone={row.tone}
|
aria-busy={vm.history.isFetching}
|
||||||
details={
|
data-pending={vm.history.isPlaceholderData || undefined}
|
||||||
row.otherCard ? (
|
>
|
||||||
<Anchor
|
{vm.rows ? (
|
||||||
component={Link}
|
vm.rows.length ? (
|
||||||
to={`/cards/${row.otherCard}?month=${vm.month}`}
|
vm.rows.map((row) => (
|
||||||
>
|
<section
|
||||||
{t("bank.card")}
|
key={row.item.id}
|
||||||
</Anchor>
|
className={classes.operation}
|
||||||
) : null
|
aria-label={`${row.title} ${row.item.date}`}
|
||||||
}
|
>
|
||||||
actions={
|
<EntryCard
|
||||||
<Group gap={4} wrap="nowrap">
|
title={row.title}
|
||||||
<ActionIcon
|
metadata={row.metadata}
|
||||||
variant="subtle"
|
color={row.color}
|
||||||
disabled={!row.editable || Boolean(card.archivedAt)}
|
initial={row.title[0]}
|
||||||
size="sm"
|
icon={
|
||||||
aria-label={t("bank.edit")}
|
<BankOperationIcon
|
||||||
title={t("bank.edit")}
|
kind={row.item.kind}
|
||||||
onClick={() => vm.setOperation(row.item)}
|
categoryName={row.item.categoryName}
|
||||||
>
|
/>
|
||||||
<svg
|
}
|
||||||
width="18"
|
value={row.value}
|
||||||
height="18"
|
valueLabel={t(`bank.kinds.${row.item.kind}`)}
|
||||||
viewBox="0 0 24 24"
|
tone={row.tone}
|
||||||
fill="none"
|
details={
|
||||||
stroke="currentColor"
|
row.otherCard ? (
|
||||||
strokeWidth="1.5"
|
<Anchor
|
||||||
strokeLinecap="round"
|
component={Link}
|
||||||
strokeLinejoin="round"
|
to={`/cards/${row.otherCard}?month=${vm.month}`}
|
||||||
aria-hidden="true"
|
|
||||||
>
|
>
|
||||||
<path d="m16 3 5 5-12 12-6 1 1-6L16 3Z M13 6l5 5" />
|
{t("bank.card")}
|
||||||
</svg>
|
</Anchor>
|
||||||
</ActionIcon>
|
) : null
|
||||||
<ActionIcon
|
}
|
||||||
variant="subtle"
|
actions={
|
||||||
color="red"
|
<Group gap={4} wrap="nowrap">
|
||||||
disabled={!row.editable || Boolean(card.archivedAt)}
|
<ActionIcon
|
||||||
size="sm"
|
variant="subtle"
|
||||||
aria-label={t("bank.delete")}
|
disabled={
|
||||||
title={t("bank.delete")}
|
vm.history.isPlaceholderData ||
|
||||||
onClick={() => vm.setDeleting(row.item)}
|
!row.editable ||
|
||||||
>
|
Boolean(card.archivedAt)
|
||||||
<svg
|
}
|
||||||
width="18"
|
size="sm"
|
||||||
height="18"
|
aria-label={t("bank.edit")}
|
||||||
viewBox="0 0 24 24"
|
title={t("bank.edit")}
|
||||||
fill="none"
|
onClick={() => vm.setOperation(row.item)}
|
||||||
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
|
||||||
</svg>
|
width="18"
|
||||||
</ActionIcon>
|
height="18"
|
||||||
</Group>
|
viewBox="0 0 24 24"
|
||||||
}
|
fill="none"
|
||||||
/>
|
stroke="currentColor"
|
||||||
</section>
|
strokeWidth="1.5"
|
||||||
))
|
strokeLinecap="round"
|
||||||
) : (
|
strokeLinejoin="round"
|
||||||
<Text>{t("bank.noOperations")}</Text>
|
aria-hidden="true"
|
||||||
)
|
>
|
||||||
) : vm.history.isLoading ? (
|
<path d="m16 3 5 5-12 12-6 1 1-6L16 3Z M13 6l5 5" />
|
||||||
<LoadingSkeleton label={t("bank.loadingHistory")}>
|
</svg>
|
||||||
<Stack>
|
</ActionIcon>
|
||||||
<Skeleton h={90} />
|
<ActionIcon
|
||||||
<Skeleton h={90} />
|
variant="subtle"
|
||||||
</Stack>
|
color="red"
|
||||||
</LoadingSkeleton>
|
disabled={
|
||||||
) : null}
|
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 && (
|
{vm.history.isError && (
|
||||||
<Group role="alert">
|
<Group role="alert">
|
||||||
<Text>{t(vm.rows ? "bank.refreshError" : "bank.error")}</Text>
|
<Text>{t(vm.rows ? "bank.refreshError" : "bank.error")}</Text>
|
||||||
@@ -232,23 +295,37 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
{vm.history.data && (
|
{vm.history.data && vm.pageSize !== "all" && (
|
||||||
<Group>
|
<Group
|
||||||
<Button
|
component="nav"
|
||||||
variant="default"
|
ref={paginationRef}
|
||||||
disabled={vm.page === 1 || vm.history.isFetching}
|
aria-label={t("bank.pagination")}
|
||||||
onClick={() => vm.setPage(vm.page - 1)}
|
justify="center"
|
||||||
>
|
py="sm"
|
||||||
{t("bank.previous")}
|
>
|
||||||
</Button>
|
<Pagination
|
||||||
<Text>{t("bank.page", { page: vm.page })}</Text>
|
total={vm.totalPages}
|
||||||
<Button
|
value={vm.page}
|
||||||
variant="default"
|
onChange={(page) => {
|
||||||
disabled={!vm.history.data.hasNext || vm.history.isFetching}
|
if (paginationRef.current)
|
||||||
onClick={() => vm.setPage(vm.page + 1)}
|
pendingPage.current = {
|
||||||
>
|
scope,
|
||||||
{t("bank.next")}
|
page,
|
||||||
</Button>
|
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>
|
</Group>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -13,18 +13,29 @@ import {
|
|||||||
import { apiErrorCode } from "@/shared/api";
|
import { apiErrorCode } from "@/shared/api";
|
||||||
import { formatDate, formatMoney } from "@/shared/lib";
|
import { formatDate, formatMoney } from "@/shared/lib";
|
||||||
|
|
||||||
|
const PAGE_SIZES = ["5", "10", "20", "50", "100", "all"];
|
||||||
|
|
||||||
function useCardPageVm() {
|
function useCardPageVm() {
|
||||||
const { cardId } = useParams();
|
const { cardId } = useParams();
|
||||||
const [params, setParams] = useSearchParams();
|
const [params, setParams] = useSearchParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const month = bankMonth(params.get("month"));
|
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 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 card = useBankCard(cardId);
|
||||||
const { updateCard } = useBankMutations();
|
const { updateCard } = useBankMutations();
|
||||||
const [archiveError, setArchiveError] = useState<unknown>();
|
const [archiveError, setArchiveError] = useState<unknown>();
|
||||||
const cards = useBankCards(true);
|
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 summary = useBankSummary(month, cardId);
|
||||||
const [editingCard, setEditingCard] = useState(false);
|
const [editingCard, setEditingCard] = useState(false);
|
||||||
const [operation, setOperation] = useState<BankOperation | "new" | null>(
|
const [operation, setOperation] = useState<BankOperation | "new" | null>(
|
||||||
@@ -85,6 +96,11 @@ function useCardPageVm() {
|
|||||||
summary,
|
summary,
|
||||||
month,
|
month,
|
||||||
page,
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalPages:
|
||||||
|
pageSize === "all"
|
||||||
|
? 1
|
||||||
|
: Math.max(1, Math.ceil((history.data?.total ?? 0) / Number(pageSize))),
|
||||||
rows,
|
rows,
|
||||||
editingCard,
|
editingCard,
|
||||||
setEditingCard,
|
setEditingCard,
|
||||||
@@ -94,9 +110,14 @@ function useCardPageVm() {
|
|||||||
setDeleting,
|
setDeleting,
|
||||||
notFound: apiErrorCode(card.error) === "BANK_NOT_FOUND",
|
notFound: apiErrorCode(card.error) === "BANK_NOT_FOUND",
|
||||||
setMonth: (value: string) => {
|
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>;
|
type CardPageVm = ReturnType<typeof useCardPageVm>;
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ const ru = {
|
|||||||
previous: "Назад",
|
previous: "Назад",
|
||||||
next: "Далее",
|
next: "Далее",
|
||||||
page: "Страница {{page}}",
|
page: "Страница {{page}}",
|
||||||
|
pagination: "Страницы операций",
|
||||||
|
pageSize: "Операций на странице",
|
||||||
|
allOperations: "Все",
|
||||||
transferTo: "Перевод на «{{name}}»",
|
transferTo: "Перевод на «{{name}}»",
|
||||||
transferFrom: "Перевод с «{{name}}»",
|
transferFrom: "Перевод с «{{name}}»",
|
||||||
edit: "Изменить",
|
edit: "Изменить",
|
||||||
|
|||||||
Reference in New Issue
Block a user