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:
@@ -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%);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user