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
@@ -0,0 +1,215 @@
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 { useBankFormFocus } from "./useBankFormFocus";
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 { 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 <= 80,
validationMessage("bank.validation.category"),
)
.refine(
(value) =>
!categories.data?.some(
(c) =>
c.id !== editing?.id &&
c.name.toLocaleLowerCase("ru-RU") ===
value.toLocaleLowerCase("ru-RU"),
),
validationMessage("bank.validation.duplicate"),
),
}),
),
defaultValues: { name: "" },
});
const pending = methods.formState.isSubmitting;
return (
<Modal
opened
onClose={() => !pending && props.onClose()}
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">
<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={props.onClose}>
{t("bank.close")}
</Button>
</Group>
</Stack>
</fieldset>
</form>
</FormProvider>
</Modal>
);
};
export { CategoryManager };