Files
frontend/src/entities/bank-card/ui/CategoryManager.tsx
T
ksv741 3be6e61b8f
Frontend security / security (push) Successful in 11s
Frontend verify / verify (push) Successful in 4m35s
chore: secure hooks and update dependencies #4 #9
2026-09-13 19:36:15 +03:00

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 };