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
140 lines
3.0 KiB
TypeScript
140 lines
3.0 KiB
TypeScript
import type { CSSProperties, FC } from "react";
|
|
|
|
import { Skeleton, Stack, Text, Title } from "@mantine/core";
|
|
import { OperationIcon } from "@wealthledger/icons";
|
|
import { LoadingSkeleton } from "@wealthledger/ui";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
import { formatMoney } from "@/shared/lib";
|
|
|
|
import type { BankSummary } from "../../api/types";
|
|
|
|
import { bankOperationAppearance } from "../../model/operation-appearance";
|
|
import { BankFeedback } from "../BankFeedback";
|
|
import classes from "./BankSummary.module.css";
|
|
|
|
const CATEGORY_COLUMNS = 2;
|
|
|
|
type BankSummaryViewProps = {
|
|
all?: boolean;
|
|
data?: BankSummary;
|
|
error: unknown;
|
|
fetching: boolean;
|
|
loading: boolean;
|
|
retry?: () => void;
|
|
};
|
|
|
|
const BankSummaryViewCategories: FC<BankSummaryViewProps> = (props) => {
|
|
const { t } = useTranslation();
|
|
|
|
if (props.data) {
|
|
if (props.data.categories.length > 0) {
|
|
return (
|
|
<dl
|
|
className={classes.categories}
|
|
style={
|
|
{
|
|
"--category-rows": Math.ceil(
|
|
props.data.categories.length / CATEGORY_COLUMNS,
|
|
),
|
|
} as CSSProperties
|
|
}
|
|
>
|
|
{props.data.categories.map(category => (
|
|
<div className={classes.category} key={category.categoryId}>
|
|
<dt className={classes.label}>
|
|
<OperationIcon
|
|
{...bankOperationAppearance("expense", category.categoryName)}
|
|
/>
|
|
|
|
<span>{category.categoryName}</span>
|
|
</dt>
|
|
|
|
<dd className={classes.amount}>
|
|
{formatMoney(category.net, { withZeroCents: true })}
|
|
</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Text c="dimmed" size="sm">
|
|
{t("bank.noExpenses")}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
if (props.loading) {
|
|
return (
|
|
<LoadingSkeleton label={t("bank.loading")}>
|
|
<Stack gap="sm">
|
|
<Skeleton h={32} w="35%" />
|
|
|
|
<Skeleton h={40} />
|
|
|
|
<Skeleton h={40} />
|
|
</Stack>
|
|
</LoadingSkeleton>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
|
|
const { t } = useTranslation();
|
|
|
|
return (
|
|
<Stack aria-busy={props.loading || props.fetching} gap="md">
|
|
<div className={classes.header}>
|
|
<Title order={2}>
|
|
{t(props.all ? "bank.allSummary" : "bank.summary")}
|
|
</Title>
|
|
|
|
{props.data && (
|
|
<dl className={classes.totals}>
|
|
<div>
|
|
<dt>{t("bank.summary")}</dt>
|
|
|
|
<dd>
|
|
{formatMoney(props.data.expenses, { withZeroCents: true })}
|
|
</dd>
|
|
</div>
|
|
|
|
<div>
|
|
<dt>{t("bank.refunds")}</dt>
|
|
|
|
<dd>
|
|
{formatMoney(props.data.refunds, { withZeroCents: true })}
|
|
</dd>
|
|
</div>
|
|
|
|
<div>
|
|
<dt>{t("bank.net")}</dt>
|
|
|
|
<dd className={classes.total}>
|
|
{formatMoney(props.data.net, { withZeroCents: true })}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
)}
|
|
</div>
|
|
|
|
<BankSummaryViewCategories {...props} />
|
|
|
|
{Boolean(props.error) && (
|
|
<BankFeedback
|
|
cached={Boolean(props.data)}
|
|
error={props.error}
|
|
loadingError={!props.data}
|
|
retry={props.retry}
|
|
/>
|
|
)}
|
|
</Stack>
|
|
);
|
|
};
|
|
|
|
export { BankSummaryView };
|