386 lines
9.8 KiB
TypeScript
386 lines
9.8 KiB
TypeScript
import {
|
|
ActionIcon,
|
|
Anchor,
|
|
Button,
|
|
Group,
|
|
Pagination,
|
|
Select,
|
|
Skeleton,
|
|
Stack,
|
|
Text,
|
|
Title,
|
|
} from "@mantine/core";
|
|
import { type FC, useLayoutEffect, useRef } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Link } from "react-router";
|
|
import {
|
|
BankCardFace,
|
|
BankFeedback,
|
|
BankOperationIcon,
|
|
BankPeriodPicker,
|
|
BankSummaryView,
|
|
} from "@/entities/bank-card";
|
|
import { EntryCard, LoadingSkeleton, StateView } from "@/shared/ui";
|
|
import classes from "./CardPage.module.css";
|
|
import type { CardPageVm } from "./CardPage.vm";
|
|
|
|
type CardPageViewProps = { vm: CardPageVm };
|
|
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.period.from}-${vm.period.to}-${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 (
|
|
<LoadingSkeleton label={t("bank.loading")}>
|
|
<Stack>
|
|
<Skeleton h={130} />
|
|
<Skeleton h={160} />
|
|
<Skeleton h={90} />
|
|
<Skeleton h={90} />
|
|
</Stack>
|
|
</LoadingSkeleton>
|
|
);
|
|
return (
|
|
<StateView
|
|
error
|
|
title={t(vm.notFound ? "bank.notFound" : "bank.error")}
|
|
text={t("bank.back")}
|
|
action={
|
|
<Group>
|
|
<Button component={Link} to="/cards">
|
|
{t("bank.back")}
|
|
</Button>
|
|
{!vm.notFound && (
|
|
<Button onClick={() => void vm.card.refetch()}>
|
|
{t("bank.retry")}
|
|
</Button>
|
|
)}
|
|
</Group>
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<Stack gap="lg" className={classes.page}>
|
|
<Stack>
|
|
<Title order={1} className={classes.wrap}>
|
|
{card.name}
|
|
</Title>
|
|
<div className={classes.card}>
|
|
<BankCardFace card={card} />
|
|
<Group gap={4} wrap="nowrap" className={classes.cardActions}>
|
|
<ActionIcon
|
|
variant="subtle"
|
|
size="md"
|
|
radius="md"
|
|
aria-label={t("bank.editCard")}
|
|
title={t("bank.editCard")}
|
|
onClick={() => vm.setEditingCard(true)}
|
|
>
|
|
<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"
|
|
size="md"
|
|
radius="md"
|
|
color="red"
|
|
aria-label={t(card.archivedAt ? "bank.restore" : "bank.archive")}
|
|
title={t(card.archivedAt ? "bank.restore" : "bank.archive")}
|
|
loading={vm.archivePending}
|
|
onClick={() => void vm.toggleArchive()}
|
|
>
|
|
<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={
|
|
card.archivedAt
|
|
? "M3 3h18v4H3z M5 7v14h14V7 M12 17V10m-3 3 3-3 3 3"
|
|
: "M3 6h18 M9 6V3h6v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7"
|
|
}
|
|
/>
|
|
</svg>
|
|
</ActionIcon>
|
|
</Group>
|
|
</div>
|
|
{vm.archiveError ? (
|
|
<BankFeedback
|
|
error={vm.archiveError}
|
|
retry={() => void vm.card.refetch()}
|
|
/>
|
|
) : null}
|
|
{card.archivedAt && <Text>{t("bank.archiveHint")}</Text>}
|
|
</Stack>
|
|
{vm.card.isError && (
|
|
<Group role="alert">
|
|
<Text>{t("bank.refreshError")}</Text>
|
|
<Button onClick={() => void vm.card.refetch()}>
|
|
{t("bank.retry")}
|
|
</Button>
|
|
</Group>
|
|
)}
|
|
{vm.card.isFetching && <Text role="status">{t("bank.refreshing")}</Text>}
|
|
{vm.cards.isError && (
|
|
<BankFeedback
|
|
loadingError
|
|
error={vm.cards.error}
|
|
cached={Boolean(vm.cards.data)}
|
|
retry={() => void vm.cards.refetch()}
|
|
/>
|
|
)}
|
|
<BankPeriodPicker period={vm.period} onChange={vm.setPeriod} />
|
|
<BankSummaryView
|
|
data={vm.summary.data}
|
|
loading={vm.summary.isLoading}
|
|
fetching={vm.summary.isFetching}
|
|
error={vm.summary.isError}
|
|
retry={() => void vm.summary.refetch()}
|
|
/>
|
|
<Group justify="space-between" align="end">
|
|
<Group gap="xs">
|
|
<Title order={2}>{t("bank.operations")}</Title>
|
|
<ActionIcon
|
|
variant="subtle"
|
|
size="lg"
|
|
aria-label={t("bank.addOperation")}
|
|
title={t("bank.addOperation")}
|
|
disabled={Boolean(card.archivedAt) || !vm.cards.data}
|
|
onClick={() => vm.setOperation("new")}
|
|
>
|
|
<svg
|
|
width="20"
|
|
height="20"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
strokeLinecap="round"
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M12 5v14 M5 12h14" />
|
|
</svg>
|
|
</ActionIcon>
|
|
</Group>
|
|
<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.period.from}-${vm.period.to}-${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}?from=${vm.period.from}&to=${vm.period.to}`}
|
|
>
|
|
{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)}
|
|
>
|
|
<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>
|
|
<Button onClick={() => void vm.history.refetch()}>
|
|
{t("bank.retry")}
|
|
</Button>
|
|
</Group>
|
|
)}
|
|
{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>
|
|
);
|
|
};
|
|
|
|
export { CardPageView };
|