chore: finish frontend ESLint migration #18
Enable every plugin recommended preset and replace Biome with ESLint formatting. Preserve API contracts and JSX whitespace; verify source, tests, builds and tooling.
This commit is contained in:
@@ -7,55 +7,61 @@ import {
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { MemoryRouter, Route, Routes } from "react-router";
|
||||
import { describe, expect, it, vi } 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",
|
||||
};
|
||||
const card = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
archivedAt: null,
|
||||
balance: money("10000"),
|
||||
bankName: null,
|
||||
id: "a",
|
||||
lastFour: null,
|
||||
name: "Повседневная",
|
||||
openingBalance: money("10000"),
|
||||
openingDate: "2026-01-01",
|
||||
version: 1,
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */);
|
||||
const operation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
amount: money("50.01"),
|
||||
availableRefund: null,
|
||||
cardId: "a",
|
||||
cardName: "Повседневная",
|
||||
categoryId: null,
|
||||
categoryName: null,
|
||||
createdAt: "2026-08-03T00:00:00Z",
|
||||
date: "2026-08-03",
|
||||
description: null,
|
||||
id: "transfer",
|
||||
kind: "transfer",
|
||||
originalExpenseId: null,
|
||||
targetCardId: "b",
|
||||
targetCardName: "Запасная",
|
||||
version: 1,
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */);
|
||||
function setup(
|
||||
options: {
|
||||
month?: string;
|
||||
missing?: boolean;
|
||||
summaryError?: boolean;
|
||||
cardsError?: boolean;
|
||||
missing?: boolean;
|
||||
month?: string;
|
||||
summaryError?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
configureAuthClient({
|
||||
accessToken: () => "test",
|
||||
refresh: async () => false,
|
||||
clear: () => {},
|
||||
refresh: () => Promise.resolve(false),
|
||||
});
|
||||
const requests: string[] = [];
|
||||
const summaryRequests: string[] = [];
|
||||
@@ -73,6 +79,7 @@ function setup(
|
||||
http.get("*/api/v1/bank-cards/a/operations", ({ request }) => {
|
||||
requests.push(new URL(request.url).search);
|
||||
return HttpResponse.json({
|
||||
hasNext: Number(new URL(request.url).searchParams.get("page")) < 3,
|
||||
items: [
|
||||
{
|
||||
...operation,
|
||||
@@ -81,7 +88,6 @@ function setup(
|
||||
],
|
||||
page: Number(new URL(request.url).searchParams.get("page")),
|
||||
total: 30,
|
||||
hasNext: Number(new URL(request.url).searchParams.get("page")) < 3,
|
||||
});
|
||||
}),
|
||||
http.get("*/api/v1/bank-cards/summary", ({ request }) => {
|
||||
@@ -89,12 +95,12 @@ function setup(
|
||||
return options.summaryError
|
||||
? HttpResponse.error()
|
||||
: HttpResponse.json({
|
||||
month: "2026-08",
|
||||
cardId: "a",
|
||||
expenses: money("0"),
|
||||
refunds: money("50"),
|
||||
net: money("-50"),
|
||||
categories: [],
|
||||
expenses: money("0"),
|
||||
month: "2026-08",
|
||||
net: money("-50"),
|
||||
refunds: money("50"),
|
||||
});
|
||||
}),
|
||||
);
|
||||
@@ -106,13 +112,13 @@ function setup(
|
||||
initialEntries={[`/cards/a?month=${options.month ?? "2026-08"}`]}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/cards/:cardId" element={<CardPage />} />
|
||||
<Route element={<CardPage />} path="/cards/:cardId" />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { requests, summaryRequests, client };
|
||||
return { client, requests, summaryRequests };
|
||||
}
|
||||
describe("CardPage", () => {
|
||||
it.each([
|
||||
@@ -145,10 +151,10 @@ describe("CardPage", () => {
|
||||
expect(screen.getByText("−50,01 ₽")).toBeInTheDocument();
|
||||
expect(await screen.findByText("-50,00 ₽")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
|
||||
expect(requests.every((value) => value.includes("limit=10"))).toBe(true);
|
||||
expect(requests.every(value => value.includes("limit=10"))).toBe(true);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
|
||||
await waitFor(() =>
|
||||
expect(requests.some((v) => v.includes("page=2"))).toBe(true),
|
||||
expect(requests.some(v => v.includes("page=2"))).toBe(true),
|
||||
);
|
||||
const lastPage = await screen.findByRole("button", { name: "Страница 3" });
|
||||
await waitFor(() => expect(lastPage).toBeEnabled());
|
||||
@@ -170,10 +176,10 @@ describe("CardPage", () => {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
requests.some(
|
||||
(v) =>
|
||||
v.includes("from=2026-08-10") &&
|
||||
v.includes("to=2026-08-20") &&
|
||||
v.includes("page=1"),
|
||||
v =>
|
||||
v.includes("from=2026-08-10")
|
||||
&& v.includes("to=2026-08-20")
|
||||
&& v.includes("page=1"),
|
||||
),
|
||||
).toBe(true),
|
||||
);
|
||||
@@ -187,7 +193,7 @@ describe("CardPage", () => {
|
||||
expect(screen.getByText(/10\s000 ₽/)).toBeInTheDocument();
|
||||
});
|
||||
it("keeps the journal in place while the next page loads", async () => {
|
||||
const scroll = vi.spyOn(window, "scrollBy").mockImplementation(() => {});
|
||||
const scroll = vi.spyOn(globalThis, "scrollBy").mockImplementation(() => {});
|
||||
setup();
|
||||
await screen.findByText("Перевод на «Запасная»");
|
||||
const navigation = screen.getByRole("navigation", {
|
||||
@@ -196,18 +202,16 @@ describe("CardPage", () => {
|
||||
const measure = vi
|
||||
.spyOn(navigation, "getBoundingClientRect")
|
||||
.mockReturnValue({ top: 600 } as DOMRect);
|
||||
let release = () => {};
|
||||
const pending = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
|
||||
const { promise: pending, resolve: release } = Promise.withResolvers<void>();
|
||||
server.use(
|
||||
http.get("*/api/v1/bank-cards/a/operations", async () => {
|
||||
await pending;
|
||||
return HttpResponse.json({
|
||||
items: [{ ...operation, id: "next", description: "Новая страница" }],
|
||||
hasNext: true,
|
||||
items: [{ ...operation, description: "Новая страница", id: "next" }],
|
||||
page: 2,
|
||||
total: 30,
|
||||
hasNext: true,
|
||||
});
|
||||
}),
|
||||
);
|
||||
@@ -224,11 +228,12 @@ describe("CardPage", () => {
|
||||
measure.mockReturnValue({ top: 200 } as DOMRect);
|
||||
release();
|
||||
await screen.findByText(/Новая страница/);
|
||||
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
|
||||
const list = screen.getByText(/Новая страница/).closest("[aria-busy]");
|
||||
expect(list?.parentElement?.style.minHeight).toBe("");
|
||||
expect(scroll).toHaveBeenCalledExactlyOnceWith({
|
||||
top: -400,
|
||||
behavior: "instant",
|
||||
top: -400,
|
||||
});
|
||||
measure.mockRestore();
|
||||
scroll.mockRestore();
|
||||
@@ -250,8 +255,8 @@ describe("CardPage", () => {
|
||||
fireEvent.click(await screen.findByRole("option", { name: "20" }));
|
||||
await waitFor(() => {
|
||||
expect(requests.at(-1)).toContain("limit=20");
|
||||
expect(requests.at(-1)).toContain("page=1");
|
||||
});
|
||||
expect(requests.at(-1)).toContain("page=1"); ;
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Страница 1" })).toBeEnabled(),
|
||||
);
|
||||
@@ -313,15 +318,17 @@ describe("CardPage", () => {
|
||||
),
|
||||
http.patch("*/api/v1/bank-cards/a", async ({ request }) => {
|
||||
const body = (await request.json()) as {
|
||||
version: number;
|
||||
archived: boolean;
|
||||
version: number;
|
||||
};
|
||||
bodies.push(body);
|
||||
current = {
|
||||
...current,
|
||||
version: current.version + 1,
|
||||
archivedAt: body.archived ? "2026-09-01" : null,
|
||||
} as typeof card;
|
||||
current = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
...current,
|
||||
archivedAt: body.archived ? "2026-09-01" : null,
|
||||
version: current.version + 1,
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */) as typeof card;
|
||||
return HttpResponse.json(current);
|
||||
}),
|
||||
);
|
||||
@@ -338,8 +345,8 @@ describe("CardPage", () => {
|
||||
screen.getByRole("button", { name: "Добавить операцию" }),
|
||||
).toBeEnabled();
|
||||
expect(bodies).toEqual([
|
||||
{ version: 1, archived: true },
|
||||
{ version: 2, archived: false },
|
||||
{ archived: true, version: 1 },
|
||||
{ archived: false, version: 2 },
|
||||
]);
|
||||
});
|
||||
it("recovers the prerequisite card list while preserving the visible balance and journal", async () => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { BankCardForm } from "@/entities/bank-card";
|
||||
|
||||
import { DeleteOperation } from "../DeleteOperation/DeleteOperation";
|
||||
import { OperationForm } from "../OperationForm/OperationForm";
|
||||
import { CardPageView } from "./CardPage.view";
|
||||
@@ -20,14 +22,14 @@ const CardPage: FC = () => {
|
||||
<OperationForm
|
||||
card={vm.card.data}
|
||||
cards={vm.cards.data}
|
||||
operation={vm.operation === "new" ? undefined : vm.operation}
|
||||
onClose={() => vm.setOperation(null)}
|
||||
operation={(vm.operation === "new") ? undefined : (vm.operation)}
|
||||
/>
|
||||
)}
|
||||
{vm.deleting && (
|
||||
<DeleteOperation
|
||||
operation={vm.deleting}
|
||||
onClose={() => vm.setDeleting(null)}
|
||||
operation={vm.deleting}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -12,9 +12,10 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { AddIcon, DeleteIcon, EditIcon } from "@wealthledger/icons";
|
||||
import { EntryCard, LoadingSkeleton, StateView } from "@wealthledger/ui";
|
||||
import { type FC, useLayoutEffect, useRef } from "react";
|
||||
import { type FC, type ReactNode, useLayoutEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import {
|
||||
BankCardFace,
|
||||
BankFeedback,
|
||||
@@ -22,26 +23,28 @@ import {
|
||||
BankPeriodPicker,
|
||||
BankSummaryView,
|
||||
} from "@/entities/bank-card";
|
||||
import classes from "./CardPage.module.css";
|
||||
|
||||
import type { CardPageVm } from "./CardPage.vm";
|
||||
|
||||
import classes from "./CardPage.module.css";
|
||||
|
||||
type CardPageViewProps = { vm: CardPageVm };
|
||||
const CardPageView: FC<CardPageViewProps> = (props) => {
|
||||
const { vm } = props;
|
||||
const { t } = useTranslation();
|
||||
const card = vm.card.data;
|
||||
const paginationRef = useRef<HTMLDivElement>(null);
|
||||
const pendingPage = useRef<{
|
||||
scope: string;
|
||||
const pendingPageRef = useRef<null | {
|
||||
page: number;
|
||||
scope: string;
|
||||
top: number;
|
||||
} | null>(null);
|
||||
}>(null);
|
||||
const scope = `${card?.id}-${vm.period.from}-${vm.period.to}-${vm.pageSize}`;
|
||||
useLayoutEffect(() => {
|
||||
const pending = pendingPage.current;
|
||||
const pending = pendingPageRef.current;
|
||||
if (!pending) return;
|
||||
if (pending.scope !== scope || vm.history.isError) {
|
||||
pendingPage.current = null;
|
||||
pendingPageRef.current = /* eslint-disable-line unicorn/no-null -- Reset the pending-pagination ref to its initial null sentinel. */ null;
|
||||
return;
|
||||
}
|
||||
if (vm.history.isPlaceholderData || vm.history.data?.page !== pending.page)
|
||||
@@ -49,9 +52,10 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|
||||
const pagination = paginationRef.current;
|
||||
if (pagination) {
|
||||
const delta = pagination.getBoundingClientRect().top - pending.top;
|
||||
if (delta) window.scrollBy({ top: delta, behavior: "instant" });
|
||||
if (delta) window.scrollBy({ behavior: "instant", top: delta });
|
||||
}
|
||||
pendingPage.current = null;
|
||||
// eslint-disable-next-line unicorn/no-null -- Preserve the existing nullable validation/ref contract.
|
||||
pendingPageRef.current = null;
|
||||
}, [
|
||||
scope,
|
||||
vm.history.data,
|
||||
@@ -72,10 +76,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|
||||
);
|
||||
return (
|
||||
<StateView
|
||||
error
|
||||
title={t(vm.notFound ? "bank.notFound" : "bank.error")}
|
||||
text={t("bank.back")}
|
||||
action={
|
||||
action={(
|
||||
<Group>
|
||||
<Button component={Link} to="/cards">
|
||||
{t("bank.back")}
|
||||
@@ -86,65 +87,158 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
)}
|
||||
error
|
||||
text={t("bank.back")}
|
||||
title={t(vm.notFound ? "bank.notFound" : "bank.error")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/function-return-type -- ReactNode deliberately includes lists, elements and empty output.
|
||||
function renderHistory(): ReactNode {
|
||||
if (!card) return;
|
||||
if (vm.rows) {
|
||||
if (vm.rows.length > 0) {
|
||||
return vm.rows.map(row => (
|
||||
<section
|
||||
aria-label={`${row.title} ${row.item.date}`}
|
||||
className={classes.operation}
|
||||
key={row.item.id}
|
||||
>
|
||||
<EntryCard
|
||||
actions={(
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<ActionIcon
|
||||
aria-label={t("bank.edit")}
|
||||
disabled={
|
||||
vm.history.isPlaceholderData
|
||||
|| !row.editable
|
||||
|| Boolean(card.archivedAt)
|
||||
}
|
||||
onClick={() => vm.setOperation(row.item)}
|
||||
size="sm"
|
||||
title={t("bank.edit")}
|
||||
variant="subtle"
|
||||
>
|
||||
<EditIcon size={18} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
aria-label={t("bank.delete")}
|
||||
color="red"
|
||||
disabled={
|
||||
vm.history.isPlaceholderData
|
||||
|| !row.editable
|
||||
|| Boolean(card.archivedAt)
|
||||
}
|
||||
onClick={() => vm.setDeleting(row.item)}
|
||||
size="sm"
|
||||
title={t("bank.delete")}
|
||||
variant="subtle"
|
||||
>
|
||||
<DeleteIcon size={18} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
color={row.color}
|
||||
details={
|
||||
(row.otherCard)
|
||||
? ((
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/cards/${row.otherCard}?from=${vm.period.from}&to=${vm.period.to}`}
|
||||
>
|
||||
{t("bank.card")}
|
||||
</Anchor>
|
||||
))
|
||||
: undefined
|
||||
}
|
||||
icon={(
|
||||
<BankOperationIcon
|
||||
categoryName={row.item.categoryName}
|
||||
kind={row.item.kind}
|
||||
/>
|
||||
)}
|
||||
initial={row.title[0]}
|
||||
metadata={row.metadata}
|
||||
title={row.title}
|
||||
tone={row.tone}
|
||||
value={row.value}
|
||||
valueLabel={t(`bank.kinds.${row.item.kind}`)}
|
||||
/>
|
||||
</section>
|
||||
));
|
||||
}
|
||||
return <Text>{t("bank.noOperations")}</Text>;
|
||||
}
|
||||
if (vm.history.isLoading) {
|
||||
return (
|
||||
<LoadingSkeleton label={t("bank.loadingHistory")}>
|
||||
<Stack>
|
||||
<Skeleton h={90} />
|
||||
<Skeleton h={90} />
|
||||
</Stack>
|
||||
</LoadingSkeleton>
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Stack gap="lg" className={classes.page}>
|
||||
<Stack className={classes.page} gap="lg">
|
||||
<Stack>
|
||||
<Title order={1} className={classes.wrap}>
|
||||
<Title className={classes.wrap} order={1}>
|
||||
{card.name}
|
||||
</Title>
|
||||
<div className={classes.card}>
|
||||
<BankCardFace card={card} />
|
||||
<Group gap={4} wrap="nowrap" className={classes.cardActions}>
|
||||
<Group className={classes.cardActions} gap={4} wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="md"
|
||||
radius="md"
|
||||
aria-label={t("bank.editCard")}
|
||||
title={t("bank.editCard")}
|
||||
onClick={() => vm.setEditingCard(true)}
|
||||
radius="md"
|
||||
size="md"
|
||||
title={t("bank.editCard")}
|
||||
variant="subtle"
|
||||
>
|
||||
<EditIcon size={18} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="md"
|
||||
radius="md"
|
||||
color="red"
|
||||
aria-label={t(card.archivedAt ? "bank.restore" : "bank.archive")}
|
||||
title={t(card.archivedAt ? "bank.restore" : "bank.archive")}
|
||||
color="red"
|
||||
loading={vm.archivePending}
|
||||
onClick={() => void vm.toggleArchive()}
|
||||
radius="md"
|
||||
size="md"
|
||||
title={t(card.archivedAt ? "bank.restore" : "bank.archive")}
|
||||
variant="subtle"
|
||||
>
|
||||
{card.archivedAt ? (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M3 3h18v4H3z M5 7v14h14V7 M12 17V10m-3 3 3-3 3 3" />
|
||||
</svg>
|
||||
) : (
|
||||
<DeleteIcon size={18} strokeWidth={1.5} />
|
||||
)}
|
||||
{card.archivedAt
|
||||
? (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
height="18"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
width="18"
|
||||
>
|
||||
<path d="M3 3h18v4H3z M5 7v14h14V7 M12 17V10m-3 3 3-3 3 3" />
|
||||
</svg>
|
||||
)
|
||||
: (
|
||||
<DeleteIcon size={18} strokeWidth={1.5} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</div>
|
||||
{vm.archiveError ? (
|
||||
{!!(vm.archiveError) && ((
|
||||
<BankFeedback
|
||||
error={vm.archiveError}
|
||||
retry={() => void vm.card.refetch()}
|
||||
/>
|
||||
) : null}
|
||||
))}
|
||||
{card.archivedAt && <Text>{t("bank.archiveHint")}</Text>}
|
||||
</Stack>
|
||||
{vm.card.isError && (
|
||||
@@ -158,136 +252,58 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|
||||
{vm.card.isFetching && <Text role="status">{t("bank.refreshing")}</Text>}
|
||||
{vm.cards.isError && (
|
||||
<BankFeedback
|
||||
loadingError
|
||||
error={vm.cards.error}
|
||||
cached={Boolean(vm.cards.data)}
|
||||
error={vm.cards.error}
|
||||
loadingError
|
||||
retry={() => void vm.cards.refetch()}
|
||||
/>
|
||||
)}
|
||||
<BankPeriodPicker period={vm.period} onChange={vm.setPeriod} />
|
||||
<BankPeriodPicker onChange={vm.setPeriod} period={vm.period} />
|
||||
<BankSummaryView
|
||||
data={vm.summary.data}
|
||||
loading={vm.summary.isLoading}
|
||||
fetching={vm.summary.isFetching}
|
||||
error={vm.summary.isError}
|
||||
fetching={vm.summary.isFetching}
|
||||
loading={vm.summary.isLoading}
|
||||
retry={() => void vm.summary.refetch()}
|
||||
/>
|
||||
<Group justify="space-between" align="end">
|
||||
<Group align="end" justify="space-between">
|
||||
<Group gap="xs">
|
||||
<Title order={2}>{t("bank.operations")}</Title>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("bank.addOperation")}
|
||||
title={t("bank.addOperation")}
|
||||
disabled={Boolean(card.archivedAt) || !vm.cards.data}
|
||||
onClick={() => vm.setOperation("new")}
|
||||
size="lg"
|
||||
title={t("bank.addOperation")}
|
||||
variant="subtle"
|
||||
>
|
||||
<AddIcon size={20} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Select
|
||||
label={t("bank.pageSize")}
|
||||
value={vm.pageSize}
|
||||
onChange={vm.setPageSize}
|
||||
allowDeselect={false}
|
||||
w={180}
|
||||
data={[
|
||||
"5",
|
||||
"10",
|
||||
"20",
|
||||
"50",
|
||||
"100",
|
||||
{ value: "all", label: t("bank.allOperations") },
|
||||
{ label: t("bank.allOperations"), value: "all" },
|
||||
]}
|
||||
label={t("bank.pageSize")}
|
||||
onChange={vm.setPageSize}
|
||||
value={vm.pageSize}
|
||||
w={180}
|
||||
/>
|
||||
</Group>
|
||||
<Stack
|
||||
key={`${vm.period.from}-${vm.period.to}-${vm.pageSize}-${vm.history.data?.page ?? "loading"}`}
|
||||
gap="lg"
|
||||
className={classes.operations}
|
||||
aria-busy={vm.history.isFetching}
|
||||
className={classes.operations}
|
||||
data-pending={vm.history.isPlaceholderData || undefined}
|
||||
gap="lg"
|
||||
key={`${vm.period.from}-${vm.period.to}-${vm.pageSize}-${vm.history.data?.page ?? "loading"}`}
|
||||
>
|
||||
{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}
|
||||
color={row.color}
|
||||
initial={row.title[0]}
|
||||
icon={
|
||||
<BankOperationIcon
|
||||
kind={row.item.kind}
|
||||
categoryName={row.item.categoryName}
|
||||
/>
|
||||
}
|
||||
value={row.value}
|
||||
valueLabel={t(`bank.kinds.${row.item.kind}`)}
|
||||
tone={row.tone}
|
||||
details={
|
||||
row.otherCard ? (
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/cards/${row.otherCard}?from=${vm.period.from}&to=${vm.period.to}`}
|
||||
>
|
||||
{t("bank.card")}
|
||||
</Anchor>
|
||||
) : null
|
||||
}
|
||||
actions={
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
disabled={
|
||||
vm.history.isPlaceholderData ||
|
||||
!row.editable ||
|
||||
Boolean(card.archivedAt)
|
||||
}
|
||||
size="sm"
|
||||
aria-label={t("bank.edit")}
|
||||
title={t("bank.edit")}
|
||||
onClick={() => vm.setOperation(row.item)}
|
||||
>
|
||||
<EditIcon size={18} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
disabled={
|
||||
vm.history.isPlaceholderData ||
|
||||
!row.editable ||
|
||||
Boolean(card.archivedAt)
|
||||
}
|
||||
size="sm"
|
||||
aria-label={t("bank.delete")}
|
||||
title={t("bank.delete")}
|
||||
onClick={() => vm.setDeleting(row.item)}
|
||||
>
|
||||
<DeleteIcon size={18} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
</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}
|
||||
{renderHistory()}
|
||||
</Stack>
|
||||
{vm.history.isError && (
|
||||
<Group role="alert">
|
||||
@@ -299,34 +315,34 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|
||||
)}
|
||||
{vm.history.data && vm.pageSize !== "all" && (
|
||||
<Group
|
||||
component="nav"
|
||||
ref={paginationRef}
|
||||
aria-label={t("bank.pagination")}
|
||||
component="nav"
|
||||
justify="center"
|
||||
py="sm"
|
||||
ref={paginationRef}
|
||||
>
|
||||
<Pagination
|
||||
total={vm.totalPages}
|
||||
value={vm.page}
|
||||
onChange={(page) => {
|
||||
if (paginationRef.current)
|
||||
pendingPage.current = {
|
||||
scope,
|
||||
page,
|
||||
top: paginationRef.current.getBoundingClientRect().top,
|
||||
};
|
||||
vm.setPage(page);
|
||||
}}
|
||||
disabled={vm.history.isFetching}
|
||||
siblings={0}
|
||||
getItemProps={(page) => ({
|
||||
"aria-label": t("bank.page", { page }),
|
||||
})}
|
||||
getControlProps={(control) => ({
|
||||
getControlProps={control => ({
|
||||
"aria-label": t(
|
||||
control === "previous" ? "bank.previous" : "bank.next",
|
||||
),
|
||||
})}
|
||||
getItemProps={page => ({
|
||||
"aria-label": t("bank.page", { page }),
|
||||
})}
|
||||
onChange={(page) => {
|
||||
if (paginationRef.current)
|
||||
pendingPageRef.current = {
|
||||
page,
|
||||
scope,
|
||||
top: paginationRef.current.getBoundingClientRect().top,
|
||||
};
|
||||
vm.setPage(page);
|
||||
}}
|
||||
siblings={0}
|
||||
total={vm.totalPages}
|
||||
value={vm.page}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useParams, useSearchParams } from "react-router";
|
||||
|
||||
import {
|
||||
type BankOperation,
|
||||
bankPeriod,
|
||||
@@ -13,18 +14,19 @@ import {
|
||||
import { apiErrorCode } from "@/shared/api";
|
||||
import { formatDate, formatMoney } from "@/shared/lib";
|
||||
|
||||
const PAGE_SIZES = ["5", "10", "20", "50", "100", "all"];
|
||||
const PAGE_SIZES = new Set(["5", "10", "20", "50", "100", "all"]);
|
||||
|
||||
type CardPageVm = ReturnType<typeof useCardPageVm>;
|
||||
function useCardPageVm() {
|
||||
const { cardId } = useParams();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const [parameters, setParameters] = useSearchParams();
|
||||
const { t } = useTranslation();
|
||||
const period = bankPeriod(params);
|
||||
const requestedSize = params.get("limit") ?? "10";
|
||||
const pageSize = PAGE_SIZES.includes(requestedSize) ? requestedSize : "10";
|
||||
const rawPage = Number(params.get("page") ?? 1);
|
||||
const page =
|
||||
pageSize !== "all" && Number.isSafeInteger(rawPage) && rawPage > 0
|
||||
const period = bankPeriod(parameters);
|
||||
const requestedSize = parameters.get("limit") ?? "10";
|
||||
const pageSize = PAGE_SIZES.has(requestedSize) ? requestedSize : "10";
|
||||
const rawPage = Number(parameters.get("page") ?? 1);
|
||||
const page
|
||||
= pageSize !== "all" && Number.isSafeInteger(rawPage) && rawPage > 0
|
||||
? rawPage
|
||||
: 1;
|
||||
const card = useBankCard(cardId);
|
||||
@@ -33,94 +35,90 @@ function useCardPageVm() {
|
||||
const cards = useBankCards(true);
|
||||
const history = useBankOperations(cardId, {
|
||||
...period,
|
||||
page,
|
||||
limit: pageSize === "all" ? "all" : Number(pageSize),
|
||||
page,
|
||||
});
|
||||
const summary = useBankSummary(period, cardId);
|
||||
const [editingCard, setEditingCard] = useState(false);
|
||||
const [operation, setOperation] = useState<BankOperation | "new" | null>(
|
||||
const [operation, setOperation] = useState<"new" | BankOperation | 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);
|
||||
const isOutgoing
|
||||
= item.kind === "expense"
|
||||
|| (item.kind === "transfer" && item.cardId === cardId);
|
||||
const otherCardId = isOutgoing ? item.targetCardId : item.cardId;
|
||||
let title = t(`bank.kinds.${item.kind}`);
|
||||
if (item.kind === "transfer") title = t(isOutgoing ? "bank.transferTo" : "bank.transferFrom", { name: isOutgoing ? item.targetCardName : item.cardName });
|
||||
return {
|
||||
color: isOutgoing ? "red" : "green",
|
||||
editable:
|
||||
Boolean(cards.data)
|
||||
&& !cards.data?.some(
|
||||
c =>
|
||||
(c.id === item.cardId || c.id === item.targetCardId)
|
||||
&& c.archivedAt,
|
||||
),
|
||||
item,
|
||||
color: outgoing ? "red" : "green",
|
||||
title:
|
||||
item.kind === "transfer"
|
||||
? t(outgoing ? "bank.transferTo" : "bank.transferFrom", {
|
||||
name: outgoing ? item.targetCardName : item.cardName,
|
||||
})
|
||||
: t(`bank.kinds.${item.kind}`),
|
||||
metadata: [formatDate(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,
|
||||
),
|
||||
? otherCardId
|
||||
: /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
|
||||
title,
|
||||
tone: isOutgoing ? ("negative" as const) : ("positive" as const),
|
||||
value: `${isOutgoing ? "−" : "+"}${formatMoney(item.amount)}`,
|
||||
};
|
||||
});
|
||||
return {
|
||||
card,
|
||||
cards,
|
||||
archiveError,
|
||||
archivePending: updateCard.isPending,
|
||||
card,
|
||||
cards,
|
||||
deleting,
|
||||
editingCard,
|
||||
history,
|
||||
notFound: apiErrorCode(card.error) === "BANK_NOT_FOUND",
|
||||
operation,
|
||||
page,
|
||||
pageSize,
|
||||
period,
|
||||
rows,
|
||||
setDeleting,
|
||||
setEditingCard,
|
||||
setOperation,
|
||||
setPage: (value: number) =>
|
||||
setParameters({ ...period, limit: pageSize, page: String(value) }),
|
||||
setPageSize: (value: null | string) => {
|
||||
if (value && PAGE_SIZES.has(value))
|
||||
setParameters({ ...period, limit: value });
|
||||
},
|
||||
setPeriod: (value: [null | string, null | string]) => {
|
||||
if (value[0] && value[1])
|
||||
setParameters({ from: value[0], limit: pageSize, to: value[1] });
|
||||
},
|
||||
summary,
|
||||
toggleArchive: async () => {
|
||||
if (!card.data) return;
|
||||
setArchiveError(undefined);
|
||||
try {
|
||||
await updateCard.mutateAsync({
|
||||
body: { archived: !card.data.archivedAt, version: card.data.version },
|
||||
id: card.data.id,
|
||||
body: { version: card.data.version, archived: !card.data.archivedAt },
|
||||
});
|
||||
} catch (failure) {
|
||||
setArchiveError(failure);
|
||||
}
|
||||
catch (error) {
|
||||
setArchiveError(error);
|
||||
}
|
||||
},
|
||||
history,
|
||||
summary,
|
||||
period,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages:
|
||||
pageSize === "all"
|
||||
? 1
|
||||
: Math.max(1, Math.ceil((history.data?.total ?? 0) / Number(pageSize))),
|
||||
rows,
|
||||
editingCard,
|
||||
setEditingCard,
|
||||
operation,
|
||||
setOperation,
|
||||
deleting,
|
||||
setDeleting,
|
||||
notFound: apiErrorCode(card.error) === "BANK_NOT_FOUND",
|
||||
setPeriod: (value: [string | null, string | null]) => {
|
||||
if (value[0] && value[1])
|
||||
setParams({ from: value[0], to: value[1], limit: pageSize });
|
||||
},
|
||||
setPageSize: (value: string | null) => {
|
||||
if (value && PAGE_SIZES.includes(value))
|
||||
setParams({ ...period, limit: value });
|
||||
},
|
||||
setPage: (value: number) =>
|
||||
setParams({ ...period, page: String(value), limit: pageSize }),
|
||||
};
|
||||
}
|
||||
type CardPageVm = ReturnType<typeof useCardPageVm>;
|
||||
|
||||
export { type CardPageVm, useCardPageVm };
|
||||
|
||||
@@ -1,37 +1,42 @@
|
||||
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 { http, HttpResponse } 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",
|
||||
};
|
||||
const transfer: BankOperation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
amount: { currency: "RUB", value: "100" },
|
||||
availableRefund: null,
|
||||
cardId: "a",
|
||||
cardName: "Основная",
|
||||
categoryId: null,
|
||||
categoryName: null,
|
||||
createdAt: "2026-08-01T00:00:00Z",
|
||||
date: "2026-08-01",
|
||||
description: null,
|
||||
id: "transfer",
|
||||
kind: "transfer",
|
||||
originalExpenseId: null,
|
||||
targetCardId: "b",
|
||||
targetCardName: "Запасная",
|
||||
version: 1,
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */);
|
||||
describe("DeleteOperation", () => {
|
||||
it("names both sides and requires a deliberate fresh version after conflict", async () => {
|
||||
configureAuthClient({
|
||||
accessToken: () => "test",
|
||||
refresh: async () => false,
|
||||
clear: () => {},
|
||||
refresh: () => Promise.resolve(false),
|
||||
});
|
||||
const versions: string[] = [];
|
||||
server.use(
|
||||
@@ -53,7 +58,7 @@ describe("DeleteOperation", () => {
|
||||
render(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<MantineProvider env="test">
|
||||
<DeleteOperation operation={transfer} onClose={close} />
|
||||
<DeleteOperation onClose={close} operation={transfer} />
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Button, Group, Modal, Stack, Text } from "@mantine/core";
|
||||
import { type FC, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
BankFeedback,
|
||||
type BankOperation,
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
} from "@/entities/bank-card";
|
||||
import { formatDate, formatMoney } from "@/shared/lib";
|
||||
|
||||
type DeleteOperationProps = { operation: BankOperation; onClose: () => void };
|
||||
type DeleteOperationProps = { onClose: () => void; operation: BankOperation };
|
||||
const DeleteOperation: FC<DeleteOperationProps> = (props) => {
|
||||
useBankFormFocus();
|
||||
const [opened, setOpened] = useState(true);
|
||||
@@ -19,20 +20,20 @@ const DeleteOperation: FC<DeleteOperationProps> = (props) => {
|
||||
const [latest, setLatest] = useState<BankOperation>();
|
||||
const [error, setError] = useState<unknown>();
|
||||
const query = useBankOperation(record.id);
|
||||
const { deleteOperation } = useBankMutations();
|
||||
const pending =
|
||||
!opened ||
|
||||
deleteOperation.isPending ||
|
||||
(query.isFetching && Boolean(error));
|
||||
const { deleteOperation: operationDeletion } = useBankMutations();
|
||||
const isPending
|
||||
= !opened
|
||||
|| operationDeletion.isPending
|
||||
|| (query.isFetching && Boolean(error));
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
closeButtonProps={{ "aria-label": t("bank.close"), "disabled": isPending }}
|
||||
closeOnClickOutside={!isPending}
|
||||
closeOnEscape={!isPending}
|
||||
onClose={() => !isPending && setOpened(false)}
|
||||
onExitTransitionEnd={props.onClose}
|
||||
onClose={() => !pending && setOpened(false)}
|
||||
opened={opened}
|
||||
title={t("bank.deleteTitle")}
|
||||
closeOnEscape={!pending}
|
||||
closeOnClickOutside={!pending}
|
||||
closeButtonProps={{ "aria-label": t("bank.close"), disabled: pending }}
|
||||
>
|
||||
<Stack>
|
||||
<Text>
|
||||
@@ -43,27 +44,27 @@ const DeleteOperation: FC<DeleteOperationProps> = (props) => {
|
||||
target: record.targetCardName,
|
||||
})
|
||||
: t("bank.deleteHint", {
|
||||
kind: t(`bank.kinds.${record.kind}`),
|
||||
amount: formatMoney(record.amount),
|
||||
card: record.cardName,
|
||||
kind: t(`bank.kinds.${record.kind}`),
|
||||
})}
|
||||
</Text>
|
||||
{error ? (
|
||||
{!!(error) && ((
|
||||
<>
|
||||
<BankFeedback error={error} />
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={pending}
|
||||
disabled={isPending}
|
||||
onClick={async () => {
|
||||
const result = await query.refetch();
|
||||
if (result.data && !result.error) setLatest(result.data);
|
||||
else setError(result.error);
|
||||
}}
|
||||
variant="default"
|
||||
>
|
||||
{t("bank.reloadRecord")}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
))}
|
||||
{latest && (
|
||||
<>
|
||||
<Text>
|
||||
@@ -72,13 +73,13 @@ const DeleteOperation: FC<DeleteOperationProps> = (props) => {
|
||||
})}
|
||||
</Text>
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={pending}
|
||||
disabled={isPending}
|
||||
onClick={() => {
|
||||
setRecord(latest);
|
||||
setLatest(undefined);
|
||||
setError(undefined);
|
||||
}}
|
||||
variant="default"
|
||||
>
|
||||
{t("bank.adoptVersion", { version: latest.version })}
|
||||
</Button>
|
||||
@@ -87,26 +88,27 @@ const DeleteOperation: FC<DeleteOperationProps> = (props) => {
|
||||
<Group>
|
||||
<Button
|
||||
color="red"
|
||||
loading={pending}
|
||||
loading={isPending}
|
||||
onClick={async () => {
|
||||
setError(undefined);
|
||||
try {
|
||||
await deleteOperation.mutateAsync({
|
||||
await operationDeletion.mutateAsync({
|
||||
id: record.id,
|
||||
version: record.version,
|
||||
});
|
||||
setOpened(false);
|
||||
} catch (failure) {
|
||||
setError(failure);
|
||||
}
|
||||
catch (error_) {
|
||||
setError(error_);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("bank.delete")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={pending}
|
||||
disabled={isPending}
|
||||
onClick={() => setOpened(false)}
|
||||
variant="default"
|
||||
>
|
||||
{t("bank.cancel")}
|
||||
</Button>
|
||||
|
||||
@@ -1,74 +1,36 @@
|
||||
import { Alert } from "@mantine/core";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
|
||||
import { Alert } from "@mantine/core";
|
||||
import { type FC, useState } from "react";
|
||||
import { FormProvider, useForm, useWatch } from "react-hook-form";
|
||||
|
||||
import type { OperationValues } from "./OperationForm.types";
|
||||
|
||||
import { OperationFormView } from "./OperationForm.view";
|
||||
|
||||
type ExampleProps = { pending?: boolean; conflict?: boolean };
|
||||
type ExampleProps = { conflict?: boolean; pending?: boolean };
|
||||
const OperationExample: FC<ExampleProps> = (props) => {
|
||||
const [opened, setOpened] = useState(true);
|
||||
const [action, setAction] = useState("");
|
||||
const methods = useForm<OperationValues>({
|
||||
defaultValues: {
|
||||
kind: "refund",
|
||||
cardId: "demo",
|
||||
targetCardId: "",
|
||||
categoryId: "",
|
||||
originalExpenseId: "purchase",
|
||||
amount: "500",
|
||||
cardId: "demo",
|
||||
categoryId: "",
|
||||
date: "2026-08-01",
|
||||
description: "Синтетический возврат",
|
||||
kind: "refund",
|
||||
originalExpenseId: "purchase",
|
||||
targetCardId: "",
|
||||
},
|
||||
});
|
||||
const kind = useWatch({ control: methods.control, name: "kind" });
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<OperationFormView
|
||||
opened={opened}
|
||||
pending={Boolean(props.pending)}
|
||||
editing
|
||||
categoriesOpen={false}
|
||||
onClose={() => !props.pending && setOpened(false)}
|
||||
onCancel={() => setOpened(false)}
|
||||
onExitTransitionEnd={() => setAction("Форма закрыта")}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setAction("Локальный submit");
|
||||
}}
|
||||
fields={{
|
||||
editing: true,
|
||||
refundCardId: "demo",
|
||||
kind,
|
||||
cards: [{ value: "demo", label: "Учебная карта" }],
|
||||
targets: [],
|
||||
categories: [],
|
||||
defaultCategory: "",
|
||||
kinds: [{ value: "refund", label: "Возврат" }],
|
||||
dateInputValue: () => "2026-08-01",
|
||||
validDate: (value) => value === "2026-08-01",
|
||||
maxDate: () => "2026-08-27",
|
||||
}}
|
||||
refund={{
|
||||
editing: true,
|
||||
expenses: [
|
||||
{
|
||||
value: "purchase",
|
||||
label: "01.07.2026 · Учебная покупка · 500 ₽",
|
||||
},
|
||||
],
|
||||
onSelect: () => undefined,
|
||||
limit: { currency: "RUB", value: "500" },
|
||||
}}
|
||||
loadingExpenses={false}
|
||||
editing
|
||||
emptyExpenses={false}
|
||||
sourcePage={1}
|
||||
previousDisabled
|
||||
nextDisabled
|
||||
onPrevious={() => undefined}
|
||||
onNext={() => undefined}
|
||||
onAddCategory={() => undefined}
|
||||
saveDisabled={false}
|
||||
feedback={
|
||||
props.conflict && (
|
||||
<Alert color="orange" role="alert">
|
||||
@@ -76,27 +38,66 @@ const OperationExample: FC<ExampleProps> = (props) => {
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
onReload={
|
||||
props.conflict ? () => setAction("Локальный reload") : undefined
|
||||
}
|
||||
fields={{
|
||||
cards: [{ label: "Учебная карта", value: "demo" }],
|
||||
categories: [],
|
||||
dateInputValue: () => "2026-08-01",
|
||||
defaultCategory: "",
|
||||
editing: true,
|
||||
kind,
|
||||
kinds: [{ label: "Возврат", value: "refund" }],
|
||||
maxDate: () => "2026-08-27",
|
||||
refundCardId: "demo",
|
||||
targets: [],
|
||||
validDate: value => value === "2026-08-01",
|
||||
}}
|
||||
latest={
|
||||
props.conflict
|
||||
? {
|
||||
version: 2,
|
||||
(props.conflict)
|
||||
? ({
|
||||
details: "Учебная версия",
|
||||
onAdopt: () => setAction("Локальное принятие версии"),
|
||||
}
|
||||
version: 2,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
loadingExpenses={false}
|
||||
nextDisabled
|
||||
onAddCategory={() => {}}
|
||||
onCancel={() => setOpened(false)}
|
||||
onClose={() => !props.pending && setOpened(false)}
|
||||
onExitTransitionEnd={() => setAction("Форма закрыта")}
|
||||
onNext={() => {}}
|
||||
onPrevious={() => {}}
|
||||
onReload={
|
||||
(props.conflict) ? () => setAction("Локальный reload") : undefined
|
||||
}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setAction("Локальный submit");
|
||||
}}
|
||||
opened={opened}
|
||||
pending={Boolean(props.pending)}
|
||||
previousDisabled
|
||||
refund={{
|
||||
editing: true,
|
||||
expenses: [
|
||||
{
|
||||
label: "01.07.2026 · Учебная покупка · 500 ₽",
|
||||
value: "purchase",
|
||||
},
|
||||
],
|
||||
limit: { currency: "RUB", value: "500" },
|
||||
onSelect: () => {},
|
||||
}}
|
||||
saveDisabled={false}
|
||||
sourcePage={1}
|
||||
/>
|
||||
<output aria-live="polite">{action}</output>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
const meta = {
|
||||
title: "Product/OperationForm",
|
||||
component: OperationExample,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
@@ -105,6 +106,8 @@ const meta = {
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
title: "Product/OperationForm",
|
||||
} satisfies Meta<typeof OperationExample>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
@@ -7,37 +7,46 @@ import {
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { http, HttpResponse } 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,
|
||||
paymentSystem: null,
|
||||
balance: money("0"),
|
||||
openingBalance: money("0"),
|
||||
openingDate: "2026-01-01",
|
||||
archivedAt: null,
|
||||
version: 1,
|
||||
};
|
||||
const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
archivedAt: null,
|
||||
balance: money("0"),
|
||||
bankName: null,
|
||||
id: "a",
|
||||
lastFour: null,
|
||||
name: "Повседневная",
|
||||
openingBalance: money("0"),
|
||||
openingDate: "2026-01-01",
|
||||
paymentSystem: null,
|
||||
version: 1,
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */);
|
||||
async function select(label: RegExp, value: RegExp | string) {
|
||||
fireEvent.click(screen.getByRole("combobox", { name: label }));
|
||||
fireEvent.click(await screen.findByRole("option", { name: value }));
|
||||
}
|
||||
function setup(operation?: BankOperation) {
|
||||
configureAuthClient({
|
||||
accessToken: () => "test",
|
||||
refresh: async () => false,
|
||||
clear: () => {},
|
||||
refresh: () => Promise.resolve(false),
|
||||
});
|
||||
server.use(
|
||||
http.get("*/api/v1/bank-cards/categories", () =>
|
||||
HttpResponse.json([
|
||||
{ id: "default", name: "Остальное", isDefault: true, version: 2 },
|
||||
{ id: "default", isDefault: true, name: "Остальное", version: 2 },
|
||||
]),
|
||||
),
|
||||
);
|
||||
@@ -46,35 +55,28 @@ function setup(operation?: BankOperation) {
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<MantineProvider env="test">
|
||||
<OperationForm
|
||||
operation={operation}
|
||||
card={card}
|
||||
cards={[
|
||||
card,
|
||||
{ ...card, id: "b", name: "Запасная", openingDate: "2026-08-01" },
|
||||
{
|
||||
...card,
|
||||
archivedAt: "2026-01-02",
|
||||
id: "archived",
|
||||
name: "Архивная",
|
||||
archivedAt: "2026-01-02",
|
||||
},
|
||||
]}
|
||||
onClose={close}
|
||||
operation={operation}
|
||||
/>
|
||||
</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("blocks closing and editing while save is pending, then closes after success", async () => {
|
||||
let finish: () => void = () => {};
|
||||
const response = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const { promise: response, resolve: finish } = Promise.withResolvers<void>();
|
||||
const received = vi.fn();
|
||||
server.use(
|
||||
http.post("*/api/v1/bank-cards/operations", async () => {
|
||||
@@ -101,7 +103,8 @@ describe("OperationForm", () => {
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
} finally {
|
||||
}
|
||||
finally {
|
||||
finish();
|
||||
}
|
||||
await waitFor(() => expect(close).toHaveBeenCalledOnce());
|
||||
@@ -132,16 +135,16 @@ describe("OperationForm", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||
await waitFor(() => expect(close).toHaveBeenCalled());
|
||||
expect(body).toMatchObject({
|
||||
kind: "expense",
|
||||
amount: money("92233720368547758.07"),
|
||||
categoryId: "default",
|
||||
date: "2026-08-07",
|
||||
amount: money("92233720368547758.07"),
|
||||
kind: "expense",
|
||||
});
|
||||
});
|
||||
it("checks both transfer opening dates, excludes archive and reuses intent after a lost response", async () => {
|
||||
setup();
|
||||
await select("Вид операции", "Перевод");
|
||||
await select("Карта-получатель", "Запасная");
|
||||
await select(/Вид операции/, "Перевод");
|
||||
await select(/Карта-получатель/, "Запасная");
|
||||
fireEvent.change(screen.getByLabelText(/Сумма/), {
|
||||
target: { value: "0" },
|
||||
});
|
||||
@@ -176,15 +179,15 @@ describe("OperationForm", () => {
|
||||
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"),
|
||||
cardId: "a",
|
||||
kind: "transfer",
|
||||
targetCardId: "b",
|
||||
});
|
||||
});
|
||||
it("topup omits categories and preserves entered description", async () => {
|
||||
setup();
|
||||
await select("Вид операции", "Пополнение");
|
||||
await select(/Вид операции/, "Пополнение");
|
||||
expect(
|
||||
screen.queryByRole("combobox", { name: /Категория/ }),
|
||||
).not.toBeInTheDocument();
|
||||
@@ -204,40 +207,42 @@ describe("OperationForm", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||
await waitFor(() => expect(body).toBeDefined());
|
||||
expect(body).toMatchObject({
|
||||
kind: "topup",
|
||||
amount: money("0.10"),
|
||||
description: "Внесение",
|
||||
kind: "topup",
|
||||
});
|
||||
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",
|
||||
};
|
||||
const purchase: BankOperation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
amount: money("500"),
|
||||
availableRefund: money("500"),
|
||||
cardId: "a",
|
||||
cardName: card.name,
|
||||
categoryId: "default",
|
||||
categoryName: "Остальное",
|
||||
createdAt: "2026-07-01T00:00:00Z",
|
||||
date: "2026-07-01",
|
||||
description: "Прошлая покупка",
|
||||
id: "purchase",
|
||||
kind: "expense",
|
||||
originalExpenseId: null,
|
||||
targetCardId: null,
|
||||
targetCardName: null,
|
||||
version: 1,
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */);
|
||||
server.use(
|
||||
http.get("*/api/v1/bank-cards/a/expenses", ({ request }) => {
|
||||
return HttpResponse.json({
|
||||
hasNext: new URL(request.url).searchParams.get("page") !== "2",
|
||||
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", () =>
|
||||
@@ -245,7 +250,7 @@ describe("OperationForm", () => {
|
||||
),
|
||||
);
|
||||
const close = setup();
|
||||
await select("Вид операции", "Возврат");
|
||||
await select(/Вид операции/, "Возврат");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Далее" })).toBeEnabled(),
|
||||
);
|
||||
@@ -253,7 +258,7 @@ describe("OperationForm", () => {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Назад" })).toBeEnabled(),
|
||||
);
|
||||
await select("Исходная покупка", /Прошлая покупка/);
|
||||
await select(/Исходная покупка/, /Прошлая покупка/);
|
||||
await screen.findByText("Доступно к возврату: 500 ₽");
|
||||
fireEvent.change(screen.getByLabelText(/Сумма/), {
|
||||
target: { value: "500.01" },
|
||||
@@ -284,43 +289,45 @@ describe("OperationForm", () => {
|
||||
await waitFor(() => expect(close).toHaveBeenCalled());
|
||||
expect(bodies[0]).toEqual(bodies[1]);
|
||||
expect(bodies[0]).toMatchObject({
|
||||
date: "2026-08-01",
|
||||
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 refund: BankOperation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
amount: money("500"),
|
||||
availableRefund: money("500"),
|
||||
cardId: "a",
|
||||
cardName: card.name,
|
||||
categoryId: "default",
|
||||
categoryName: "Остальное",
|
||||
createdAt: "2026-08-01T00:00:00Z",
|
||||
date: "2026-08-01",
|
||||
description: "",
|
||||
id: "refund",
|
||||
kind: "refund",
|
||||
originalExpenseId: "purchase",
|
||||
targetCardId: null,
|
||||
targetCardName: null,
|
||||
version: 2,
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */);
|
||||
const purchase = {
|
||||
...refund,
|
||||
availableRefund: money("0"),
|
||||
date: "2026-07-01",
|
||||
id: "purchase",
|
||||
kind: "expense",
|
||||
date: "2026-07-01",
|
||||
availableRefund: money("0"),
|
||||
};
|
||||
server.use(
|
||||
http.get("*/api/v1/bank-cards/a/expenses", () =>
|
||||
HttpResponse.json({
|
||||
hasNext: false,
|
||||
items: [purchase],
|
||||
page: 1,
|
||||
total: 1,
|
||||
hasNext: false,
|
||||
}),
|
||||
),
|
||||
http.get("*/api/v1/bank-cards/operations/purchase", () =>
|
||||
@@ -357,39 +364,41 @@ describe("OperationForm", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||
await waitFor(() => expect(close).toHaveBeenCalled());
|
||||
expect(body).toMatchObject({
|
||||
version: 2,
|
||||
amount: money("500"),
|
||||
originalExpenseId: "purchase",
|
||||
description: "Полностью возвращено",
|
||||
originalExpenseId: "purchase",
|
||||
version: 2,
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
};
|
||||
const operation: BankOperation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
amount: money("100"),
|
||||
availableRefund: money("100"),
|
||||
cardId: "a",
|
||||
cardName: card.name,
|
||||
categoryId: "default",
|
||||
categoryName: "Остальное",
|
||||
createdAt: "2026-08-01T00:00:00Z",
|
||||
date: "2026-08-01",
|
||||
description: "Первое",
|
||||
id: "expense",
|
||||
kind: "expense",
|
||||
originalExpenseId: null,
|
||||
targetCardId: null,
|
||||
targetCardName: null,
|
||||
version: 1,
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */);
|
||||
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 ? "Первое" : "Другое исправление",
|
||||
version: readVersion,
|
||||
}),
|
||||
),
|
||||
http.patch(
|
||||
@@ -434,9 +443,9 @@ describe("OperationForm", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||
await waitFor(() => expect(close).toHaveBeenCalled());
|
||||
expect(bodies[2]).toMatchObject({
|
||||
version: 2,
|
||||
description: "Мой черновик",
|
||||
amount: money("150"),
|
||||
description: "Мой черновик",
|
||||
version: 2,
|
||||
});
|
||||
});
|
||||
it("creates a category inside expense without losing operation fields", async () => {
|
||||
@@ -452,20 +461,20 @@ describe("OperationForm", () => {
|
||||
fireEvent.change(within(dialog).getByLabelText(/Название категории/), {
|
||||
target: { value: " кафе " },
|
||||
});
|
||||
let created = false;
|
||||
let isCreated = false;
|
||||
server.use(
|
||||
http.post("*/api/v1/bank-cards/categories", () => {
|
||||
created = true;
|
||||
isCreated = true;
|
||||
return HttpResponse.json(
|
||||
{ id: "new", name: "кафе", version: 1, isDefault: false },
|
||||
{ id: "new", isDefault: false, name: "кафе", version: 1 },
|
||||
{ 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 }]
|
||||
{ id: "default", isDefault: true, name: "Остальное", version: 2 },
|
||||
...(isCreated
|
||||
? [{ id: "new", isDefault: false, name: "кафе", version: 1 }]
|
||||
: []),
|
||||
]),
|
||||
),
|
||||
@@ -495,9 +504,9 @@ describe("OperationForm", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||
await waitFor(() => expect(body).toBeDefined());
|
||||
expect(body).toMatchObject({
|
||||
amount: money("1.20"),
|
||||
categoryId: "new",
|
||||
description: "Черновик расхода",
|
||||
amount: money("1.20"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { FormProvider } from "react-hook-form";
|
||||
|
||||
import { BankFeedback, CategoryManager } from "@/entities/bank-card";
|
||||
|
||||
import type { OperationFormProps } from "./OperationForm.types";
|
||||
|
||||
import { OperationFormView } from "./OperationForm.view";
|
||||
import { useOperationFormVm } from "./OperationForm.vm";
|
||||
|
||||
@@ -12,34 +16,35 @@ const OperationForm: FC<OperationFormProps> = (props) => {
|
||||
<FormProvider {...vm.methods}>
|
||||
<OperationFormView
|
||||
{...vm.view}
|
||||
refundFeedback={
|
||||
<>
|
||||
{vm.expensesError && (
|
||||
<BankFeedback
|
||||
loadingError
|
||||
error={vm.expensesError}
|
||||
retry={vm.retryExpenses}
|
||||
/>
|
||||
)}{" "}
|
||||
{vm.sourceError && (
|
||||
<BankFeedback
|
||||
loadingError
|
||||
error={vm.sourceError}
|
||||
retry={vm.retrySource}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
categoriesFeedback={
|
||||
vm.categoriesError && (
|
||||
<BankFeedback
|
||||
loadingError
|
||||
error={vm.categoriesError}
|
||||
loadingError
|
||||
retry={vm.retryCategories}
|
||||
/>
|
||||
)
|
||||
}
|
||||
feedback={vm.error ? <BankFeedback error={vm.error} /> : null}
|
||||
feedback={(vm.error) ? (<BankFeedback error={vm.error} />) : undefined}
|
||||
refundFeedback={(
|
||||
<>
|
||||
{vm.expensesError && (
|
||||
<BankFeedback
|
||||
error={vm.expensesError}
|
||||
loadingError
|
||||
retry={vm.retryExpenses}
|
||||
/>
|
||||
)}
|
||||
{" "}
|
||||
{vm.sourceError && (
|
||||
<BankFeedback
|
||||
error={vm.sourceError}
|
||||
loadingError
|
||||
retry={vm.retrySource}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</FormProvider>
|
||||
{vm.categoriesOpen && (
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import type { BankCard, BankOperation } from "@/entities/bank-card";
|
||||
|
||||
type OperationValues = {
|
||||
kind: BankOperation["kind"];
|
||||
cardId: string;
|
||||
targetCardId: string;
|
||||
categoryId: string;
|
||||
originalExpenseId: string;
|
||||
amount: string;
|
||||
date: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type OperationFormProps = {
|
||||
card: BankCard;
|
||||
cards: BankCard[];
|
||||
operation?: BankOperation;
|
||||
onClose: () => void;
|
||||
operation?: BankOperation;
|
||||
};
|
||||
|
||||
type OperationValues = {
|
||||
amount: string;
|
||||
cardId: string;
|
||||
categoryId: string;
|
||||
date: string;
|
||||
description: string;
|
||||
kind: BankOperation["kind"];
|
||||
originalExpenseId: string;
|
||||
targetCardId: string;
|
||||
};
|
||||
|
||||
export type { OperationFormProps, OperationValues };
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { FC, ReactNode, SubmitEventHandler } from "react";
|
||||
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
@@ -9,35 +11,38 @@ import {
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import type { FC, FormEventHandler, ReactNode } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { BankOperation } from "@/entities/bank-card";
|
||||
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
import classes from "./OperationForm.module.css";
|
||||
|
||||
import type { OperationValues } from "./OperationForm.types";
|
||||
|
||||
type Choice = { value: string; label: string };
|
||||
import classes from "./OperationForm.module.css";
|
||||
|
||||
type Choice = { label: string; value: string };
|
||||
type OperationFieldsProps = {
|
||||
editing: boolean;
|
||||
refundCardId: string;
|
||||
kind: OperationValues["kind"];
|
||||
cards: Choice[];
|
||||
targets: Choice[];
|
||||
categories: Choice[];
|
||||
defaultCategory: string;
|
||||
kinds: Choice[];
|
||||
dateInputValue: (value: string) => string;
|
||||
validDate: (value: string) => boolean;
|
||||
defaultCategory: string;
|
||||
editing: boolean;
|
||||
kind: OperationValues["kind"];
|
||||
kinds: Choice[];
|
||||
maxDate: () => string;
|
||||
refundCardId: string;
|
||||
targets: Choice[];
|
||||
validDate: (value: string) => boolean;
|
||||
};
|
||||
const OperationFields: FC<OperationFieldsProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
control,
|
||||
formState: { errors, isSubmitting },
|
||||
register,
|
||||
setValue,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useFormContext<OperationValues>();
|
||||
return (
|
||||
<Stack>
|
||||
@@ -46,9 +51,9 @@ const OperationFields: FC<OperationFieldsProps> = (props) => {
|
||||
name="kind"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
data={props.kinds}
|
||||
label={t("bank.kind")}
|
||||
required
|
||||
data={props.kinds}
|
||||
{...field}
|
||||
disabled={isSubmitting || props.editing}
|
||||
onChange={(value) => {
|
||||
@@ -63,11 +68,11 @@ const OperationFields: FC<OperationFieldsProps> = (props) => {
|
||||
name="cardId"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
data={props.cards}
|
||||
label={t(
|
||||
props.kind === "transfer" ? "bank.source" : "bank.operationCard",
|
||||
)}
|
||||
required
|
||||
data={props.cards}
|
||||
{...field}
|
||||
disabled={isSubmitting || props.kind === "refund"}
|
||||
error={errors.cardId?.message}
|
||||
@@ -80,9 +85,9 @@ const OperationFields: FC<OperationFieldsProps> = (props) => {
|
||||
name="targetCardId"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
data={props.targets}
|
||||
label={t("bank.destination")}
|
||||
required
|
||||
data={props.targets}
|
||||
{...field}
|
||||
disabled={isSubmitting}
|
||||
error={errors.targetCardId?.message}
|
||||
@@ -91,9 +96,9 @@ const OperationFields: FC<OperationFieldsProps> = (props) => {
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
inputMode="decimal"
|
||||
label={t("bank.amount")}
|
||||
required
|
||||
inputMode="decimal"
|
||||
{...register("amount")}
|
||||
error={errors.amount?.message}
|
||||
/>
|
||||
@@ -103,30 +108,28 @@ const OperationFields: FC<OperationFieldsProps> = (props) => {
|
||||
render={({ field }) => (
|
||||
<DateInput
|
||||
{...field}
|
||||
label={t("bank.date")}
|
||||
required
|
||||
valueFormat="DD.MM.YYYY"
|
||||
value={props.validDate(field.value) ? field.value : null}
|
||||
dateParser={(value) =>
|
||||
dateParser={value =>
|
||||
props.validDate(props.dateInputValue(value))
|
||||
? props.dateInputValue(value)
|
||||
: null
|
||||
}
|
||||
: /* eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. */ null}
|
||||
error={errors.date?.message}
|
||||
fixOnBlur={false}
|
||||
onInput={(event) =>
|
||||
field.onChange(props.dateInputValue(event.currentTarget.value))
|
||||
}
|
||||
onChange={(value) => field.onChange(value ?? "")}
|
||||
label={t("bank.date")}
|
||||
maxDate={props.maxDate()}
|
||||
onBlur={(event) => {
|
||||
field.onChange(props.dateInputValue(event.currentTarget.value));
|
||||
field.onBlur();
|
||||
}}
|
||||
maxDate={props.maxDate()}
|
||||
onChange={value => field.onChange(value ?? "")}
|
||||
onInput={event =>
|
||||
field.onChange(props.dateInputValue(event.currentTarget.value))}
|
||||
popoverProps={{
|
||||
withinPortal: false,
|
||||
middlewares: { shift: { crossAxis: true } },
|
||||
withinPortal: false,
|
||||
}}
|
||||
error={errors.date?.message}
|
||||
required
|
||||
value={(props.validDate(field.value)) ? (field.value) : undefined}
|
||||
valueFormat="DD.MM.YYYY"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -136,13 +139,13 @@ const OperationFields: FC<OperationFieldsProps> = (props) => {
|
||||
name="categoryId"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
data={props.categories}
|
||||
label={t("bank.category")}
|
||||
required
|
||||
data={props.categories}
|
||||
{...field}
|
||||
value={field.value || props.defaultCategory}
|
||||
disabled={isSubmitting}
|
||||
error={errors.categoryId?.message}
|
||||
value={field.value || props.defaultCategory}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -158,8 +161,8 @@ const OperationFields: FC<OperationFieldsProps> = (props) => {
|
||||
type RefundFieldsProps = {
|
||||
editing: boolean;
|
||||
expenses: Choice[];
|
||||
onSelect: (id: string) => void;
|
||||
limit: BankOperation["availableRefund"] | undefined;
|
||||
onSelect: (id: string) => void;
|
||||
};
|
||||
const RefundFields: FC<RefundFieldsProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -174,16 +177,16 @@ const RefundFields: FC<RefundFieldsProps> = (props) => {
|
||||
name="originalExpenseId"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
data={props.expenses}
|
||||
label={t("bank.expense")}
|
||||
required
|
||||
data={props.expenses}
|
||||
{...field}
|
||||
disabled={isSubmitting || props.editing}
|
||||
error={errors.originalExpenseId?.message}
|
||||
onChange={(value) => {
|
||||
field.onChange(value ?? "");
|
||||
props.onSelect(value ?? "");
|
||||
}}
|
||||
disabled={isSubmitting || props.editing}
|
||||
error={errors.originalExpenseId?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -198,48 +201,48 @@ const RefundFields: FC<RefundFieldsProps> = (props) => {
|
||||
};
|
||||
|
||||
type OperationFormViewProps = {
|
||||
opened: boolean;
|
||||
pending: boolean;
|
||||
categoriesFeedback?: ReactNode;
|
||||
categoriesOpen: boolean;
|
||||
editing: boolean;
|
||||
emptyExpenses: boolean;
|
||||
feedback?: ReactNode;
|
||||
fields: OperationFieldsProps;
|
||||
latest?: { details: string; onAdopt: () => void; version: number };
|
||||
loadingExpenses: boolean;
|
||||
nextDisabled: boolean;
|
||||
onAddCategory: () => void;
|
||||
onCancel: () => void;
|
||||
onClose: () => void;
|
||||
onExitTransitionEnd: () => void;
|
||||
onCancel: () => void;
|
||||
onSubmit: FormEventHandler<HTMLFormElement>;
|
||||
fields: OperationFieldsProps;
|
||||
refund: RefundFieldsProps;
|
||||
loadingExpenses: boolean;
|
||||
emptyExpenses: boolean;
|
||||
sourcePage: number;
|
||||
previousDisabled: boolean;
|
||||
nextDisabled: boolean;
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
onAddCategory: () => void;
|
||||
onPrevious: () => void;
|
||||
onReload?: () => void;
|
||||
latest?: { version: number; details: string; onAdopt: () => void };
|
||||
saveDisabled: boolean;
|
||||
onSubmit: SubmitEventHandler<HTMLFormElement>;
|
||||
opened: boolean;
|
||||
pending: boolean;
|
||||
previousDisabled: boolean;
|
||||
refund: RefundFieldsProps;
|
||||
refundFeedback?: ReactNode;
|
||||
categoriesFeedback?: ReactNode;
|
||||
feedback?: ReactNode;
|
||||
saveDisabled: boolean;
|
||||
sourcePage: number;
|
||||
};
|
||||
const OperationFormView: FC<OperationFormViewProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Modal
|
||||
opened={props.opened}
|
||||
onExitTransitionEnd={props.onExitTransitionEnd}
|
||||
onClose={props.onClose}
|
||||
title={t(props.editing ? "bank.editOperation" : "bank.addOperation")}
|
||||
closeOnEscape={!props.pending && !props.categoriesOpen}
|
||||
closeOnClickOutside={!props.pending && !props.categoriesOpen}
|
||||
closeButtonProps={{
|
||||
"aria-label": t("bank.close"),
|
||||
disabled: props.pending,
|
||||
"disabled": props.pending,
|
||||
}}
|
||||
closeOnClickOutside={!props.pending && !props.categoriesOpen}
|
||||
closeOnEscape={!props.pending && !props.categoriesOpen}
|
||||
onClose={props.onClose}
|
||||
onExitTransitionEnd={props.onExitTransitionEnd}
|
||||
opened={props.opened}
|
||||
title={t(props.editing ? "bank.editOperation" : "bank.addOperation")}
|
||||
>
|
||||
<form onSubmit={props.onSubmit}>
|
||||
<fieldset disabled={props.pending} className={classes.fieldset}>
|
||||
<fieldset className={classes.fieldset} disabled={props.pending}>
|
||||
<Stack>
|
||||
<OperationFields {...props.fields} />
|
||||
{props.fields.kind === "refund" && (
|
||||
@@ -252,17 +255,17 @@ const OperationFormView: FC<OperationFormViewProps> = (props) => {
|
||||
{!props.editing && (
|
||||
<Group>
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={props.previousDisabled}
|
||||
onClick={props.onPrevious}
|
||||
variant="default"
|
||||
>
|
||||
{t("bank.previous")}
|
||||
</Button>
|
||||
<Text>{t("bank.page", { page: props.sourcePage })}</Text>
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={props.nextDisabled}
|
||||
onClick={props.onNext}
|
||||
variant="default"
|
||||
>
|
||||
{t("bank.next")}
|
||||
</Button>
|
||||
@@ -272,14 +275,14 @@ const OperationFormView: FC<OperationFormViewProps> = (props) => {
|
||||
</>
|
||||
)}
|
||||
{props.fields.kind === "expense" && (
|
||||
<Button variant="subtle" onClick={props.onAddCategory}>
|
||||
<Button onClick={props.onAddCategory} variant="subtle">
|
||||
{t("bank.addCategory")}
|
||||
</Button>
|
||||
)}
|
||||
{props.categoriesFeedback}
|
||||
{props.feedback}
|
||||
{props.onReload && (
|
||||
<Button variant="default" onClick={props.onReload}>
|
||||
<Button onClick={props.onReload} variant="default">
|
||||
{t("bank.reloadRecord")}
|
||||
</Button>
|
||||
)}
|
||||
@@ -288,20 +291,20 @@ const OperationFormView: FC<OperationFormViewProps> = (props) => {
|
||||
<Text>
|
||||
{t("bank.versionHint", { details: props.latest.details })}
|
||||
</Text>
|
||||
<Button variant="default" onClick={props.latest.onAdopt}>
|
||||
<Button onClick={props.latest.onAdopt} variant="default">
|
||||
{t("bank.adoptVersion", { version: props.latest.version })}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Group>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={props.pending}
|
||||
disabled={props.saveDisabled}
|
||||
loading={props.pending}
|
||||
type="submit"
|
||||
>
|
||||
{t("bank.save")}
|
||||
</Button>
|
||||
<Button variant="default" onClick={props.onCancel}>
|
||||
<Button onClick={props.onCancel} variant="default">
|
||||
{t("bank.cancel")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -3,11 +3,12 @@ import { useState } from "react";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
type BankOperation,
|
||||
bankDateInputValue,
|
||||
bankDateSchema,
|
||||
bankMoneyValue,
|
||||
type BankOperation,
|
||||
bankOperationKinds,
|
||||
bankToday,
|
||||
moneyCents,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
} from "@/entities/bank-card";
|
||||
import { validationMessage } from "@/shared/i18n";
|
||||
import { formatDate, formatMoney } from "@/shared/lib";
|
||||
|
||||
import type {
|
||||
OperationFormProps,
|
||||
OperationValues,
|
||||
@@ -28,12 +30,9 @@ import type {
|
||||
const MAX_DESCRIPTION_LENGTH = 500;
|
||||
|
||||
const operationSchema = z.object({
|
||||
kind: z.enum(bankOperationKinds),
|
||||
cardId: z.string(),
|
||||
targetCardId: z.string(),
|
||||
categoryId: z.string(),
|
||||
originalExpenseId: z.string(),
|
||||
amount: positiveMoneySchema,
|
||||
cardId: z.string(),
|
||||
categoryId: z.string(),
|
||||
date: bankDateSchema,
|
||||
description: z
|
||||
.string()
|
||||
@@ -41,8 +40,38 @@ const operationSchema = z.object({
|
||||
MAX_DESCRIPTION_LENGTH,
|
||||
validationMessage("bank.validation.description")(undefined),
|
||||
),
|
||||
kind: z.enum(bankOperationKinds),
|
||||
originalExpenseId: z.string(),
|
||||
targetCardId: z.string(),
|
||||
});
|
||||
|
||||
function validateRefund(values: OperationValues, context: z.RefinementCtx,
|
||||
expense: BankOperation | undefined, limit: BankOperation["availableRefund"] | undefined) {
|
||||
if (!expense || expense.cardId !== values.cardId)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["originalExpenseId"],
|
||||
...validationMessage("bank.validation.required")(undefined),
|
||||
});
|
||||
if (expense && values.date < expense.date)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["date"],
|
||||
...validationMessage("bank.validation.start")(undefined),
|
||||
});
|
||||
const amount = moneyCents(values.amount);
|
||||
if (
|
||||
limit
|
||||
&& amount !== null
|
||||
&& amount > (moneyCents(limit.value) ?? 0n)
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["amount"],
|
||||
...validationMessage("bank.validation.refund")(undefined),
|
||||
});
|
||||
}
|
||||
|
||||
const useOperationFormVm = (props: OperationFormProps) => {
|
||||
useBankFormFocus();
|
||||
const [opened, setOpened] = useState(true);
|
||||
@@ -65,97 +94,70 @@ const useOperationFormVm = (props: OperationFormProps) => {
|
||||
const categories = useBankCategories();
|
||||
const mutation = useBankMutations();
|
||||
const methods = useForm<OperationValues>({
|
||||
defaultValues: {
|
||||
amount: props.operation?.amount.value ?? "",
|
||||
cardId: props.operation?.cardId ?? props.card.id,
|
||||
categoryId: props.operation?.categoryId ?? "",
|
||||
date: props.operation?.date ?? bankToday(),
|
||||
description: props.operation?.description ?? "",
|
||||
kind: props.operation?.kind ?? "expense",
|
||||
originalExpenseId: props.operation?.originalExpenseId ?? "",
|
||||
targetCardId: props.operation?.targetCardId ?? "",
|
||||
},
|
||||
resolver: zodResolver(
|
||||
operationSchema.superRefine((values, ctx) => {
|
||||
operationSchema.superRefine((values, context) => {
|
||||
// Repeated intent reaches server idempotency even after its source changed.
|
||||
if (
|
||||
submittedValues &&
|
||||
JSON.stringify(values) === JSON.stringify(submittedValues)
|
||||
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);
|
||||
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({
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["cardId"],
|
||||
...validationMessage("bank.validation.required")(undefined),
|
||||
});
|
||||
if (
|
||||
values.kind === "transfer" &&
|
||||
(!target || target.archivedAt || target.id === values.cardId)
|
||||
values.kind === "transfer"
|
||||
&& (!target || target.archivedAt || target.id === values.cardId)
|
||||
)
|
||||
ctx.addIssue({
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["targetCardId"],
|
||||
...validationMessage("bank.validation.destination")(undefined),
|
||||
});
|
||||
if (
|
||||
(source && values.date < source.openingDate) ||
|
||||
(values.kind === "transfer" &&
|
||||
target &&
|
||||
values.date < target.openingDate)
|
||||
(source && values.date < source.openingDate)
|
||||
|| (target
|
||||
&& values.kind === "transfer"
|
||||
&& values.date < target.openingDate)
|
||||
)
|
||||
ctx.addIssue({
|
||||
context.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 === "refund") validateRefund(values, context, sourceQuery.data,
|
||||
props.operation ? refundLimit : sourceQuery.data?.availableRefund);
|
||||
if (
|
||||
values.kind === "expense" &&
|
||||
!categories.data?.some(
|
||||
(c) =>
|
||||
c.id ===
|
||||
(values.categoryId ||
|
||||
categories.data?.find((c) => c.isDefault)?.id),
|
||||
values.kind === "expense"
|
||||
&& !categories.data?.some(
|
||||
c =>
|
||||
c.id
|
||||
=== (values.categoryId
|
||||
|| categories.data?.find(c => c.isDefault)?.id),
|
||||
)
|
||||
)
|
||||
ctx.addIssue({
|
||||
context.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 = useWatch({ control: methods.control, name: "kind" });
|
||||
const expenses = useBankExpenses(
|
||||
@@ -163,30 +165,24 @@ const useOperationFormVm = (props: OperationFormProps) => {
|
||||
{ page: sourcePage },
|
||||
);
|
||||
const source = useWatch({ control: methods.control, name: "cardId" });
|
||||
const pending = !opened || methods.formState.isSubmitting;
|
||||
const isPending = !opened || methods.formState.isSubmitting;
|
||||
const options = props.cards
|
||||
.filter((c) => !c.archivedAt)
|
||||
.map((c) => ({ value: c.id, label: c.name }));
|
||||
.filter(c => !c.archivedAt)
|
||||
.map(c => ({ label: c.name, value: c.id }));
|
||||
const onSubmit = methods.handleSubmit(async (values) => {
|
||||
setSubmittedValues(values);
|
||||
setError(undefined);
|
||||
try {
|
||||
const body = {
|
||||
kind: values.kind,
|
||||
cardId: values.cardId,
|
||||
...(values.kind === "transfer"
|
||||
? { targetCardId: values.targetCardId }
|
||||
: {}),
|
||||
...(values.kind === "expense"
|
||||
? {
|
||||
categoryId:
|
||||
values.categoryId ||
|
||||
categories.data?.find((c) => c.isDefault)?.id,
|
||||
}
|
||||
: {}),
|
||||
...(values.kind === "refund"
|
||||
? { originalExpenseId: values.originalExpenseId }
|
||||
: {}),
|
||||
kind: values.kind,
|
||||
...((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),
|
||||
@@ -196,8 +192,8 @@ const useOperationFormVm = (props: OperationFormProps) => {
|
||||
};
|
||||
if (props.operation)
|
||||
await mutation.updateOperation.mutateAsync({
|
||||
id: props.operation.id,
|
||||
body: { ...body, version },
|
||||
id: props.operation.id,
|
||||
});
|
||||
else
|
||||
await mutation.createOperation.mutateAsync({
|
||||
@@ -205,8 +201,9 @@ const useOperationFormVm = (props: OperationFormProps) => {
|
||||
...body,
|
||||
});
|
||||
setOpened(false);
|
||||
} catch (failure) {
|
||||
setError(failure);
|
||||
}
|
||||
catch (error_) {
|
||||
setError(error_);
|
||||
}
|
||||
});
|
||||
const onReload = async () => {
|
||||
@@ -216,101 +213,103 @@ const useOperationFormVm = (props: OperationFormProps) => {
|
||||
if (props.operation) {
|
||||
setLatest(result.data);
|
||||
setRefundLimit(result.data.availableRefund);
|
||||
} else setError(undefined);
|
||||
} else setError(result.error);
|
||||
}
|
||||
else setError(undefined);
|
||||
}
|
||||
else setError(result.error);
|
||||
};
|
||||
|
||||
const expenseChoice = (item: BankOperation) => ({
|
||||
value: item.id,
|
||||
label: t("bank.expenseChoice", {
|
||||
date: formatDate(item.date),
|
||||
name: item.description || item.categoryName,
|
||||
amount: formatMoney(item.amount),
|
||||
available: formatMoney(item.availableRefund),
|
||||
date: formatDate(item.date),
|
||||
name: item.description || item.categoryName,
|
||||
}),
|
||||
value: item.id,
|
||||
});
|
||||
const expenseOptions = expenses.data?.items.map(expenseChoice) ?? [];
|
||||
const expenseOptions = expenses.data?.items.map(element => expenseChoice(element)) ?? [];
|
||||
const selected = sourceQuery.data
|
||||
? expenseChoice(sourceQuery.data)
|
||||
: undefined;
|
||||
return {
|
||||
methods,
|
||||
categoriesError: categories.isError ? categories.error : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
|
||||
categoriesOpen,
|
||||
error,
|
||||
categoriesError: categories.isError ? categories.error : null,
|
||||
expensesError: expenses.isError ? expenses.error : null,
|
||||
sourceError: sourceQuery.isError ? sourceQuery.error : null,
|
||||
retryCategories: () => void categories.refetch(),
|
||||
retryExpenses: () => void expenses.refetch(),
|
||||
retrySource: () => void sourceQuery.refetch(),
|
||||
closeCategories: () => setCategoriesOpen(false),
|
||||
error,
|
||||
expensesError: expenses.isError ? expenses.error : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
|
||||
methods,
|
||||
onCategoryCreated: (id: string) => {
|
||||
methods.setValue("categoryId", id);
|
||||
setCategoriesOpen(false);
|
||||
},
|
||||
retryCategories: () => void categories.refetch(),
|
||||
retryExpenses: () => void expenses.refetch(),
|
||||
retrySource: () => void sourceQuery.refetch(),
|
||||
sourceError: sourceQuery.isError ? sourceQuery.error : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
|
||||
view: {
|
||||
opened,
|
||||
pending,
|
||||
categoriesOpen,
|
||||
editing: Boolean(props.operation),
|
||||
onClose: () => !pending && !categoriesOpen && setOpened(false),
|
||||
onExitTransitionEnd: props.onClose,
|
||||
onCancel: () => setOpened(false),
|
||||
onSubmit,
|
||||
emptyExpenses: Boolean(
|
||||
expenses.data && expenses.data.items.length === 0 && !props.operation,
|
||||
),
|
||||
fields: {
|
||||
refundCardId: props.card.id,
|
||||
cards: options,
|
||||
categories:
|
||||
categories.data?.map(c => ({ label: c.name, value: c.id })) ?? [],
|
||||
dateInputValue: bankDateInputValue,
|
||||
defaultCategory: categories.data?.find(c => c.isDefault)?.id ?? "",
|
||||
editing: Boolean(props.operation),
|
||||
kind,
|
||||
cards: options,
|
||||
targets: options.filter((c) => c.value !== source),
|
||||
categories:
|
||||
categories.data?.map((c) => ({ value: c.id, label: c.name })) ?? [],
|
||||
defaultCategory: categories.data?.find((c) => c.isDefault)?.id ?? "",
|
||||
kinds: bankOperationKinds.map((value) => ({
|
||||
value,
|
||||
kinds: bankOperationKinds.map(value => ({
|
||||
label: t(`bank.kinds.${value}`),
|
||||
value,
|
||||
})),
|
||||
dateInputValue: bankDateInputValue,
|
||||
validDate: (value: string) => bankDateSchema.safeParse(value).success,
|
||||
maxDate: bankToday,
|
||||
refundCardId: props.card.id,
|
||||
targets: options.filter(c => c.value !== source),
|
||||
validDate: (value: string) => bankDateSchema.safeParse(value).success,
|
||||
},
|
||||
refund: {
|
||||
editing: Boolean(props.operation),
|
||||
expenses:
|
||||
selected &&
|
||||
!expenseOptions.some((item) => item.value === selected.value)
|
||||
? [selected, ...expenseOptions]
|
||||
: expenseOptions,
|
||||
onSelect: setSourceId,
|
||||
limit: props.operation
|
||||
? refundLimit
|
||||
: sourceQuery.data?.availableRefund,
|
||||
},
|
||||
loadingExpenses: expenses.isLoading,
|
||||
emptyExpenses: Boolean(
|
||||
expenses.data && !expenses.data.items.length && !props.operation,
|
||||
),
|
||||
sourcePage,
|
||||
previousDisabled: sourcePage === 1 || expenses.isFetching,
|
||||
nextDisabled: !expenses.data?.hasNext || expenses.isFetching,
|
||||
onPrevious: () => setSourcePage(sourcePage - 1),
|
||||
onNext: () => setSourcePage(sourcePage + 1),
|
||||
onAddCategory: () => setCategoriesOpen(true),
|
||||
onReload: error && (props.operation || sourceId) ? onReload : undefined,
|
||||
latest: latest
|
||||
? {
|
||||
version: latest.version,
|
||||
details: `${formatDate(latest.date)} · ${formatMoney(latest.amount)} · ${latest.description ?? ""}`,
|
||||
onAdopt: () => {
|
||||
setVersion(latest.version);
|
||||
setLatest(undefined);
|
||||
setError(undefined);
|
||||
},
|
||||
version: latest.version,
|
||||
}
|
||||
: undefined,
|
||||
loadingExpenses: expenses.isLoading,
|
||||
nextDisabled: !expenses.data?.hasNext || expenses.isFetching,
|
||||
onAddCategory: () => setCategoriesOpen(true),
|
||||
onCancel: () => setOpened(false),
|
||||
onClose: () => !isPending && !categoriesOpen && setOpened(false),
|
||||
onExitTransitionEnd: props.onClose,
|
||||
onNext: () => setSourcePage(sourcePage + 1),
|
||||
onPrevious: () => setSourcePage(sourcePage - 1),
|
||||
onReload: error && (sourceId || props.operation) ? onReload : undefined,
|
||||
onSubmit,
|
||||
opened,
|
||||
pending: isPending,
|
||||
previousDisabled: sourcePage === 1 || expenses.isFetching,
|
||||
refund: {
|
||||
editing: Boolean(props.operation),
|
||||
expenses:
|
||||
selected
|
||||
&& expenseOptions.every(item => item.value !== selected.value)
|
||||
? [selected, ...expenseOptions]
|
||||
: expenseOptions,
|
||||
limit: props.operation
|
||||
? refundLimit
|
||||
: sourceQuery.data?.availableRefund,
|
||||
onSelect: setSourceId,
|
||||
},
|
||||
saveDisabled:
|
||||
(kind === "expense" && !categories.data) ||
|
||||
(kind === "refund" && !sourceQuery.data),
|
||||
(kind === "expense" && !categories.data)
|
||||
|| (kind === "refund" && !sourceQuery.data),
|
||||
sourcePage,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user