refactor: enforce frontend view model boundaries
Frontend verify / commits (pull_request) Successful in 28s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 8m19s

This commit is contained in:
2026-09-28 18:48:23 +03:00
parent 5b69003516
commit ca0ad4f824
287 changed files with 8558 additions and 4538 deletions
+36 -15
View File
@@ -18,8 +18,11 @@ import { server } from "@/test/server";
import { CardPage } from "./CardPage";
const money = (value: string) => ({ currency: "RUB", value });
const card = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const card
= {
archivedAt: null,
balance: money("10000"),
bankName: null,
@@ -29,10 +32,12 @@ const card = (/* eslint-disable unicorn/no-null -- Fixture preserves the product
openingBalance: money("10000"),
openingDate: "2026-01-01",
version: 1,
}
/* eslint-enable unicorn/no-null */);
const operation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
};
/* eslint-enable unicorn/no-null */
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const operation
= {
amount: money("50.01"),
availableRefund: null,
cardId: "a",
@@ -48,8 +53,9 @@ const operation = (/* eslint-disable unicorn/no-null -- Fixture preserves the pr
targetCardId: "b",
targetCardName: "Запасная",
version: 1,
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
function setup(
options: {
cardsError?: boolean;
@@ -78,6 +84,7 @@ function setup(
),
http.get("*/api/v1/bank-cards/a/operations", ({ request }) => {
requests.push(new URL(request.url).search);
return HttpResponse.json({
hasNext: Number(new URL(request.url).searchParams.get("page")) < 3,
items: [
@@ -92,6 +99,7 @@ function setup(
}),
http.get("*/api/v1/bank-cards/summary", ({ request }) => {
summaryRequests.push(new URL(request.url).search);
return options.summaryError
? HttpResponse.error()
: HttpResponse.json({
@@ -118,8 +126,10 @@ function setup(
</MantineProvider>
</QueryClientProvider>,
);
return { client, requests, summaryRequests };
}
describe("CardPage", () => {
it.each([
["0001-01", "0001-01-31"],
@@ -132,6 +142,7 @@ describe("CardPage", () => {
const { requests, summaryRequests } = setup({ month });
await screen.findByText("Перевод на «Запасная»");
await waitFor(() => expect(summaryRequests).toHaveLength(1));
for (const request of [requests[0], summaryRequests[0]]) {
const query = new URLSearchParams(request);
expect(query.get("from")).toBe(`${month}-01`);
@@ -193,20 +204,28 @@ describe("CardPage", () => {
expect(screen.getByText(/10\s000 ₽/)).toBeInTheDocument();
});
it("keeps the journal in place while the next page loads", async () => {
const scroll = vi.spyOn(globalThis, "scrollBy").mockImplementation(() => {});
const scroll = vi
.spyOn(globalThis, "scrollBy")
.mockImplementation(() => {});
setup();
await screen.findByText("Перевод на «Запасная»");
const navigation = screen.getByRole("navigation", {
name: "Страницы операций",
});
const measure = vi
.spyOn(navigation, "getBoundingClientRect")
.mockReturnValue({ top: 600 } as DOMRect);
const { promise: pending, resolve: release } = Promise.withResolvers<void>();
const { promise: pending, resolve: release }
= Promise.withResolvers<void>();
server.use(
http.get("*/api/v1/bank-cards/a/operations", async () => {
await pending;
return HttpResponse.json({
hasNext: true,
items: [{ ...operation, description: "Новая страница", id: "next" }],
@@ -256,7 +275,7 @@ describe("CardPage", () => {
await waitFor(() => {
expect(requests.at(-1)).toContain("limit=20");
});
expect(requests.at(-1)).toContain("page=1"); ;
expect(requests.at(-1)).toContain("page=1");
await waitFor(() =>
expect(screen.getByRole("button", { name: "Страница 1" })).toBeEnabled(),
);
@@ -321,14 +340,16 @@ describe("CardPage", () => {
archived: boolean;
version: number;
};
bodies.push(body);
current = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
current
= {
...current,
archivedAt: body.archived ? "2026-09-01" : null,
version: current.version + 1,
}
/* eslint-enable unicorn/no-null */) as typeof card;
} as /* eslint-enable unicorn/no-null */ typeof card;
return HttpResponse.json(current);
}),
);
+8 -10
View File
@@ -9,28 +9,26 @@ import { useCardPageVm } from "./CardPage.vm";
const CardPage: FC = () => {
const vm = useCardPageVm();
return (
<>
<CardPageView vm={vm} />
{vm.editingCard && vm.card.data && (
<BankCardForm
card={vm.card.data}
onClose={() => vm.setEditingCard(false)}
/>
<BankCardForm card={vm.card.data} onClose={vm.closeEditing} />
)}
{vm.operation && vm.card.data && vm.cards.data && (
<OperationForm
card={vm.card.data}
cards={vm.cards.data}
onClose={() => vm.setOperation(null)}
operation={(vm.operation === "new") ? undefined : (vm.operation)}
onClose={vm.closeOperation}
operation={vm.editableOperation}
/>
)}
{vm.deleting && (
<DeleteOperation
onClose={() => vm.setDeleting(null)}
operation={vm.deleting}
/>
<DeleteOperation onClose={vm.closeDeletion} operation={vm.deleting} />
)}
</>
);
+141 -144
View File
@@ -10,16 +10,22 @@ import {
Text,
Title,
} from "@mantine/core";
import { AddIcon, DeleteIcon, EditIcon } from "@wealthledger/icons";
import {
AddIcon,
DeleteIcon,
EditIcon,
OperationIcon,
RestoreIcon,
} from "@wealthledger/icons";
import { EntryCard, LoadingSkeleton, StateView } from "@wealthledger/ui";
import { type FC, type ReactNode, useLayoutEffect, useRef } from "react";
import { type FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import {
BankCardFace,
BankFeedback,
BankOperationIcon,
bankOperationAppearance,
BankPeriodPicker,
BankSummaryView,
} from "@/entities/bank-card";
@@ -27,80 +33,33 @@ import {
import type { CardPageVm } from "./CardPage.vm";
import classes from "./CardPage.module.css";
import { useCardPagePresentationVm } from "./CardPagePresentation.vm";
type CardPageViewProps = { vm: CardPageVm };
const CardPageView: FC<CardPageViewProps> = (props) => {
// eslint-disable-next-line sonarjs/function-return-type -- React renders elements, lists and empty output.
const CardPageViewHistory: FC<CardPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
const card = vm.card.data;
const paginationRef = useRef<HTMLDivElement>(null);
const pendingPageRef = useRef<null | {
page: number;
scope: string;
top: number;
}>(null);
const scope = `${card?.id}-${vm.period.from}-${vm.period.to}-${vm.pageSize}`;
useLayoutEffect(() => {
const pending = pendingPageRef.current;
if (!pending) return;
if (pending.scope !== scope || vm.history.isError) {
pendingPageRef.current = /* eslint-disable-line unicorn/no-null -- Reset the pending-pagination ref to its initial null sentinel. */ 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({ behavior: "instant", top: delta });
}
// eslint-disable-next-line unicorn/no-null -- Preserve the existing nullable validation/ref contract.
pendingPageRef.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
action={(
<Group>
<Button component={Link} to="/cards">
{t("bank.back")}
</Button>
{!vm.notFound && (
<Button onClick={() => void vm.card.refetch()}>
{t("bank.retry")}
</Button>
)}
</Group>
)}
error
text={t("bank.back")}
title={t(vm.notFound ? "bank.notFound" : "bank.error")}
/>
);
}
// eslint-disable-next-line sonarjs/function-return-type -- ReactNode deliberately includes lists, elements and empty output.
function renderHistory(): ReactNode {
if (!card) return;
if (vm.rows) {
if (vm.rows.length > 0) {
return vm.rows.map(row => (
if (!card) return;
if (vm.rows) {
if (vm.rows.length > 0) {
return vm.rows.map((row) => {
const details = row.otherCard
? (
<Anchor
component={Link}
to={`/cards/${row.otherCard}?from=${vm.period.from}&to=${vm.period.to}`}
>
{t("bank.card")}
</Anchor>
)
: undefined;
return (
<section
aria-label={`${row.title} ${row.item.date}`}
className={classes.operation}
@@ -116,13 +75,14 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|| !row.editable
|| Boolean(card.archivedAt)
}
onClick={() => vm.setOperation(row.item)}
onClick={row.onEdit}
size="sm"
title={t("bank.edit")}
variant="subtle"
>
<EditIcon size={18} strokeWidth={1.5} />
</ActionIcon>
<ActionIcon
aria-label={t("bank.delete")}
color="red"
@@ -131,7 +91,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|| !row.editable
|| Boolean(card.archivedAt)
}
onClick={() => vm.setDeleting(row.item)}
onClick={row.onDelete}
size="sm"
title={t("bank.delete")}
variant="subtle"
@@ -141,22 +101,13 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
</Group>
)}
color={row.color}
details={
(row.otherCard)
? ((
<Anchor
component={Link}
to={`/cards/${row.otherCard}?from=${vm.period.from}&to=${vm.period.to}`}
>
{t("bank.card")}
</Anchor>
))
: undefined
}
details={details}
icon={(
<BankOperationIcon
categoryName={row.item.categoryName}
kind={row.item.kind}
<OperationIcon
{...bankOperationAppearance(
row.item.kind,
row.item.categoryName,
)}
/>
)}
initial={row.title[0]}
@@ -167,33 +118,96 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
valueLabel={t(`bank.kinds.${row.item.kind}`)}
/>
</section>
));
}
return <Text>{t("bank.noOperations")}</Text>;
);
});
}
if (vm.history.isLoading) {
return <Text>{t("bank.noOperations")}</Text>;
}
if (vm.history.isLoading) {
return (
<LoadingSkeleton label={t("bank.loadingHistory")}>
<Stack>
<Skeleton h={90} />
<Skeleton h={90} />
</Stack>
</LoadingSkeleton>
);
}
};
const CardPageView: FC<CardPageViewProps> = (props) => {
const { vm } = props;
const {
archiveActionLabel,
card,
getControlProps,
getItemProps,
onAddOperation,
onEditCard,
onPageChange,
onToggleArchive,
paginationRef,
retryCard,
retryCards,
retryHistory,
retrySummary,
t,
} = useCardPagePresentationVm(vm);
if (!card) {
if (vm.card.isLoading)
return (
<LoadingSkeleton label={t("bank.loadingHistory")}>
<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={retryCard}>{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.setEditingCard(true)}
onClick={onEditCard}
radius="md"
size="md"
title={t("bank.editCard")}
@@ -201,31 +215,20 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
>
<EditIcon size={18} strokeWidth={1.5} />
</ActionIcon>
<ActionIcon
aria-label={t(card.archivedAt ? "bank.restore" : "bank.archive")}
aria-label={archiveActionLabel}
color="red"
loading={vm.archivePending}
onClick={() => void vm.toggleArchive()}
onClick={onToggleArchive}
radius="md"
size="md"
title={t(card.archivedAt ? "bank.restore" : "bank.archive")}
title={archiveActionLabel}
variant="subtle"
>
{card.archivedAt
? (
<svg
aria-hidden="true"
fill="none"
height="18"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width="18"
>
<path d="M3 3h18v4H3z M5 7v14h14V7 M12 17V10m-3 3 3-3 3 3" />
</svg>
<RestoreIcon size={18} />
)
: (
<DeleteIcon size={18} strokeWidth={1.5} />
@@ -233,46 +236,51 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
</ActionIcon>
</Group>
</div>
{!!(vm.archiveError) && ((
<BankFeedback
error={vm.archiveError}
retry={() => void vm.card.refetch()}
/>
))}
{!!vm.archiveError && (
<BankFeedback error={vm.archiveError} retry={retryCard} />
)}
{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>
<Button onClick={retryCard}>{t("bank.retry")}</Button>
</Group>
)}
{vm.card.isFetching && <Text role="status">{t("bank.refreshing")}</Text>}
{vm.cards.isError && (
<BankFeedback
cached={Boolean(vm.cards.data)}
error={vm.cards.error}
loadingError
retry={() => void vm.cards.refetch()}
retry={retryCards}
/>
)}
<BankPeriodPicker onChange={vm.setPeriod} period={vm.period} />
<BankSummaryView
data={vm.summary.data}
error={vm.summary.isError}
fetching={vm.summary.isFetching}
loading={vm.summary.isLoading}
retry={() => void vm.summary.refetch()}
retry={retrySummary}
/>
<Group align="end" justify="space-between">
<Group gap="xs">
<Title order={2}>{t("bank.operations")}</Title>
<ActionIcon
aria-label={t("bank.addOperation")}
disabled={Boolean(card.archivedAt) || !vm.cards.data}
onClick={() => vm.setOperation("new")}
onClick={onAddOperation}
size="lg"
title={t("bank.addOperation")}
variant="subtle"
@@ -280,6 +288,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
<AddIcon size={20} strokeWidth={1.5} />
</ActionIcon>
</Group>
<Select
allowDeselect={false}
data={[
@@ -296,6 +305,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
w={180}
/>
</Group>
<Stack
aria-busy={vm.history.isFetching}
className={classes.operations}
@@ -303,16 +313,17 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
gap="lg"
key={`${vm.period.from}-${vm.period.to}-${vm.pageSize}-${vm.history.data?.page ?? "loading"}`}
>
{renderHistory()}
<CardPageViewHistory {...props} />
</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>
<Button onClick={retryHistory}>{t("bank.retry")}</Button>
</Group>
)}
{vm.history.data && vm.pageSize !== "all" && (
<Group
aria-label={t("bank.pagination")}
@@ -323,23 +334,9 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
>
<Pagination
disabled={vm.history.isFetching}
getControlProps={control => ({
"aria-label": t(
control === "previous" ? "bank.previous" : "bank.next",
),
})}
getItemProps={page => ({
"aria-label": t("bank.page", { page }),
})}
onChange={(page) => {
if (paginationRef.current)
pendingPageRef.current = {
page,
scope,
top: paginationRef.current.getBoundingClientRect().top,
};
vm.setPage(page);
}}
getControlProps={getControlProps}
getItemProps={getItemProps}
onChange={onPageChange}
siblings={0}
total={vm.totalPages}
value={vm.page}
+30 -4
View File
@@ -17,6 +17,7 @@ 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();
@@ -25,32 +26,46 @@ function useCardPageVm() {
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 } = useBankMutations();
const [archiveError, setArchiveError] = useState<unknown>();
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>(
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 });
if (item.kind === "transfer")
title = t(isOutgoing ? "bank.transferTo" : "bank.transferFrom", {
name: isOutgoing ? item.targetCardName : item.cardName,
});
return {
color: isOutgoing ? "red" : "green",
editable:
@@ -64,24 +79,34 @@ function useCardPageVm() {
metadata: [formatDate(item.date), item.categoryName, item.description]
.filter(Boolean)
.join(" · "),
onDelete: () => setDeleting(item),
onEdit: () => setOperation(item),
otherCard:
item.kind === "transfer"
? otherCardId
: /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
: null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
title,
tone: isOutgoing ? ("negative" as const) : ("positive" as const),
value: `${isOutgoing ? "−" : "+"}${formatMoney(item.amount)}`,
};
});
return {
archiveError,
archivePending: updateCard.isPending,
card,
cards,
closeDeletion: () => setDeleting(null),
closeEditing: () => setEditingCard(false),
closeOperation: () => setOperation(null),
deleting,
editableOperation: operation === "new" ? undefined : (operation ?? undefined),
editingCard,
history,
notFound: isApiErrorCode(apiErrorCode(card.error), ApiErrorCode.BankNotFound),
notFound: isApiErrorCode(
apiErrorCode(card.error),
ApiErrorCode.BankNotFound,
),
operation,
page,
pageSize,
@@ -104,6 +129,7 @@ function useCardPageVm() {
toggleArchive: async () => {
if (!card.data) return;
setArchiveError(undefined);
try {
await updateCard.mutateAsync({
body: { archived: !card.data.archivedAt, version: card.data.version },
@@ -0,0 +1,98 @@
import type { ComponentProps } from "react";
import { Pagination } from "@mantine/core";
import { useLayoutEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import type { CardPageVm } from "./CardPage.vm";
function useCardPagePresentationVm(vm: CardPageVm) {
const { t } = useTranslation();
const card = vm.card.data;
const paginationRef = useRef<HTMLDivElement>(null);
const pendingPageRef = useRef<null
| {
page: number;
scope: string;
top: number;
}>(null);
const scope = `${card?.id}-${vm.period.from}-${vm.period.to}-${vm.pageSize}`;
useLayoutEffect(() => {
const pending = pendingPageRef.current;
if (!pending) return;
if (pending.scope !== scope || vm.history.isError) {
pendingPageRef.current
// eslint-disable-next-line unicorn/no-null -- Reset the pending-pagination ref to its initial null sentinel.
= 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({ behavior: "instant", top: delta });
}
// eslint-disable-next-line unicorn/no-null -- Preserve the existing nullable validation/ref contract.
pendingPageRef.current = null;
}, [
scope,
vm.history.data,
vm.history.isPlaceholderData,
vm.history.isError,
]);
const onEditCard = () => vm.setEditingCard(true);
const archiveActionLabel = t(card?.archivedAt ? "bank.restore" : "bank.archive");
const onToggleArchive = () => void vm.toggleArchive();
const retryCard = () => void vm.card.refetch();
const retryCards = () => void vm.cards.refetch();
const retrySummary = () => void vm.summary.refetch();
const onAddOperation = () => vm.setOperation("new");
const retryHistory = () => void vm.history.refetch();
const getControlProps: ComponentProps<
typeof Pagination
>["getControlProps"] = control => ({
"aria-label": t(control === "previous" ? "bank.previous" : "bank.next"),
});
const getItemProps: ComponentProps<typeof Pagination>["getItemProps"] = page => ({
"aria-label": t("bank.page", { page }),
});
const onPageChange: ComponentProps<typeof Pagination>["onChange"] = (page) => {
if (paginationRef.current)
pendingPageRef.current = {
page,
scope,
top: paginationRef.current.getBoundingClientRect().top,
};
vm.setPage(page);
};
return {
archiveActionLabel,
card,
getControlProps,
getItemProps,
onAddOperation,
onEditCard,
onPageChange,
onToggleArchive,
paginationRef,
retryCard,
retryCards,
retryHistory,
retrySummary,
t,
};
}
export { useCardPagePresentationVm };
@@ -12,8 +12,10 @@ import { server } from "@/test/server";
import { DeleteOperation } from "./DeleteOperation";
const transfer: BankOperation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const transfer: BankOperation
= {
amount: { currency: "RUB", value: "100" },
availableRefund: null,
cardId: "a",
@@ -29,8 +31,9 @@ const transfer: BankOperation = (/* eslint-disable unicorn/no-null -- Fixture pr
targetCardId: "b",
targetCardName: "Запасная",
version: 1,
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
describe("DeleteOperation", () => {
it("names both sides and requires a deliberate fresh version after conflict", async () => {
configureAuthClient({
@@ -46,6 +49,7 @@ describe("DeleteOperation", () => {
http.delete("*/api/v1/bank-cards/operations/transfer", ({ request }) => {
const version = new URL(request.url).searchParams.get("version") ?? "";
versions.push(version);
return version === "1"
? HttpResponse.json(
{ code: "BANK_VERSION_CONFLICT" },
@@ -1,36 +1,34 @@
import { Button, Group, Modal, Stack, Text } from "@mantine/core";
import { type FC, useState } from "react";
import { useTranslation } from "react-i18next";
import { type FC } from "react";
import {
BankFeedback,
type BankOperation,
useBankFormFocus,
useBankMutations,
useBankOperation,
} from "@/entities/bank-card";
import { BankFeedback, type BankOperation } from "@/entities/bank-card";
import { formatDate, formatMoney } from "@/shared/lib";
import { useDeleteOperationVm } from "./DeleteOperation.vm";
type DeleteOperationProps = { onClose: () => void; operation: BankOperation };
const DeleteOperation: FC<DeleteOperationProps> = (props) => {
useBankFormFocus();
const [opened, setOpened] = useState(true);
const { t } = useTranslation();
const [record, setRecord] = useState(props.operation);
const [latest, setLatest] = useState<BankOperation>();
const [error, setError] = useState<unknown>();
const query = useBankOperation(record.id);
const { deleteOperation: operationDeletion } = useBankMutations();
const isPending
= !opened
|| operationDeletion.isPending
|| (query.isFetching && Boolean(error));
const {
error,
isPending,
latest,
onAdoptVersion,
onCancel,
onDelete,
onReloadRecord,
onRequestClose,
opened,
record,
t,
} = useDeleteOperationVm(props);
return (
<Modal
closeButtonProps={{ "aria-label": t("bank.close"), "disabled": isPending }}
closeOnClickOutside={!isPending}
closeOnEscape={!isPending}
onClose={() => !isPending && setOpened(false)}
onClose={onRequestClose}
onExitTransitionEnd={props.onClose}
opened={opened}
title={t("bank.deleteTitle")}
@@ -49,22 +47,21 @@ const DeleteOperation: FC<DeleteOperationProps> = (props) => {
kind: t(`bank.kinds.${record.kind}`),
})}
</Text>
{!!(error) && ((
{!!error && (
<>
<BankFeedback error={error} />
<Button
disabled={isPending}
onClick={async () => {
const result = await query.refetch();
if (result.data && !result.error) setLatest(result.data);
else setError(result.error);
}}
onClick={onReloadRecord}
variant="default"
>
{t("bank.reloadRecord")}
</Button>
</>
))}
)}
{latest && (
<>
<Text>
@@ -72,44 +69,23 @@ const DeleteOperation: FC<DeleteOperationProps> = (props) => {
details: `${formatDate(latest.date)} · ${formatMoney(latest.amount)} · ${latest.description ?? ""}`,
})}
</Text>
<Button
disabled={isPending}
onClick={() => {
setRecord(latest);
setLatest(undefined);
setError(undefined);
}}
onClick={onAdoptVersion}
variant="default"
>
{t("bank.adoptVersion", { version: latest.version })}
</Button>
</>
)}
<Group>
<Button
color="red"
loading={isPending}
onClick={async () => {
setError(undefined);
try {
await operationDeletion.mutateAsync({
id: record.id,
version: record.version,
});
setOpened(false);
}
catch (error_) {
setError(error_);
}
}}
>
<Button color="red" loading={isPending} onClick={onDelete}>
{t("bank.delete")}
</Button>
<Button
disabled={isPending}
onClick={() => setOpened(false)}
variant="default"
>
<Button disabled={isPending} onClick={onCancel} variant="default">
{t("bank.cancel")}
</Button>
</Group>
@@ -0,0 +1,75 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
type BankOperation,
useBankFormFocus,
useBankMutations,
useBankOperation,
} from "@/entities/bank-card";
type DeleteOperationProps = { onClose: () => void; operation: BankOperation };
function useDeleteOperationVm(props: DeleteOperationProps) {
useBankFormFocus();
const [opened, setOpened] = useState(true);
const { t } = useTranslation();
const [record, setRecord] = useState(props.operation);
const [latest, setLatest] = useState<BankOperation>();
const [error, setError] = useState<unknown>();
const query = useBankOperation(record.id);
const { deleteOperation: operationDeletion } = useBankMutations();
const isPending
= !opened
|| operationDeletion.isPending
|| (query.isFetching && Boolean(error));
const onRequestClose = () => !isPending && setOpened(false);
const onReloadRecord = async () => {
const result = await query.refetch();
if (result.data && !result.error) setLatest(result.data);
else setError(result.error);
};
const onAdoptVersion = () => {
if (!latest) return;
setRecord(latest);
setLatest(undefined);
setError(undefined);
};
const onDelete = async () => {
setError(undefined);
try {
await operationDeletion.mutateAsync({
id: record.id,
version: record.version,
});
setOpened(false);
}
catch (error_) {
setError(error_);
}
};
const onCancel = () => setOpened(false);
return {
error,
isPending,
latest,
onAdoptVersion,
onCancel,
onDelete,
onReloadRecord,
onRequestClose,
opened,
record,
t,
};
}
export { useDeleteOperationVm };
@@ -0,0 +1,99 @@
import type { ComponentProps } from "react";
import { DateInput } from "@mantine/dates";
import { useController, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { OperationValues } from "./OperationForm.types";
type Choice = { label: string; value: string };
type OperationFieldsProps = {
cards: Choice[];
categories: Choice[];
dateInputValue: (value: string) => string;
defaultCategory: string;
editing: boolean;
kind: OperationValues["kind"];
kinds: Choice[];
maxDate: () => string;
refundCardId: string;
targets: Choice[];
validDate: (value: string) => boolean;
};
function useOperationFieldsVm(props: OperationFieldsProps) {
const { t } = useTranslation();
const {
control,
formState: { errors, isSubmitting },
register,
setValue,
} = useFormContext<OperationValues>();
const { field: kindField } = useController({ control, name: "kind" });
const { field: cardIdField } = useController({ control, name: "cardId" });
const { field: targetCardIdField } = useController({
control,
name: "targetCardId",
});
const { field: dateField } = useController({ control, name: "date" });
const { field: categoryIdField } = useController({
control,
name: "categoryId",
});
const onKindChange: (value: null | string) => void = (value) => {
kindField.onChange(value ?? "expense");
if (value === "refund") setValue("cardId", props.refundCardId);
};
const sourceCardLabel = t(
props.kind === "transfer" ? "bank.source" : "bank.operationCard",
);
const dateParser: ComponentProps<typeof DateInput>["dateParser"] = value =>
props.validDate(props.dateInputValue(value))
? props.dateInputValue(value)
: null; // eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled dateField as null.
const onDateBlur: ComponentProps<typeof DateInput>["onBlur"] = (event) => {
dateField.onChange(props.dateInputValue(event.currentTarget.value));
dateField.onBlur();
};
const onDateChange: (value: null | string) => void = value =>
dateField.onChange(value ?? "");
const onDateInput: ComponentProps<typeof DateInput>["onInput"] = event =>
dateField.onChange(props.dateInputValue(event.currentTarget.value));
const dateValue = props.validDate(dateField.value)
? dateField.value
: undefined;
return {
cardIdField,
categoryIdField,
dateField,
dateParser,
dateValue,
errors,
isSubmitting,
kindField,
onDateBlur,
onDateChange,
onDateInput,
onKindChange,
register,
sourceCardLabel,
t,
targetCardIdField,
};
}
export { useOperationFieldsVm };
@@ -0,0 +1,131 @@
import { z } from "zod";
import {
type BankCard,
bankDateSchema,
type BankOperation,
bankOperationKinds,
moneyCents,
positiveMoneySchema,
} from "@/entities/bank-card";
import { validationMessage } from "@/shared/i18n";
import type { OperationValues } from "./OperationForm.types";
// ZodIssueCode is deprecated; this is the custom refinement code accepted by Zod.
enum FormIssueCode {
Custom = "custom",
}
type SchemaInputs = {
cards: BankCard[];
categories?: { id: string; isDefault: boolean }[];
expense?: BankOperation;
limit?: BankOperation["availableRefund"];
submittedValues?: OperationValues;
};
const MAX_DESCRIPTION_LENGTH = 500;
const operationSchema = z.object({
amount: positiveMoneySchema,
cardId: z.string(),
categoryId: z.string(),
date: bankDateSchema,
description: z
.string()
.max(
MAX_DESCRIPTION_LENGTH,
validationMessage("bank.validation.description")(undefined),
),
kind: z.enum(bankOperationKinds),
originalExpenseId: z.string(),
targetCardId: z.string(),
});
function createOperationSchema({
cards,
categories,
expense,
limit,
submittedValues,
}: SchemaInputs) {
return operationSchema.superRefine((values, context) => {
// Repeated intent reaches server idempotency even after its source changed.
if (
submittedValues
&& JSON.stringify(values) === JSON.stringify(submittedValues)
)
return;
const source = cards.find(c => c.id === values.cardId);
const target = cards.find(c => c.id === values.targetCardId);
if (!source || source.archivedAt)
context.addIssue({
code: FormIssueCode.Custom,
path: ["cardId"],
...validationMessage("bank.validation.required")(undefined),
});
if (
values.kind === "transfer"
&& (!target || target.archivedAt || target.id === values.cardId)
)
context.addIssue({
code: FormIssueCode.Custom,
path: ["targetCardId"],
...validationMessage("bank.validation.destination")(undefined),
});
if (
(source && values.date < source.openingDate)
|| (target && values.kind === "transfer" && values.date < target.openingDate)
)
context.addIssue({
code: FormIssueCode.Custom,
path: ["date"],
...validationMessage("bank.validation.start")(undefined),
});
if (values.kind === "refund")
validateRefund(values, context, expense, limit);
if (
values.kind === "expense"
&& !categories?.some(
c =>
c.id
=== (values.categoryId || categories?.find(c => c.isDefault)?.id),
)
)
context.addIssue({
code: FormIssueCode.Custom,
path: ["categoryId"],
...validationMessage("bank.validation.required")(undefined),
});
});
}
function validateRefund(
values: OperationValues,
context: z.RefinementCtx,
expense: BankOperation | undefined,
limit?: BankOperation["availableRefund"],
) {
if (!expense || expense.cardId !== values.cardId)
context.addIssue({
code: FormIssueCode.Custom,
path: ["originalExpenseId"],
...validationMessage("bank.validation.required")(undefined),
});
if (expense && values.date < expense.date)
context.addIssue({
code: FormIssueCode.Custom,
path: ["date"],
...validationMessage("bank.validation.start")(undefined),
});
const amount = moneyCents(values.amount);
if (limit && amount !== null && amount > (moneyCents(limit.value) ?? 0n))
context.addIssue({
code: FormIssueCode.Custom,
path: ["amount"],
...validationMessage("bank.validation.refund")(undefined),
});
}
export { createOperationSchema };
@@ -9,9 +9,11 @@ import type { OperationValues } from "./OperationForm.types";
import { OperationFormView } from "./OperationForm.view";
type ExampleProps = { conflict?: boolean; pending?: boolean };
const OperationExample: FC<ExampleProps> = (props) => {
const [opened, setOpened] = useState(true);
const [action, setAction] = useState("");
const methods = useForm<OperationValues>({
defaultValues: {
amount: "500",
@@ -24,7 +26,9 @@ const OperationExample: FC<ExampleProps> = (props) => {
targetCardId: "",
},
});
const kind = useWatch({ control: methods.control, name: "kind" });
return (
<FormProvider {...methods}>
<OperationFormView
@@ -52,12 +56,12 @@ const OperationExample: FC<ExampleProps> = (props) => {
validDate: value => value === "2026-08-01",
}}
latest={
(props.conflict)
? ({
props.conflict
? {
details: "Учебная версия",
onAdopt: () => setAction("Локальное принятие версии"),
version: 2,
})
}
: undefined
}
loadingExpenses={false}
@@ -69,7 +73,7 @@ const OperationExample: FC<ExampleProps> = (props) => {
onNext={() => {}}
onPrevious={() => {}}
onReload={
(props.conflict) ? () => setAction("Локальный reload") : undefined
props.conflict ? () => setAction("Локальный reload") : undefined
}
onSubmit={(event) => {
event.preventDefault();
@@ -92,10 +96,12 @@ const OperationExample: FC<ExampleProps> = (props) => {
saveDisabled={false}
sourcePage={1}
/>
<output aria-live="polite">{action}</output>
</FormProvider>
);
};
const meta = {
component: OperationExample,
parameters: {
@@ -109,8 +115,11 @@ const meta = {
tags: ["autodocs"],
title: "Product/OperationForm",
} satisfies Meta<typeof OperationExample>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Refund: Story = {};
export const Pending: Story = { args: { pending: true } };
export const Conflict: Story = { args: { conflict: true } };
@@ -19,8 +19,11 @@ import { server } from "@/test/server";
import { OperationForm } from "./OperationForm";
const money = (value: string) => ({ currency: "RUB", value });
const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const card: BankCard
= {
archivedAt: null,
balance: money("0"),
bankName: null,
@@ -31,12 +34,14 @@ const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves t
openingDate: "2026-01-01",
paymentSystem: null,
version: 1,
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
async function select(label: RegExp, value: RegExp | string) {
fireEvent.click(screen.getByRole("combobox", { name: label }));
fireEvent.click(await screen.findByRole("option", { name: value }));
}
function setup(operation?: BankOperation) {
configureAuthClient({
accessToken: () => "test",
@@ -72,16 +77,21 @@ function setup(operation?: BankOperation) {
</MantineProvider>
</QueryClientProvider>,
);
return close;
}
describe("OperationForm", () => {
it("blocks closing and editing while save is pending, then closes after success", async () => {
const { promise: response, resolve: finish } = Promise.withResolvers<void>();
const { promise: response, resolve: finish }
= Promise.withResolvers<void>();
const received = vi.fn();
server.use(
http.post("*/api/v1/bank-cards/operations", async () => {
received();
await response;
return HttpResponse.json({}, { status: 201 });
}),
);
@@ -96,6 +106,7 @@ describe("OperationForm", () => {
});
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(received).toHaveBeenCalledOnce());
try {
expect(screen.getByLabelText(/Сумма/)).toBeDisabled();
expect(screen.getByRole("button", { name: "Закрыть" })).toBeDisabled();
@@ -107,6 +118,7 @@ describe("OperationForm", () => {
finally {
finish();
}
await waitFor(() => expect(close).toHaveBeenCalledOnce());
});
@@ -126,6 +138,7 @@ describe("OperationForm", () => {
server.use(
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
body = await request.json();
return HttpResponse.json({}, { status: 201 });
}),
);
@@ -155,6 +168,7 @@ describe("OperationForm", () => {
server.use(
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
bodies.push(await request.json());
return bodies.length === 1
? HttpResponse.error()
: HttpResponse.json({}, { status: 201 });
@@ -195,6 +209,7 @@ describe("OperationForm", () => {
server.use(
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
body = await request.json();
return HttpResponse.json({}, { status: 201 });
}),
);
@@ -214,8 +229,9 @@ describe("OperationForm", () => {
expect(body).not.toHaveProperty("categoryId");
});
it("finds a previous-month purchase across pages, checks remaining refund and retains the complete-refund intent", async () => {
const purchase: BankOperation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const purchase: BankOperation
= {
amount: money("500"),
availableRefund: money("500"),
cardId: "a",
@@ -231,8 +247,9 @@ describe("OperationForm", () => {
targetCardId: null,
targetCardName: null,
version: 1,
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
server.use(
http.get("*/api/v1/bank-cards/a/expenses", ({ request }) => {
return HttpResponse.json({
@@ -270,6 +287,7 @@ describe("OperationForm", () => {
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
bodies.push(await request.json());
purchase.availableRefund = money("0");
return bodies.length === 1
? HttpResponse.error()
: HttpResponse.json({}, { status: 201 });
@@ -295,8 +313,9 @@ describe("OperationForm", () => {
});
});
it("edits a full refund with its own amount excluded from the limit and immutable purchase", async () => {
const refund: BankOperation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const refund: BankOperation
= {
amount: money("500"),
availableRefund: money("500"),
cardId: "a",
@@ -312,8 +331,9 @@ describe("OperationForm", () => {
targetCardId: null,
targetCardName: null,
version: 2,
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
const purchase = {
...refund,
availableRefund: money("0"),
@@ -321,6 +341,7 @@ describe("OperationForm", () => {
id: "purchase",
kind: "expense",
};
server.use(
http.get("*/api/v1/bank-cards/a/expenses", () =>
HttpResponse.json({
@@ -354,6 +375,7 @@ describe("OperationForm", () => {
"*/api/v1/bank-cards/operations/refund",
async ({ request }) => {
body = await request.json();
return HttpResponse.json(refund);
},
),
@@ -372,8 +394,9 @@ describe("OperationForm", () => {
});
it("keeps edit draft and stale version until explicit adoption after conflict", async () => {
const operation: BankOperation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
const operation: BankOperation
= {
amount: money("100"),
availableRefund: money("100"),
cardId: "a",
@@ -389,8 +412,9 @@ describe("OperationForm", () => {
targetCardId: null,
targetCardName: null,
version: 1,
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
let readVersion = 1;
const bodies: Record<string, unknown>[] = [];
server.use(
@@ -407,6 +431,7 @@ describe("OperationForm", () => {
const body = (await request.json()) as Record<string, unknown>;
bodies.push(body);
readVersion = 2;
return body.version === 1
? HttpResponse.json(
{ code: "BANK_VERSION_CONFLICT" },
@@ -465,6 +490,7 @@ describe("OperationForm", () => {
server.use(
http.post("*/api/v1/bank-cards/categories", () => {
isCreated = true;
return HttpResponse.json(
{ id: "new", isDefault: false, name: "кафе", version: 1 },
{ status: 201 },
@@ -498,6 +524,7 @@ describe("OperationForm", () => {
server.use(
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
body = await request.json();
return HttpResponse.json({}, { status: 201 });
}),
);
@@ -11,6 +11,8 @@ import { useOperationFormVm } from "./OperationForm.vm";
const OperationForm: FC<OperationFormProps> = (props) => {
const vm = useOperationFormVm(props);
const feedback = vm.error ? <BankFeedback error={vm.error} /> : undefined;
return (
<>
<FormProvider {...vm.methods}>
@@ -25,7 +27,7 @@ const OperationForm: FC<OperationFormProps> = (props) => {
/>
)
}
feedback={(vm.error) ? (<BankFeedback error={vm.error} />) : undefined}
feedback={feedback}
refundFeedback={(
<>
{vm.expensesError && (
@@ -34,8 +36,7 @@ const OperationForm: FC<OperationFormProps> = (props) => {
loadingError
retry={vm.retryExpenses}
/>
)}
{" "}
)}{" "}
{vm.sourceError && (
<BankFeedback
error={vm.sourceError}
@@ -47,6 +48,7 @@ const OperationForm: FC<OperationFormProps> = (props) => {
)}
/>
</FormProvider>
{vm.categoriesOpen && (
<CategoryManager
onClose={vm.closeCategories}
@@ -11,7 +11,6 @@ import {
TextInput,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { BankOperation } from "@/entities/bank-card";
@@ -20,9 +19,12 @@ import { formatMoney } from "@/shared/lib";
import type { OperationValues } from "./OperationForm.types";
import { useOperationFieldsVm } from "./OperationFields.vm";
import classes from "./OperationForm.module.css";
import { useRefundFieldsVm } from "./RefundFields.vm";
type Choice = { label: string; value: string };
type OperationFieldsProps = {
cards: Choice[];
categories: Choice[];
@@ -36,65 +38,58 @@ type OperationFieldsProps = {
targets: Choice[];
validDate: (value: string) => boolean;
};
const OperationFields: FC<OperationFieldsProps> = (props) => {
const { t } = useTranslation();
const {
control,
formState: { errors, isSubmitting },
cardIdField,
categoryIdField,
dateField,
dateParser,
dateValue,
errors,
isSubmitting,
kindField,
onDateBlur,
onDateChange,
onDateInput,
onKindChange,
register,
setValue,
} = useFormContext<OperationValues>();
sourceCardLabel,
t,
targetCardIdField,
} = useOperationFieldsVm(props);
return (
<Stack>
<Controller
control={control}
name="kind"
render={({ field }) => (
<Select
data={props.kinds}
label={t("bank.kind")}
required
{...field}
disabled={isSubmitting || props.editing}
onChange={(value) => {
field.onChange(value ?? "expense");
if (value === "refund") setValue("cardId", props.refundCardId);
}}
/>
)}
<Select
data={props.kinds}
label={t("bank.kind")}
required
{...kindField}
disabled={isSubmitting || props.editing}
onChange={onKindChange}
/>
<Controller
control={control}
name="cardId"
render={({ field }) => (
<Select
data={props.cards}
label={t(
props.kind === "transfer" ? "bank.source" : "bank.operationCard",
)}
required
{...field}
disabled={isSubmitting || props.kind === "refund"}
error={errors.cardId?.message}
/>
)}
<Select
data={props.cards}
label={sourceCardLabel}
required
{...cardIdField}
disabled={isSubmitting || props.kind === "refund"}
error={errors.cardId?.message}
/>
{props.kind === "transfer" && (
<Controller
control={control}
name="targetCardId"
render={({ field }) => (
<Select
data={props.targets}
label={t("bank.destination")}
required
{...field}
disabled={isSubmitting}
error={errors.targetCardId?.message}
/>
)}
<Select
data={props.targets}
label={t("bank.destination")}
required
{...targetCardIdField}
disabled={isSubmitting}
error={errors.targetCardId?.message}
/>
)}
<TextInput
inputMode="decimal"
label={t("bank.amount")}
@@ -102,54 +97,38 @@ const OperationFields: FC<OperationFieldsProps> = (props) => {
{...register("amount")}
error={errors.amount?.message}
/>
<Controller
control={control}
name="date"
render={({ field }) => (
<DateInput
{...field}
dateParser={value =>
props.validDate(props.dateInputValue(value))
? props.dateInputValue(value)
: /* eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. */ null}
error={errors.date?.message}
fixOnBlur={false}
label={t("bank.date")}
maxDate={props.maxDate()}
onBlur={(event) => {
field.onChange(props.dateInputValue(event.currentTarget.value));
field.onBlur();
}}
onChange={value => field.onChange(value ?? "")}
onInput={event =>
field.onChange(props.dateInputValue(event.currentTarget.value))}
popoverProps={{
middlewares: { shift: { crossAxis: true } },
withinPortal: false,
}}
required
value={(props.validDate(field.value)) ? (field.value) : undefined}
valueFormat="DD.MM.YYYY"
/>
)}
<DateInput
{...dateField}
dateParser={dateParser}
error={errors.date?.message}
fixOnBlur={false}
label={t("bank.date")}
maxDate={props.maxDate()}
onBlur={onDateBlur}
onChange={onDateChange}
onInput={onDateInput}
popoverProps={{
middlewares: { shift: { crossAxis: true } },
withinPortal: false,
}}
required
value={dateValue}
valueFormat="DD.MM.YYYY"
/>
{props.kind === "expense" && (
<Controller
control={control}
name="categoryId"
render={({ field }) => (
<Select
data={props.categories}
label={t("bank.category")}
required
{...field}
disabled={isSubmitting}
error={errors.categoryId?.message}
value={field.value || props.defaultCategory}
/>
)}
<Select
data={props.categories}
label={t("bank.category")}
required
{...categoryIdField}
disabled={isSubmitting}
error={errors.categoryId?.message}
value={categoryIdField.value || props.defaultCategory}
/>
)}
<Textarea
label={t("bank.description")}
{...register("description")}
@@ -158,43 +137,36 @@ const OperationFields: FC<OperationFieldsProps> = (props) => {
</Stack>
);
};
type RefundFieldsProps = {
editing: boolean;
expenses: Choice[];
limit: BankOperation["availableRefund"] | undefined;
onSelect: (id: string) => void;
};
const RefundFields: FC<RefundFieldsProps> = (props) => {
const { t } = useTranslation();
const {
control,
formState: { errors, isSubmitting },
} = useFormContext<OperationValues>();
const { errors, isSubmitting, onExpenseChange, originalExpenseIdField, t }
= useRefundFieldsVm(props);
return (
<Stack>
<Controller
control={control}
name="originalExpenseId"
render={({ field }) => (
<Select
data={props.expenses}
label={t("bank.expense")}
required
{...field}
disabled={isSubmitting || props.editing}
error={errors.originalExpenseId?.message}
onChange={(value) => {
field.onChange(value ?? "");
props.onSelect(value ?? "");
}}
/>
)}
<Select
data={props.expenses}
label={t("bank.expense")}
required
{...originalExpenseIdField}
disabled={isSubmitting || props.editing}
error={errors.originalExpenseId?.message}
onChange={onExpenseChange}
/>
{props.limit && (
<Text>
{t("bank.availableRefund", { amount: formatMoney(props.limit) })}
</Text>
)}
{props.editing && <Text>{t("bank.purchaseImmutable")}</Text>}
</Stack>
);
@@ -226,8 +198,14 @@ type OperationFormViewProps = {
saveDisabled: boolean;
sourcePage: number;
};
const OperationFormView: FC<OperationFormViewProps> = (props) => {
const { t } = useTranslation();
const displayTitle = t(
props.editing ? "bank.editOperation" : "bank.addOperation",
);
return (
<Modal
closeButtonProps={{
@@ -239,19 +217,23 @@ const OperationFormView: FC<OperationFormViewProps> = (props) => {
onClose={props.onClose}
onExitTransitionEnd={props.onExitTransitionEnd}
opened={props.opened}
title={t(props.editing ? "bank.editOperation" : "bank.addOperation")}
title={displayTitle}
>
<form onSubmit={props.onSubmit}>
<fieldset className={classes.fieldset} disabled={props.pending}>
<Stack>
<OperationFields {...props.fields} />
{props.fields.kind === "refund" && (
<>
<RefundFields {...props.refund} />
{props.loadingExpenses && (
<Text role="status">{t("bank.loadingHistory")}</Text>
)}
{props.emptyExpenses && <Text>{t("bank.noPurchases")}</Text>}
{!props.editing && (
<Group>
<Button
@@ -261,7 +243,9 @@ const OperationFormView: FC<OperationFormViewProps> = (props) => {
>
{t("bank.previous")}
</Button>
<Text>{t("bank.page", { page: props.sourcePage })}</Text>
<Button
disabled={props.nextDisabled}
onClick={props.onNext}
@@ -271,31 +255,39 @@ const OperationFormView: FC<OperationFormViewProps> = (props) => {
</Button>
</Group>
)}
{props.refundFeedback}
</>
)}
{props.fields.kind === "expense" && (
<Button onClick={props.onAddCategory} variant="subtle">
{t("bank.addCategory")}
</Button>
)}
{props.categoriesFeedback}
{props.feedback}
{props.onReload && (
<Button onClick={props.onReload} variant="default">
{t("bank.reloadRecord")}
</Button>
)}
{props.latest && (
<>
<Text>
{t("bank.versionHint", { details: props.latest.details })}
</Text>
<Button onClick={props.latest.onAdopt} variant="default">
{t("bank.adoptVersion", { version: props.latest.version })}
</Button>
</>
)}
<Group>
<Button
disabled={props.saveDisabled}
@@ -304,6 +296,7 @@ const OperationFormView: FC<OperationFormViewProps> = (props) => {
>
{t("bank.save")}
</Button>
<Button onClick={props.onCancel} variant="default">
{t("bank.cancel")}
</Button>
@@ -2,7 +2,6 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import {
bankDateInputValue,
@@ -11,15 +10,12 @@ import {
type BankOperation,
bankOperationKinds,
bankToday,
moneyCents,
positiveMoneySchema,
useBankCategories,
useBankExpenses,
useBankFormFocus,
useBankMutations,
useBankOperation,
} from "@/entities/bank-card";
import { validationMessage } from "@/shared/i18n";
import { formatDate, formatMoney } from "@/shared/lib";
import type {
@@ -27,53 +23,7 @@ import type {
OperationValues,
} from "./OperationForm.types";
// ZodIssueCode is deprecated; this is the custom refinement code accepted by Zod.
enum FormIssueCode { Custom = "custom" }
const MAX_DESCRIPTION_LENGTH = 500;
const operationSchema = z.object({
amount: positiveMoneySchema,
cardId: z.string(),
categoryId: z.string(),
date: bankDateSchema,
description: z
.string()
.max(
MAX_DESCRIPTION_LENGTH,
validationMessage("bank.validation.description")(undefined),
),
kind: z.enum(bankOperationKinds),
originalExpenseId: z.string(),
targetCardId: z.string(),
});
function validateRefund(values: OperationValues, context: z.RefinementCtx,
expense: BankOperation | undefined, limit: BankOperation["availableRefund"] | undefined) {
if (!expense || expense.cardId !== values.cardId)
context.addIssue({
code: FormIssueCode.Custom,
path: ["originalExpenseId"],
...validationMessage("bank.validation.required")(undefined),
});
if (expense && values.date < expense.date)
context.addIssue({
code: FormIssueCode.Custom,
path: ["date"],
...validationMessage("bank.validation.start")(undefined),
});
const amount = moneyCents(values.amount);
if (
limit
&& amount !== null
&& amount > (moneyCents(limit.value) ?? 0n)
)
context.addIssue({
code: FormIssueCode.Custom,
path: ["amount"],
...validationMessage("bank.validation.refund")(undefined),
});
}
import { createOperationSchema } from "./OperationForm.schema";
const useOperationFormVm = (props: OperationFormProps) => {
useBankFormFocus();
@@ -83,19 +33,24 @@ const useOperationFormVm = (props: OperationFormProps) => {
const [version, setVersion] = useState(props.operation?.version ?? 1);
const [latest, setLatest] = useState<BankOperation>();
const [sourcePage, setSourcePage] = useState(1);
const [sourceId, setSourceId] = useState(
props.operation?.originalExpenseId ?? "",
);
const sourceQuery = useBankOperation(sourceId || undefined);
const currentQuery = useBankOperation(props.operation?.id);
const [refundLimit, setRefundLimit] = useState(
props.operation?.availableRefund,
);
const [intent] = useState(() => crypto.randomUUID());
const [submittedValues, setSubmittedValues] = useState<OperationValues>();
const [error, setError] = useState<unknown>();
const categories = useBankCategories();
const mutation = useBankMutations();
const methods = useForm<OperationValues>({
defaultValues: {
amount: props.operation?.amount.value ?? "",
@@ -108,84 +63,50 @@ const useOperationFormVm = (props: OperationFormProps) => {
targetCardId: props.operation?.targetCardId ?? "",
},
resolver: zodResolver(
operationSchema.superRefine((values, context) => {
// Repeated intent reaches server idempotency even after its source changed.
if (
submittedValues
&& JSON.stringify(values) === JSON.stringify(submittedValues)
)
return;
const source = props.cards.find(c => c.id === values.cardId);
const target = props.cards.find(c => c.id === values.targetCardId);
if (!source || source.archivedAt)
context.addIssue({
code: FormIssueCode.Custom,
path: ["cardId"],
...validationMessage("bank.validation.required")(undefined),
});
if (
values.kind === "transfer"
&& (!target || target.archivedAt || target.id === values.cardId)
)
context.addIssue({
code: FormIssueCode.Custom,
path: ["targetCardId"],
...validationMessage("bank.validation.destination")(undefined),
});
if (
(source && values.date < source.openingDate)
|| (target
&& values.kind === "transfer"
&& values.date < target.openingDate)
)
context.addIssue({
code: FormIssueCode.Custom,
path: ["date"],
...validationMessage("bank.validation.start")(undefined),
});
if (values.kind === "refund") validateRefund(values, context, sourceQuery.data,
props.operation ? refundLimit : sourceQuery.data?.availableRefund);
if (
values.kind === "expense"
&& !categories.data?.some(
c =>
c.id
=== (values.categoryId
|| categories.data?.find(c => c.isDefault)?.id),
)
)
context.addIssue({
code: FormIssueCode.Custom,
path: ["categoryId"],
...validationMessage("bank.validation.required")(undefined),
});
createOperationSchema({
cards: props.cards,
categories: categories.data,
expense: sourceQuery.data,
limit: props.operation
? refundLimit
: sourceQuery.data?.availableRefund,
submittedValues,
}),
),
});
const kind = useWatch({ control: methods.control, name: "kind" });
const expenses = useBankExpenses(
kind === "refund" ? props.card.id : undefined,
{ page: sourcePage },
);
const source = useWatch({ control: methods.control, name: "cardId" });
const isPending = !opened || methods.formState.isSubmitting;
const options = props.cards
.filter(c => !c.archivedAt)
.map(c => ({ label: c.name, value: c.id }));
const onSubmit = methods.handleSubmit(async (values) => {
setSubmittedValues(values);
setError(undefined);
try {
const body = {
cardId: values.cardId,
kind: values.kind,
...((values.kind === "transfer") && { targetCardId: values.targetCardId }),
...((values.kind === "expense") && {
...(values.kind === "transfer" && {
targetCardId: values.targetCardId,
}),
...(values.kind === "expense" && {
categoryId:
values.categoryId
|| categories.data?.find(c => c.isDefault)?.id,
values.categoryId || categories.data?.find(c => c.isDefault)?.id,
}),
...(values.kind === "refund" && {
originalExpenseId: values.originalExpenseId,
}),
...((values.kind === "refund") && { originalExpenseId: values.originalExpenseId }),
amount: {
currency: "RUB" as const,
value: bankMoneyValue(values.amount),
@@ -193,6 +114,7 @@ const useOperationFormVm = (props: OperationFormProps) => {
date: values.date,
description: values.description,
};
if (props.operation)
await mutation.updateOperation.mutateAsync({
body: { ...body, version },
@@ -209,6 +131,7 @@ const useOperationFormVm = (props: OperationFormProps) => {
setError(error_);
}
});
const onReload = async () => {
const query = props.operation ? currentQuery : sourceQuery;
const result = await query.refetch();
@@ -231,16 +154,24 @@ const useOperationFormVm = (props: OperationFormProps) => {
}),
value: item.id,
});
const expenseOptions = expenses.data?.items.map(element => expenseChoice(element)) ?? [];
const expenseOptions
= expenses.data?.items.map(element => expenseChoice(element)) ?? [];
const selected = sourceQuery.data
? expenseChoice(sourceQuery.data)
: undefined;
return {
categoriesError: categories.isError ? categories.error : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
categoriesError: categories.isError
? categories.error
: null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
categoriesOpen,
closeCategories: () => setCategoriesOpen(false),
error,
expensesError: expenses.isError ? expenses.error : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
expensesError: expenses.isError
? expenses.error
: null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
methods,
onCategoryCreated: (id: string) => {
methods.setValue("categoryId", id);
@@ -249,7 +180,9 @@ const useOperationFormVm = (props: OperationFormProps) => {
retryCategories: () => void categories.refetch(),
retryExpenses: () => void expenses.refetch(),
retrySource: () => void sourceQuery.refetch(),
sourceError: sourceQuery.isError ? sourceQuery.error : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
sourceError: sourceQuery.isError
? sourceQuery.error
: null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
view: {
categoriesOpen,
editing: Boolean(props.operation),
@@ -0,0 +1,38 @@
import { useController, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { BankOperation } from "@/entities/bank-card";
import type { OperationValues } from "./OperationForm.types";
type Choice = { label: string; value: string };
type RefundFieldsProps = {
editing: boolean;
expenses: Choice[];
limit: BankOperation["availableRefund"] | undefined;
onSelect: (id: string) => void;
};
function useRefundFieldsVm(props: RefundFieldsProps) {
const { t } = useTranslation();
const {
control,
formState: { errors, isSubmitting },
} = useFormContext<OperationValues>();
const { field: originalExpenseIdField } = useController({
control,
name: "originalExpenseId",
});
const onExpenseChange: (value: null | string) => void = (value) => {
originalExpenseIdField.onChange(value ?? "");
props.onSelect(value ?? "");
};
return { errors, isSubmitting, onExpenseChange, originalExpenseIdField, t };
}
export { useRefundFieldsVm };