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:
2026-09-28 16:36:40 +03:00
committed by ksv741
parent 57630102e4
commit 72d7835227
253 changed files with 10059 additions and 8864 deletions
+70 -63
View File
@@ -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 () => {
+4 -2
View File
@@ -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}
/>
)}
</>
+175 -159
View File
@@ -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>
)}
+60 -62
View File
@@ -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,
},
};
};