feat: polish bank operation cards and dates
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user