Files
frontend/src/pages/card/ui/OperationForm/OperationForm.vm.ts
T
ksv741 5b30bb802a feat: add quiet reading with rare confirmations
Part of the quiet-user-feedback change; docs and specs land in their own repositories.
2026-10-06 07:01:58 +03:00

232 lines
6.5 KiB
TypeScript

import { zodResolver } from "@hookform/resolvers/zod";
import { notifications } from "@mantine/notifications";
import { useState } from "react";
import { useForm, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
bankDateField,
bankMoneyValue,
type BankOperation,
bankOperationKinds,
bankToday,
useBankCategories,
useBankFormFocus,
useBankOperation,
useCreateBankOperation,
useUpdateBankOperation,
} from "@/entities/bank-card";
import { formatDate, formatMoney } from "@/shared/lib";
import type {
OperationFormProps,
OperationValues,
} from "./OperationForm.types";
import { createOperationSchema } from "./OperationForm.schema";
import { useRefundPurchase } from "./useRefundPurchase";
const useOperationFormVm = (props: OperationFormProps) => {
useBankFormFocus();
const [opened, setOpened] = useState(true);
const { t } = useTranslation();
const [categoriesOpen, setCategoriesOpen] = useState(false);
const [version, setVersion] = useState(props.operation?.version ?? 1);
const [latest, setLatest] = useState<BankOperation>();
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 defaultCategoryId = categories.data?.find(c => c.isDefault)?.id;
const operationCreation = useCreateBankOperation();
const updateOperation = useUpdateBankOperation();
const methods = useForm<OperationValues>({
defaultValues: {
amount: props.operation?.amount.value ?? "",
cardId: props.operation?.cardId ?? props.card.id,
categoryId: props.operation?.categoryId ?? "",
date: props.operation?.date ?? bankToday(),
description: props.operation?.description ?? "",
kind: props.operation?.kind ?? "expense",
originalExpenseId: props.operation?.originalExpenseId ?? "",
targetCardId: props.operation?.targetCardId ?? "",
},
resolver: (values, context, options) => zodResolver(
createOperationSchema({
cards: props.cards,
categories: categories.data,
expense: sourceQuery.data,
limit: props.operation
? refundLimit
: sourceQuery.data?.availableRefund,
submittedValues,
}),
)(values, context, options),
});
const kind = useWatch({ control: methods.control, name: "kind" });
const purchase = useRefundPurchase(
methods.control, props.card.id, kind, Boolean(props.operation),
);
const { sourceId, sourceQuery } = purchase;
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" && {
categoryId:
values.categoryId || defaultCategoryId,
}),
...(values.kind === "refund" && {
originalExpenseId: values.originalExpenseId,
}),
amount: {
currency: "RUB" as const,
value: bankMoneyValue(values.amount),
},
date: values.date,
description: values.description,
};
const actionId = crypto.randomUUID();
if (props.operation)
await updateOperation.mutateAsync({
body: { ...body, version },
id: props.operation.id,
});
else
await operationCreation.mutateAsync({
id: intent,
...body,
});
notifications.show({
id: "bank-operation-saved-" + actionId,
message: t("bank.operationSaved"),
});
setOpened(false);
}
catch (error_) {
setError(error_);
}
});
const onReload = async () => {
const query = props.operation ? currentQuery : sourceQuery;
const result = await query.refetch();
if (result.data && !result.error) {
if (props.operation) {
setLatest(result.data);
setRefundLimit(result.data.availableRefund);
}
else setError(undefined);
}
else setError(result.error);
};
return {
categoriesError: categories.isError
? categories.error
: null,
categoriesOpen,
closeCategories: () => setCategoriesOpen(false),
error,
expensesError: purchase.error,
methods,
onCategoryCreated: (id: string) => {
methods.setValue("categoryId", id);
setCategoriesOpen(false);
},
retryCategories: () => void categories.refetch(),
retryExpenses: purchase.retry,
retrySource: () => void sourceQuery.refetch(),
sourceError: sourceQuery.isError
? sourceQuery.error
: null,
view: {
categoriesOpen,
editing: Boolean(props.operation),
emptyExpenses: purchase.empty,
fields: {
bindDate: bankDateField,
cards: options,
categories:
categories.data?.map(c => ({ label: c.name, value: c.id })) ?? [],
defaultCategory: defaultCategoryId ?? "",
editing: Boolean(props.operation),
kind,
kinds: bankOperationKinds.map(value => ({
label: t(`bank.kinds.${value}`),
value,
})),
maxDate: bankToday,
refundCardId: props.card.id,
targets: options.filter(c => c.value !== source),
},
latest: latest
? {
details: `${formatDate(latest.date)} · ${formatMoney(latest.amount)} · ${latest.description ?? ""}`,
onAdopt: () => {
setVersion(latest.version);
setLatest(undefined);
setError(undefined);
},
version: latest.version,
}
: undefined,
loadingExpenses: purchase.loading,
nextDisabled: purchase.nextDisabled,
onAddCategory: () => setCategoriesOpen(true),
onCancel: () => setOpened(false),
onClose: () => !isPending && !categoriesOpen && setOpened(false),
onExitTransitionEnd: props.onClose,
onNext: purchase.onNext,
onPrevious: purchase.onPrevious,
onReload: error && (sourceId || props.operation) ? onReload : undefined,
onSubmit,
opened,
pending: isPending,
previousDisabled: purchase.previousDisabled,
refund: {
editing: Boolean(props.operation),
expenses: purchase.options,
limit: props.operation
? refundLimit
: sourceQuery.data?.availableRefund,
},
saveDisabled:
(kind === "expense" && !categories.data)
|| (kind === "refund" && !sourceQuery.data),
sourcePage: purchase.page,
},
};
};
export { useOperationFormVm };