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 @@
|
||||
export { CardPage } from "./ui/CardPage";
|
||||
@@ -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 };
|
||||
@@ -0,0 +1 @@
|
||||
export { CardsPage } from "./ui/CardsPage";
|
||||
@@ -0,0 +1,10 @@
|
||||
.card {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.card:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
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 } 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 { CardsPage } from "./CardsPage";
|
||||
|
||||
const money = (value: string) => ({ currency: "RUB", value });
|
||||
function renderCards(fail = false, categoryFail = false) {
|
||||
configureAuthClient({
|
||||
accessToken: () => "test",
|
||||
refresh: async () => false,
|
||||
clear: () => {},
|
||||
});
|
||||
server.use(
|
||||
http.get("*/api/v1/bank-cards/categories", () =>
|
||||
categoryFail ? HttpResponse.error() : HttpResponse.json([]),
|
||||
),
|
||||
http.get("*/api/v1/bank-cards", () =>
|
||||
fail ? HttpResponse.error() : HttpResponse.json([]),
|
||||
),
|
||||
http.get("*/api/v1/bank-cards/summary", () =>
|
||||
HttpResponse.json({
|
||||
month: "2026-09",
|
||||
cardId: null,
|
||||
expenses: money("0"),
|
||||
refunds: money("0"),
|
||||
net: money("0"),
|
||||
categories: [],
|
||||
}),
|
||||
),
|
||||
);
|
||||
return render(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<MantineProvider>
|
||||
<MemoryRouter initialEntries={["/cards?month=2026-09"]}>
|
||||
<CardsPage />
|
||||
</MemoryRouter>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
describe("CardsPage", () => {
|
||||
it("preserves exact negative opening amount and the creation intent through unknown failure", async () => {
|
||||
renderCards();
|
||||
await screen.findByRole("heading", { name: "Добавьте первую карту" });
|
||||
fireEvent.click(
|
||||
screen.getAllByRole("button", { name: "Добавить карту" })[0],
|
||||
);
|
||||
const dialog = screen.getByRole("dialog");
|
||||
const submitted: Record<string, unknown>[] = [];
|
||||
let resolve: (() => void) | undefined;
|
||||
server.use(
|
||||
http.post("*/api/v1/bank-cards", async ({ request }) => {
|
||||
submitted.push((await request.json()) as Record<string, unknown>);
|
||||
if (submitted.length === 1) {
|
||||
await new Promise<void>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return HttpResponse.error();
|
||||
}
|
||||
return HttpResponse.json({ id: submitted[0].id }, { status: 201 });
|
||||
}),
|
||||
);
|
||||
fireEvent.change(within(dialog).getByLabelText(/Название карты/), {
|
||||
target: { value: " Повседневная " },
|
||||
});
|
||||
fireEvent.change(within(dialog).getByLabelText(/Начальный остаток/), {
|
||||
target: { value: "1,001" },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Сохранить" }));
|
||||
expect(
|
||||
await screen.findByText(/Введите сумму в допустимом диапазоне/),
|
||||
).toBeInTheDocument();
|
||||
expect(submitted).toHaveLength(0);
|
||||
fireEvent.change(within(dialog).getByLabelText(/Начальный остаток/), {
|
||||
target: { value: "-92233720368547758,08" },
|
||||
});
|
||||
fireEvent.change(within(dialog).getByLabelText(/Дата начала учёта/), {
|
||||
target: { value: "2026-01-01" },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Сохранить" }));
|
||||
await waitFor(() => expect(submitted).toHaveLength(1));
|
||||
expect(
|
||||
within(dialog).getByRole("button", { name: "Отмена" }),
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
within(dialog).getByRole("button", { name: "Закрыть" }),
|
||||
).toBeDisabled();
|
||||
resolve?.();
|
||||
await screen.findByText(/Не удалось подтвердить сохранение/);
|
||||
expect(within(dialog).getByLabelText(/Начальный остаток/)).toHaveValue(
|
||||
"-92233720368547758,08",
|
||||
);
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Сохранить" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(submitted[0]).toEqual(submitted[1]);
|
||||
expect(submitted[0]).toMatchObject({
|
||||
name: "Повседневная",
|
||||
bankName: null,
|
||||
lastFour: null,
|
||||
openingBalance: money("-92233720368547758.08"),
|
||||
});
|
||||
});
|
||||
it("uses archive only for card list and keeps all-card summary including negative net", async () => {
|
||||
renderCards();
|
||||
const requests: string[] = [];
|
||||
server.use(
|
||||
http.get("*/api/v1/bank-cards", ({ request }) => {
|
||||
requests.push(new URL(request.url).search);
|
||||
return HttpResponse.json([
|
||||
{
|
||||
id: "demo",
|
||||
name: "Запасная",
|
||||
bankName: null,
|
||||
lastFour: null,
|
||||
balance: money("0"),
|
||||
archivedAt: "2026-09-01",
|
||||
version: 1,
|
||||
openingBalance: money("0"),
|
||||
openingDate: "2026-01-01",
|
||||
},
|
||||
]);
|
||||
}),
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("Показать архив"));
|
||||
await screen.findByText("Запасная");
|
||||
expect(requests).toContain("?includeArchived=true");
|
||||
expect(screen.getByLabelText("Месяц")).toHaveTextContent("September 2026");
|
||||
expect(screen.getByRole("link", { name: /Запасная/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/cards/demo?month=2026-09",
|
||||
);
|
||||
});
|
||||
it("shows retry after failure instead of presenting an empty list", async () => {
|
||||
renderCards(true);
|
||||
// Mount starts asynchronously; this handler owns the request.
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Не удалось загрузить данные карт.",
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "Добавьте первую карту" }),
|
||||
).not.toBeInTheDocument();
|
||||
server.use(http.get("*/api/v1/bank-cards", () => HttpResponse.json([])));
|
||||
fireEvent.click(
|
||||
within(screen.getByRole("alert")).getByRole("button", {
|
||||
name: "Повторить",
|
||||
}),
|
||||
);
|
||||
await screen.findByRole("heading", { name: "Добавьте первую карту" });
|
||||
});
|
||||
it("makes failed category loading recoverable without hiding successful cards data", async () => {
|
||||
renderCards(false, true);
|
||||
await screen.findByRole("heading", { name: "Добавьте первую карту" });
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Не удалось загрузить данные карт.",
|
||||
);
|
||||
server.use(
|
||||
http.get("*/api/v1/bank-cards/categories", () =>
|
||||
HttpResponse.json([
|
||||
{ id: "default", name: "Другое", version: 1, isDefault: true },
|
||||
]),
|
||||
),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole("alert")).getByRole("button", {
|
||||
name: "Повторить",
|
||||
}),
|
||||
);
|
||||
await screen.findByRole("button", { name: "Категории" });
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { FC } from "react";
|
||||
import { BankCardForm, CategoryManager } from "@/entities/bank-card";
|
||||
import { CardsPageView } from "./CardsPage.view";
|
||||
import { useCardsPageVm } from "./CardsPage.vm";
|
||||
|
||||
const CardsPage: FC = () => {
|
||||
const vm = useCardsPageVm();
|
||||
return (
|
||||
<>
|
||||
<CardsPageView vm={vm} />
|
||||
{vm.adding && <BankCardForm onClose={() => vm.setAdding(false)} />}
|
||||
{vm.managingCategories && (
|
||||
<CategoryManager onClose={() => vm.setManagingCategories(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { CardsPage };
|
||||
@@ -0,0 +1,137 @@
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Switch,
|
||||
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 { LoadingSkeleton, StateView } from "@/shared/ui";
|
||||
import classes from "./CardsPage.module.css";
|
||||
import type { CardsPageVm } from "./CardsPage.vm";
|
||||
|
||||
type CardsPageViewProps = { vm: CardsPageVm };
|
||||
const CardsPageView: FC<CardsPageViewProps> = (props) => {
|
||||
const { vm } = props;
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between">
|
||||
<Title order={1}>{t("bank.title")}</Title>
|
||||
<Button onClick={() => vm.setAdding(true)}>{t("bank.addCard")}</Button>
|
||||
</Group>
|
||||
<Text c="dimmed">{t("bank.manualBalance")}</Text>
|
||||
<Switch
|
||||
label={t("bank.showArchive")}
|
||||
checked={vm.archive}
|
||||
onChange={(event) => vm.setArchive(event.currentTarget.checked)}
|
||||
/>
|
||||
{vm.items ? (
|
||||
vm.items.length ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
||||
{vm.items.map((card) => (
|
||||
<Paper
|
||||
key={card.id}
|
||||
component={Link}
|
||||
to={card.to}
|
||||
withBorder
|
||||
p="md"
|
||||
className={classes.card}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Group>
|
||||
<Text fw={600} size="lg">
|
||||
{card.name}
|
||||
</Text>
|
||||
{card.archivedAt && (
|
||||
<Badge color="gray">{t("bank.archived")}</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text c="dimmed">
|
||||
{card.bankName}
|
||||
{card.lastFour ? ` · •• ${card.lastFour}` : ""}
|
||||
</Text>
|
||||
<Text size="xl" fw={650}>
|
||||
{formatMoney(card.balance)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<StateView
|
||||
title={t("bank.empty")}
|
||||
text={t(vm.archive ? "bank.emptyHint" : "bank.noActive")}
|
||||
action={
|
||||
<Button onClick={() => vm.setAdding(true)}>
|
||||
{t("bank.addCard")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
) : vm.cards.isLoading ? (
|
||||
<LoadingSkeleton label={t("bank.loading")}>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
||||
<Skeleton h={120} />
|
||||
<Skeleton h={120} />
|
||||
</SimpleGrid>
|
||||
</LoadingSkeleton>
|
||||
) : null}
|
||||
{vm.cards.isError && (
|
||||
<Group role="alert">
|
||||
<Text>{t(vm.items ? "bank.refreshError" : "bank.error")}</Text>
|
||||
<Button variant="default" onClick={() => void vm.cards.refetch()}>
|
||||
{t("bank.retry")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
{vm.cards.data && vm.cards.isFetching && (
|
||||
<Text role="status">{t("bank.refreshing")}</Text>
|
||||
)}
|
||||
<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
|
||||
all
|
||||
data={vm.summary.data}
|
||||
loading={vm.summary.isLoading}
|
||||
error={vm.summary.isError}
|
||||
fetching={vm.summary.isFetching}
|
||||
retry={() => void vm.summary.refetch()}
|
||||
/>
|
||||
{vm.categories.isError && (
|
||||
<BankFeedback
|
||||
loadingError
|
||||
error={vm.categories.error}
|
||||
cached={Boolean(vm.categories.data)}
|
||||
retry={() => void vm.categories.refetch()}
|
||||
/>
|
||||
)}
|
||||
{Boolean(vm.categories.data?.length || vm.cards.data?.length) && (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => vm.setManagingCategories(true)}
|
||||
>
|
||||
{t("bank.categories")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export { CardsPageView };
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import {
|
||||
bankMonth,
|
||||
useBankCards,
|
||||
useBankCategories,
|
||||
useBankSummary,
|
||||
} from "@/entities/bank-card";
|
||||
|
||||
function useCardsPageVm() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const month = bankMonth(params.get("month"));
|
||||
const [archive, setArchive] = useState(false);
|
||||
const categories = useBankCategories();
|
||||
const [managingCategories, setManagingCategories] = useState(false);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const cards = useBankCards(archive);
|
||||
const summary = useBankSummary(month);
|
||||
return {
|
||||
categories,
|
||||
managingCategories,
|
||||
setManagingCategories,
|
||||
month,
|
||||
archive,
|
||||
setArchive,
|
||||
adding,
|
||||
setAdding,
|
||||
cards,
|
||||
summary,
|
||||
setMonth: (value: string) => {
|
||||
if (value) setParams({ month: bankMonth(value) });
|
||||
},
|
||||
items: cards.data?.map((card) => ({
|
||||
...card,
|
||||
to: `/cards/${card.id}?month=${month}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
type CardsPageVm = ReturnType<typeof useCardsPageVm>;
|
||||
|
||||
export { type CardsPageVm, useCardsPageVm };
|
||||
Reference in New Issue
Block a user