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.
This commit is contained in:
2026-09-12 18:34:41 +03:00
parent 427ca23a43
commit ff4e88edbe
50 changed files with 7795 additions and 10 deletions
+417
View File
@@ -0,0 +1,417 @@
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 };