refactor: establish local frontend workspace boundaries
Conventional commits / commits (pull_request) Successful in 24s
Frontend security / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m18s
Frontend security / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 6m45s

OpenSpec change: evolve-frontend-stack
This commit was merged in pull request #34.
This commit is contained in:
2026-09-22 22:11:02 +03:00
parent c86be7bf73
commit 85fcfe3d07
101 changed files with 1487 additions and 666 deletions
+23 -71
View File
@@ -10,6 +10,8 @@ import {
Text,
Title,
} from "@mantine/core";
import { AddIcon, DeleteIcon, EditIcon } from "@wealthledger/icons";
import { EntryCard, LoadingSkeleton, StateView } from "@wealthledger/ui";
import { type FC, useLayoutEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
@@ -20,7 +22,6 @@ import {
BankPeriodPicker,
BankSummaryView,
} from "@/entities/bank-card";
import { EntryCard, LoadingSkeleton, StateView } from "@/shared/ui";
import classes from "./CardPage.module.css";
import type { CardPageVm } from "./CardPage.vm";
@@ -106,19 +107,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
title={t("bank.editCard")}
onClick={() => vm.setEditingCard(true)}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="m16 3 5 5-12 12-6 1 1-6L16 3Z M13 6l5 5" />
</svg>
<EditIcon size={18} strokeWidth={1.5} />
</ActionIcon>
<ActionIcon
variant="subtle"
@@ -130,25 +119,23 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
loading={vm.archivePending}
onClick={() => void vm.toggleArchive()}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path
d={
card.archivedAt
? "M3 3h18v4H3z M5 7v14h14V7 M12 17V10m-3 3 3-3 3 3"
: "M3 6h18 M9 6V3h6v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7"
}
/>
</svg>
{card.archivedAt ? (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M3 3h18v4H3z M5 7v14h14V7 M12 17V10m-3 3 3-3 3 3" />
</svg>
) : (
<DeleteIcon size={18} strokeWidth={1.5} />
)}
</ActionIcon>
</Group>
</div>
@@ -196,18 +183,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
disabled={Boolean(card.archivedAt) || !vm.cards.data}
onClick={() => vm.setOperation("new")}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
aria-hidden="true"
>
<path d="M12 5v14 M5 12h14" />
</svg>
<AddIcon size={20} strokeWidth={1.5} />
</ActionIcon>
</Group>
<Select
@@ -279,19 +255,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
title={t("bank.edit")}
onClick={() => vm.setOperation(row.item)}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="m16 3 5 5-12 12-6 1 1-6L16 3Z M13 6l5 5" />
</svg>
<EditIcon size={18} strokeWidth={1.5} />
</ActionIcon>
<ActionIcon
variant="subtle"
@@ -306,19 +270,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
title={t("bank.delete")}
onClick={() => vm.setDeleting(row.item)}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M3 6h18 M9 6V3h6v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7" />
</svg>
<DeleteIcon size={18} strokeWidth={1.5} />
</ActionIcon>
</Group>
}
+113
View File
@@ -0,0 +1,113 @@
import { Alert } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { type FC, useState } from "react";
import { FormProvider, useForm, useWatch } from "react-hook-form";
import type { OperationValues } from "./OperationForm.types";
import { OperationFormView } from "./OperationForm.view";
type ExampleProps = { pending?: boolean; conflict?: boolean };
const OperationExample: FC<ExampleProps> = (props) => {
const [opened, setOpened] = useState(true);
const [action, setAction] = useState("");
const methods = useForm<OperationValues>({
defaultValues: {
kind: "refund",
cardId: "demo",
targetCardId: "",
categoryId: "",
originalExpenseId: "purchase",
amount: "500",
date: "2026-08-01",
description: "Синтетический возврат",
},
});
const kind = useWatch({ control: methods.control, name: "kind" });
return (
<FormProvider {...methods}>
<OperationFormView
opened={opened}
pending={Boolean(props.pending)}
editing
categoriesOpen={false}
onClose={() => !props.pending && setOpened(false)}
onCancel={() => setOpened(false)}
onExitTransitionEnd={() => setAction("Форма закрыта")}
onSubmit={(event) => {
event.preventDefault();
setAction("Локальный submit");
}}
fields={{
editing: true,
refundCardId: "demo",
kind,
cards: [{ value: "demo", label: "Учебная карта" }],
targets: [],
categories: [],
defaultCategory: "",
kinds: [{ value: "refund", label: "Возврат" }],
dateInputValue: () => "2026-08-01",
validDate: (value) => value === "2026-08-01",
maxDate: () => "2026-08-27",
}}
refund={{
editing: true,
expenses: [
{
value: "purchase",
label: "01.07.2026 · Учебная покупка · 500 ₽",
},
],
onSelect: () => undefined,
limit: { currency: "RUB", value: "500" },
}}
loadingExpenses={false}
emptyExpenses={false}
sourcePage={1}
previousDisabled
nextDisabled
onPrevious={() => undefined}
onNext={() => undefined}
onAddCategory={() => undefined}
saveDisabled={false}
feedback={
props.conflict && (
<Alert color="orange" role="alert">
Синтетический конфликт версии
</Alert>
)
}
onReload={
props.conflict ? () => setAction("Локальный reload") : undefined
}
latest={
props.conflict
? {
version: 2,
details: "Учебная версия",
onAdopt: () => setAction("Локальное принятие версии"),
}
: undefined
}
/>
<output aria-live="polite">{action}</output>
</FormProvider>
);
};
const meta = {
title: "Product/OperationForm",
component: OperationExample,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component:
"Synthetic presentation без API/VM. Date callbacks фиксируют fixture 01.08.2026; денежная validation, recovery и nested category flow проверяются application tests. Actions только локальные.",
},
},
},
} satisfies Meta<typeof OperationExample>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Refund: Story = {};
export const Pending: Story = { args: { pending: true } };
export const Conflict: Story = { args: { conflict: true } };
+38
View File
@@ -70,6 +70,43 @@ async function select(label: string, value: string | RegExp) {
fireEvent.click(await screen.findByRole("option", { name: value }));
}
describe("OperationForm", () => {
it("blocks closing and editing while save is pending, then closes after success", async () => {
let finish: () => void = () => {};
const response = new Promise<void>((resolve) => {
finish = resolve;
});
const received = vi.fn();
server.use(
http.post("*/api/v1/bank-cards/operations", async () => {
received();
await response;
return HttpResponse.json({}, { status: 201 });
}),
);
const close = setup();
await waitFor(() =>
expect(screen.getByRole("combobox", { name: /Категория/ })).toHaveValue(
"Остальное",
),
);
fireEvent.change(screen.getByLabelText(/Сумма/), {
target: { value: "10" },
});
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(received).toHaveBeenCalledOnce());
try {
expect(screen.getByLabelText(/Сумма/)).toBeDisabled();
expect(screen.getByRole("button", { name: "Закрыть" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Отмена" })).toBeDisabled();
fireEvent.keyDown(document, { key: "Escape" });
expect(close).not.toHaveBeenCalled();
expect(screen.getByRole("dialog")).toBeInTheDocument();
} finally {
finish();
}
await waitFor(() => expect(close).toHaveBeenCalledOnce());
});
it("uses renamed default category and exact maximum positive money despite zero balance", async () => {
const close = setup();
await waitFor(() =>
@@ -95,6 +132,7 @@ describe("OperationForm", () => {
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(close).toHaveBeenCalled());
expect(body).toMatchObject({
kind: "expense",
categoryId: "default",
date: "2026-08-07",
amount: money("92233720368547758.07"),
+42 -411
View File
@@ -1,420 +1,51 @@
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 { z } from "zod";
import {
type BankCard,
BankFeedback,
type BankOperation,
bankDateSchema,
bankMoneyValue,
bankToday,
CategoryManager,
moneyCents,
positiveMoneySchema,
useBankCategories,
useBankExpenses,
useBankFormFocus,
useBankMutations,
useBankOperation,
} from "@/entities/bank-card";
import { validationMessage } from "@/shared/i18n";
import { formatDate, formatMoney } from "@/shared/lib";
import classes from "./OperationForm.module.css";
import type { OperationValues } from "./OperationForm.types";
import { OperationFields, RefundFields } from "./OperationForm.view";
import type { FC } from "react";
import { FormProvider } from "react-hook-form";
import { BankFeedback, CategoryManager } from "@/entities/bank-card";
import type { OperationFormProps } from "./OperationForm.types";
import { OperationFormView } from "./OperationForm.view";
import { useOperationFormVm } from "./OperationForm.vm";
const MAX_DESCRIPTION_LENGTH = 500;
const operationSchema = z.object({
kind: z.enum(["expense", "topup", "transfer", "refund"]),
cardId: z.string(),
targetCardId: z.string(),
categoryId: z.string(),
originalExpenseId: z.string(),
amount: positiveMoneySchema,
date: bankDateSchema,
description: z
.string()
.max(
MAX_DESCRIPTION_LENGTH,
validationMessage("bank.validation.description")(undefined),
),
});
type OperationFormProps = {
card: BankCard;
cards: BankCard[];
operation?: BankOperation;
onClose: () => void;
};
const OperationForm: FC<OperationFormProps> = (props) => {
useBankFormFocus();
const [opened, setOpened] = useState(true);
const { t } = useTranslation();
const [categoriesOpen, setCategoriesOpen] = useState(false);
const [version, setVersion] = useState(props.operation?.version ?? 1);
const [latest, setLatest] = useState<BankOperation>();
const [sourcePage, setSourcePage] = useState(1);
const [sourceId, setSourceId] = useState(
props.operation?.originalExpenseId ?? "",
);
const sourceQuery = useBankOperation(sourceId || undefined);
const currentQuery = useBankOperation(props.operation?.id);
const [refundLimit, setRefundLimit] = useState(
props.operation?.availableRefund,
);
const [intent] = useState(() => crypto.randomUUID());
const [submittedValues, setSubmittedValues] = useState<OperationValues>();
const [error, setError] = useState<unknown>();
const categories = useBankCategories();
const mutation = useBankMutations();
const methods = useForm<OperationValues>({
resolver: zodResolver(
operationSchema.superRefine((values, ctx) => {
// Repeated intent reaches server idempotency even after its source changed.
if (
submittedValues &&
JSON.stringify(values) === JSON.stringify(submittedValues)
)
return;
const source = props.cards.find((c) => c.id === values.cardId);
const target = props.cards.find((c) => c.id === values.targetCardId);
if (!source || source.archivedAt)
ctx.addIssue({
code: "custom",
path: ["cardId"],
...validationMessage("bank.validation.required")(undefined),
});
if (
values.kind === "transfer" &&
(!target || target.archivedAt || target.id === values.cardId)
)
ctx.addIssue({
code: "custom",
path: ["targetCardId"],
...validationMessage("bank.validation.destination")(undefined),
});
if (
(source && values.date < source.openingDate) ||
(values.kind === "transfer" &&
target &&
values.date < target.openingDate)
)
ctx.addIssue({
code: "custom",
path: ["date"],
...validationMessage("bank.validation.start")(undefined),
});
if (values.kind === "refund") {
const expense = sourceQuery.data;
if (!expense || expense.cardId !== values.cardId)
ctx.addIssue({
code: "custom",
path: ["originalExpenseId"],
...validationMessage("bank.validation.required")(undefined),
});
if (expense && values.date < expense.date)
ctx.addIssue({
code: "custom",
path: ["date"],
...validationMessage("bank.validation.start")(undefined),
});
const limit = props.operation
? refundLimit
: expense?.availableRefund;
const amount = moneyCents(values.amount);
if (
limit &&
amount !== null &&
amount > (moneyCents(limit.value) ?? 0n)
)
ctx.addIssue({
code: "custom",
path: ["amount"],
...validationMessage("bank.validation.refund")(undefined),
});
}
if (
values.kind === "expense" &&
!categories.data?.some(
(c) =>
c.id ===
(values.categoryId ||
categories.data?.find((c) => c.isDefault)?.id),
)
)
ctx.addIssue({
code: "custom",
path: ["categoryId"],
...validationMessage("bank.validation.required")(undefined),
});
}),
),
defaultValues: {
kind: props.operation?.kind ?? "expense",
cardId: props.operation?.cardId ?? props.card.id,
targetCardId: props.operation?.targetCardId ?? "",
categoryId: props.operation?.categoryId ?? "",
originalExpenseId: props.operation?.originalExpenseId ?? "",
amount: props.operation?.amount.value ?? "",
date: props.operation?.date ?? bankToday(),
description: props.operation?.description ?? "",
},
});
const kind = methods.watch("kind");
const expenses = useBankExpenses(
kind === "refund" ? props.card.id : undefined,
{ page: sourcePage },
);
const source = methods.watch("cardId");
const pending = !opened || methods.formState.isSubmitting;
const options = props.cards
.filter((c) => !c.archivedAt)
.map((c) => ({ value: c.id, label: c.name }));
const vm = useOperationFormVm(props);
return (
<>
<Modal
opened={opened}
onExitTransitionEnd={props.onClose}
onClose={() => !pending && !categoriesOpen && setOpened(false)}
title={t(props.operation ? "bank.editOperation" : "bank.addOperation")}
closeOnEscape={!pending && !categoriesOpen}
closeOnClickOutside={!pending && !categoriesOpen}
closeButtonProps={{ "aria-label": t("bank.close"), disabled: pending }}
>
<FormProvider {...methods}>
<form
onSubmit={methods.handleSubmit(async (values) => {
setSubmittedValues(values);
setError(undefined);
try {
const body = {
kind: values.kind,
cardId: values.cardId,
...(values.kind === "transfer"
? { targetCardId: values.targetCardId }
: {}),
...(values.kind === "expense"
? {
categoryId:
values.categoryId ||
categories.data?.find((c) => c.isDefault)?.id,
}
: {}),
...(values.kind === "refund"
? { originalExpenseId: values.originalExpenseId }
: {}),
amount: {
currency: "RUB" as const,
value: bankMoneyValue(values.amount),
},
date: values.date,
description: values.description,
};
if (props.operation)
await mutation.updateOperation.mutateAsync({
id: props.operation.id,
body: { ...body, version },
});
else
await mutation.createOperation.mutateAsync({
id: intent,
...body,
});
setOpened(false);
} catch (failure) {
setError(failure);
}
})}
>
<fieldset disabled={pending} className={classes.fieldset}>
<Stack>
<OperationFields
refundCardId={props.card.id}
editing={Boolean(props.operation)}
kind={kind}
cards={options}
targets={options.filter((c) => c.value !== source)}
categories={
categories.data?.map((c) => ({
value: c.id,
label: c.name,
})) ?? []
}
defaultCategory={
categories.data?.find((c) => c.isDefault)?.id ?? ""
}
<FormProvider {...vm.methods}>
<OperationFormView
{...vm.view}
refundFeedback={
<>
{vm.expensesError && (
<BankFeedback
loadingError
error={vm.expensesError}
retry={vm.retryExpenses}
/>
{kind === "refund" && (
<>
<RefundFields
editing={Boolean(props.operation)}
expenses={
expenses.data?.items.map((item) => ({
value: item.id,
label: t("bank.expenseChoice", {
date: formatDate(item.date),
name: item.description || item.categoryName,
amount: formatMoney(item.amount),
available: formatMoney(item.availableRefund),
}),
})) ?? []
}
selected={
sourceQuery.data
? {
value: sourceQuery.data.id,
label: t("bank.expenseChoice", {
date: formatDate(sourceQuery.data.date),
name:
sourceQuery.data.description ||
sourceQuery.data.categoryName,
amount: formatMoney(sourceQuery.data.amount),
available: formatMoney(
sourceQuery.data.availableRefund,
),
}),
}
: undefined
}
onSelect={setSourceId}
limit={
props.operation
? refundLimit
: sourceQuery.data?.availableRefund
}
/>
{expenses.isLoading && (
<Text role="status">{t("bank.loadingHistory")}</Text>
)}
{expenses.data &&
!expenses.data.items.length &&
!props.operation && <Text>{t("bank.noPurchases")}</Text>}
{!props.operation && (
<Group>
<Button
variant="default"
disabled={sourcePage === 1 || expenses.isFetching}
onClick={() => setSourcePage(sourcePage - 1)}
>
{t("bank.previous")}
</Button>
<Text>{t("bank.page", { page: sourcePage })}</Text>
<Button
variant="default"
disabled={
!expenses.data?.hasNext || expenses.isFetching
}
onClick={() => setSourcePage(sourcePage + 1)}
>
{t("bank.next")}
</Button>
</Group>
)}
{expenses.isError && (
<BankFeedback
loadingError
error={expenses.error}
retry={() => void expenses.refetch()}
/>
)}{" "}
{sourceQuery.isError && (
<BankFeedback
loadingError
error={sourceQuery.error}
retry={() => void sourceQuery.refetch()}
/>
)}
</>
)}
{kind === "expense" && (
<Button
variant="subtle"
onClick={() => setCategoriesOpen(true)}
>
{t("bank.addCategory")}
</Button>
)}
{categories.isError && (
<BankFeedback
loadingError
error={categories.error}
retry={() => void categories.refetch()}
/>
)}
{error ? (
<>
<BankFeedback error={error} />
{(props.operation || sourceId) && (
<Button
variant="default"
onClick={async () => {
const query = props.operation
? currentQuery
: sourceQuery;
const result = await query.refetch();
if (result.data && !result.error) {
if (props.operation) {
setLatest(result.data);
setRefundLimit(result.data.availableRefund);
} else setError(undefined);
} else setError(result.error);
}}
>
{t("bank.reloadRecord")}
</Button>
)}
</>
) : null}
{latest && (
<>
<Text>
{t("bank.versionHint", {
details: `${formatDate(latest.date)} · ${formatMoney(latest.amount)} · ${latest.description ?? ""}`,
})}
</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}
disabled={
(kind === "expense" && !categories.data) ||
(kind === "refund" && !sourceQuery.data)
}
>
{t("bank.save")}
</Button>
<Button variant="default" onClick={() => setOpened(false)}>
{t("bank.cancel")}
</Button>
</Group>
</Stack>
</fieldset>
</form>
</FormProvider>
</Modal>
{categoriesOpen && (
)}{" "}
{vm.sourceError && (
<BankFeedback
loadingError
error={vm.sourceError}
retry={vm.retrySource}
/>
)}
</>
}
categoriesFeedback={
vm.categoriesError && (
<BankFeedback
loadingError
error={vm.categoriesError}
retry={vm.retryCategories}
/>
)
}
feedback={vm.error ? <BankFeedback error={vm.error} /> : null}
/>
</FormProvider>
{vm.categoriesOpen && (
<CategoryManager
onClose={() => setCategoriesOpen(false)}
onCreated={(id) => {
methods.setValue("categoryId", id);
setCategoriesOpen(false);
}}
onClose={vm.closeCategories}
onCreated={vm.onCategoryCreated}
/>
)}
</>
+9 -2
View File
@@ -1,4 +1,4 @@
import type { BankOperation } from "@/entities/bank-card";
import type { BankCard, BankOperation } from "@/entities/bank-card";
type OperationValues = {
kind: BankOperation["kind"];
@@ -11,4 +11,11 @@ type OperationValues = {
description: string;
};
export type { OperationValues };
type OperationFormProps = {
card: BankCard;
cards: BankCard[];
operation?: BankOperation;
onClose: () => void;
};
export type { OperationFormProps, OperationValues };
+141 -29
View File
@@ -1,15 +1,20 @@
import { Select, Stack, Text, Textarea, TextInput } from "@mantine/core";
import {
Button,
Group,
Modal,
Select,
Stack,
Text,
Textarea,
TextInput,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import type { FC } from "react";
import type { FC, FormEventHandler, ReactNode } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
type BankOperation,
bankDateInputValue,
bankDateSchema,
bankToday,
} from "@/entities/bank-card";
import type { BankOperation } from "@/entities/bank-card";
import { formatMoney } from "@/shared/lib";
import classes from "./OperationForm.module.css";
import type { OperationValues } from "./OperationForm.types";
type Choice = { value: string; label: string };
@@ -21,6 +26,10 @@ type OperationFieldsProps = {
targets: Choice[];
categories: Choice[];
defaultCategory: string;
kinds: Choice[];
dateInputValue: (value: string) => string;
validDate: (value: string) => boolean;
maxDate: () => string;
};
const OperationFields: FC<OperationFieldsProps> = (props) => {
const { t } = useTranslation();
@@ -39,9 +48,7 @@ const OperationFields: FC<OperationFieldsProps> = (props) => {
<Select
label={t("bank.kind")}
required
data={(["expense", "topup", "transfer", "refund"] as const).map(
(value) => ({ value, label: t(`bank.kinds.${value}`) }),
)}
data={props.kinds}
{...field}
disabled={isSubmitting || props.editing}
onChange={(value) => {
@@ -99,24 +106,22 @@ const OperationFields: FC<OperationFieldsProps> = (props) => {
label={t("bank.date")}
required
valueFormat="DD.MM.YYYY"
value={
bankDateSchema.safeParse(field.value).success ? field.value : null
}
value={props.validDate(field.value) ? field.value : null}
dateParser={(value) =>
bankDateSchema.safeParse(bankDateInputValue(value)).success
? bankDateInputValue(value)
props.validDate(props.dateInputValue(value))
? props.dateInputValue(value)
: null
}
fixOnBlur={false}
onInput={(event) =>
field.onChange(bankDateInputValue(event.currentTarget.value))
field.onChange(props.dateInputValue(event.currentTarget.value))
}
onChange={(value) => field.onChange(value ?? "")}
onBlur={(event) => {
field.onChange(bankDateInputValue(event.currentTarget.value));
field.onChange(props.dateInputValue(event.currentTarget.value));
field.onBlur();
}}
maxDate={bankToday()}
maxDate={props.maxDate()}
popoverProps={{
withinPortal: false,
middlewares: { shift: { crossAxis: true } },
@@ -153,7 +158,6 @@ const OperationFields: FC<OperationFieldsProps> = (props) => {
type RefundFieldsProps = {
editing: boolean;
expenses: Choice[];
selected?: Choice;
onSelect: (id: string) => void;
limit: BankOperation["availableRefund"] | undefined;
};
@@ -172,14 +176,7 @@ const RefundFields: FC<RefundFieldsProps> = (props) => {
<Select
label={t("bank.expense")}
required
data={
props.selected &&
!props.expenses.some(
(item) => item.value === props.selected?.value,
)
? [props.selected, ...props.expenses]
: props.expenses
}
data={props.expenses}
{...field}
onChange={(value) => {
field.onChange(value ?? "");
@@ -200,4 +197,119 @@ const RefundFields: FC<RefundFieldsProps> = (props) => {
);
};
export { OperationFields, RefundFields };
type OperationFormViewProps = {
opened: boolean;
pending: boolean;
categoriesOpen: boolean;
editing: boolean;
onClose: () => void;
onExitTransitionEnd: () => void;
onCancel: () => void;
onSubmit: FormEventHandler<HTMLFormElement>;
fields: OperationFieldsProps;
refund: RefundFieldsProps;
loadingExpenses: boolean;
emptyExpenses: boolean;
sourcePage: number;
previousDisabled: boolean;
nextDisabled: boolean;
onPrevious: () => void;
onNext: () => void;
onAddCategory: () => void;
onReload?: () => void;
latest?: { version: number; details: string; onAdopt: () => void };
saveDisabled: boolean;
refundFeedback?: ReactNode;
categoriesFeedback?: ReactNode;
feedback?: ReactNode;
};
const OperationFormView: FC<OperationFormViewProps> = (props) => {
const { t } = useTranslation();
return (
<Modal
opened={props.opened}
onExitTransitionEnd={props.onExitTransitionEnd}
onClose={props.onClose}
title={t(props.editing ? "bank.editOperation" : "bank.addOperation")}
closeOnEscape={!props.pending && !props.categoriesOpen}
closeOnClickOutside={!props.pending && !props.categoriesOpen}
closeButtonProps={{
"aria-label": t("bank.close"),
disabled: props.pending,
}}
>
<form onSubmit={props.onSubmit}>
<fieldset disabled={props.pending} className={classes.fieldset}>
<Stack>
<OperationFields {...props.fields} />
{props.fields.kind === "refund" && (
<>
<RefundFields {...props.refund} />
{props.loadingExpenses && (
<Text role="status">{t("bank.loadingHistory")}</Text>
)}
{props.emptyExpenses && <Text>{t("bank.noPurchases")}</Text>}
{!props.editing && (
<Group>
<Button
variant="default"
disabled={props.previousDisabled}
onClick={props.onPrevious}
>
{t("bank.previous")}
</Button>
<Text>{t("bank.page", { page: props.sourcePage })}</Text>
<Button
variant="default"
disabled={props.nextDisabled}
onClick={props.onNext}
>
{t("bank.next")}
</Button>
</Group>
)}
{props.refundFeedback}
</>
)}
{props.fields.kind === "expense" && (
<Button variant="subtle" onClick={props.onAddCategory}>
{t("bank.addCategory")}
</Button>
)}
{props.categoriesFeedback}
{props.feedback}
{props.onReload && (
<Button variant="default" onClick={props.onReload}>
{t("bank.reloadRecord")}
</Button>
)}
{props.latest && (
<>
<Text>
{t("bank.versionHint", { details: props.latest.details })}
</Text>
<Button variant="default" onClick={props.latest.onAdopt}>
{t("bank.adoptVersion", { version: props.latest.version })}
</Button>
</>
)}
<Group>
<Button
type="submit"
loading={props.pending}
disabled={props.saveDisabled}
>
{t("bank.save")}
</Button>
<Button variant="default" onClick={props.onCancel}>
{t("bank.cancel")}
</Button>
</Group>
</Stack>
</fieldset>
</form>
</Modal>
);
};
export { OperationFormView };
+318
View File
@@ -0,0 +1,318 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import {
type BankOperation,
bankDateInputValue,
bankDateSchema,
bankMoneyValue,
bankOperationKinds,
bankToday,
moneyCents,
positiveMoneySchema,
useBankCategories,
useBankExpenses,
useBankFormFocus,
useBankMutations,
useBankOperation,
} from "@/entities/bank-card";
import { validationMessage } from "@/shared/i18n";
import { formatDate, formatMoney } from "@/shared/lib";
import type {
OperationFormProps,
OperationValues,
} from "./OperationForm.types";
const MAX_DESCRIPTION_LENGTH = 500;
const operationSchema = z.object({
kind: z.enum(bankOperationKinds),
cardId: z.string(),
targetCardId: z.string(),
categoryId: z.string(),
originalExpenseId: z.string(),
amount: positiveMoneySchema,
date: bankDateSchema,
description: z
.string()
.max(
MAX_DESCRIPTION_LENGTH,
validationMessage("bank.validation.description")(undefined),
),
});
const useOperationFormVm = (props: OperationFormProps) => {
useBankFormFocus();
const [opened, setOpened] = useState(true);
const { t } = useTranslation();
const [categoriesOpen, setCategoriesOpen] = useState(false);
const [version, setVersion] = useState(props.operation?.version ?? 1);
const [latest, setLatest] = useState<BankOperation>();
const [sourcePage, setSourcePage] = useState(1);
const [sourceId, setSourceId] = useState(
props.operation?.originalExpenseId ?? "",
);
const sourceQuery = useBankOperation(sourceId || undefined);
const currentQuery = useBankOperation(props.operation?.id);
const [refundLimit, setRefundLimit] = useState(
props.operation?.availableRefund,
);
const [intent] = useState(() => crypto.randomUUID());
const [submittedValues, setSubmittedValues] = useState<OperationValues>();
const [error, setError] = useState<unknown>();
const categories = useBankCategories();
const mutation = useBankMutations();
const methods = useForm<OperationValues>({
resolver: zodResolver(
operationSchema.superRefine((values, ctx) => {
// Repeated intent reaches server idempotency even after its source changed.
if (
submittedValues &&
JSON.stringify(values) === JSON.stringify(submittedValues)
)
return;
const source = props.cards.find((c) => c.id === values.cardId);
const target = props.cards.find((c) => c.id === values.targetCardId);
if (!source || source.archivedAt)
ctx.addIssue({
code: "custom",
path: ["cardId"],
...validationMessage("bank.validation.required")(undefined),
});
if (
values.kind === "transfer" &&
(!target || target.archivedAt || target.id === values.cardId)
)
ctx.addIssue({
code: "custom",
path: ["targetCardId"],
...validationMessage("bank.validation.destination")(undefined),
});
if (
(source && values.date < source.openingDate) ||
(values.kind === "transfer" &&
target &&
values.date < target.openingDate)
)
ctx.addIssue({
code: "custom",
path: ["date"],
...validationMessage("bank.validation.start")(undefined),
});
if (values.kind === "refund") {
const expense = sourceQuery.data;
if (!expense || expense.cardId !== values.cardId)
ctx.addIssue({
code: "custom",
path: ["originalExpenseId"],
...validationMessage("bank.validation.required")(undefined),
});
if (expense && values.date < expense.date)
ctx.addIssue({
code: "custom",
path: ["date"],
...validationMessage("bank.validation.start")(undefined),
});
const limit = props.operation
? refundLimit
: expense?.availableRefund;
const amount = moneyCents(values.amount);
if (
limit &&
amount !== null &&
amount > (moneyCents(limit.value) ?? 0n)
)
ctx.addIssue({
code: "custom",
path: ["amount"],
...validationMessage("bank.validation.refund")(undefined),
});
}
if (
values.kind === "expense" &&
!categories.data?.some(
(c) =>
c.id ===
(values.categoryId ||
categories.data?.find((c) => c.isDefault)?.id),
)
)
ctx.addIssue({
code: "custom",
path: ["categoryId"],
...validationMessage("bank.validation.required")(undefined),
});
}),
),
defaultValues: {
kind: props.operation?.kind ?? "expense",
cardId: props.operation?.cardId ?? props.card.id,
targetCardId: props.operation?.targetCardId ?? "",
categoryId: props.operation?.categoryId ?? "",
originalExpenseId: props.operation?.originalExpenseId ?? "",
amount: props.operation?.amount.value ?? "",
date: props.operation?.date ?? bankToday(),
description: props.operation?.description ?? "",
},
});
const kind = methods.watch("kind");
const expenses = useBankExpenses(
kind === "refund" ? props.card.id : undefined,
{ page: sourcePage },
);
const source = methods.watch("cardId");
const pending = !opened || methods.formState.isSubmitting;
const options = props.cards
.filter((c) => !c.archivedAt)
.map((c) => ({ value: c.id, label: c.name }));
const onSubmit = methods.handleSubmit(async (values) => {
setSubmittedValues(values);
setError(undefined);
try {
const body = {
kind: values.kind,
cardId: values.cardId,
...(values.kind === "transfer"
? { targetCardId: values.targetCardId }
: {}),
...(values.kind === "expense"
? {
categoryId:
values.categoryId ||
categories.data?.find((c) => c.isDefault)?.id,
}
: {}),
...(values.kind === "refund"
? { originalExpenseId: values.originalExpenseId }
: {}),
amount: {
currency: "RUB" as const,
value: bankMoneyValue(values.amount),
},
date: values.date,
description: values.description,
};
if (props.operation)
await mutation.updateOperation.mutateAsync({
id: props.operation.id,
body: { ...body, version },
});
else
await mutation.createOperation.mutateAsync({
id: intent,
...body,
});
setOpened(false);
} catch (failure) {
setError(failure);
}
});
const onReload = async () => {
const query = props.operation ? currentQuery : sourceQuery;
const result = await query.refetch();
if (result.data && !result.error) {
if (props.operation) {
setLatest(result.data);
setRefundLimit(result.data.availableRefund);
} else setError(undefined);
} else setError(result.error);
};
const expenseChoice = (item: BankOperation) => ({
value: item.id,
label: t("bank.expenseChoice", {
date: formatDate(item.date),
name: item.description || item.categoryName,
amount: formatMoney(item.amount),
available: formatMoney(item.availableRefund),
}),
});
const expenseOptions = expenses.data?.items.map(expenseChoice) ?? [];
const selected = sourceQuery.data
? expenseChoice(sourceQuery.data)
: undefined;
return {
methods,
categoriesOpen,
error,
categoriesError: categories.isError ? categories.error : null,
expensesError: expenses.isError ? expenses.error : null,
sourceError: sourceQuery.isError ? sourceQuery.error : null,
retryCategories: () => void categories.refetch(),
retryExpenses: () => void expenses.refetch(),
retrySource: () => void sourceQuery.refetch(),
closeCategories: () => setCategoriesOpen(false),
onCategoryCreated: (id: string) => {
methods.setValue("categoryId", id);
setCategoriesOpen(false);
},
view: {
opened,
pending,
categoriesOpen,
editing: Boolean(props.operation),
onClose: () => !pending && !categoriesOpen && setOpened(false),
onExitTransitionEnd: props.onClose,
onCancel: () => setOpened(false),
onSubmit,
fields: {
refundCardId: props.card.id,
editing: Boolean(props.operation),
kind,
cards: options,
targets: options.filter((c) => c.value !== source),
categories:
categories.data?.map((c) => ({ value: c.id, label: c.name })) ?? [],
defaultCategory: categories.data?.find((c) => c.isDefault)?.id ?? "",
kinds: bankOperationKinds.map((value) => ({
value,
label: t(`bank.kinds.${value}`),
})),
dateInputValue: bankDateInputValue,
validDate: (value: string) => bankDateSchema.safeParse(value).success,
maxDate: bankToday,
},
refund: {
editing: Boolean(props.operation),
expenses:
selected &&
!expenseOptions.some((item) => item.value === selected.value)
? [selected, ...expenseOptions]
: expenseOptions,
onSelect: setSourceId,
limit: props.operation
? refundLimit
: sourceQuery.data?.availableRefund,
},
loadingExpenses: expenses.isLoading,
emptyExpenses: Boolean(
expenses.data && !expenses.data.items.length && !props.operation,
),
sourcePage,
previousDisabled: sourcePage === 1 || expenses.isFetching,
nextDisabled: !expenses.data?.hasNext || expenses.isFetching,
onPrevious: () => setSourcePage(sourcePage - 1),
onNext: () => setSourcePage(sourcePage + 1),
onAddCategory: () => setCategoriesOpen(true),
onReload: error && (props.operation || sourceId) ? onReload : undefined,
latest: latest
? {
version: latest.version,
details: `${formatDate(latest.date)} · ${formatMoney(latest.amount)} · ${latest.description ?? ""}`,
onAdopt: () => {
setVersion(latest.version);
setLatest(undefined);
setError(undefined);
},
}
: undefined,
saveDisabled:
(kind === "expense" && !categories.data) ||
(kind === "refund" && !sourceQuery.data),
},
};
};
export { useOperationFormVm };