feat: improve card operations pagination and scroll behavior
This commit is contained in:
+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>
|
||||
|
||||
Reference in New Issue
Block a user