refactor: enforce frontend view model boundaries
This commit is contained in:
@@ -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);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user