import { zodResolver } from "@hookform/resolvers/zod"; import { Button, Group, Modal, Stack, Text } from "@mantine/core"; import { type FC, useState } from "react"; import { FormProvider, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { z } from "zod"; import { type BankCard, BankFeedback, type BankOperation, bankDateSchema, bankMoneyValue, bankToday, CategoryManager, moneyCents, positiveMoneySchema, useBankCategories, useBankExpenses, useBankFormFocus, useBankMutations, useBankOperation, } from "@/entities/bank-card"; import { validationMessage } from "@/shared/i18n"; import { formatMoney } from "@/shared/lib"; import classes from "./OperationForm.module.css"; import type { OperationValues } from "./OperationForm.types"; import { OperationFields, RefundFields } from "./OperationForm.view"; const operationSchema = z.object({ kind: z.enum(["expense", "topup", "transfer", "refund"]), cardId: z.string(), targetCardId: z.string(), categoryId: z.string(), originalExpenseId: z.string(), amount: positiveMoneySchema, date: bankDateSchema, description: z .string() .max(500, validationMessage("bank.validation.description")(undefined)), }); type OperationFormProps = { card: BankCard; cards: BankCard[]; operation?: BankOperation; onClose: () => void; }; const OperationForm: FC = (props) => { useBankFormFocus(); 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 = methods.watch("kind"); const expenses = useBankExpenses( kind === "refund" ? props.card.id : undefined, { page: sourcePage }, ); const source = methods.watch("cardId"); const pending = methods.formState.isSubmitting; const options = props.cards .filter((c) => !c.archivedAt) .map((c) => ({ value: c.id, label: c.name })); return ( <> !pending && !categoriesOpen && props.onClose()} title={t(props.operation ? "bank.editOperation" : "bank.addOperation")} closeOnEscape={!pending && !categoriesOpen} closeOnClickOutside={!pending && !categoriesOpen} closeButtonProps={{ "aria-label": t("bank.close"), disabled: pending }} >
{ 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, }); props.onClose(); } catch (failure) { setError(failure); } })} >
c.value !== source)} categories={ categories.data?.map((c) => ({ value: c.id, label: c.name, })) ?? [] } defaultCategory={ categories.data?.find((c) => c.isDefault)?.id ?? "" } /> {kind === "refund" && ( <> ({ value: item.id, label: t("bank.expenseChoice", { date: item.date, name: item.description || item.categoryName, amount: formatMoney(item.amount), available: formatMoney(item.availableRefund), }), })) ?? [] } selected={ sourceQuery.data ? { value: sourceQuery.data.id, label: t("bank.expenseChoice", { date: sourceQuery.data.date, name: sourceQuery.data.description || sourceQuery.data.categoryName, amount: formatMoney(sourceQuery.data.amount), available: formatMoney( sourceQuery.data.availableRefund, ), }), } : undefined } onSelect={setSourceId} limit={ props.operation ? refundLimit : sourceQuery.data?.availableRefund } /> {expenses.isLoading && ( {t("bank.loadingHistory")} )} {expenses.data && !expenses.data.items.length && !props.operation && {t("bank.noPurchases")}} {!props.operation && ( {t("bank.page", { page: sourcePage })} )} {expenses.isError && ( void expenses.refetch()} /> )}{" "} {sourceQuery.isError && ( void sourceQuery.refetch()} /> )} )} {kind === "expense" && ( )} {categories.isError && ( void categories.refetch()} /> )} {error ? ( <> {(props.operation || sourceId) && ( )} ) : null} {latest && ( <> {t("bank.versionHint", { details: `${latest.date} · ${formatMoney(latest.amount)} · ${latest.description ?? ""}`, })} )}
{categoriesOpen && ( setCategoriesOpen(false)} onCreated={(id) => { methods.setValue("categoryId", id); setCategoriesOpen(false); }} /> )} ); }; export { OperationForm };