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
+19
View File
@@ -0,0 +1,19 @@
.wrap {
overflow-wrap: anywhere;
min-width: 0;
}
.operation {
min-width: 0;
}
.operation p {
white-space: normal;
overflow-wrap: anywhere;
}
.operation > div > div {
min-width: 0;
}
@media (min-width: 36em) {
.operation > div:first-child {
grid-template-columns: auto minmax(0, 1fr) minmax(0, 40%);
}
}
+231
View File
@@ -0,0 +1,231 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import {
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { MemoryRouter, Route, Routes } from "react-router";
import { describe, expect, it } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import { CardPage } from "./CardPage";
const money = (value: string) => ({ currency: "RUB", value });
const card = {
id: "a",
name: "Повседневная",
bankName: null,
lastFour: null,
balance: money("10000"),
openingBalance: money("10000"),
openingDate: "2026-01-01",
archivedAt: null,
version: 1,
};
const operation = {
id: "transfer",
kind: "transfer",
cardId: "a",
cardName: "Повседневная",
targetCardId: "b",
targetCardName: "Запасная",
categoryId: null,
categoryName: null,
originalExpenseId: null,
amount: money("50.01"),
availableRefund: null,
date: "2026-08-03",
description: null,
version: 1,
createdAt: "2026-08-03T00:00:00Z",
};
function setup(
options: {
missing?: boolean;
summaryError?: boolean;
cardsError?: boolean;
} = {},
) {
configureAuthClient({
accessToken: () => "test",
refresh: async () => false,
clear: () => {},
});
const requests: string[] = [];
server.use(
http.get("*/api/v1/bank-cards/a", () =>
options.missing
? HttpResponse.json({ code: "BANK_NOT_FOUND" }, { status: 404 })
: HttpResponse.json(card),
),
http.get("*/api/v1/bank-cards", () =>
options.cardsError
? HttpResponse.error()
: HttpResponse.json([card, { ...card, id: "b", name: "Запасная" }]),
),
http.get("*/api/v1/bank-cards/a/operations", ({ request }) => {
requests.push(new URL(request.url).search);
return HttpResponse.json({
items: [operation],
page: 1,
total: 2,
hasNext: true,
});
}),
http.get("*/api/v1/bank-cards/summary", () =>
options.summaryError
? HttpResponse.error()
: HttpResponse.json({
month: "2026-08",
cardId: "a",
expenses: money("0"),
refunds: money("50"),
net: money("-50"),
categories: [],
}),
),
);
const client = createTestQueryClient();
render(
<QueryClientProvider client={client}>
<MantineProvider>
<MemoryRouter initialEntries={["/cards/a?month=2026-08"]}>
<Routes>
<Route path="/cards/:cardId" element={<CardPage />} />
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
return { requests, client };
}
describe("CardPage", () => {
it("shares month with history, pages the journal and keeps current balance", async () => {
const { requests } = setup();
await screen.findByRole("heading", { name: "Повседневная" });
expect(
await screen.findByText("Перевод на «Запасная»"),
).toBeInTheDocument();
expect(screen.getByText("−50,01 ₽")).toBeInTheDocument();
expect(screen.getByText("-50 ₽")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
await waitFor(() =>
expect(requests.some((v) => v.includes("page=2"))).toBe(true),
);
fireEvent.click(screen.getByLabelText("Месяц"));
fireEvent.click(await screen.findByRole("button", { name: "Jul" }));
await waitFor(() =>
expect(
requests.some(
(v) => v.includes("month=2026-07") && v.includes("page=1"),
),
).toBe(true),
);
expect(screen.getByText(/10\s000 ₽/)).toBeInTheDocument();
});
it("keeps card and journal when summary fails", async () => {
setup({ summaryError: true });
await screen.findByRole("heading", { name: "Повседневная" });
await screen.findByRole("alert");
expect(
await screen.findByText("Перевод на «Запасная»"),
).toBeInTheDocument();
expect(screen.queryByText("0 ₽")).not.toBeInTheDocument();
});
it("keeps cached card and history after refetch failure", async () => {
const { client } = setup();
await screen.findByText("Перевод на «Запасная»");
server.use(
http.get("*/api/v1/bank-cards/a", () => HttpResponse.error()),
http.get("*/api/v1/bank-cards/a/operations", () => HttpResponse.error()),
);
await client.invalidateQueries();
await screen.findAllByRole("alert");
expect(
screen.getByRole("heading", { name: "Повседневная" }),
).toBeInTheDocument();
expect(screen.getByText("Перевод на «Запасная»")).toBeInTheDocument();
});
it("handles inaccessible card neutrally", async () => {
setup({ missing: true });
await screen.findByRole("heading", { name: "Карта не найдена" });
expect(screen.getByRole("link", { name: "К картам" })).toHaveAttribute(
"href",
"/cards",
);
expect(screen.queryByText(/10\s000 ₽/)).not.toBeInTheDocument();
});
it("archives without deleting history and restores financial actions", async () => {
setup();
await screen.findByText("Перевод на «Запасная»");
let current = card;
const bodies: unknown[] = [];
server.use(
http.get("*/api/v1/bank-cards/a", () => HttpResponse.json(current)),
http.get("*/api/v1/bank-cards", () =>
HttpResponse.json([current, { ...card, id: "b", name: "Запасная" }]),
),
http.patch("*/api/v1/bank-cards/a", async ({ request }) => {
const body = (await request.json()) as {
version: number;
archived: boolean;
};
bodies.push(body);
current = {
...current,
version: current.version + 1,
archivedAt: body.archived ? "2026-09-01" : null,
} as typeof card;
return HttpResponse.json(current);
}),
);
fireEvent.click(screen.getByRole("button", { name: "Убрать в архив" }));
await screen.findByRole("button", { name: "Вернуть из архива" });
expect(
screen.getByRole("button", { name: "Добавить операцию" }),
).toBeDisabled();
expect(screen.getByRole("button", { name: "Изменить" })).toBeDisabled();
expect(screen.getByText("Перевод на «Запасная»")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Вернуть из архива" }));
await screen.findByRole("button", { name: "Убрать в архив" });
expect(
screen.getByRole("button", { name: "Добавить операцию" }),
).toBeEnabled();
expect(bodies).toEqual([
{ version: 1, archived: true },
{ version: 2, archived: false },
]);
});
it("recovers the prerequisite card list while preserving the visible balance and journal", async () => {
setup({ cardsError: true });
await screen.findByText("Перевод на «Запасная»");
expect(await screen.findByRole("alert")).toHaveTextContent(
"Не удалось загрузить данные карт.",
);
expect(
screen.getByRole("button", { name: "Добавить операцию" }),
).toBeDisabled();
server.use(
http.get("*/api/v1/bank-cards", () =>
HttpResponse.json([card, { ...card, id: "b", name: "Запасная" }]),
),
);
fireEvent.click(
within(screen.getByRole("alert")).getByRole("button", {
name: "Повторить",
}),
);
await waitFor(() =>
expect(
screen.getByRole("button", { name: "Добавить операцию" }),
).toBeEnabled(),
);
expect(screen.getByText("Перевод на «Запасная»")).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
});
+37
View File
@@ -0,0 +1,37 @@
import type { FC } from "react";
import { BankCardForm } from "@/entities/bank-card";
import { CardPageView } from "./CardPage.view";
import { useCardPageVm } from "./CardPage.vm";
import { DeleteOperation } from "./DeleteOperation";
import { OperationForm } from "./OperationForm";
const CardPage: FC = () => {
const vm = useCardPageVm();
return (
<>
<CardPageView vm={vm} />
{vm.editingCard && vm.card.data && (
<BankCardForm
card={vm.card.data}
onClose={() => vm.setEditingCard(false)}
/>
)}
{vm.operation && vm.card.data && vm.cards.data && (
<OperationForm
card={vm.card.data}
cards={vm.cards.data}
operation={vm.operation === "new" ? undefined : vm.operation}
onClose={() => vm.setOperation(null)}
/>
)}
{vm.deleting && (
<DeleteOperation
operation={vm.deleting}
onClose={() => vm.setDeleting(null)}
/>
)}
</>
);
};
export { CardPage };
+226
View File
@@ -0,0 +1,226 @@
import {
Anchor,
Badge,
Button,
Group,
Paper,
Skeleton,
Stack,
Text,
Title,
} from "@mantine/core";
import { MonthPickerInput } from "@mantine/dates";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { BankFeedback, BankSummaryView } from "@/entities/bank-card";
import { formatMoney } from "@/shared/lib";
import { EntryCard, LoadingSkeleton, StateView } from "@/shared/ui";
import classes from "./CardPage.module.css";
import type { CardPageVm } from "./CardPage.vm";
type CardPageViewProps = { vm: CardPageVm };
const CardPageView: FC<CardPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
const card = vm.card.data;
if (!card) {
if (vm.card.isLoading)
return (
<LoadingSkeleton label={t("bank.loading")}>
<Stack>
<Skeleton h={130} />
<Skeleton h={160} />
<Skeleton h={90} />
<Skeleton h={90} />
</Stack>
</LoadingSkeleton>
);
return (
<StateView
error
title={t(vm.notFound ? "bank.notFound" : "bank.error")}
text={t("bank.back")}
action={
<Group>
<Button component={Link} to="/cards">
{t("bank.back")}
</Button>
{!vm.notFound && (
<Button onClick={() => void vm.card.refetch()}>
{t("bank.retry")}
</Button>
)}
</Group>
}
/>
);
}
return (
<Stack gap="lg">
<Paper p="md" withBorder>
<Stack>
<Group justify="space-between">
<Title order={1} className={classes.wrap}>
{card.name}
</Title>
{card.archivedAt && (
<Badge color="gray">{t("bank.archived")}</Badge>
)}
</Group>
<Text c="dimmed">
{card.bankName}
{card.lastFour ? ` · •• ${card.lastFour}` : ""}
</Text>
<Text size="xl" fw={700} className={classes.wrap}>
{formatMoney(card.balance)}
</Text>
<Text c="dimmed">{t("bank.manualBalance")}</Text>
<Group>
<Button
disabled={Boolean(card.archivedAt) || !vm.cards.data}
onClick={() => vm.setOperation("new")}
>
{t("bank.addOperation")}
</Button>
<Button variant="default" onClick={() => vm.setEditingCard(true)}>
{t("bank.editCard")}
</Button>
<Button
variant="default"
loading={vm.archivePending}
onClick={() => void vm.toggleArchive()}
>
{t(card.archivedAt ? "bank.restore" : "bank.archive")}
</Button>
</Group>
{vm.archiveError ? (
<BankFeedback
error={vm.archiveError}
retry={() => void vm.card.refetch()}
/>
) : null}
{card.archivedAt && <Text>{t("bank.archiveHint")}</Text>}
</Stack>
</Paper>
{vm.card.isError && (
<Group role="alert">
<Text>{t("bank.refreshError")}</Text>
<Button onClick={() => void vm.card.refetch()}>
{t("bank.retry")}
</Button>
</Group>
)}
{vm.card.isFetching && <Text role="status">{t("bank.refreshing")}</Text>}
{vm.cards.isError && (
<BankFeedback
loadingError
error={vm.cards.error}
cached={Boolean(vm.cards.data)}
retry={() => void vm.cards.refetch()}
/>
)}
<MonthPickerInput
label={t("bank.month")}
value={`${vm.month}-01`}
onChange={(value) => {
if (value) vm.setMonth(value.slice(0, 7));
}}
popoverProps={{ middlewares: { shift: { crossAxis: true } } }}
/>
<BankSummaryView
data={vm.summary.data}
loading={vm.summary.isLoading}
fetching={vm.summary.isFetching}
error={vm.summary.isError}
retry={() => void vm.summary.refetch()}
/>
<Title order={2}>{t("bank.operations")}</Title>
{vm.rows ? (
vm.rows.length ? (
vm.rows.map((row) => (
<section
key={row.item.id}
className={classes.operation}
aria-label={`${row.title} ${row.item.date}`}
>
<EntryCard
title={row.title}
metadata={row.metadata}
initial={row.title[0]}
value={row.value}
valueLabel={t(`bank.kinds.${row.item.kind}`)}
tone={row.tone}
details={
row.otherCard ? (
<Anchor
component={Link}
to={`/cards/${row.otherCard}?month=${vm.month}`}
>
{t("bank.card")}
</Anchor>
) : null
}
/>
<Group mt="xs">
<Button
variant="subtle"
disabled={!row.editable || Boolean(card.archivedAt)}
onClick={() => vm.setOperation(row.item)}
>
{t("bank.edit")}
</Button>
<Button
variant="subtle"
color="red"
disabled={!row.editable || Boolean(card.archivedAt)}
onClick={() => vm.setDeleting(row.item)}
>
{t("bank.delete")}
</Button>
</Group>
</section>
))
) : (
<Text>{t("bank.noOperations")}</Text>
)
) : vm.history.isLoading ? (
<LoadingSkeleton label={t("bank.loadingHistory")}>
<Stack>
<Skeleton h={90} />
<Skeleton h={90} />
</Stack>
</LoadingSkeleton>
) : null}
{vm.history.isError && (
<Group role="alert">
<Text>{t(vm.rows ? "bank.refreshError" : "bank.error")}</Text>
<Button onClick={() => void vm.history.refetch()}>
{t("bank.retry")}
</Button>
</Group>
)}
{vm.history.data && (
<Group>
<Button
variant="default"
disabled={vm.page === 1 || vm.history.isFetching}
onClick={() => vm.setPage(vm.page - 1)}
>
{t("bank.previous")}
</Button>
<Text>{t("bank.page", { page: vm.page })}</Text>
<Button
variant="default"
disabled={!vm.history.data.hasNext || vm.history.isFetching}
onClick={() => vm.setPage(vm.page + 1)}
>
{t("bank.next")}
</Button>
</Group>
)}
</Stack>
);
};
export { CardPageView };
+103
View File
@@ -0,0 +1,103 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useParams, useSearchParams } from "react-router";
import {
type BankOperation,
bankMonth,
useBankCard,
useBankCards,
useBankMutations,
useBankOperations,
useBankSummary,
} from "@/entities/bank-card";
import { apiErrorCode } from "@/shared/api";
import { formatMoney } from "@/shared/lib";
function useCardPageVm() {
const { cardId } = useParams();
const [params, setParams] = useSearchParams();
const { t } = useTranslation();
const month = bankMonth(params.get("month"));
const rawPage = Number(params.get("page") ?? 1);
const page = Number.isSafeInteger(rawPage) && rawPage > 0 ? rawPage : 1;
const card = useBankCard(cardId);
const { updateCard } = useBankMutations();
const [archiveError, setArchiveError] = useState<unknown>();
const cards = useBankCards(true);
const history = useBankOperations(cardId, { month, page });
const summary = useBankSummary(month, cardId);
const [editingCard, setEditingCard] = useState(false);
const [operation, setOperation] = useState<BankOperation | "new" | null>(
null,
);
const [deleting, setDeleting] = useState<BankOperation | null>(null);
const rows = history.data?.items.map((item) => {
const outgoing =
item.kind === "expense" ||
(item.kind === "transfer" && item.cardId === cardId);
return {
item,
title:
item.kind === "transfer"
? t(outgoing ? "bank.transferTo" : "bank.transferFrom", {
name: outgoing ? item.targetCardName : item.cardName,
})
: t(`bank.kinds.${item.kind}`),
metadata: [item.date, item.categoryName, item.description]
.filter(Boolean)
.join(" · "),
value: `${outgoing ? "−" : "+"}${formatMoney(item.amount)}`,
tone: outgoing ? ("negative" as const) : ("positive" as const),
otherCard:
item.kind === "transfer"
? outgoing
? item.targetCardId
: item.cardId
: null,
editable:
Boolean(cards.data) &&
!cards.data?.some(
(c) =>
(c.id === item.cardId || c.id === item.targetCardId) &&
c.archivedAt,
),
};
});
return {
card,
cards,
archiveError,
archivePending: updateCard.isPending,
toggleArchive: async () => {
if (!card.data) return;
setArchiveError(undefined);
try {
await updateCard.mutateAsync({
id: card.data.id,
body: { version: card.data.version, archived: !card.data.archivedAt },
});
} catch (failure) {
setArchiveError(failure);
}
},
history,
summary,
month,
page,
rows,
editingCard,
setEditingCard,
operation,
setOperation,
deleting,
setDeleting,
notFound: apiErrorCode(card.error) === "BANK_NOT_FOUND",
setMonth: (value: string) => {
if (value) setParams({ month: bankMonth(value) });
},
setPage: (value: number) => setParams({ month, page: String(value) }),
};
}
type CardPageVm = ReturnType<typeof useCardPageVm>;
export { type CardPageVm, useCardPageVm };
@@ -0,0 +1,76 @@
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 type { BankOperation } from "@/entities/bank-card";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import { DeleteOperation } from "./DeleteOperation";
const transfer: BankOperation = {
id: "transfer",
kind: "transfer",
cardId: "a",
cardName: "Основная",
targetCardId: "b",
targetCardName: "Запасная",
categoryId: null,
categoryName: null,
originalExpenseId: null,
amount: { currency: "RUB", value: "100" },
availableRefund: null,
date: "2026-08-01",
description: null,
version: 1,
createdAt: "2026-08-01T00:00:00Z",
};
describe("DeleteOperation", () => {
it("names both sides and requires a deliberate fresh version after conflict", async () => {
configureAuthClient({
accessToken: () => "test",
refresh: async () => false,
clear: () => {},
});
const versions: string[] = [];
server.use(
http.get("*/api/v1/bank-cards/operations/transfer", () =>
HttpResponse.json({ ...transfer, version: 2 }),
),
http.delete("*/api/v1/bank-cards/operations/transfer", ({ request }) => {
const version = new URL(request.url).searchParams.get("version") ?? "";
versions.push(version);
return version === "1"
? HttpResponse.json(
{ code: "BANK_VERSION_CONFLICT" },
{ status: 409 },
)
: new HttpResponse(null, { status: 204 });
}),
);
const close = vi.fn();
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<DeleteOperation operation={transfer} onClose={close} />
</MantineProvider>
</QueryClientProvider>,
);
expect(screen.getByText(/«Основная» → «Запасная»/)).toHaveTextContent(
"Обе стороны перевода будут удалены.",
);
fireEvent.click(screen.getByRole("button", { name: "Удалить" }));
await screen.findByText(/Запись уже изменена/);
expect(close).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Перечитать запись" }));
fireEvent.click(
await screen.findByRole("button", {
name: "Использовать актуальную версию 2",
}),
);
fireEvent.click(screen.getByRole("button", { name: "Удалить" }));
await waitFor(() => expect(close).toHaveBeenCalled());
expect(versions).toEqual(["1", "2"]);
});
});
+111
View File
@@ -0,0 +1,111 @@
import { Button, Group, Modal, Stack, Text } from "@mantine/core";
import { type FC, useState } from "react";
import { useTranslation } from "react-i18next";
import {
BankFeedback,
type BankOperation,
useBankFormFocus,
useBankMutations,
useBankOperation,
} from "@/entities/bank-card";
import { formatMoney } from "@/shared/lib";
type DeleteOperationProps = { operation: BankOperation; onClose: () => void };
const DeleteOperation: FC<DeleteOperationProps> = (props) => {
useBankFormFocus();
const { t } = useTranslation();
const [record, setRecord] = useState(props.operation);
const [latest, setLatest] = useState<BankOperation>();
const [error, setError] = useState<unknown>();
const query = useBankOperation(record.id);
const { deleteOperation } = useBankMutations();
const pending =
deleteOperation.isPending || (query.isFetching && Boolean(error));
return (
<Modal
opened
onClose={() => !pending && props.onClose()}
title={t("bank.deleteTitle")}
closeOnEscape={!pending}
closeOnClickOutside={!pending}
closeButtonProps={{ "aria-label": t("bank.close"), disabled: pending }}
>
<Stack>
<Text>
{record.kind === "transfer"
? t("bank.deleteTransfer", {
amount: formatMoney(record.amount),
source: record.cardName,
target: record.targetCardName,
})
: t("bank.deleteHint", {
kind: t(`bank.kinds.${record.kind}`),
amount: formatMoney(record.amount),
card: record.cardName,
})}
</Text>
{error ? (
<>
<BankFeedback error={error} />
<Button
variant="default"
disabled={pending}
onClick={async () => {
const result = await query.refetch();
if (result.data && !result.error) setLatest(result.data);
else setError(result.error);
}}
>
{t("bank.reloadRecord")}
</Button>
</>
) : null}
{latest && (
<>
<Text>
{t("bank.versionHint", {
details: `${latest.date} · ${formatMoney(latest.amount)} · ${latest.description ?? ""}`,
})}
</Text>
<Button
variant="default"
disabled={pending}
onClick={() => {
setRecord(latest);
setLatest(undefined);
setError(undefined);
}}
>
{t("bank.adoptVersion", { version: latest.version })}
</Button>
</>
)}
<Group>
<Button
color="red"
loading={pending}
onClick={async () => {
setError(undefined);
try {
await deleteOperation.mutateAsync({
id: record.id,
version: record.version,
});
props.onClose();
} catch (failure) {
setError(failure);
}
}}
>
{t("bank.delete")}
</Button>
<Button variant="default" disabled={pending} onClick={props.onClose}>
{t("bank.cancel")}
</Button>
</Group>
</Stack>
</Modal>
);
};
export { DeleteOperation };
@@ -0,0 +1,6 @@
.fieldset {
border: 0;
margin: 0;
padding: 0;
min-width: 0;
}
+458
View File
@@ -0,0 +1,458 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import {
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { describe, expect, it, vi } from "vitest";
import type { BankCard, BankOperation } from "@/entities/bank-card";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import { OperationForm } from "./OperationForm";
const money = (value: string) => ({ currency: "RUB", value });
const card: BankCard = {
id: "a",
name: "Повседневная",
bankName: null,
lastFour: null,
balance: money("0"),
openingBalance: money("0"),
openingDate: "2026-01-01",
archivedAt: null,
version: 1,
};
function setup(operation?: BankOperation) {
configureAuthClient({
accessToken: () => "test",
refresh: async () => false,
clear: () => {},
});
server.use(
http.get("*/api/v1/bank-cards/categories", () =>
HttpResponse.json([
{ id: "default", name: "Остальное", isDefault: true, version: 2 },
]),
),
);
const close = vi.fn();
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<OperationForm
operation={operation}
card={card}
cards={[
card,
{ ...card, id: "b", name: "Запасная", openingDate: "2026-08-01" },
{
...card,
id: "archived",
name: "Архивная",
archivedAt: "2026-01-02",
},
]}
onClose={close}
/>
</MantineProvider>
</QueryClientProvider>,
);
return close;
}
async function select(label: string, value: string | RegExp) {
fireEvent.click(screen.getByRole("combobox", { name: new RegExp(label) }));
fireEvent.click(await screen.findByRole("option", { name: value }));
}
describe("OperationForm", () => {
it("uses renamed default category and exact maximum positive money despite zero balance", async () => {
const close = setup();
await waitFor(() =>
expect(screen.getByRole("combobox", { name: /Категория/ })).toHaveValue(
"Остальное",
),
);
let body: unknown;
server.use(
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
body = await request.json();
return HttpResponse.json({}, { status: 201 });
}),
);
fireEvent.change(screen.getByLabelText(/Сумма/), {
target: { value: "92233720368547758,07" },
});
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(close).toHaveBeenCalled());
expect(body).toMatchObject({
categoryId: "default",
amount: money("92233720368547758.07"),
});
});
it("checks both transfer opening dates, excludes archive and reuses intent after a lost response", async () => {
setup();
await select("Вид операции", "Перевод");
await select("Карта-получатель", "Запасная");
fireEvent.change(screen.getByLabelText(/Сумма/), {
target: { value: "0" },
});
fireEvent.change(screen.getByLabelText(/Дата операции/), {
target: { value: "2026-07-31" },
});
const bodies: unknown[] = [];
server.use(
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
bodies.push(await request.json());
return bodies.length === 1
? HttpResponse.error()
: HttpResponse.json({}, { status: 201 });
}),
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
expect(
await screen.findByText("Сумма операции должна быть больше нуля."),
).toBeInTheDocument();
expect(screen.getByText(/Дата не может быть раньше/)).toBeInTheDocument();
expect(bodies).toHaveLength(0);
fireEvent.change(screen.getByLabelText(/Сумма/), {
target: { value: "150,01" },
});
fireEvent.change(screen.getByLabelText(/Дата операции/), {
target: { value: "2026-08-02" },
});
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await screen.findByText(/Не удалось подтвердить сохранение/);
expect(screen.getByLabelText(/Сумма/)).toHaveValue("150,01");
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(bodies).toHaveLength(2));
expect(bodies[0]).toEqual(bodies[1]);
expect(bodies[0]).toMatchObject({
kind: "transfer",
cardId: "a",
targetCardId: "b",
amount: money("150.01"),
});
});
it("topup omits categories and preserves entered description", async () => {
setup();
await select("Вид операции", "Пополнение");
expect(
screen.queryByRole("combobox", { name: /Категория/ }),
).not.toBeInTheDocument();
let body: unknown;
server.use(
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
body = await request.json();
return HttpResponse.json({}, { status: 201 });
}),
);
fireEvent.change(screen.getByLabelText(/Сумма/), {
target: { value: "0.10" },
});
fireEvent.change(screen.getByLabelText(/Описание/), {
target: { value: "Внесение" },
});
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(body).toBeDefined());
expect(body).toMatchObject({
kind: "topup",
amount: money("0.10"),
description: "Внесение",
});
expect(body).not.toHaveProperty("categoryId");
});
it("finds a previous-month purchase across pages, checks remaining refund and retains the complete-refund intent", async () => {
const purchase: BankOperation = {
id: "purchase",
kind: "expense",
cardId: "a",
cardName: card.name,
targetCardId: null,
targetCardName: null,
categoryId: "default",
categoryName: "Остальное",
originalExpenseId: null,
amount: money("500"),
availableRefund: money("500"),
date: "2026-07-01",
description: "Прошлая покупка",
version: 1,
createdAt: "2026-07-01T00:00:00Z",
};
server.use(
http.get("*/api/v1/bank-cards/a/expenses", ({ request }) => {
return HttpResponse.json({
items:
new URL(request.url).searchParams.get("page") === "2"
? [purchase]
: [],
page: 1,
total: 1,
hasNext: new URL(request.url).searchParams.get("page") !== "2",
});
}),
http.get("*/api/v1/bank-cards/operations/purchase", () =>
HttpResponse.json(purchase),
),
);
const close = setup();
await select("Вид операции", "Возврат");
await waitFor(() =>
expect(screen.getByRole("button", { name: "Далее" })).toBeEnabled(),
);
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
await waitFor(() =>
expect(screen.getByRole("button", { name: "Назад" })).toBeEnabled(),
);
await select("Исходная покупка", /Прошлая покупка/);
await screen.findByText("Доступно к возврату: 500 ₽");
fireEvent.change(screen.getByLabelText(/Сумма/), {
target: { value: "500.01" },
});
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await screen.findByText("Сумма превышает доступный возврат.");
const bodies: unknown[] = [];
server.use(
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
bodies.push(await request.json());
purchase.availableRefund = money("0");
return bodies.length === 1
? HttpResponse.error()
: HttpResponse.json({}, { status: 201 });
}),
);
fireEvent.change(screen.getByLabelText(/Сумма/), {
target: { value: "500" },
});
fireEvent.change(screen.getByLabelText(/Дата операции/), {
target: { value: "2026-08-01" },
});
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await screen.findByText(/Не удалось подтвердить сохранение/);
fireEvent.click(screen.getByRole("button", { name: "Перечитать запись" }));
await screen.findByText("Доступно к возврату: 0 ₽");
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(close).toHaveBeenCalled());
expect(bodies[0]).toEqual(bodies[1]);
expect(bodies[0]).toMatchObject({
kind: "refund",
originalExpenseId: "purchase",
date: "2026-08-01",
});
});
it("edits a full refund with its own amount excluded from the limit and immutable purchase", async () => {
const refund: BankOperation = {
id: "refund",
kind: "refund",
cardId: "a",
cardName: card.name,
targetCardId: null,
targetCardName: null,
categoryId: "default",
categoryName: "Остальное",
originalExpenseId: "purchase",
amount: money("500"),
availableRefund: money("500"),
date: "2026-08-01",
description: "",
version: 2,
createdAt: "2026-08-01T00:00:00Z",
};
const purchase = {
...refund,
id: "purchase",
kind: "expense",
date: "2026-07-01",
availableRefund: money("0"),
};
server.use(
http.get("*/api/v1/bank-cards/a/expenses", () =>
HttpResponse.json({
items: [purchase],
page: 1,
total: 1,
hasNext: false,
}),
),
http.get("*/api/v1/bank-cards/operations/purchase", () =>
HttpResponse.json(purchase),
),
http.get("*/api/v1/bank-cards/operations/refund", () =>
HttpResponse.json(refund),
),
);
const close = setup(refund);
await screen.findByText("Доступно к возврату: 500 ₽");
expect(
screen.getByRole("combobox", { name: /Исходная покупка/ }),
).toBeDisabled();
expect(
screen.getByRole("combobox", { name: /Вид операции/ }),
).toBeDisabled();
fireEvent.change(screen.getByLabelText(/Описание/), {
target: { value: "Полностью возвращено" },
});
let body: unknown;
server.use(
http.patch(
"*/api/v1/bank-cards/operations/refund",
async ({ request }) => {
body = await request.json();
return HttpResponse.json(refund);
},
),
);
await waitFor(() =>
expect(screen.getByRole("button", { name: "Сохранить" })).toBeEnabled(),
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(close).toHaveBeenCalled());
expect(body).toMatchObject({
version: 2,
amount: money("500"),
originalExpenseId: "purchase",
description: "Полностью возвращено",
});
});
it("keeps edit draft and stale version until explicit adoption after conflict", async () => {
const operation: BankOperation = {
id: "expense",
kind: "expense",
cardId: "a",
cardName: card.name,
targetCardId: null,
targetCardName: null,
categoryId: "default",
categoryName: "Остальное",
originalExpenseId: null,
amount: money("100"),
availableRefund: money("100"),
date: "2026-08-01",
description: "Первое",
version: 1,
createdAt: "2026-08-01T00:00:00Z",
};
let readVersion = 1;
const bodies: Record<string, unknown>[] = [];
server.use(
http.get("*/api/v1/bank-cards/operations/expense", () =>
HttpResponse.json({
...operation,
version: readVersion,
description: readVersion === 1 ? "Первое" : "Другое исправление",
}),
),
http.patch(
"*/api/v1/bank-cards/operations/expense",
async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
bodies.push(body);
readVersion = 2;
return body.version === 1
? HttpResponse.json(
{ code: "BANK_VERSION_CONFLICT" },
{ status: 409 },
)
: HttpResponse.json({ ...operation, ...body });
},
),
);
const close = setup(operation);
fireEvent.change(screen.getByLabelText(/Описание/), {
target: { value: "Мой черновик" },
});
fireEvent.change(screen.getByLabelText(/Сумма/), {
target: { value: "150" },
});
await waitFor(() =>
expect(screen.getByRole("button", { name: "Сохранить" })).toBeEnabled(),
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await screen.findByText(/Запись уже изменена/);
expect(screen.getByLabelText(/Описание/)).toHaveValue("Мой черновик");
fireEvent.click(screen.getByRole("button", { name: "Перечитать запись" }));
await screen.findByRole("button", {
name: "Использовать актуальную версию 2",
});
expect(screen.getByLabelText(/Сумма/)).toHaveValue("150");
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(bodies).toHaveLength(2));
expect(bodies[1].version).toBe(1);
fireEvent.click(
screen.getByRole("button", { name: "Использовать актуальную версию 2" }),
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(close).toHaveBeenCalled());
expect(bodies[2]).toMatchObject({
version: 2,
description: "Мой черновик",
amount: money("150"),
});
});
it("creates a category inside expense without losing operation fields", async () => {
setup();
fireEvent.change(screen.getByLabelText(/Сумма/), {
target: { value: "001.20" },
});
fireEvent.change(screen.getByLabelText(/Описание/), {
target: { value: "Черновик расхода" },
});
fireEvent.click(screen.getByRole("button", { name: "Добавить категорию" }));
const dialog = screen.getByRole("dialog", { name: "Категории" });
fireEvent.change(within(dialog).getByLabelText(/Название категории/), {
target: { value: " кафе " },
});
let created = false;
server.use(
http.post("*/api/v1/bank-cards/categories", () => {
created = true;
return HttpResponse.json(
{ id: "new", name: "кафе", version: 1, isDefault: false },
{ status: 201 },
);
}),
http.get("*/api/v1/bank-cards/categories", () =>
HttpResponse.json([
{ id: "default", name: "Остальное", isDefault: true, version: 2 },
...(created
? [{ id: "new", name: "кафе", isDefault: false, version: 1 }]
: []),
]),
),
);
fireEvent.click(
within(dialog).getByRole("button", { name: "Добавить категорию" }),
);
await waitFor(() =>
expect(
screen.queryByRole("dialog", { name: "Категории" }),
).not.toBeInTheDocument(),
);
expect(screen.getByLabelText(/Сумма/)).toHaveValue("001.20");
expect(screen.getByLabelText(/Описание/)).toHaveValue("Черновик расхода");
await waitFor(() =>
expect(screen.getByRole("combobox", { name: /Категория/ })).toHaveValue(
"кафе",
),
);
let body: unknown;
server.use(
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
body = await request.json();
return HttpResponse.json({}, { status: 201 });
}),
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(body).toBeDefined());
expect(body).toMatchObject({
categoryId: "new",
description: "Черновик расхода",
amount: money("1.20"),
});
});
});
+417
View File
@@ -0,0 +1,417 @@
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 { formatMoney } from "@/shared/lib";
import classes from "./OperationForm.module.css";
import type { OperationValues } from "./OperationForm.types";
import { OperationFields, RefundFields } from "./OperationForm.view";
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(500, validationMessage("bank.validation.description")(undefined)),
});
type OperationFormProps = {
card: BankCard;
cards: BankCard[];
operation?: BankOperation;
onClose: () => void;
};
const OperationForm: FC<OperationFormProps> = (props) => {
useBankFormFocus();
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 = methods.formState.isSubmitting;
const options = props.cards
.filter((c) => !c.archivedAt)
.map((c) => ({ value: c.id, label: c.name }));
return (
<>
<Modal
opened
onClose={() => !pending && !categoriesOpen && props.onClose()}
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,
});
props.onClose();
} 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 ?? ""
}
/>
{kind === "refund" && (
<>
<RefundFields
editing={Boolean(props.operation)}
expenses={
expenses.data?.items.map((item) => ({
value: item.id,
label: t("bank.expenseChoice", {
date: 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: 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: `${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={props.onClose}>
{t("bank.cancel")}
</Button>
</Group>
</Stack>
</fieldset>
</form>
</FormProvider>
</Modal>
{categoriesOpen && (
<CategoryManager
onClose={() => setCategoriesOpen(false)}
onCreated={(id) => {
methods.setValue("categoryId", id);
setCategoriesOpen(false);
}}
/>
)}
</>
);
};
export { OperationForm };
+14
View File
@@ -0,0 +1,14 @@
import type { BankOperation } from "@/entities/bank-card";
type OperationValues = {
kind: BankOperation["kind"];
cardId: string;
targetCardId: string;
categoryId: string;
originalExpenseId: string;
amount: string;
date: string;
description: string;
};
export type { OperationValues };
+198
View File
@@ -0,0 +1,198 @@
import { Select, Stack, Text, Textarea, 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 BankOperation,
bankDateSchema,
bankToday,
} from "@/entities/bank-card";
import { formatMoney } from "@/shared/lib";
import type { OperationValues } from "./OperationForm.types";
type Choice = { value: string; label: string };
type OperationFieldsProps = {
editing: boolean;
refundCardId: string;
kind: OperationValues["kind"];
cards: Choice[];
targets: Choice[];
categories: Choice[];
defaultCategory: string;
};
const OperationFields: FC<OperationFieldsProps> = (props) => {
const { t } = useTranslation();
const {
control,
register,
setValue,
formState: { errors, isSubmitting },
} = useFormContext<OperationValues>();
return (
<Stack>
<Controller
control={control}
name="kind"
render={({ field }) => (
<Select
label={t("bank.kind")}
required
data={(["expense", "topup", "transfer", "refund"] as const).map(
(value) => ({ value, label: t(`bank.kinds.${value}`) }),
)}
{...field}
disabled={isSubmitting || props.editing}
onChange={(value) => {
field.onChange(value ?? "expense");
if (value === "refund") setValue("cardId", props.refundCardId);
}}
/>
)}
/>
<Controller
control={control}
name="cardId"
render={({ field }) => (
<Select
label={t(
props.kind === "transfer" ? "bank.source" : "bank.operationCard",
)}
required
data={props.cards}
{...field}
disabled={isSubmitting || props.kind === "refund"}
error={errors.cardId?.message}
/>
)}
/>
{props.kind === "transfer" && (
<Controller
control={control}
name="targetCardId"
render={({ field }) => (
<Select
label={t("bank.destination")}
required
data={props.targets}
{...field}
disabled={isSubmitting}
error={errors.targetCardId?.message}
/>
)}
/>
)}
<TextInput
label={t("bank.amount")}
required
inputMode="decimal"
{...register("amount")}
error={errors.amount?.message}
/>
<Controller
control={control}
name="date"
render={({ field }) => (
<DateInput
{...field}
label={t("bank.date")}
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 } },
}}
error={errors.date?.message}
/>
)}
/>
{props.kind === "expense" && (
<Controller
control={control}
name="categoryId"
render={({ field }) => (
<Select
label={t("bank.category")}
required
data={props.categories}
{...field}
value={field.value || props.defaultCategory}
disabled={isSubmitting}
error={errors.categoryId?.message}
/>
)}
/>
)}
<Textarea
label={t("bank.description")}
{...register("description")}
error={errors.description?.message}
/>
</Stack>
);
};
type RefundFieldsProps = {
editing: boolean;
expenses: Choice[];
selected?: Choice;
onSelect: (id: string) => void;
limit: BankOperation["availableRefund"] | undefined;
};
const RefundFields: FC<RefundFieldsProps> = (props) => {
const { t } = useTranslation();
const {
control,
formState: { errors, isSubmitting },
} = useFormContext<OperationValues>();
return (
<Stack>
<Controller
control={control}
name="originalExpenseId"
render={({ field }) => (
<Select
label={t("bank.expense")}
required
data={
props.selected &&
!props.expenses.some(
(item) => item.value === props.selected?.value,
)
? [props.selected, ...props.expenses]
: props.expenses
}
{...field}
onChange={(value) => {
field.onChange(value ?? "");
props.onSelect(value ?? "");
}}
disabled={isSubmitting || props.editing}
error={errors.originalExpenseId?.message}
/>
)}
/>
{props.limit && (
<Text>
{t("bank.availableRefund", { amount: formatMoney(props.limit) })}
</Text>
)}
{props.editing && <Text>{t("bank.purchaseImmutable")}</Text>}
</Stack>
);
};
export { OperationFields, RefundFields };