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
+189 -112
View File
@@ -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>