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
167 lines
4.7 KiB
TypeScript
167 lines
4.7 KiB
TypeScript
import { useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { useParams, useSearchParams } from "react-router";
|
||
|
||
import {
|
||
type BankOperation,
|
||
bankPeriod,
|
||
useBankCard,
|
||
useBankCards,
|
||
useBankOperations,
|
||
useBankSummary,
|
||
useUpdateBankCard,
|
||
} from "@/entities/bank-card";
|
||
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
|
||
import { formatDate, formatMoney } from "@/shared/lib";
|
||
|
||
const PAGE_SIZES = new Set(["5", "10", "20", "50", "100", "all"]);
|
||
|
||
type CardPageVm = ReturnType<typeof useCardPageVm>;
|
||
|
||
function useCardPageVm() {
|
||
const { cardId } = useParams();
|
||
const [parameters, setParameters] = useSearchParams();
|
||
const { t } = useTranslation();
|
||
const period = bankPeriod(parameters);
|
||
const requestedSize = parameters.get("limit") ?? "10";
|
||
const pageSize = PAGE_SIZES.has(requestedSize) ? requestedSize : "10";
|
||
const rawPage = Number(parameters.get("page") ?? 1);
|
||
|
||
const page
|
||
= pageSize !== "all" && Number.isSafeInteger(rawPage) && rawPage > 0
|
||
? rawPage
|
||
: 1;
|
||
|
||
const card = useBankCard(cardId);
|
||
const updateCard = useUpdateBankCard();
|
||
const cards = useBankCards(true);
|
||
|
||
const history = useBankOperations(cardId, {
|
||
...period,
|
||
limit: pageSize === "all" ? "all" : Number(pageSize),
|
||
page,
|
||
});
|
||
|
||
const summary = useBankSummary(period, cardId);
|
||
const [editingCard, setEditingCard] = useState(false);
|
||
|
||
const [operation, setOperation] = useState<"new"
|
||
| BankOperation
|
||
| null>(
|
||
null,
|
||
);
|
||
|
||
const [deleting, setDeleting] = useState<BankOperation | null>(null);
|
||
|
||
const rows = history.data?.items.map((item) => {
|
||
const isOutgoing
|
||
= item.kind === "expense"
|
||
|| (item.kind === "transfer" && item.cardId === cardId);
|
||
|
||
const otherCardId = isOutgoing ? item.targetCardId : item.cardId;
|
||
let title = t(`bank.kinds.${item.kind}`);
|
||
if (item.kind === "transfer")
|
||
title = t(isOutgoing ? "bank.transferTo" : "bank.transferFrom", {
|
||
name: isOutgoing ? item.targetCardName : item.cardName,
|
||
});
|
||
|
||
return {
|
||
color: isOutgoing ? "red" : "green",
|
||
editable:
|
||
!history.isPlaceholderData
|
||
&& !card.data?.archivedAt
|
||
&& Boolean(cards.data)
|
||
&& !cards.data?.some(
|
||
c =>
|
||
(c.id === item.cardId || c.id === item.targetCardId)
|
||
&& c.archivedAt,
|
||
),
|
||
item,
|
||
metadata: [formatDate(item.date), item.categoryName, item.description]
|
||
.filter(Boolean)
|
||
.join(" · "),
|
||
onDelete: () => setDeleting(item),
|
||
onEdit: () => setOperation(item),
|
||
otherCard:
|
||
item.kind === "transfer"
|
||
? otherCardId
|
||
: null,
|
||
title,
|
||
tone: isOutgoing ? ("negative" as const) : ("positive" as const),
|
||
value: `${isOutgoing ? "−" : "+"}${formatMoney(item.amount)}`,
|
||
};
|
||
});
|
||
|
||
return {
|
||
archiveError: updateCard.isError ? updateCard.error : undefined,
|
||
archivePending: updateCard.isPending,
|
||
canAddOperation: !card.data?.archivedAt && Boolean(cards.data),
|
||
card: card.data,
|
||
cardError: card.error,
|
||
cardLoading: card.isLoading,
|
||
cardRefreshError: card.isError,
|
||
cardRefreshing: card.isFetching,
|
||
cardRetry: () => card.refetch(),
|
||
cards: cards.data,
|
||
cardsFeedback: cards.isError
|
||
? { cached: Boolean(cards.data), error: cards.error }
|
||
: undefined,
|
||
cardsRetry: () => cards.refetch(),
|
||
closeDeletion: () => setDeleting(null),
|
||
closeEditing: () => setEditingCard(false),
|
||
closeOperation: () => setOperation(null),
|
||
deleting,
|
||
editableOperation: operation === "new" ? undefined : (operation ?? undefined),
|
||
editingCard,
|
||
history: {
|
||
changingPage: history.isPlaceholderData,
|
||
error: history.error,
|
||
loading: history.isLoading,
|
||
page: history.data?.page,
|
||
refetch: () => history.refetch(),
|
||
},
|
||
notFound: isApiErrorCode(
|
||
apiErrorCode(card.error),
|
||
ApiErrorCode.BankNotFound,
|
||
),
|
||
openCardEditor: () => setEditingCard(true),
|
||
openNewOperation: () => setOperation("new"),
|
||
operation,
|
||
page,
|
||
pageSize,
|
||
period,
|
||
rows,
|
||
setPage: (value: number) =>
|
||
setParameters({ ...period, limit: pageSize, page: String(value) }),
|
||
setPageSize: (value: null | string) => {
|
||
if (value && PAGE_SIZES.has(value))
|
||
setParameters({ ...period, limit: value });
|
||
},
|
||
setPeriod: (value: [null | string, null | string]) => {
|
||
if (value[0] && value[1])
|
||
setParameters({ from: value[0], limit: pageSize, to: value[1] });
|
||
},
|
||
summary: {
|
||
data: summary.data,
|
||
error: summary.error,
|
||
fetching: summary.isFetching,
|
||
loading: summary.isLoading,
|
||
refetch: () => summary.refetch(),
|
||
},
|
||
toggleArchive: () => {
|
||
if (!card.data) return;
|
||
|
||
updateCard.mutate({
|
||
body: { archived: !card.data.archivedAt, version: card.data.version },
|
||
id: card.data.id,
|
||
});
|
||
},
|
||
totalPages:
|
||
pageSize === "all"
|
||
? 1
|
||
: Math.max(1, Math.ceil((history.data?.total ?? 0) / Number(pageSize))),
|
||
};
|
||
}
|
||
|
||
export { type CardPageVm, useCardPageVm };
|