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:
@@ -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 };
|
||||
Reference in New Issue
Block a user