feat: polish bank operation cards and dates

This commit is contained in:
2026-09-12 19:56:12 +03:00
parent dedfec10d1
commit 6b310db0b8
20 changed files with 382 additions and 26 deletions
@@ -67,7 +67,7 @@ describe("BankCardForm", () => {
it("retains an invalid opening date draft and explains conflict with saved history", async () => {
const close = setup(card);
fireEvent.change(screen.getByLabelText(/Дата начала/), {
target: { value: "2026-08-01" },
target: { value: "01.08.2026" },
});
server.use(
http.patch("*/api/v1/bank-cards/a", () =>
@@ -81,7 +81,7 @@ describe("BankCardForm", () => {
await screen.findByText(
"Дата начала противоречит сохранённой истории карты.",
);
expect(screen.getByLabelText(/Дата начала/)).toHaveValue("2026-08-01");
expect(screen.getByLabelText(/Дата начала/)).toHaveValue("01.08.2026");
expect(close).not.toHaveBeenCalled();
});
it("keeps card edit version until explicit refresh adoption", async () => {
+2 -1
View File
@@ -3,6 +3,7 @@ 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 {
@@ -110,7 +111,7 @@ const BankCardForm: FC<BankCardFormProps> = (props) => {
<>
<Text>
{t("bank.versionHint", {
details: `${latest.name} · ${latest.openingBalance.value} RUB · ${latest.openingDate}`,
details: `${latest.name} · ${latest.openingBalance.value} RUB · ${formatDate(latest.openingDate)}`,
})}
</Text>
<Button
@@ -5,6 +5,7 @@ import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
type BankCardValues,
bankDateInputValue,
bankDateSchema,
bankToday,
} from "../model/form-validation";
@@ -52,18 +53,22 @@ const BankCardFields: FC<BankCardFieldsProps> = (props) => {
{...field}
label={t("bank.openingDate")}
required
valueFormat="YYYY-MM-DD"
valueFormat="DD.MM.YYYY"
value={
bankDateSchema.safeParse(field.value).success ? field.value : null
}
dateParser={(value) =>
bankDateSchema.safeParse(value).success ? value : null
bankDateSchema.safeParse(bankDateInputValue(value)).success
? bankDateInputValue(value)
: null
}
fixOnBlur={false}
onInput={(event) => field.onChange(event.currentTarget.value)}
onInput={(event) =>
field.onChange(bankDateInputValue(event.currentTarget.value))
}
onChange={(value) => field.onChange(value ?? "")}
onBlur={(event) => {
field.onChange(event.currentTarget.value);
field.onChange(bankDateInputValue(event.currentTarget.value));
field.onBlur();
}}
maxDate={bankToday()}
@@ -0,0 +1,68 @@
import { Stack } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { EntryCard } from "@/shared/ui";
import { BankOperationIcon } from "./BankOperationIcon";
const meta = {
title: "Product/BankCards/OperationIcons",
component: BankOperationIcon,
tags: ["autodocs"],
args: { kind: "expense", categoryName: "Продукты" },
} satisfies Meta<typeof BankOperationIcon>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Categories: Story = {
render: () => (
<Stack maw={640}>
{[
"Алкоголь",
"Вклады",
"Другое",
"Жильё",
"Здоровье",
"Инвестиции и накопления",
"Кафе",
"Образование",
"Переводы",
"Погашение кредита",
"Подписки",
"Покупки",
"Продукты",
"Путешествия",
"Развлечения",
"Связь и интернет",
"Снятие наличных",
"Ставки",
"Страхование",
"Табак",
"Такси",
"Транспорт",
"Хостинг и ИИ-сервисы",
"Цифровые покупки",
"Моя категория",
].map((name) => (
<EntryCard
key={name}
title="Расход"
metadata={`12.09.2026 · ${name}`}
initial="Р"
color="red"
icon={<BankOperationIcon kind="expense" categoryName={name} />}
value="−1 250,00 ₽"
valueLabel="Расход"
tone="negative"
/>
))}
<EntryCard
title="Пополнение"
metadata="12.09.2026"
initial="П"
color="green"
icon={<BankOperationIcon kind="topup" />}
value="+15 000,00 ₽"
valueLabel="Пополнение"
tone="positive"
/>
</Stack>
),
};
@@ -0,0 +1,66 @@
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="Жильё" />,
);
const path = () => container.querySelector("path")?.getAttribute("d");
const housing = path();
rerender(<BankOperationIcon kind="expense" categoryName=" ЖИЛЬЕ " />);
expect(path()).toBe(housing);
rerender(<BankOperationIcon kind="expense" categoryName="Другое" />);
const fallback = path();
expect(fallback).not.toBe(housing);
for (const categoryName of ["Моя категория", "constructor", null]) {
rerender(<BankOperationIcon kind="expense" categoryName={categoryName} />);
expect(path()).toBe(fallback);
}
const paths = [fallback, housing];
for (const kind of ["topup", "transfer", "refund"] as const) {
rerender(<BankOperationIcon kind={kind} categoryName="Жильё" />);
expect(path()).toBeTruthy();
paths.push(path());
}
expect(new Set(paths).size).toBe(5);
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="Другое" />,
);
const paths = new Set([container.querySelector("path")?.getAttribute("d")]);
for (const categoryName of [
"Алкоголь",
"Вклады",
"Жильё",
"Здоровье",
"Инвестиции и накопления",
"Кафе",
"Образование",
"Переводы",
"Погашение кредита",
"Подписки",
"Покупки",
"Продукты",
"Путешествия",
"Развлечения",
"Связь и интернет",
"Снятие наличных",
"Ставки",
"Страхование",
"Табак",
"Такси",
"Транспорт",
"Хостинг и ИИ-сервисы",
"Цифровые покупки",
]) {
rerender(<BankOperationIcon kind="expense" categoryName={categoryName} />);
const path = container.querySelector("path")?.getAttribute("d");
expect(path).toBeTruthy();
expect(paths.has(path), categoryName).toBe(false);
paths.add(path);
}
});
@@ -0,0 +1,31 @@
import type { FC } from "react";
import type { BankOperation } from "../api/queries";
import { bankOperationAppearance } from "../model/operation-appearance";
type BankOperationIconProps = {
kind: BankOperation["kind"];
categoryName?: string | null;
};
const BankOperationIcon: FC<BankOperationIconProps> = (props) => {
const icon = bankOperationAppearance(props.kind, props.categoryName);
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
style={{ flexShrink: 0, color: `var(--mantine-color-${icon.color}-7)` }}
>
<path d={icon.path} />
</svg>
);
};
export { BankOperationIcon };
@@ -13,6 +13,7 @@ import { useTranslation } from "react-i18next";
import { formatMoney } from "@/shared/lib";
import { LoadingSkeleton } from "@/shared/ui";
import type { BankSummary } from "../api/queries";
import { BankOperationIcon } from "./BankOperationIcon";
type BankSummaryViewProps = {
data?: BankSummary;
@@ -48,7 +49,13 @@ const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
{props.data.categories.length ? (
props.data.categories.map((category) => (
<Paper p="sm" key={category.categoryId} withBorder>
<Text fw={600}>{category.categoryName}</Text>
<Group gap="xs" wrap="nowrap">
<BankOperationIcon
kind="expense"
categoryName={category.categoryName}
/>
<Text fw={600}>{category.categoryName}</Text>
</Group>
<Group>
{(["expenses", "refunds", "net"] as const).map((key) => (
<Text key={key} style={{ overflowWrap: "anywhere" }}>
@@ -22,6 +22,7 @@ import { useBankMutations } from "../api/mutations";
import { type ExpenseCategory, useBankCategories } from "../api/queries";
import { BankFeedback } from "./BankFeedback";
import classes from "./BankForms.module.css";
import { BankOperationIcon } from "./BankOperationIcon";
import { useBankFormFocus } from "./useBankFormFocus";
type CategoryManagerProps = {
@@ -120,6 +121,10 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
{categories.data?.map((category) => (
<Group justify="space-between" key={category.id}>
<Group gap="xs">
<BankOperationIcon
kind="expense"
categoryName={category.name}
/>
<Text>{category.name}</Text>
{category.isDefault && (
<Badge>{t("bank.defaultCategory")}</Badge>