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 { type BankOperation, bankDateInputValue, bankDateSchema, bankMoneyValue, 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 { OperationFormProps, OperationValues, } from "./OperationForm.types"; const MAX_DESCRIPTION_LENGTH = 500; const operationSchema = z.object({ kind: z.enum(bankOperationKinds), cardId: z.string(), targetCardId: z.string(), categoryId: z.string(), originalExpenseId: z.string(), amount: positiveMoneySchema, date: bankDateSchema, description: z .string() .max( MAX_DESCRIPTION_LENGTH, validationMessage("bank.validation.description")(undefined), ), }); 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(); 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(); const [error, setError] = useState(); const categories = useBankCategories(); const mutation = useBankMutations(); const methods = useForm({ resolver: zodResolver( operationSchema.superRefine((values, ctx) => { // 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) ctx.addIssue({ code: "custom", path: ["cardId"], ...validationMessage("bank.validation.required")(undefined), }); if ( values.kind === "transfer" && (!target || target.archivedAt || target.id === values.cardId) ) ctx.addIssue({ code: "custom", path: ["targetCardId"], ...validationMessage("bank.validation.destination")(undefined), }); if ( (source && values.date < source.openingDate) || (values.kind === "transfer" && target && values.date < target.openingDate) ) ctx.addIssue({ code: "custom", path: ["date"], ...validationMessage("bank.validation.start")(undefined), }); if (values.kind === "refund") { const expense = sourceQuery.data; if (!expense || expense.cardId !== values.cardId) ctx.addIssue({ code: "custom", path: ["originalExpenseId"], ...validationMessage("bank.validation.required")(undefined), }); if (expense && values.date < expense.date) ctx.addIssue({ code: "custom", path: ["date"], ...validationMessage("bank.validation.start")(undefined), }); const limit = props.operation ? refundLimit : expense?.availableRefund; const amount = moneyCents(values.amount); if ( limit && amount !== null && amount > (moneyCents(limit.value) ?? 0n) ) ctx.addIssue({ code: "custom", path: ["amount"], ...validationMessage("bank.validation.refund")(undefined), }); } if ( values.kind === "expense" && !categories.data?.some( (c) => c.id === (values.categoryId || categories.data?.find((c) => c.isDefault)?.id), ) ) ctx.addIssue({ code: "custom", path: ["categoryId"], ...validationMessage("bank.validation.required")(undefined), }); }), ), defaultValues: { kind: props.operation?.kind ?? "expense", cardId: props.operation?.cardId ?? props.card.id, targetCardId: props.operation?.targetCardId ?? "", categoryId: props.operation?.categoryId ?? "", originalExpenseId: props.operation?.originalExpenseId ?? "", amount: props.operation?.amount.value ?? "", date: props.operation?.date ?? bankToday(), description: props.operation?.description ?? "", }, }); 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 pending = !opened || methods.formState.isSubmitting; const options = props.cards .filter((c) => !c.archivedAt) .map((c) => ({ value: c.id, label: c.name })); const onSubmit = methods.handleSubmit(async (values) => { setSubmittedValues(values); setError(undefined); try { const body = { kind: values.kind, cardId: values.cardId, ...(values.kind === "transfer" ? { targetCardId: values.targetCardId } : {}), ...(values.kind === "expense" ? { categoryId: values.categoryId || categories.data?.find((c) => c.isDefault)?.id, } : {}), ...(values.kind === "refund" ? { originalExpenseId: values.originalExpenseId } : {}), amount: { currency: "RUB" as const, value: bankMoneyValue(values.amount), }, date: values.date, description: values.description, }; if (props.operation) await mutation.updateOperation.mutateAsync({ id: props.operation.id, body: { ...body, version }, }); else await mutation.createOperation.mutateAsync({ id: intent, ...body, }); setOpened(false); } catch (failure) { setError(failure); } }); 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); }; const expenseChoice = (item: BankOperation) => ({ value: item.id, label: t("bank.expenseChoice", { date: formatDate(item.date), name: item.description || item.categoryName, amount: formatMoney(item.amount), available: formatMoney(item.availableRefund), }), }); const expenseOptions = expenses.data?.items.map(expenseChoice) ?? []; const selected = sourceQuery.data ? expenseChoice(sourceQuery.data) : undefined; return { methods, categoriesOpen, error, categoriesError: categories.isError ? categories.error : null, expensesError: expenses.isError ? expenses.error : null, sourceError: sourceQuery.isError ? sourceQuery.error : null, retryCategories: () => void categories.refetch(), retryExpenses: () => void expenses.refetch(), retrySource: () => void sourceQuery.refetch(), closeCategories: () => setCategoriesOpen(false), onCategoryCreated: (id: string) => { methods.setValue("categoryId", id); setCategoriesOpen(false); }, view: { opened, pending, categoriesOpen, editing: Boolean(props.operation), onClose: () => !pending && !categoriesOpen && setOpened(false), onExitTransitionEnd: props.onClose, onCancel: () => setOpened(false), onSubmit, fields: { refundCardId: props.card.id, editing: Boolean(props.operation), kind, cards: options, targets: options.filter((c) => c.value !== source), categories: categories.data?.map((c) => ({ value: c.id, label: c.name })) ?? [], defaultCategory: categories.data?.find((c) => c.isDefault)?.id ?? "", kinds: bankOperationKinds.map((value) => ({ value, label: t(`bank.kinds.${value}`), })), dateInputValue: bankDateInputValue, validDate: (value: string) => bankDateSchema.safeParse(value).success, maxDate: bankToday, }, refund: { editing: Boolean(props.operation), expenses: selected && !expenseOptions.some((item) => item.value === selected.value) ? [selected, ...expenseOptions] : expenseOptions, onSelect: setSourceId, limit: props.operation ? refundLimit : sourceQuery.data?.availableRefund, }, loadingExpenses: expenses.isLoading, emptyExpenses: Boolean( expenses.data && !expenses.data.items.length && !props.operation, ), sourcePage, previousDisabled: sourcePage === 1 || expenses.isFetching, nextDisabled: !expenses.data?.hasNext || expenses.isFetching, onPrevious: () => setSourcePage(sourcePage - 1), onNext: () => setSourcePage(sourcePage + 1), onAddCategory: () => setCategoriesOpen(true), onReload: error && (props.operation || sourceId) ? onReload : undefined, latest: latest ? { version: latest.version, details: `${formatDate(latest.date)} · ${formatMoney(latest.amount)} · ${latest.description ?? ""}`, onAdopt: () => { setVersion(latest.version); setLatest(undefined); setError(undefined); }, } : undefined, saveDisabled: (kind === "expense" && !categories.data) || (kind === "refund" && !sourceQuery.data), }, }; }; export { useOperationFormVm };