AuthForm disables the switch-auth Link while submit is pending so pointer click and Enter can no longer leave the auth route and lose the draft/error context. The form restores a true Link after the request settles. CardsPage/CardPage distinguish cold vs cached banking failures: - cold card/section errors expose accessible alert + retry - cached failures keep the previous data and offer a section GET retry - summary-only/history-only outages do not hide successful sections - notFound still skips the retry CTA Tests cover the pending link guard, cold/cached cards error with retry, summary failure with retry, and the prerequisite card list recovery. Closes #56, #57
223 lines
4.7 KiB
TypeScript
223 lines
4.7 KiB
TypeScript
import {
|
|
ActionIcon,
|
|
Button,
|
|
Group,
|
|
Pagination,
|
|
Select,
|
|
Skeleton,
|
|
Stack,
|
|
Text,
|
|
Title,
|
|
} from "@mantine/core";
|
|
import {
|
|
AddIcon,
|
|
DeleteIcon,
|
|
EditIcon,
|
|
RestoreIcon,
|
|
} from "@wealthledger/icons";
|
|
import { LoadingSkeleton, StateView } from "@wealthledger/ui";
|
|
import { type FC } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Link } from "react-router";
|
|
|
|
import {
|
|
BankCardFace,
|
|
BankFeedback,
|
|
BankPeriodPicker,
|
|
BankSummaryView,
|
|
} from "@/entities/bank-card";
|
|
|
|
import type { CardPageVm } from "./CardPage.vm";
|
|
|
|
import { CardHistory } from "./CardHistory.view";
|
|
import classes from "./CardPage.module.css";
|
|
import { useCardPagePresentationVm } from "./CardPagePresentation.vm";
|
|
|
|
type CardPageViewProps = { vm: CardPageVm };
|
|
|
|
const CardPageView: FC<CardPageViewProps> = (props) => {
|
|
const { vm } = props;
|
|
|
|
const {
|
|
getControlProps,
|
|
getItemProps,
|
|
onPageChange,
|
|
paginationRef,
|
|
} = useCardPagePresentationVm(vm);
|
|
|
|
const { t } = useTranslation();
|
|
const card = vm.card;
|
|
const archiveActionLabel = t(card?.archivedAt ? "bank.restore" : "bank.archive");
|
|
|
|
if (!card) {
|
|
if (vm.cardLoading)
|
|
return (
|
|
<LoadingSkeleton label={t("bank.loading")}>
|
|
<Stack>
|
|
<Skeleton h={130} />
|
|
|
|
<Skeleton h={160} />
|
|
|
|
<Skeleton h={90} />
|
|
|
|
<Skeleton h={90} />
|
|
</Stack>
|
|
</LoadingSkeleton>
|
|
);
|
|
|
|
const errorTitle = t(vm.notFound ? "bank.notFound" : "bank.error");
|
|
|
|
return (
|
|
<StateView
|
|
action={(
|
|
<Group>
|
|
<Button component={Link} to="/cards">
|
|
{t("bank.back")}
|
|
</Button>
|
|
|
|
{!vm.notFound && (
|
|
<Button onClick={vm.cardRetry} variant="default">
|
|
{t("bank.retry")}
|
|
</Button>
|
|
)}
|
|
</Group>
|
|
)}
|
|
error
|
|
text={t("bank.back")}
|
|
title={errorTitle}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Stack className={classes.page} gap="lg">
|
|
<Stack>
|
|
<Title className={classes.wrap} order={1}>
|
|
{card.name}
|
|
</Title>
|
|
|
|
<div className={classes.card}>
|
|
<BankCardFace card={card} />
|
|
|
|
<Group className={classes.cardActions} gap={4} wrap="nowrap">
|
|
<ActionIcon
|
|
aria-label={t("bank.editCard")}
|
|
onClick={vm.openCardEditor}
|
|
radius="md"
|
|
size="md"
|
|
title={t("bank.editCard")}
|
|
variant="subtle"
|
|
>
|
|
<EditIcon size={18} strokeWidth={1.5} />
|
|
</ActionIcon>
|
|
|
|
<ActionIcon
|
|
aria-label={archiveActionLabel}
|
|
color="red"
|
|
loading={vm.archivePending}
|
|
onClick={vm.toggleArchive}
|
|
radius="md"
|
|
size="md"
|
|
title={archiveActionLabel}
|
|
variant="subtle"
|
|
>
|
|
{card.archivedAt
|
|
? <RestoreIcon size={18} />
|
|
: <DeleteIcon size={18} strokeWidth={1.5} />}
|
|
</ActionIcon>
|
|
</Group>
|
|
</div>
|
|
|
|
{!!vm.archiveError && (
|
|
<BankFeedback error={vm.archiveError} />
|
|
)}
|
|
|
|
{card.archivedAt && <Text>{t("bank.archiveHint")}</Text>}
|
|
|
|
{vm.cardError && vm.card && (
|
|
<BankFeedback
|
|
cached
|
|
error={vm.cardError}
|
|
retry={vm.cardRetry}
|
|
/>
|
|
)}
|
|
</Stack>
|
|
|
|
{vm.cardsFeedback && (
|
|
<BankFeedback
|
|
{...vm.cardsFeedback}
|
|
loadingError
|
|
retry={vm.cardsRetry}
|
|
/>
|
|
)}
|
|
|
|
<BankPeriodPicker onChange={vm.setPeriod} period={vm.period} />
|
|
|
|
<BankSummaryView {...vm.summary} retry={vm.summary.refetch} />
|
|
|
|
<Group align="end" justify="space-between">
|
|
<Group gap="xs">
|
|
<Title order={2}>{t("bank.operations")}</Title>
|
|
|
|
<ActionIcon
|
|
aria-label={t("bank.addOperation")}
|
|
disabled={!vm.canAddOperation}
|
|
onClick={vm.openNewOperation}
|
|
size="lg"
|
|
title={t("bank.addOperation")}
|
|
variant="subtle"
|
|
>
|
|
<AddIcon size={20} strokeWidth={1.5} />
|
|
</ActionIcon>
|
|
</Group>
|
|
|
|
<Select
|
|
allowDeselect={false}
|
|
data={[
|
|
"5",
|
|
"10",
|
|
"20",
|
|
"50",
|
|
"100",
|
|
{ label: t("bank.allOperations"), value: "all" },
|
|
]}
|
|
label={t("bank.pageSize")}
|
|
onChange={vm.setPageSize}
|
|
value={vm.pageSize}
|
|
w={180}
|
|
/>
|
|
</Group>
|
|
|
|
<Stack
|
|
className={classes.operations}
|
|
data-pending={vm.history.changingPage || undefined}
|
|
gap="lg"
|
|
key={`${vm.period.from}-${vm.period.to}-${vm.pageSize}-${vm.history.page ?? "loading"}`}
|
|
>
|
|
<CardHistory history={vm.history} period={vm.period} rows={vm.rows} />
|
|
</Stack>
|
|
|
|
{vm.history.page !== undefined && vm.pageSize !== "all" && (
|
|
<Group
|
|
aria-label={t("bank.pagination")}
|
|
component="nav"
|
|
justify="center"
|
|
py="sm"
|
|
ref={paginationRef}
|
|
>
|
|
<Pagination
|
|
getControlProps={getControlProps}
|
|
getItemProps={getItemProps}
|
|
onChange={onPageChange}
|
|
siblings={0}
|
|
total={vm.totalPages}
|
|
value={vm.page}
|
|
/>
|
|
</Group>
|
|
)}
|
|
</Stack>
|
|
);
|
|
};
|
|
|
|
export { CardPageView };
|