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,44 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { FormProvider, useForm } from "react-hook-form";
import type { BankCardValues } from "../model/form-validation";
import { BankCardFields } from "./BankCardForm.view";
const meta = {
title: "Product/BankCards/CardFields",
component: BankCardFields,
tags: ["autodocs"],
args: { archived: false },
decorators: [
(Story) => {
const methods = useForm<BankCardValues>({
defaultValues: {
name: "Учебная повседневная карта",
bankName: "Вымышленный банк",
lastFour: "1234",
openingBalance: "-92233720368547758.08",
openingDate: "2026-08-01",
},
});
return (
<FormProvider {...methods}>
<Story />
</FormProvider>
);
},
],
parameters: {
docs: {
description: {
component:
"Поля ручной дебетовой карты на вымышленных данных. Сохранения и обращения к API нет. Архив оставляет описательные сведения доступными, а финансовые поля — только для чтения.",
},
},
},
} satisfies Meta<typeof BankCardFields>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Active: Story = { name: "Независимая карта в рублях" };
export const Archived: Story = {
name: "Финансовые поля архивной карты",
args: { archived: true },
};
@@ -0,0 +1,122 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import type { BankCard } from "../api/queries";
import { BankCardForm } from "./BankCardForm";
const card: BankCard = {
id: "a",
name: "Основная",
bankName: "Банк",
lastFour: "1234",
openingBalance: { currency: "RUB", value: "100" },
balance: { currency: "RUB", value: "50" },
openingDate: "2026-01-01",
archivedAt: null,
version: 1,
};
function setup(value: BankCard) {
configureAuthClient({
accessToken: () => "test",
refresh: async () => false,
clear: () => {},
});
server.use(http.get("*/api/v1/bank-cards/a", () => HttpResponse.json(value)));
const close = vi.fn();
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<BankCardForm card={value} onClose={close} />
</MantineProvider>
</QueryClientProvider>,
);
return close;
}
describe("BankCardForm", () => {
it("clears optional metadata on archive while omitting protected financial fields", async () => {
const close = setup({ ...card, archivedAt: "2026-09-01" });
expect(screen.getByLabelText(/Начальный остаток/)).toHaveAttribute(
"readonly",
);
expect(screen.getByLabelText(/Дата начала/)).toHaveAttribute("readonly");
fireEvent.change(screen.getByLabelText(/Банк/), { target: { value: "" } });
fireEvent.change(screen.getByLabelText(/Последние четыре/), {
target: { value: "" },
});
let body: unknown;
server.use(
http.patch("*/api/v1/bank-cards/a", async ({ request }) => {
body = await request.json();
return HttpResponse.json(card);
}),
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(close).toHaveBeenCalled());
expect(body).toEqual({
version: 1,
name: "Основная",
bankName: null,
lastFour: null,
});
});
it("retains an invalid opening date draft and explains conflict with saved history", async () => {
const close = setup(card);
fireEvent.change(screen.getByLabelText(/Дата начала/), {
target: { value: "2026-08-01" },
});
server.use(
http.patch("*/api/v1/bank-cards/a", () =>
HttpResponse.json(
{ code: "BANK_OPENING_DATE_CONFLICT" },
{ status: 400 },
),
),
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await screen.findByText(
"Дата начала противоречит сохранённой истории карты.",
);
expect(screen.getByLabelText(/Дата начала/)).toHaveValue("2026-08-01");
expect(close).not.toHaveBeenCalled();
});
it("keeps card edit version until explicit refresh adoption", async () => {
const close = setup(card);
let version = 1;
const bodies: Record<string, unknown>[] = [];
server.use(
http.get("*/api/v1/bank-cards/a", () =>
HttpResponse.json({ ...card, version }),
),
http.patch("*/api/v1/bank-cards/a", async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
bodies.push(body);
version = 2;
return body.version === 1
? HttpResponse.json(
{ code: "BANK_VERSION_CONFLICT" },
{ status: 409 },
)
: HttpResponse.json(card);
}),
);
fireEvent.change(screen.getByLabelText(/Название карты/), {
target: { value: "Мой черновик" },
});
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await screen.findByText(/Запись уже изменена/);
fireEvent.click(screen.getByRole("button", { name: "Перечитать запись" }));
const adopt = await screen.findByRole("button", {
name: "Использовать актуальную версию 2",
});
expect(screen.getByLabelText(/Название карты/)).toHaveValue("Мой черновик");
fireEvent.click(adopt);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(close).toHaveBeenCalled());
expect(bodies.map((b) => b.version)).toEqual([1, 2]);
});
});
+144
View File
@@ -0,0 +1,144 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button, Group, Modal, Stack, Text } from "@mantine/core";
import { type FC, useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useBankMutations } from "../api/mutations";
import { type BankCard, useBankCard } from "../api/queries";
import {
type BankCardValues,
bankCardSchema,
bankMoneyValue,
bankToday,
} from "../model/form-validation";
import { BankCardFields } from "./BankCardForm.view";
import { BankFeedback } from "./BankFeedback";
import classes from "./BankForms.module.css";
import { useBankFormFocus } from "./useBankFormFocus";
type BankCardFormProps = { card?: BankCard; onClose: () => void };
const BankCardForm: FC<BankCardFormProps> = (props) => {
useBankFormFocus();
const { t } = useTranslation();
const [intent] = useState(() => crypto.randomUUID());
const [version, setVersion] = useState(props.card?.version ?? 1);
const [error, setError] = useState<unknown>();
const [latest, setLatest] = useState<BankCard>();
const fresh = useBankCard(props.card?.id);
const mutation = useBankMutations();
const methods = useForm<BankCardValues>({
resolver: zodResolver(bankCardSchema),
defaultValues: {
name: props.card?.name ?? "",
bankName: props.card?.bankName ?? "",
lastFour: props.card?.lastFour ?? "",
openingBalance: props.card?.openingBalance.value ?? "0",
openingDate: props.card?.openingDate ?? bankToday(),
},
});
const pending =
methods.formState.isSubmitting || (fresh.isFetching && Boolean(error));
return (
<Modal
opened
onClose={() => !pending && props.onClose()}
title={t(props.card ? "bank.editCard" : "bank.addCard")}
closeButtonProps={{ "aria-label": t("bank.close"), disabled: pending }}
closeOnClickOutside={!pending}
closeOnEscape={!pending}
>
<FormProvider {...methods}>
<form
onSubmit={methods.handleSubmit(async (values) => {
setError(undefined);
try {
const metadata = {
name: values.name,
bankName: values.bankName || null,
lastFour: values.lastFour || null,
};
const financial = {
openingBalance: {
currency: "RUB" as const,
value: bankMoneyValue(values.openingBalance),
},
openingDate: values.openingDate,
};
if (props.card)
await mutation.updateCard.mutateAsync({
id: props.card.id,
body: {
...metadata,
...(!props.card.archivedAt ? financial : {}),
version,
},
});
else
await mutation.createCard.mutateAsync({
id: intent,
...metadata,
...financial,
});
props.onClose();
} catch (failure) {
setError(failure);
}
})}
>
<fieldset className={classes.fieldset} disabled={pending}>
<Stack>
<BankCardFields archived={Boolean(props.card?.archivedAt)} />
{error ? (
<>
<BankFeedback error={error} />
{props.card && (
<Button
variant="default"
onClick={async () => {
const response = await fresh.refetch();
if (response.data && !response.error)
setLatest(response.data);
else setError(response.error);
}}
>
{t("bank.reloadRecord")}
</Button>
)}
</>
) : null}
{latest && (
<>
<Text>
{t("bank.versionHint", {
details: `${latest.name} · ${latest.openingBalance.value} RUB · ${latest.openingDate}`,
})}
</Text>
<Button
variant="default"
onClick={() => {
setVersion(latest.version);
setLatest(undefined);
setError(undefined);
}}
>
{t("bank.adoptVersion", { version: latest.version })}
</Button>
</>
)}
<Group>
<Button type="submit" loading={pending}>
{t("bank.save")}
</Button>
<Button variant="default" onClick={props.onClose}>
{t("bank.cancel")}
</Button>
</Group>
</Stack>
</fieldset>
</form>
</FormProvider>
</Modal>
);
};
export { BankCardForm };
@@ -0,0 +1,87 @@
import { Stack, Text, 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 BankCardValues,
bankDateSchema,
bankToday,
} from "../model/form-validation";
type BankCardFieldsProps = { archived: boolean };
const BankCardFields: FC<BankCardFieldsProps> = (props) => {
const {
control,
register,
formState: { errors },
} = useFormContext<BankCardValues>();
const { t } = useTranslation();
return (
<Stack>
<TextInput
label={t("bank.name")}
required
{...register("name")}
error={errors.name?.message}
/>
<TextInput
label={t("bank.bankName")}
{...register("bankName")}
error={errors.bankName?.message}
/>
<TextInput
label={t("bank.lastFour")}
inputMode="numeric"
{...register("lastFour")}
error={errors.lastFour?.message}
/>
<TextInput
label={t("bank.openingBalance")}
required
inputMode="decimal"
readOnly={props.archived}
{...register("openingBalance")}
error={errors.openingBalance?.message}
/>
<Controller
control={control}
name="openingDate"
render={({ field }) => (
<DateInput
{...field}
label={t("bank.openingDate")}
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 } },
}}
readOnly={props.archived}
error={errors.openingDate?.message}
/>
)}
/>
<Text size="sm" c="dimmed">
{t("bank.openingHint")}
</Text>
{props.archived && <Text>{t("bank.archiveHint")}</Text>}
</Stack>
);
};
export { BankCardFields };
@@ -0,0 +1,37 @@
import { Alert, Button, Group, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { apiErrorCode } from "@/shared/api";
type BankFeedbackProps = {
error: unknown;
retry?: () => void;
cached?: boolean;
loadingError?: boolean;
};
const BankFeedback: FC<BankFeedbackProps> = (props) => {
const { t } = useTranslation();
const code = apiErrorCode(props.error);
return (
<Alert color="orange" role="alert">
<Group>
<Text>
{props.cached
? t("bank.refreshError")
: t(`bank.errors.${code}` as "bank.errors.BANK_INVALID_INPUT", {
defaultValue: t(
props.loadingError ? "bank.error" : "bank.requestError",
),
})}
</Text>
{props.retry && (
<Button variant="default" onClick={props.retry}>
{t("bank.retry")}
</Button>
)}
</Group>
</Alert>
);
};
export { BankFeedback };
@@ -0,0 +1,6 @@
.fieldset {
border: 0;
padding: 0;
margin: 0;
min-width: 0;
}
@@ -0,0 +1,102 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { BankSummaryView } from "./BankSummary.view";
const money = (value: string) => ({ currency: "RUB", value });
const meta = {
title: "Product/BankCards/Summary",
component: BankSummaryView,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component:
"Месячные расходы ручного учёта. Вымышленные суммы, без API и сохранения. Возвраты учитываются отдельно; отрицательный итог не превращается в процентную диаграмму.",
},
},
},
args: {
loading: false,
error: false,
fetching: false,
retry: () => {},
data: {
month: "2026-09",
cardId: null,
expenses: money("8500"),
refunds: money("1000"),
net: money("7500"),
categories: [
{
categoryId: "food",
categoryName: "Продукты",
expenses: money("8500"),
refunds: money("1000"),
net: money("7500"),
},
],
},
},
} satisfies Meta<typeof BankSummaryView>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Loaded: Story = { name: "Расходы и возвраты" };
export const RefundMonth: Story = {
name: "Возврат покупки прошлого месяца",
args: {
data: {
month: "2026-09",
cardId: null,
expenses: money("0"),
refunds: money("600"),
net: money("-600"),
categories: [
{
categoryId: "shopping",
categoryName:
"Покупки с длинным пользовательским названием категории",
expenses: money("0"),
refunds: money("600"),
net: money("-600"),
},
],
},
},
};
export const Empty: Story = {
name: "Известные нули",
args: {
data: {
month: "2026-09",
cardId: null,
expenses: money("0"),
refunds: money("0"),
net: money("0"),
categories: [],
},
},
};
export const Loading: Story = {
name: "Начальная загрузка",
args: { data: undefined, loading: true },
};
export const Failed: Story = {
name: "Ошибка без данных",
args: { data: undefined, error: true },
};
export const CachedError: Story = {
name: "Сохранённые суммы при ошибке обновления",
args: { error: true },
};
export const LongAmounts: Story = {
name: "Точный итог длиннее предела отдельного ввода",
args: {
data: {
month: "2026-09",
cardId: null,
expenses: money("184467440737095516.14"),
refunds: money("0"),
net: money("184467440737095516.14"),
categories: [],
},
},
};
@@ -0,0 +1,90 @@
import {
Button,
Group,
Paper,
SimpleGrid,
Skeleton,
Stack,
Text,
Title,
} from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { formatMoney } from "@/shared/lib";
import { LoadingSkeleton } from "@/shared/ui";
import type { BankSummary } from "../api/queries";
type BankSummaryViewProps = {
data?: BankSummary;
loading: boolean;
error: boolean;
fetching: boolean;
retry: () => void;
all?: boolean;
};
const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
const { t } = useTranslation();
return (
<Paper p="md" withBorder>
<Stack>
<Title order={2}>
{t(props.all ? "bank.allSummary" : "bank.summary")}
</Title>
{props.data ? (
<>
<SimpleGrid cols={{ base: 1, sm: 3 }}>
{(["expenses", "refunds", "net"] as const).map((key) => (
<div key={key}>
<Text c="dimmed">{t(`bank.${key}`)}</Text>
<Text size="xl" fw={650} style={{ overflowWrap: "anywhere" }}>
{formatMoney(props.data?.[key] ?? null)}
</Text>
</div>
))}
</SimpleGrid>
<Text size="sm" c="dimmed">
{t("bank.summaryHint")}
</Text>
{props.data.categories.length ? (
props.data.categories.map((category) => (
<Paper p="sm" key={category.categoryId} withBorder>
<Text fw={600}>{category.categoryName}</Text>
<Group>
{(["expenses", "refunds", "net"] as const).map((key) => (
<Text key={key} style={{ overflowWrap: "anywhere" }}>
{t(`bank.${key}`)}: {formatMoney(category[key])}
</Text>
))}
</Group>
</Paper>
))
) : (
<Text>{t("bank.noExpenses")}</Text>
)}
</>
) : props.loading ? (
<LoadingSkeleton label={t("bank.loading")}>
<SimpleGrid cols={3}>
<Skeleton h={60} />
<Skeleton h={60} />
<Skeleton h={60} />
</SimpleGrid>
</LoadingSkeleton>
) : null}
{props.error && (
<Group role="alert">
<Text>{t(props.data ? "bank.refreshError" : "bank.error")}</Text>
<Button variant="default" onClick={props.retry}>
{t("bank.retry")}
</Button>
</Group>
)}
{props.data && props.fetching && (
<Text role="status">{t("bank.refreshing")}</Text>
)}
</Stack>
</Paper>
);
};
export { BankSummaryView };
@@ -0,0 +1,74 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { describe, expect, it } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import { CategoryManager } from "./CategoryManager";
describe("CategoryManager", () => {
it("rejects duplicate case-insensitive and too long names then renames default identity", async () => {
configureAuthClient({
accessToken: () => "test",
refresh: async () => false,
clear: () => {},
});
let category = {
id: "default",
name: "Другое",
isDefault: true,
version: 1,
};
let creates = 0;
let body: unknown;
server.use(
http.get("*/api/v1/bank-cards/categories", () =>
HttpResponse.json([category]),
),
http.post("*/api/v1/bank-cards/categories", () => {
creates++;
return HttpResponse.json({});
}),
http.patch(
"*/api/v1/bank-cards/categories/default",
async ({ request }) => {
body = await request.json();
category = { ...category, name: "Остальное", version: 2 };
return HttpResponse.json(category);
},
),
);
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<CategoryManager onClose={() => {}} />
</MantineProvider>
</QueryClientProvider>,
);
await screen.findByText("Другое");
fireEvent.change(screen.getByLabelText(/Название категории/), {
target: { value: " другое " },
});
fireEvent.click(screen.getByRole("button", { name: "Добавить категорию" }));
await screen.findByText("Категория с таким названием уже есть.");
fireEvent.change(screen.getByLabelText(/Название категории/), {
target: { value: "a".repeat(81) },
});
fireEvent.click(screen.getByRole("button", { name: "Добавить категорию" }));
await screen.findByText("Введите название от 1 до 80 символов.");
expect(creates).toBe(0);
fireEvent.click(screen.getByRole("button", { name: "Переименовать" }));
fireEvent.input(screen.getByLabelText(/Название категории/), {
target: { value: "Остальное" },
});
fireEvent.blur(screen.getByLabelText(/Название категории/));
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await screen.findByText("Остальное");
await waitFor(() =>
expect(body).toEqual({ name: "Остальное", version: 1 }),
);
expect(screen.getByText("По умолчанию")).toBeInTheDocument();
});
});
@@ -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 };
@@ -0,0 +1,16 @@
import { useEffect, useState } from "react";
function useBankFormFocus() {
const [trigger] = useState(() => document.activeElement);
useEffect(
() => () => {
queueMicrotask(() => {
if (trigger instanceof HTMLElement && trigger.isConnected)
trigger.focus();
});
},
[trigger],
);
}
export { useBankFormFocus };