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
+198
View File
@@ -0,0 +1,198 @@
import { Select, Stack, Text, Textarea, TextInput } from "@mantine/core";
import { DateInput } from "@mantine/dates";
import type { FC } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
type BankOperation,
bankDateSchema,
bankToday,
} from "@/entities/bank-card";
import { formatMoney } from "@/shared/lib";
import type { OperationValues } from "./OperationForm.types";
type Choice = { value: string; label: string };
type OperationFieldsProps = {
editing: boolean;
refundCardId: string;
kind: OperationValues["kind"];
cards: Choice[];
targets: Choice[];
categories: Choice[];
defaultCategory: string;
};
const OperationFields: FC<OperationFieldsProps> = (props) => {
const { t } = useTranslation();
const {
control,
register,
setValue,
formState: { errors, isSubmitting },
} = useFormContext<OperationValues>();
return (
<Stack>
<Controller
control={control}
name="kind"
render={({ field }) => (
<Select
label={t("bank.kind")}
required
data={(["expense", "topup", "transfer", "refund"] as const).map(
(value) => ({ value, label: t(`bank.kinds.${value}`) }),
)}
{...field}
disabled={isSubmitting || props.editing}
onChange={(value) => {
field.onChange(value ?? "expense");
if (value === "refund") setValue("cardId", props.refundCardId);
}}
/>
)}
/>
<Controller
control={control}
name="cardId"
render={({ field }) => (
<Select
label={t(
props.kind === "transfer" ? "bank.source" : "bank.operationCard",
)}
required
data={props.cards}
{...field}
disabled={isSubmitting || props.kind === "refund"}
error={errors.cardId?.message}
/>
)}
/>
{props.kind === "transfer" && (
<Controller
control={control}
name="targetCardId"
render={({ field }) => (
<Select
label={t("bank.destination")}
required
data={props.targets}
{...field}
disabled={isSubmitting}
error={errors.targetCardId?.message}
/>
)}
/>
)}
<TextInput
label={t("bank.amount")}
required
inputMode="decimal"
{...register("amount")}
error={errors.amount?.message}
/>
<Controller
control={control}
name="date"
render={({ field }) => (
<DateInput
{...field}
label={t("bank.date")}
required
valueFormat="YYYY-MM-DD"
value={
bankDateSchema.safeParse(field.value).success ? field.value : null
}
dateParser={(value) =>
bankDateSchema.safeParse(value).success ? value : null
}
fixOnBlur={false}
onInput={(event) => field.onChange(event.currentTarget.value)}
onChange={(value) => field.onChange(value ?? "")}
onBlur={(event) => {
field.onChange(event.currentTarget.value);
field.onBlur();
}}
maxDate={bankToday()}
popoverProps={{
withinPortal: false,
middlewares: { shift: { crossAxis: true } },
}}
error={errors.date?.message}
/>
)}
/>
{props.kind === "expense" && (
<Controller
control={control}
name="categoryId"
render={({ field }) => (
<Select
label={t("bank.category")}
required
data={props.categories}
{...field}
value={field.value || props.defaultCategory}
disabled={isSubmitting}
error={errors.categoryId?.message}
/>
)}
/>
)}
<Textarea
label={t("bank.description")}
{...register("description")}
error={errors.description?.message}
/>
</Stack>
);
};
type RefundFieldsProps = {
editing: boolean;
expenses: Choice[];
selected?: Choice;
onSelect: (id: string) => void;
limit: BankOperation["availableRefund"] | undefined;
};
const RefundFields: FC<RefundFieldsProps> = (props) => {
const { t } = useTranslation();
const {
control,
formState: { errors, isSubmitting },
} = useFormContext<OperationValues>();
return (
<Stack>
<Controller
control={control}
name="originalExpenseId"
render={({ field }) => (
<Select
label={t("bank.expense")}
required
data={
props.selected &&
!props.expenses.some(
(item) => item.value === props.selected?.value,
)
? [props.selected, ...props.expenses]
: props.expenses
}
{...field}
onChange={(value) => {
field.onChange(value ?? "");
props.onSelect(value ?? "");
}}
disabled={isSubmitting || props.editing}
error={errors.originalExpenseId?.message}
/>
)}
/>
{props.limit && (
<Text>
{t("bank.availableRefund", { amount: formatMoney(props.limit) })}
</Text>
)}
{props.editing && <Text>{t("bank.purchaseImmutable")}</Text>}
</Stack>
);
};
export { OperationFields, RefundFields };