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