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
@@ -1,67 +1,75 @@
import { SimpleGrid } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { SimpleGrid } from "@mantine/core";
import { BankCardFace } from "./BankCardFace";
const meta = {
title: "Product/BankCards/CardAppearance",
component: BankCardFace,
tags: ["autodocs"],
const meta = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
args: {
card: {
id: "fictional",
name: "Для повседневных покупок",
bankName: "Сбербанк",
lastFour: "4829",
paymentSystem: "mir",
openingBalance: { value: "0", currency: "RUB" },
openingDate: "2026-01-01",
balance: { value: "128450.75", currency: "RUB" },
archivedAt: null,
balance: { currency: "RUB", value: "128450.75" },
bankName: "Сбербанк",
id: "fictional",
lastFour: "4829",
name: "Для повседневных покупок",
openingBalance: { currency: "RUB", value: "0" },
openingDate: "2026-01-01",
paymentSystem: "mir",
version: 1,
},
},
} satisfies Meta<typeof BankCardFace>;
component: BankCardFace,
tags: ["autodocs"],
title: "Product/BankCards/CardAppearance",
}
/* eslint-enable unicorn/no-null */) satisfies Meta<typeof BankCardFace>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Banks: Story = {
render: (args) => (
render: arguments_ => (
<SimpleGrid cols={{ base: 1, sm: 2 }} maw={920}>
{["Сбербанк", "Т-Банк", "Альфа-Банк", "ВТБ", null].map(
{["Сбербанк", "Т-Банк", "Альфа-Банк", "ВТБ", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null].map(
(bankName, index) => (
<BankCardFace
card={(/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
...arguments_.card,
bankName,
paymentSystem:
/* eslint-disable-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key. */ (["mir", "mastercard", "visa", "mir", null] as const)[index]
?? null,
}
/* eslint-enable unicorn/no-null */)}
key={bankName ?? "unknown"}
card={{
...args.card,
bankName,
paymentSystem:
(["mir", "mastercard", "visa", "mir", null] as const)[index] ??
null,
}}
/>
),
)}
</SimpleGrid>
),
};
export const MissingDetails: Story = {
args: {
card: {
...meta.args.card,
bankName: null,
lastFour: null,
paymentSystem: null,
export const MissingDetails: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
args: {
card: {
...meta.args.card,
bankName: null,
lastFour: null,
paymentSystem: null,
},
},
},
};
}
/* eslint-enable unicorn/no-null */);
export const LongArchived: Story = {
args: {
card: {
...meta.args.card,
name: "Карта с очень длинным пользовательским названием для повседневных покупок",
bankName: "Банк с очень длинным названием без пробелов".repeat(2),
archivedAt: "2026-09-12",
balance: { currency: "RUB", value: "184467440737095516.14" },
bankName: "Банк с очень длинным названием без пробелов".repeat(2),
name: "Карта с очень длинным пользовательским названием для повседневных покупок",
paymentSystem: "mastercard",
balance: { value: "184467440737095516.14", currency: "RUB" },
},
},
};
@@ -1,7 +1,11 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { formatMoney } from "@/shared/lib";
import type { BankCard } from "../../api/queries";
import classes from "./BankCardFace.module.css";
type BankCardFaceProps = { card: BankCard };
@@ -9,15 +13,7 @@ const BankCardFace: FC<BankCardFaceProps> = (props) => {
const { card } = props;
const { t } = useTranslation();
const bank = card.bankName?.toLocaleLowerCase("ru") ?? "";
const tone = /сбер|sber/.test(bank)
? "green"
: /тинькофф|т[ -]?банк|t[ -]?bank|tinkoff/.test(bank)
? "yellow"
: /альфа|alfa/.test(bank)
? "red"
: /втб|vtb|газпром|gazprom/.test(bank)
? "blue"
: "graphite";
const tone = bankTone(bank);
return (
<div className={classes.face} data-tone={tone}>
<div className={classes.top}>
@@ -26,26 +22,28 @@ const BankCardFace: FC<BankCardFaceProps> = (props) => {
<span className={classes.archive}>{t("bank.archived")}</span>
)}
</div>
<div className={classes.hardware} aria-hidden="true">
<div aria-hidden="true" className={classes.hardware}>
<span className={classes.chip} />
<svg
aria-hidden="true"
width="24"
fill="none"
height="30"
viewBox="0 0 24 30"
fill="none"
width="24"
>
<path
d="M7 11a8 8 0 0 1 0 8m5-12a15 15 0 0 1 0 16m5-20a23 23 0 0 1 0 24"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeWidth="2"
/>
</svg>
</div>
<div
className={classes.number}
>{`•••• •••• •••• ${card.lastFour || "••••"}`}</div>
>
{`•••• •••• •••• ${card.lastFour || "••••"}`}
</div>
<div className={classes.bottom}>
<div className={classes.details}>
<div className={classes.name}>{card.name}</div>
@@ -54,7 +52,7 @@ const BankCardFace: FC<BankCardFaceProps> = (props) => {
{card.paymentSystem && (
<span className={classes.network} data-network={card.paymentSystem}>
{card.paymentSystem === "mastercard" && (
<span className={classes.circles} aria-hidden="true" />
<span aria-hidden="true" className={classes.circles} />
)}
{t(`bank.paymentSystems.${card.paymentSystem}`)}
</span>
@@ -64,4 +62,11 @@ const BankCardFace: FC<BankCardFaceProps> = (props) => {
);
};
function bankTone(bank: string) {
if (/сбер|sber/.test(bank)) return "green";
if (/тинькофф|т[ -]?банк|t[ -]?bank|tinkoff/.test(bank)) return "yellow";
if (/альфа|alfa/.test(bank)) return "red";
return /втб|vtb|газпром|gazprom/.test(bank) ? "blue" : "graphite";
}
export { BankCardFace };
@@ -1,20 +1,21 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { FormProvider, useForm } from "react-hook-form";
import type { BankCardValues } from "../../model/form-validation";
import { BankCardFields } from "./BankCardForm.view";
const meta = {
title: "Product/BankCards/CardFields",
component: BankCardFields,
tags: ["autodocs"],
args: { archived: false },
component: BankCardFields,
decorators: [
(Story) => {
const methods = useForm<BankCardValues>({
defaultValues: {
name: "Учебная повседневная карта",
bankName: "Вымышленный банк",
lastFour: "1234",
name: "Учебная повседневная карта",
openingBalance: "-92233720368547758.08",
openingDate: "2026-08-01",
},
@@ -34,11 +35,13 @@ const meta = {
},
},
},
tags: ["autodocs"],
title: "Product/BankCards/CardFields",
} satisfies Meta<typeof BankCardFields>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Active: Story = { name: "Независимая карта в рублях" };
export const Archived: Story = {
name: "Финансовые поля архивной карты",
args: { archived: true },
name: "Финансовые поля архивной карты",
};
@@ -1,31 +1,36 @@
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 { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import type { BankCard } from "../../api/queries";
import { BankCardForm } from "./BankCardForm";
const card: BankCard = {
id: "a",
name: "Основная",
bankName: "Банк",
lastFour: "1234",
paymentSystem: null,
openingBalance: { currency: "RUB", value: "100" },
balance: { currency: "RUB", value: "50" },
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: { currency: "RUB", value: "50" },
bankName: "Банк",
id: "a",
lastFour: "1234",
name: "Основная",
openingBalance: { currency: "RUB", value: "100" },
openingDate: "2026-01-01",
paymentSystem: null,
version: 1,
}
/* eslint-enable unicorn/no-null */);
function setup(value: BankCard) {
configureAuthClient({
accessToken: () => "test",
refresh: async () => false,
clear: () => {},
refresh: () => Promise.resolve(false),
});
server.use(http.get("*/api/v1/bank-cards/a", () => HttpResponse.json(value)));
const close = vi.fn();
@@ -64,13 +69,15 @@ describe("BankCardForm", () => {
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(close).toHaveBeenCalled());
expect(body).toEqual({
version: 1,
name: "Основная",
bankName: null,
lastFour: null,
paymentSystem: null,
});
expect(body).toEqual((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
bankName: null,
lastFour: null,
name: "Основная",
paymentSystem: null,
version: 1,
}
/* eslint-enable unicorn/no-null */));
});
it("loads and saves the selected payment system", async () => {
const close = setup({ ...card, paymentSystem: "mir" });
@@ -146,6 +153,6 @@ describe("BankCardForm", () => {
fireEvent.click(adopt);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(close).toHaveBeenCalled());
expect(bodies.map((b) => b.version)).toEqual([1, 2]);
expect(bodies.map(b => b.version)).toEqual([1, 2]);
});
});
@@ -3,12 +3,14 @@ import { Button, Group, Modal, Stack, Text } from "@mantine/core";
import { type FC, useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { formatDate } from "@/shared/lib";
import { useBankMutations } from "../../api/mutations";
import { type BankCard, useBankCard } from "../../api/queries";
import {
type BankCardValues,
bankCardSchema,
type BankCardValues,
bankMoneyValue,
bankToday,
} from "../../model/form-validation";
@@ -29,29 +31,29 @@ const BankCardForm: FC<BankCardFormProps> = (props) => {
const fresh = useBankCard(props.card?.id);
const mutation = useBankMutations();
const methods = useForm<BankCardValues>({
resolver: zodResolver(bankCardSchema),
defaultValues: {
name: props.card?.name ?? "",
bankName: props.card?.bankName ?? "",
lastFour: props.card?.lastFour ?? "",
paymentSystem: props.card?.paymentSystem ?? "",
name: props.card?.name ?? "",
openingBalance: props.card?.openingBalance.value ?? "0",
openingDate: props.card?.openingDate ?? bankToday(),
paymentSystem: props.card?.paymentSystem ?? "",
},
resolver: zodResolver(bankCardSchema),
});
const pending =
!opened ||
methods.formState.isSubmitting ||
(fresh.isFetching && Boolean(error));
const isPending
= !opened
|| methods.formState.isSubmitting
|| (fresh.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(props.card ? "bank.editCard" : "bank.addCard")}
closeButtonProps={{ "aria-label": t("bank.close"), disabled: pending }}
closeOnClickOutside={!pending}
closeOnEscape={!pending}
>
<FormProvider {...methods}>
<form
@@ -59,10 +61,10 @@ const BankCardForm: FC<BankCardFormProps> = (props) => {
setError(undefined);
try {
const metadata = {
bankName: values.bankName || /* eslint-disable-line unicorn/no-null -- PATCH uses null to clear these optional card fields. */ null,
lastFour: values.lastFour || /* eslint-disable-line unicorn/no-null -- PATCH uses null to clear these optional card fields. */ null,
name: values.name,
bankName: values.bankName || null,
lastFour: values.lastFour || null,
paymentSystem: values.paymentSystem || null,
paymentSystem: values.paymentSystem || /* eslint-disable-line unicorn/no-null -- PATCH uses null to clear these optional card fields. */ null,
};
const financial = {
openingBalance: {
@@ -73,12 +75,12 @@ const BankCardForm: FC<BankCardFormProps> = (props) => {
};
if (props.card)
await mutation.updateCard.mutateAsync({
id: props.card.id,
body: {
...metadata,
...(!props.card.archivedAt ? financial : {}),
...(!props.card.archivedAt && financial),
version,
},
id: props.card.id,
});
else
await mutation.createCard.mutateAsync({
@@ -87,32 +89,33 @@ const BankCardForm: FC<BankCardFormProps> = (props) => {
...financial,
});
setOpened(false);
} catch (failure) {
setError(failure);
}
catch (error_) {
setError(error_);
}
})}
>
<fieldset className={classes.fieldset} disabled={pending}>
<fieldset className={classes.fieldset} disabled={isPending}>
<Stack>
<BankCardFields archived={Boolean(props.card?.archivedAt)} />
{error ? (
{!!(error) && ((
<>
<BankFeedback error={error} />
{props.card && (
<Button
variant="default"
onClick={async () => {
const response = await fresh.refetch();
if (response.data && !response.error)
setLatest(response.data);
else setError(response.error);
}}
variant="default"
>
{t("bank.reloadRecord")}
</Button>
)}
</>
) : null}
))}
{latest && (
<>
<Text>
@@ -121,22 +124,22 @@ const BankCardForm: FC<BankCardFormProps> = (props) => {
})}
</Text>
<Button
variant="default"
onClick={() => {
setVersion(latest.version);
setLatest(undefined);
setError(undefined);
}}
variant="default"
>
{t("bank.adoptVersion", { version: latest.version })}
</Button>
</>
)}
<Group>
<Button type="submit" loading={pending}>
<Button loading={isPending} type="submit">
{t("bank.save")}
</Button>
<Button variant="default" onClick={() => setOpened(false)}>
<Button onClick={() => setOpened(false)} variant="default">
{t("bank.cancel")}
</Button>
</Group>
@@ -1,8 +1,10 @@
import type { FC } from "react";
import { Select, Stack, Text, TextInput } from "@mantine/core";
import { DateInput } from "@mantine/dates";
import type { FC } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
type BankCardValues,
bankDateInputValue,
@@ -14,8 +16,8 @@ type BankCardFieldsProps = { archived: boolean };
const BankCardFields: FC<BankCardFieldsProps> = (props) => {
const {
control,
register,
formState: { errors },
register,
} = useFormContext<BankCardValues>();
const { t } = useTranslation();
return (
@@ -32,8 +34,8 @@ const BankCardFields: FC<BankCardFieldsProps> = (props) => {
error={errors.bankName?.message}
/>
<TextInput
label={t("bank.lastFour")}
inputMode="numeric"
label={t("bank.lastFour")}
{...register("lastFour")}
error={errors.lastFour?.message}
/>
@@ -43,25 +45,25 @@ const BankCardFields: FC<BankCardFieldsProps> = (props) => {
render={({ field }) => (
<Select
{...field}
value={field.value || null}
onChange={(value) => field.onChange(value ?? "")}
label={t("bank.paymentSystem")}
placeholder={t("bank.paymentSystemUnknown")}
clearable
data={["mir", "visa", "mastercard"].map((value) => ({
value,
label: t(`bank.paymentSystems.${value}`),
}))}
comboboxProps={{ withinPortal: false }}
data={["mir", "visa", "mastercard"].map(value => ({
label: t(`bank.paymentSystems.${value}`),
value,
}))}
error={errors.paymentSystem?.message}
label={t("bank.paymentSystem")}
onChange={value => field.onChange(value ?? "")}
placeholder={t("bank.paymentSystemUnknown")}
value={field.value || /* eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. */ null}
/>
)}
/>
<TextInput
label={t("bank.openingBalance")}
required
inputMode="decimal"
label={t("bank.openingBalance")}
readOnly={props.archived}
required
{...register("openingBalance")}
error={errors.openingBalance?.message}
/>
@@ -71,37 +73,35 @@ const BankCardFields: FC<BankCardFieldsProps> = (props) => {
render={({ field }) => (
<DateInput
{...field}
label={t("bank.openingDate")}
required
valueFormat="DD.MM.YYYY"
value={
bankDateSchema.safeParse(field.value).success ? field.value : null
}
dateParser={(value) =>
dateParser={value =>
bankDateSchema.safeParse(bankDateInputValue(value)).success
? bankDateInputValue(value)
: null
}
: /* eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. */ null}
error={errors.openingDate?.message}
fixOnBlur={false}
onInput={(event) =>
field.onChange(bankDateInputValue(event.currentTarget.value))
}
onChange={(value) => field.onChange(value ?? "")}
label={t("bank.openingDate")}
maxDate={bankToday()}
onBlur={(event) => {
field.onChange(bankDateInputValue(event.currentTarget.value));
field.onBlur();
}}
maxDate={bankToday()}
onChange={value => field.onChange(value ?? "")}
onInput={event =>
field.onChange(bankDateInputValue(event.currentTarget.value))}
popoverProps={{
withinPortal: false,
middlewares: { shift: { crossAxis: true } },
withinPortal: false,
}}
readOnly={props.archived}
error={errors.openingDate?.message}
required
value={
(bankDateSchema.safeParse(field.value).success) ? (field.value) : undefined
}
valueFormat="DD.MM.YYYY"
/>
)}
/>
<Text size="sm" c="dimmed">
<Text c="dimmed" size="sm">
{t("bank.openingHint")}
</Text>
{props.archived && <Text>{t("bank.archiveHint")}</Text>}
+6 -4
View File
@@ -1,13 +1,15 @@
import { Alert, Button, Group, Text } from "@mantine/core";
import type { FC } from "react";
import { Alert, Button, Group, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { apiErrorCode } from "@/shared/api";
type BankFeedbackProps = {
error: unknown;
retry?: () => void;
cached?: boolean;
error: unknown;
loadingError?: boolean;
retry?: () => void;
};
const BankFeedback: FC<BankFeedbackProps> = (props) => {
const { t } = useTranslation();
@@ -25,7 +27,7 @@ const BankFeedback: FC<BankFeedbackProps> = (props) => {
})}
</Text>
{props.retry && (
<Button variant="default" onClick={props.retry}>
<Button onClick={props.retry} variant="default">
{t("bank.retry")}
</Button>
)}
@@ -1,13 +1,15 @@
import { Stack } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Stack } from "@mantine/core";
import { EntryCard } from "@wealthledger/ui";
import { BankOperationIcon } from "./BankOperationIcon";
const meta = {
title: "Product/BankCards/OperationIcons",
args: { categoryName: "Продукты", kind: "expense" },
component: BankOperationIcon,
tags: ["autodocs"],
args: { kind: "expense", categoryName: "Продукты" },
title: "Product/BankCards/OperationIcons",
} satisfies Meta<typeof BankOperationIcon>;
export default meta;
type Story = StoryObj<typeof meta>;
@@ -40,28 +42,28 @@ export const Categories: Story = {
"Хостинг и ИИ-сервисы",
"Цифровые покупки",
"Моя категория",
].map((name) => (
].map(name => (
<EntryCard
key={name}
title="Расход"
metadata={`12.09.2026 · ${name}`}
initial="Р"
color="red"
icon={<BankOperationIcon kind="expense" categoryName={name} />}
icon={<BankOperationIcon categoryName={name} kind="expense" />}
initial="Р"
key={name}
metadata={`12.09.2026 · ${name}`}
title="Расход"
tone="negative"
value="−1 250,00 ₽"
valueLabel="Расход"
tone="negative"
/>
))}
<EntryCard
title="Пополнение"
metadata="12.09.2026"
initial="П"
color="green"
icon={<BankOperationIcon kind="topup" />}
initial="П"
metadata="12.09.2026"
title="Пополнение"
tone="positive"
value="+15 000,00 ₽"
valueLabel="Пополнение"
tone="positive"
/>
</Stack>
),
@@ -1,36 +1,40 @@
import { render } from "@testing-library/react";
import { expect, it } from "vitest";
import { BankOperationIcon } from "./BankOperationIcon";
it("matches normalized category names, falls back after renaming and distinguishes operation kinds", () => {
const { container, rerender } = render(
<BankOperationIcon kind="expense" categoryName="Жильё" />,
<BankOperationIcon categoryName="Жильё" kind="expense" />,
);
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
const path = () => container.querySelector("path")?.getAttribute("d");
const housing = path();
rerender(<BankOperationIcon kind="expense" categoryName=" ЖИЛЬЕ " />);
rerender(<BankOperationIcon categoryName=" ЖИЛЬЕ " kind="expense" />);
expect(path()).toBe(housing);
rerender(<BankOperationIcon kind="expense" categoryName="Другое" />);
rerender(<BankOperationIcon categoryName="Другое" kind="expense" />);
const fallback = path();
expect(fallback).not.toBe(housing);
for (const categoryName of ["Моя категория", "constructor", null]) {
rerender(<BankOperationIcon kind="expense" categoryName={categoryName} />);
for (const categoryName of ["Моя категория", "constructor", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null]) {
rerender(<BankOperationIcon categoryName={categoryName} kind="expense" />);
expect(path()).toBe(fallback);
}
const paths = [fallback, housing];
for (const kind of ["topup", "transfer", "refund"] as const) {
rerender(<BankOperationIcon kind={kind} categoryName="Жильё" />);
rerender(<BankOperationIcon categoryName="Жильё" kind={kind} />);
expect(path()).toBeTruthy();
paths.push(path());
}
expect(new Set(paths).size).toBe(5);
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
expect(container.querySelector("svg")).toHaveAttribute("aria-hidden", "true");
});
it("provides a distinct icon for every existing named category", () => {
const { container, rerender } = render(
<BankOperationIcon kind="expense" categoryName="Другое" />,
<BankOperationIcon categoryName="Другое" kind="expense" />,
);
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
const paths = new Set([container.querySelector("path")?.getAttribute("d")]);
for (const categoryName of [
"Алкоголь",
@@ -57,7 +61,8 @@ it("provides a distinct icon for every existing named category", () => {
"Хостинг и ИИ-сервисы",
"Цифровые покупки",
]) {
rerender(<BankOperationIcon kind="expense" categoryName={categoryName} />);
rerender(<BankOperationIcon categoryName={categoryName} kind="expense" />);
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
const path = container.querySelector("path")?.getAttribute("d");
expect(path).toBeTruthy();
expect(paths.has(path), categoryName).toBe(false);
@@ -1,34 +1,36 @@
import type { FC } from "react";
import { bindIconAnimation } from "@/shared/lib";
import type { BankOperation } from "../../api/queries";
import { bankOperationAppearance } from "../../model/operation-appearance";
import classes from "./BankOperationIcon.module.css";
type BankOperationIconProps = {
categoryName?: null | string;
kind: BankOperation["kind"];
categoryName?: string | null;
};
const BankOperationIcon: FC<BankOperationIconProps> = (props) => {
const icon = bankOperationAppearance(props.kind, props.categoryName);
return (
<svg
ref={bindIconAnimation}
aria-hidden="true"
className={`${classes.icon} ${icon.animation ? classes[icon.animation] : ""}`}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
focusable="false"
height="24"
ref={bindIconAnimation}
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
style={{ flexShrink: 0, color: `var(--mantine-color-${icon.color}-7)` }}
strokeWidth="1.6"
style={{ color: `var(--mantine-color-${icon.color}-7)`, flexShrink: 0 }}
viewBox="0 0 24 24"
width="24"
>
{icon.path.split(" M").map((part, index) => (
<path key={part} d={index ? `M${part}` : part} pathLength={1} />
<path d={index ? `M${part}` : part} key={part} pathLength={1} />
))}
</svg>
);
@@ -3,34 +3,34 @@ import { type FC, useState } from "react";
import { useTranslation } from "react-i18next";
type BankPeriodPickerProps = {
onChange: (value: [null | string, null | string]) => void;
period: { from: string; to: string };
onChange: (value: [string | null, string | null]) => void;
};
const BankPeriodPicker: FC<BankPeriodPickerProps> = (props) => {
const { period, onChange } = props;
const { onChange, period } = props;
const { t } = useTranslation();
const scope = `${period.from}-${period.to}`;
const [draft, setDraft] = useState<{
scope: string;
value: [string | null, string | null];
value: [null | string, null | string];
}>();
const value: [string | null, string | null] =
draft?.scope === scope ? draft.value : [period.from, period.to];
const value: [null | string, null | string]
= draft?.scope === scope ? draft.value : [period.from, period.to];
return (
<DatePickerInput
type="range"
allowSingleDateInRange
label={t("bank.period")}
valueFormat="DD.MM.YYYY"
value={value}
onChange={(next) => {
setDraft({ scope, value: next });
onChange(next);
}}
popoverProps={{
returnFocus: true,
middlewares: { shift: { crossAxis: true } },
returnFocus: true,
}}
type="range"
value={value}
valueFormat="DD.MM.YYYY"
/>
);
};
@@ -1,11 +1,54 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { BankSummaryView } from "./BankSummary.view";
const money = (value: string) => ({ currency: "RUB", value });
const meta = {
title: "Product/BankCards/Summary",
const meta = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
args: {
data: {
cardId: null,
categories: [
{
categoryId: "food",
categoryName: "Продукты",
expenses: money("3500"),
net: money("2500"),
refunds: money("1000"),
},
{
categoryId: "cafe",
categoryName: "Кафе",
expenses: money("2200"),
net: money("2200"),
refunds: money("0"),
},
{
categoryId: "taxi",
categoryName: "Такси",
expenses: money("1800"),
net: money("1800"),
refunds: money("0"),
},
{
categoryId: "health",
categoryName: "Здоровье",
expenses: money("1000"),
net: money("1000"),
refunds: money("0"),
},
],
expenses: money("8500"),
month: "2026-09",
net: money("7500"),
refunds: money("1000"),
},
error: false,
fetching: false,
loading: false,
retry: () => {},
},
component: BankSummaryView,
tags: ["autodocs"],
parameters: {
docs: {
description: {
@@ -14,118 +57,84 @@ const meta = {
},
},
},
args: {
loading: false,
error: false,
fetching: false,
retry: () => {},
data: {
month: "2026-09",
cardId: null,
expenses: money("8500"),
refunds: money("1000"),
net: money("7500"),
categories: [
{
categoryId: "food",
categoryName: "Продукты",
expenses: money("3500"),
refunds: money("1000"),
net: money("2500"),
},
{
categoryId: "cafe",
categoryName: "Кафе",
expenses: money("2200"),
refunds: money("0"),
net: money("2200"),
},
{
categoryId: "taxi",
categoryName: "Такси",
expenses: money("1800"),
refunds: money("0"),
net: money("1800"),
},
{
categoryId: "health",
categoryName: "Здоровье",
expenses: money("1000"),
refunds: money("0"),
net: money("1000"),
},
],
},
},
} satisfies Meta<typeof BankSummaryView>;
tags: ["autodocs"],
title: "Product/BankCards/Summary",
}
/* eslint-enable unicorn/no-null */) satisfies Meta<typeof BankSummaryView>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Loaded: Story = { name: "Расходы по категориям" };
export const RefundMonth: Story = {
name: "Возврат покупки прошлого месяца",
args: {
data: {
month: "2026-09",
cardId: null,
expenses: money("0"),
refunds: money("600"),
net: money("-600"),
categories: [
{
categoryId: "shopping",
categoryName:
export const RefundMonth: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
args: {
data: {
cardId: null,
categories: [
{
categoryId: "shopping",
categoryName:
"Покупки с длинным пользовательским названием категории",
expenses: money("0"),
refunds: money("600"),
net: money("-600"),
},
],
expenses: money("0"),
net: money("-600"),
refunds: money("600"),
},
],
expenses: money("0"),
month: "2026-09",
net: money("-600"),
refunds: money("600"),
},
},
},
};
export const Empty: Story = {
name: "Известные нули",
args: {
data: {
month: "2026-09",
cardId: null,
expenses: money("0"),
refunds: money("0"),
net: money("0"),
categories: [],
name: "Возврат покупки прошлого месяца",
}
/* eslint-enable unicorn/no-null */);
export const Empty: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
args: {
data: {
cardId: null,
categories: [],
expenses: money("0"),
month: "2026-09",
net: money("0"),
refunds: money("0"),
},
},
},
};
name: "Известные нули",
}
/* eslint-enable unicorn/no-null */);
export const Loading: Story = {
name: "Начальная загрузка",
args: { data: undefined, loading: true },
name: "Начальная загрузка",
};
export const Failed: Story = {
name: "Ошибка без данных",
args: { data: undefined, error: true },
name: "Ошибка без данных",
};
export const CachedError: Story = {
name: "Сохранённые суммы при ошибке обновления",
args: { error: true },
name: "Сохранённые суммы при ошибке обновления",
};
export const LongAmounts: Story = {
name: "Точный итог длиннее предела отдельного ввода",
args: {
data: {
month: "2026-09",
cardId: null,
expenses: money("184467440737095516.14"),
refunds: money("0"),
net: money("184467440737095516.14"),
categories: [
{
categoryId: "shopping",
categoryName: "Покупки",
expenses: money("184467440737095516.14"),
refunds: money("0"),
net: money("184467440737095516.14"),
},
],
export const LongAmounts: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
args: {
data: {
cardId: null,
categories: [
{
categoryId: "shopping",
categoryName: "Покупки",
expenses: money("184467440737095516.14"),
net: money("184467440737095516.14"),
refunds: money("0"),
},
],
expenses: money("184467440737095516.14"),
month: "2026-09",
net: money("184467440737095516.14"),
refunds: money("0"),
},
},
},
};
name: "Точный итог длиннее предела отдельного ввода",
}
/* eslint-enable unicorn/no-null */);
@@ -1,24 +1,78 @@
import type { CSSProperties, FC } from "react";
import { Button, Group, Skeleton, Stack, Text, Title } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { CSSProperties, FC } from "react";
import { useTranslation } from "react-i18next";
import { formatMoney } from "@/shared/lib";
import type { BankSummary } from "../../api/queries";
import { BankOperationIcon } from "../BankOperationIcon/BankOperationIcon";
import classes from "./BankSummary.module.css";
const CATEGORY_COLUMNS = 2;
type BankSummaryViewProps = {
all?: boolean;
data?: BankSummary;
loading: boolean;
error: boolean;
fetching: boolean;
loading: boolean;
retry: () => void;
all?: boolean;
};
const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
const { t } = useTranslation();
function renderCategories() {
if (props.data) {
if (props.data.categories.length > 0) {
return (
<dl
className={classes.categories}
style={
{
"--category-rows": Math.ceil(props.data.categories.length / CATEGORY_COLUMNS),
} as CSSProperties
}
>
{props.data.categories.map(category => (
<div className={classes.category} key={category.categoryId}>
<dt className={classes.label}>
<BankOperationIcon
categoryName={category.categoryName}
kind="expense"
/>
<span>{category.categoryName}</span>
</dt>
<dd className={classes.amount}>
{formatMoney(category.net, { withZeroCents: true })}
</dd>
</div>
))}
</dl>
);
}
return (
<Text c="dimmed" size="sm">
{t("bank.noExpenses")}
</Text>
);
}
if (props.loading) {
return (
<LoadingSkeleton label={t("bank.loading")}>
<Stack gap="sm">
<Skeleton h={32} w="35%" />
<Skeleton h={40} />
<Skeleton h={40} />
</Stack>
</LoadingSkeleton>
);
}
}
return (
<Stack gap="md" aria-busy={props.loading || props.fetching}>
<Stack aria-busy={props.loading || props.fetching} gap="md">
<div className={classes.header}>
<Title order={2}>
{t(props.all ? "bank.allSummary" : "bank.summary")}
@@ -29,49 +83,11 @@ const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
</Text>
)}
</div>
{props.data ? (
props.data.categories.length ? (
<dl
className={classes.categories}
style={
{
"--category-rows": Math.ceil(props.data.categories.length / 2),
} as CSSProperties
}
>
{props.data.categories.map((category) => (
<div className={classes.category} key={category.categoryId}>
<dt className={classes.label}>
<BankOperationIcon
kind="expense"
categoryName={category.categoryName}
/>
<span>{category.categoryName}</span>
</dt>
<dd className={classes.amount}>
{formatMoney(category.net, { withZeroCents: true })}
</dd>
</div>
))}
</dl>
) : (
<Text size="sm" c="dimmed">
{t("bank.noExpenses")}
</Text>
)
) : props.loading ? (
<LoadingSkeleton label={t("bank.loading")}>
<Stack gap="sm">
<Skeleton h={32} w="35%" />
<Skeleton h={40} />
<Skeleton h={40} />
</Stack>
</LoadingSkeleton>
) : null}
{renderCategories()}
{props.error && (
<Group role="alert">
<Text>{t(props.data ? "bank.refreshError" : "bank.error")}</Text>
<Button variant="default" onClick={props.retry}>
<Button onClick={props.retry} variant="default">
{t("bank.retry")}
</Button>
</Group>
@@ -7,36 +7,36 @@ import {
screen,
waitFor,
} from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { http, HttpResponse } from "msw";
import { describe, expect, it } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import { CategoryManager } from "./CategoryManager";
describe("CategoryManager", () => {
it("ignores a late reload for A while editing B and submits B's draft and version", async () => {
configureAuthClient({
accessToken: () => "test",
refresh: async () => false,
clear: () => {},
refresh: () => Promise.resolve(false),
});
const categories = [
{ id: "a", name: "Первая", isDefault: false, version: 1 },
{ id: "b", name: "Вторая", isDefault: false, version: 4 },
{ id: "a", isDefault: false, name: "Первая", version: 1 },
{ id: "b", isDefault: false, name: "Вторая", version: 4 },
];
let reads = 0;
let release = () => {};
const reloaded = new Promise<void>((resolve) => {
release = resolve;
});
const patches: { id: unknown; body: unknown }[] = [];
const { promise: reloaded, resolve: release } = Promise.withResolvers<void>();
const patches: { body: unknown; id: unknown }[] = [];
server.use(
http.get("*/api/v1/bank-cards/categories", async () => {
reads++;
if (reads === 3) await reloaded;
return HttpResponse.json(
categories.map((category) =>
categories.map(category =>
category.id === "a" && reads > 1
? {
...category,
@@ -49,8 +49,8 @@ describe("CategoryManager", () => {
}),
http.patch(
"*/api/v1/bank-cards/categories/:id",
async ({ request, params }) => {
patches.push({ id: params.id, body: await request.json() });
async ({ params, request }) => {
patches.push({ body: await request.json(), id: params.id });
return patches.length === 1
? HttpResponse.json(
{ code: "BANK_VERSION_CONFLICT" },
@@ -67,9 +67,8 @@ describe("CategoryManager", () => {
</MantineProvider>
</QueryClientProvider>,
);
fireEvent.click(
(await screen.findAllByRole("button", { name: "Переименовать" }))[0],
);
const renameButtons = await screen.findAllByRole("button", { name: "Переименовать" });
fireEvent.click(renameButtons[0]);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
fireEvent.click(
await screen.findByRole("button", { name: "Перечитать запись" }),
@@ -99,8 +98,8 @@ describe("CategoryManager", () => {
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() =>
expect(patches).toEqual([
{ id: "a", body: { name: "Первая", version: 1 } },
{ id: "b", body: { name: "Новая вторая", version: 4 } },
{ body: { name: "Первая", version: 1 }, id: "a" },
{ body: { name: "Новая вторая", version: 4 }, id: "b" },
]),
);
});
@@ -110,15 +109,15 @@ describe("CategoryManager", () => {
async (action) => {
configureAuthClient({
accessToken: () => "test",
refresh: async () => false,
clear: () => {},
refresh: () => Promise.resolve(false),
});
let version = 1;
let writes = 0;
server.use(
http.get("*/api/v1/bank-cards/categories", () =>
HttpResponse.json([
{ id: "a", name: "Первая", isDefault: false, version },
{ id: "a", isDefault: false, name: "Первая", version },
]),
),
http.patch("*/api/v1/bank-cards/categories/a", () => {
@@ -161,13 +160,13 @@ describe("CategoryManager", () => {
it("rejects duplicate case-insensitive and too long names then renames default identity", async () => {
configureAuthClient({
accessToken: () => "test",
refresh: async () => false,
clear: () => {},
refresh: () => Promise.resolve(false),
});
let category = {
id: "default",
name: "Другое",
isDefault: true,
name: "Другое",
version: 1,
};
let creates = 0;
@@ -17,7 +17,9 @@ import {
} from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { validationMessage } from "@/shared/i18n";
import { useBankMutations } from "../../api/mutations";
import { type ExpenseCategory, useBankCategories } from "../../api/queries";
import { BankFeedback } from "../BankFeedback";
@@ -60,44 +62,40 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
const categories = useBankCategories();
const mutation = useBankMutations();
const [editing, setEditing] = useState<ExpenseCategory>();
const editSession = useRef(0);
const editSessionRef = useRef(0);
const [error, setError] = useState<unknown>();
const [latest, setLatest] = useState<ExpenseCategory>();
const methods = useForm<CategoryValues>({
resolver: zodResolver(
z.object({
name: z
.string()
.trim()
.refine(
(value) =>
value.length > 0 && value.length <= MAX_CATEGORY_NAME_LENGTH,
{ error: validationMessage("bank.validation.category") },
)
.refine(
(value) =>
!categories.data?.some(
(c) =>
c.id !== editing?.id &&
c.name.toLocaleLowerCase("ru-RU") ===
value.toLocaleLowerCase("ru-RU"),
),
{ error: validationMessage("bank.validation.duplicate") },
const schema = z.object({
name: z
.string()
.trim()
.refine(
value =>
value.length > 0 && value.length <= MAX_CATEGORY_NAME_LENGTH,
{ error: validationMessage("bank.validation.category") },
)
.refine(
value =>
!categories.data?.some(
c =>
c.id !== editing?.id
&& c.name.toLocaleLowerCase("ru-RU")
=== value.toLocaleLowerCase("ru-RU"),
),
}),
),
defaultValues: { name: "" },
{ error: validationMessage("bank.validation.duplicate") },
),
});
const pending = !opened || methods.formState.isSubmitting;
const methods = useForm<CategoryValues>({ defaultValues: { name: "" }, resolver: zodResolver(schema) });
const isPending = !opened || methods.formState.isSubmitting;
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.categories")}
closeOnClickOutside={!pending}
closeOnEscape={!pending}
closeButtonProps={{ "aria-label": t("bank.close"), disabled: pending }}
>
<FormProvider {...methods}>
<form
@@ -107,8 +105,8 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
try {
if (editing)
await mutation.updateCategory.mutateAsync({
id: editing.id,
body: { name: values.name, version: editing.version },
id: editing.id,
});
else {
const result = await mutation.createCategory.mutateAsync({
@@ -116,24 +114,25 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
});
if (result) props.onCreated?.(result.id);
}
editSession.current++;
editSessionRef.current++;
setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" });
} catch (failure) {
setError(failure);
}
catch (error_) {
setError(error_);
}
})(event);
}}
>
<fieldset disabled={pending} className={classes.fieldset}>
<fieldset className={classes.fieldset} disabled={isPending}>
<Stack>
{categories.data?.map((category) => (
{categories.data?.map(category => (
<Group justify="space-between" key={category.id}>
<Group gap="xs">
<BankOperationIcon
kind="expense"
categoryName={category.name}
kind="expense"
/>
<Text>{category.name}</Text>
{category.isDefault && (
@@ -141,14 +140,14 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
)}
</Group>
<Button
variant="subtle"
onClick={() => {
editSession.current++;
editSessionRef.current++;
setEditing(category);
methods.reset({ name: category.name });
setError(undefined);
setLatest(undefined);
}}
variant="subtle"
>
{t("bank.rename")}
</Button>
@@ -159,35 +158,35 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
)}
{categories.isError && (
<BankFeedback
loadingError
error={categories.error}
cached={Boolean(categories.data)}
error={categories.error}
loadingError
retry={() => void categories.refetch()}
/>
)}
<CategoryFields />
{error ? (
{!!(error) && ((
<>
<BankFeedback error={error} />
{editing && (
<Button
variant="default"
onClick={async () => {
const session = editSession.current;
const session = editSessionRef.current;
const response = await categories.refetch();
if (session !== editSession.current) return;
if (session !== editSessionRef.current) return;
if (response.error) setError(response.error);
else
setLatest(
response.data?.find((c) => c.id === editing.id),
response.data?.find(c => c.id === editing.id),
);
}}
variant="default"
>
{t("bank.reloadRecord")}
</Button>
)}
</>
) : null}
))}
{latest && latest.id === editing?.id && (
<>
<Text>{t("bank.versionHint", { details: latest.name })}</Text>
@@ -203,24 +202,24 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
</>
)}
<Group>
<Button type="submit" loading={pending}>
<Button loading={isPending} type="submit">
{t(editing ? "bank.save" : "bank.addCategory")}
</Button>
{editing && (
<Button
variant="default"
onClick={() => {
editSession.current++;
editSessionRef.current++;
setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" });
setError(undefined);
}}
variant="default"
>
{t("bank.cancel")}
</Button>
)}
<Button variant="default" onClick={() => setOpened(false)}>
<Button onClick={() => setOpened(false)} variant="default">
{t("bank.close")}
</Button>
</Group>