Files
frontend/src/pages/card/ui/OperationForm.tsx
T
ksv741 ff4e88edbe feat: add manual bank card screens
Add cards, operations, refunds, categories and monthly summaries with Mantine date controls.

Derive banking types from runtime OpenAPI. Cover recovery and session isolation.
2026-09-12 18:34:41 +03:00

418 lines
12 KiB
TypeScript

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<OperationFormProps> = (props) => {
useBankFormFocus();
const { t } = useTranslation();
const [categoriesOpen, setCategoriesOpen] = useState(false);
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>({
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 (
<>
<Modal
opened
onClose={() => !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 }}
>
<FormProvider {...methods}>
<form
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,
});
props.onClose();
} catch (failure) {
setError(failure);
}
})}
>
<fieldset disabled={pending} className={classes.fieldset}>
<Stack>
<OperationFields
refundCardId={props.card.id}
editing={Boolean(props.operation)}
kind={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 ?? ""
}
/>
{kind === "refund" && (
<>
<RefundFields
editing={Boolean(props.operation)}
expenses={
expenses.data?.items.map((item) => ({
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 && (
<Text role="status">{t("bank.loadingHistory")}</Text>
)}
{expenses.data &&
!expenses.data.items.length &&
!props.operation && <Text>{t("bank.noPurchases")}</Text>}
{!props.operation && (
<Group>
<Button
variant="default"
disabled={sourcePage === 1 || expenses.isFetching}
onClick={() => setSourcePage(sourcePage - 1)}
>
{t("bank.previous")}
</Button>
<Text>{t("bank.page", { page: sourcePage })}</Text>
<Button
variant="default"
disabled={
!expenses.data?.hasNext || expenses.isFetching
}
onClick={() => setSourcePage(sourcePage + 1)}
>
{t("bank.next")}
</Button>
</Group>
)}
{expenses.isError && (
<BankFeedback
loadingError
error={expenses.error}
retry={() => void expenses.refetch()}
/>
)}{" "}
{sourceQuery.isError && (
<BankFeedback
loadingError
error={sourceQuery.error}
retry={() => void sourceQuery.refetch()}
/>
)}
</>
)}
{kind === "expense" && (
<Button
variant="subtle"
onClick={() => setCategoriesOpen(true)}
>
{t("bank.addCategory")}
</Button>
)}
{categories.isError && (
<BankFeedback
loadingError
error={categories.error}
retry={() => void categories.refetch()}
/>
)}
{error ? (
<>
<BankFeedback error={error} />
{(props.operation || sourceId) && (
<Button
variant="default"
onClick={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);
}}
>
{t("bank.reloadRecord")}
</Button>
)}
</>
) : null}
{latest && (
<>
<Text>
{t("bank.versionHint", {
details: `${latest.date} · ${formatMoney(latest.amount)} · ${latest.description ?? ""}`,
})}
</Text>
<Button
variant="default"
onClick={() => {
setVersion(latest.version);
setLatest(undefined);
setError(undefined);
}}
>
{t("bank.adoptVersion", { version: latest.version })}
</Button>
</>
)}
<Group>
<Button
type="submit"
loading={pending}
disabled={
(kind === "expense" && !categories.data) ||
(kind === "refund" && !sourceQuery.data)
}
>
{t("bank.save")}
</Button>
<Button variant="default" onClick={props.onClose}>
{t("bank.cancel")}
</Button>
</Group>
</Stack>
</fieldset>
</form>
</FormProvider>
</Modal>
{categoriesOpen && (
<CategoryManager
onClose={() => setCategoriesOpen(false)}
onCreated={(id) => {
methods.setValue("categoryId", id);
setCategoriesOpen(false);
}}
/>
)}
</>
);
};
export { OperationForm };