feat: improve card operations pagination and scroll behavior

This commit is contained in:
2026-09-12 20:31:44 +03:00
parent ae06f6e6e0
commit 9a65687d38
7 changed files with 431 additions and 128 deletions
@@ -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));
+49 -6
View File
@@ -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),
}); });
} }
+20
View File
@@ -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);
}
}
}
+106 -6
View File
@@ -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
View File
@@ -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>
+25 -4
View File
@@ -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>;
+3
View File
@@ -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: "Изменить",