feat: polish bank operation cards and dates
This commit is contained in:
@@ -11,6 +11,7 @@ export {
|
||||
useBankSummary,
|
||||
} from "./api/queries";
|
||||
export {
|
||||
bankDateInputValue,
|
||||
bankDateSchema,
|
||||
bankMoneyValue,
|
||||
bankMonth,
|
||||
@@ -20,6 +21,7 @@ export {
|
||||
} from "./model/form-validation";
|
||||
export { BankCardForm } from "./ui/BankCardForm";
|
||||
export { BankFeedback } from "./ui/BankFeedback";
|
||||
export { BankOperationIcon } from "./ui/BankOperationIcon";
|
||||
export { BankSummaryView } from "./ui/BankSummary.view";
|
||||
export { CategoryManager } from "./ui/CategoryManager";
|
||||
export { useBankFormFocus } from "./ui/useBankFormFocus";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
bankCardSchema,
|
||||
bankDateInputValue,
|
||||
bankDateSchema,
|
||||
bankMoneyValue,
|
||||
bankMonth,
|
||||
@@ -54,3 +55,15 @@ describe("bank form input", () => {
|
||||
expect(bankDateSchema.safeParse("2026-02-30").success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("converts local date input without accepting impossible or unfinished dates", () => {
|
||||
expect(bankDateInputValue("07.08.2026")).toBe("2026-08-07");
|
||||
expect(
|
||||
bankDateSchema.safeParse(bankDateInputValue("29.02.2024")).success,
|
||||
).toBe(true);
|
||||
for (const value of ["31.02.2026", "07.08.", ""]) {
|
||||
expect(bankDateSchema.safeParse(bankDateInputValue(value)).success).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -9,6 +9,9 @@ function bankToday() {
|
||||
day: "2-digit",
|
||||
}).format(new Date());
|
||||
}
|
||||
function bankDateInputValue(value: string) {
|
||||
return value.replace(/^(\d{2})\.(\d{2})\.(\d{4})$/, "$3-$2-$1");
|
||||
}
|
||||
function bankMonth(value: string | null) {
|
||||
return value && /^\d{4}-(0[1-9]|1[0-2])$/.test(value)
|
||||
? value
|
||||
@@ -76,6 +79,7 @@ type BankCardValues = z.infer<typeof bankCardSchema>;
|
||||
export {
|
||||
type BankCardValues,
|
||||
bankCardSchema,
|
||||
bankDateInputValue,
|
||||
bankDateSchema,
|
||||
bankMoneyValue,
|
||||
bankMonth,
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { BankOperation } from "../api/queries";
|
||||
|
||||
type OperationIcon = { path: string; color: string };
|
||||
|
||||
const categoryIcons: Record<string, OperationIcon> = {
|
||||
алкоголь: {
|
||||
path: "M7 3h10v5a5 5 0 0 1-10 0V3Z M7 7h10 M12 13v8 M8 21h8",
|
||||
color: "grape",
|
||||
},
|
||||
вклады: {
|
||||
path: "M4 8c0-4 16-4 16 0s-16 4-16 0Z M4 8v5c0 4 16 4 16 0V8 M4 13v5c0 4 16 4 16 0v-5",
|
||||
color: "green",
|
||||
},
|
||||
"инвестиции и накопления": {
|
||||
path: "M3 3v18h18 M6 15l5-5 4 3 6-8 M16 5h5v5",
|
||||
color: "green",
|
||||
},
|
||||
образование: {
|
||||
path: "M2 8l10-5 10 5-10 5L2 8Z M6 10v7c4 3 8 3 12 0v-7 M22 8v9",
|
||||
color: "blue",
|
||||
},
|
||||
переводы: { path: "M3 7h18l-4-4 M21 17H3l4 4", color: "blue" },
|
||||
"погашение кредита": {
|
||||
path: "M3 5h18v14H3V5Z M3 9h18 M8 14l3 3 5-5",
|
||||
color: "orange",
|
||||
},
|
||||
подписки: {
|
||||
path: "M4 10a8 8 0 0 1 13-6l3 3 M20 3v4h-4 M20 14a8 8 0 0 1-13 6l-3-3 M4 21v-4h4",
|
||||
color: "grape",
|
||||
},
|
||||
путешествия: {
|
||||
path: "M10 3h4v7l7 5v3l-7-3v5l2 1v1l-4-1-4 1v-1l2-1v-5l-7 3v-3l7-5V3Z",
|
||||
color: "blue",
|
||||
},
|
||||
"связь и интернет": {
|
||||
path: "M3 7a14 14 0 0 1 18 0 M6 11a9 9 0 0 1 12 0 M9 15a4 4 0 0 1 6 0 M12 19h.01",
|
||||
color: "blue",
|
||||
},
|
||||
"снятие наличных": {
|
||||
path: "M4 10V3h16v7 M7 7h10v14H7V7Z M7 11h10 M10 16h4",
|
||||
color: "green",
|
||||
},
|
||||
ставки: {
|
||||
path: "M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z M7 7h.01 M17 7h.01 M12 12h.01 M7 17h.01 M17 17h.01",
|
||||
color: "grape",
|
||||
},
|
||||
страхование: {
|
||||
path: "M12 2l8 4v6c0 5-8 10-8 10S4 17 4 12V6l8-4Z M8 12l3 3 5-6",
|
||||
color: "green",
|
||||
},
|
||||
табак: {
|
||||
path: "M3 14h18v5H3v-5Z M16 14v5 M8 10V8c0-2 3-2 3-4V2 M15 10V8c0-2 3-2 3-4",
|
||||
color: "orange",
|
||||
},
|
||||
"хостинг и ии-сервисы": {
|
||||
path: "M3 3h18v7H3V3Z M3 14h18v7H3v-7Z M7 6.5h.01 M7 17.5h.01 M15 6.5h3 M15 17.5h3",
|
||||
color: "grape",
|
||||
},
|
||||
"цифровые покупки": {
|
||||
path: "M3 3h18v13H3V3Z M8 21h8 M12 16v5 M12 6v7 M9 10l3 3 3-3",
|
||||
color: "blue",
|
||||
},
|
||||
продукты: {
|
||||
path: "M3 9h18l-2 11H5L3 9Z M8 9l4-6 4 6 M9 13v3 M15 13v3",
|
||||
color: "green",
|
||||
},
|
||||
кафе: {
|
||||
path: "M4 8h12v7a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V8Z M16 9h2a3 3 0 0 1 0 6h-2 M8 3v2 M12 3v2 M3 22h15",
|
||||
color: "orange",
|
||||
},
|
||||
такси: {
|
||||
path: "M4 10l2-6h12l2 6 M3 10h18v8H3v-8Z M6 18v3 M18 18v3 M6 14h2 M16 14h2 M10 4V2h4v2",
|
||||
color: "yellow",
|
||||
},
|
||||
транспорт: {
|
||||
path: "M6 3h12a2 2 0 0 1 2 2v13H4V5a2 2 0 0 1 2-2Z M4 11h16 M7 15h1 M16 15h1 M7 18v3 M17 18v3 M9 6h6",
|
||||
color: "blue",
|
||||
},
|
||||
жилье: { path: "M3 11l9-8 9 8 M5 10v11h14V10 M9 21v-8h6v8", color: "orange" },
|
||||
здоровье: { path: "M9 3h6v6h6v6h-6v6H9v-6H3V9h6V3Z", color: "red" },
|
||||
покупки: {
|
||||
path: "M5 7h14l2 14H3L5 7Z M8 8V6a4 4 0 0 1 8 0v2",
|
||||
color: "grape",
|
||||
},
|
||||
развлечения: {
|
||||
path: "M9 5v14a3 3 0 1 1-3-3h3 M9 5l11-2v14a3 3 0 1 1-3-3h3 M9 9l11-2",
|
||||
color: "grape",
|
||||
},
|
||||
};
|
||||
const otherIcon = {
|
||||
path: "M4 4h6v6H4V4Z M14 4h6v6h-6V4Z M4 14h6v6H4v-6Z M14 14h6v6h-6v-6Z",
|
||||
color: "gray",
|
||||
};
|
||||
const operationIcons = {
|
||||
topup: {
|
||||
path: "M20 8V5H5a2 2 0 0 1 0-4h13v4 M3 3v16a2 2 0 0 0 2 2h15V8H5 M16 12v5 M13.5 14.5h5",
|
||||
color: "green",
|
||||
},
|
||||
transfer: { path: "M3 7h18l-4-4 M21 17H3l4 4", color: "blue" },
|
||||
refund: { path: "M8 4L3 9l5 5 M3 9h11a7 7 0 0 1 0 14", color: "teal" },
|
||||
};
|
||||
|
||||
function bankOperationAppearance(
|
||||
kind: BankOperation["kind"],
|
||||
categoryName?: string | null,
|
||||
) {
|
||||
const name = categoryName
|
||||
?.trim()
|
||||
.toLocaleLowerCase("ru-RU")
|
||||
.replaceAll("ё", "е");
|
||||
return kind === "expense"
|
||||
? name && Object.hasOwn(categoryIcons, name)
|
||||
? categoryIcons[name]
|
||||
: otherIcon
|
||||
: operationIcons[kind];
|
||||
}
|
||||
|
||||
export { bankOperationAppearance };
|
||||
@@ -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