226 lines
5.9 KiB
TypeScript
226 lines
5.9 KiB
TypeScript
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import {
|
|
Badge,
|
|
Button,
|
|
Group,
|
|
Modal,
|
|
Stack,
|
|
Text,
|
|
TextInput,
|
|
} from "@mantine/core";
|
|
import { type FC, useState } from "react";
|
|
import {
|
|
Controller,
|
|
FormProvider,
|
|
useForm,
|
|
useFormContext,
|
|
} from "react-hook-form";
|
|
import { useTranslation } from "react-i18next";
|
|
import { z } from "zod";
|
|
import { validationMessage } from "@/shared/i18n";
|
|
import { useBankMutations } from "../api/mutations";
|
|
import { type ExpenseCategory, useBankCategories } from "../api/queries";
|
|
import { BankFeedback } from "./BankFeedback";
|
|
import classes from "./BankForms.module.css";
|
|
import { BankOperationIcon } from "./BankOperationIcon";
|
|
import { useBankFormFocus } from "./useBankFormFocus";
|
|
|
|
const MAX_CATEGORY_NAME_LENGTH = 80;
|
|
|
|
type CategoryManagerProps = {
|
|
onClose: () => void;
|
|
onCreated?: (id: string) => void;
|
|
};
|
|
type CategoryValues = { name: string };
|
|
const CategoryFields: FC = () => {
|
|
const { t } = useTranslation();
|
|
const {
|
|
control,
|
|
formState: { errors },
|
|
} = useFormContext<CategoryValues>();
|
|
return (
|
|
<Controller
|
|
control={control}
|
|
name="name"
|
|
render={({ field }) => (
|
|
<TextInput
|
|
label={t("bank.categoryName")}
|
|
required
|
|
{...field}
|
|
error={errors.name?.message}
|
|
/>
|
|
)}
|
|
/>
|
|
);
|
|
};
|
|
const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
|
useBankFormFocus();
|
|
const [opened, setOpened] = useState(true);
|
|
const { t } = useTranslation();
|
|
const categories = useBankCategories();
|
|
const mutation = useBankMutations();
|
|
const [editing, setEditing] = useState<ExpenseCategory>();
|
|
const [error, setError] = useState<unknown>();
|
|
const [latest, setLatest] = useState<ExpenseCategory>();
|
|
const methods = useForm<CategoryValues>({
|
|
resolver: zodResolver(
|
|
z.object({
|
|
name: z
|
|
.string()
|
|
.trim()
|
|
.refine(
|
|
(value) =>
|
|
value.length > 0 && value.length <= MAX_CATEGORY_NAME_LENGTH,
|
|
{ error: validationMessage("bank.validation.category") },
|
|
)
|
|
.refine(
|
|
(value) =>
|
|
!categories.data?.some(
|
|
(c) =>
|
|
c.id !== editing?.id &&
|
|
c.name.toLocaleLowerCase("ru-RU") ===
|
|
value.toLocaleLowerCase("ru-RU"),
|
|
),
|
|
{ error: validationMessage("bank.validation.duplicate") },
|
|
),
|
|
}),
|
|
),
|
|
defaultValues: { name: "" },
|
|
});
|
|
const pending = !opened || methods.formState.isSubmitting;
|
|
return (
|
|
<Modal
|
|
opened={opened}
|
|
onExitTransitionEnd={props.onClose}
|
|
onClose={() => !pending && setOpened(false)}
|
|
title={t("bank.categories")}
|
|
closeOnClickOutside={!pending}
|
|
closeOnEscape={!pending}
|
|
closeButtonProps={{ "aria-label": t("bank.close"), disabled: pending }}
|
|
>
|
|
<FormProvider {...methods}>
|
|
<form
|
|
onSubmit={methods.handleSubmit(async (values) => {
|
|
setError(undefined);
|
|
try {
|
|
if (editing)
|
|
await mutation.updateCategory.mutateAsync({
|
|
id: editing.id,
|
|
body: { name: values.name, version: editing.version },
|
|
});
|
|
else {
|
|
const result = await mutation.createCategory.mutateAsync({
|
|
name: values.name,
|
|
});
|
|
if (result) props.onCreated?.(result.id);
|
|
}
|
|
setEditing(undefined);
|
|
methods.reset({ name: "" });
|
|
} catch (failure) {
|
|
setError(failure);
|
|
}
|
|
})}
|
|
>
|
|
<fieldset disabled={pending} className={classes.fieldset}>
|
|
<Stack>
|
|
{categories.data?.map((category) => (
|
|
<Group justify="space-between" key={category.id}>
|
|
<Group gap="xs">
|
|
<BankOperationIcon
|
|
kind="expense"
|
|
categoryName={category.name}
|
|
/>
|
|
<Text>{category.name}</Text>
|
|
{category.isDefault && (
|
|
<Badge>{t("bank.defaultCategory")}</Badge>
|
|
)}
|
|
</Group>
|
|
<Button
|
|
variant="subtle"
|
|
onClick={() => {
|
|
setEditing(category);
|
|
methods.reset({ name: category.name });
|
|
setError(undefined);
|
|
setLatest(undefined);
|
|
}}
|
|
>
|
|
{t("bank.rename")}
|
|
</Button>
|
|
</Group>
|
|
))}
|
|
{categories.isLoading && (
|
|
<Text role="status">{t("bank.loading")}</Text>
|
|
)}
|
|
{categories.isError && (
|
|
<BankFeedback
|
|
loadingError
|
|
error={categories.error}
|
|
cached={Boolean(categories.data)}
|
|
retry={() => void categories.refetch()}
|
|
/>
|
|
)}
|
|
<CategoryFields />
|
|
{error ? (
|
|
<>
|
|
<BankFeedback error={error} />
|
|
{editing && (
|
|
<Button
|
|
variant="default"
|
|
onClick={async () => {
|
|
const response = await categories.refetch();
|
|
if (response.error) setError(response.error);
|
|
else
|
|
setLatest(
|
|
response.data?.find((c) => c.id === editing.id),
|
|
);
|
|
}}
|
|
>
|
|
{t("bank.reloadRecord")}
|
|
</Button>
|
|
)}
|
|
</>
|
|
) : null}
|
|
{latest && (
|
|
<>
|
|
<Text>{t("bank.versionHint", { details: latest.name })}</Text>
|
|
<Button
|
|
onClick={() => {
|
|
setEditing(latest);
|
|
setLatest(undefined);
|
|
setError(undefined);
|
|
}}
|
|
>
|
|
{t("bank.adoptVersion", { version: latest.version })}
|
|
</Button>
|
|
</>
|
|
)}
|
|
<Group>
|
|
<Button type="submit" loading={pending}>
|
|
{t(editing ? "bank.save" : "bank.addCategory")}
|
|
</Button>
|
|
{editing && (
|
|
<Button
|
|
variant="default"
|
|
onClick={() => {
|
|
setEditing(undefined);
|
|
methods.reset({ name: "" });
|
|
setError(undefined);
|
|
}}
|
|
>
|
|
{t("bank.cancel")}
|
|
</Button>
|
|
)}
|
|
<Button variant="default" onClick={() => setOpened(false)}>
|
|
{t("bank.close")}
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</fieldset>
|
|
</form>
|
|
</FormProvider>
|
|
</Modal>
|
|
);
|
|
};
|
|
|
|
export { CategoryManager };
|