feat: polish bank operation cards and dates
This commit is contained in:
@@ -11,6 +11,7 @@ export {
|
|||||||
useBankSummary,
|
useBankSummary,
|
||||||
} from "./api/queries";
|
} from "./api/queries";
|
||||||
export {
|
export {
|
||||||
|
bankDateInputValue,
|
||||||
bankDateSchema,
|
bankDateSchema,
|
||||||
bankMoneyValue,
|
bankMoneyValue,
|
||||||
bankMonth,
|
bankMonth,
|
||||||
@@ -20,6 +21,7 @@ export {
|
|||||||
} from "./model/form-validation";
|
} from "./model/form-validation";
|
||||||
export { BankCardForm } from "./ui/BankCardForm";
|
export { BankCardForm } from "./ui/BankCardForm";
|
||||||
export { BankFeedback } from "./ui/BankFeedback";
|
export { BankFeedback } from "./ui/BankFeedback";
|
||||||
|
export { BankOperationIcon } from "./ui/BankOperationIcon";
|
||||||
export { BankSummaryView } from "./ui/BankSummary.view";
|
export { BankSummaryView } from "./ui/BankSummary.view";
|
||||||
export { CategoryManager } from "./ui/CategoryManager";
|
export { CategoryManager } from "./ui/CategoryManager";
|
||||||
export { useBankFormFocus } from "./ui/useBankFormFocus";
|
export { useBankFormFocus } from "./ui/useBankFormFocus";
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
bankCardSchema,
|
bankCardSchema,
|
||||||
|
bankDateInputValue,
|
||||||
bankDateSchema,
|
bankDateSchema,
|
||||||
bankMoneyValue,
|
bankMoneyValue,
|
||||||
bankMonth,
|
bankMonth,
|
||||||
@@ -54,3 +55,15 @@ describe("bank form input", () => {
|
|||||||
expect(bankDateSchema.safeParse("2026-02-30").success).toBe(false);
|
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",
|
day: "2-digit",
|
||||||
}).format(new Date());
|
}).format(new Date());
|
||||||
}
|
}
|
||||||
|
function bankDateInputValue(value: string) {
|
||||||
|
return value.replace(/^(\d{2})\.(\d{2})\.(\d{4})$/, "$3-$2-$1");
|
||||||
|
}
|
||||||
function bankMonth(value: string | null) {
|
function bankMonth(value: string | null) {
|
||||||
return value && /^\d{4}-(0[1-9]|1[0-2])$/.test(value)
|
return value && /^\d{4}-(0[1-9]|1[0-2])$/.test(value)
|
||||||
? value
|
? value
|
||||||
@@ -76,6 +79,7 @@ type BankCardValues = z.infer<typeof bankCardSchema>;
|
|||||||
export {
|
export {
|
||||||
type BankCardValues,
|
type BankCardValues,
|
||||||
bankCardSchema,
|
bankCardSchema,
|
||||||
|
bankDateInputValue,
|
||||||
bankDateSchema,
|
bankDateSchema,
|
||||||
bankMoneyValue,
|
bankMoneyValue,
|
||||||
bankMonth,
|
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 () => {
|
it("retains an invalid opening date draft and explains conflict with saved history", async () => {
|
||||||
const close = setup(card);
|
const close = setup(card);
|
||||||
fireEvent.change(screen.getByLabelText(/Дата начала/), {
|
fireEvent.change(screen.getByLabelText(/Дата начала/), {
|
||||||
target: { value: "2026-08-01" },
|
target: { value: "01.08.2026" },
|
||||||
});
|
});
|
||||||
server.use(
|
server.use(
|
||||||
http.patch("*/api/v1/bank-cards/a", () =>
|
http.patch("*/api/v1/bank-cards/a", () =>
|
||||||
@@ -81,7 +81,7 @@ describe("BankCardForm", () => {
|
|||||||
await screen.findByText(
|
await screen.findByText(
|
||||||
"Дата начала противоречит сохранённой истории карты.",
|
"Дата начала противоречит сохранённой истории карты.",
|
||||||
);
|
);
|
||||||
expect(screen.getByLabelText(/Дата начала/)).toHaveValue("2026-08-01");
|
expect(screen.getByLabelText(/Дата начала/)).toHaveValue("01.08.2026");
|
||||||
expect(close).not.toHaveBeenCalled();
|
expect(close).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
it("keeps card edit version until explicit refresh adoption", async () => {
|
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 { type FC, useState } from "react";
|
||||||
import { FormProvider, useForm } from "react-hook-form";
|
import { FormProvider, useForm } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { formatDate } from "@/shared/lib";
|
||||||
import { useBankMutations } from "../api/mutations";
|
import { useBankMutations } from "../api/mutations";
|
||||||
import { type BankCard, useBankCard } from "../api/queries";
|
import { type BankCard, useBankCard } from "../api/queries";
|
||||||
import {
|
import {
|
||||||
@@ -110,7 +111,7 @@ const BankCardForm: FC<BankCardFormProps> = (props) => {
|
|||||||
<>
|
<>
|
||||||
<Text>
|
<Text>
|
||||||
{t("bank.versionHint", {
|
{t("bank.versionHint", {
|
||||||
details: `${latest.name} · ${latest.openingBalance.value} RUB · ${latest.openingDate}`,
|
details: `${latest.name} · ${latest.openingBalance.value} RUB · ${formatDate(latest.openingDate)}`,
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Controller, useFormContext } from "react-hook-form";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
type BankCardValues,
|
type BankCardValues,
|
||||||
|
bankDateInputValue,
|
||||||
bankDateSchema,
|
bankDateSchema,
|
||||||
bankToday,
|
bankToday,
|
||||||
} from "../model/form-validation";
|
} from "../model/form-validation";
|
||||||
@@ -52,18 +53,22 @@ const BankCardFields: FC<BankCardFieldsProps> = (props) => {
|
|||||||
{...field}
|
{...field}
|
||||||
label={t("bank.openingDate")}
|
label={t("bank.openingDate")}
|
||||||
required
|
required
|
||||||
valueFormat="YYYY-MM-DD"
|
valueFormat="DD.MM.YYYY"
|
||||||
value={
|
value={
|
||||||
bankDateSchema.safeParse(field.value).success ? field.value : null
|
bankDateSchema.safeParse(field.value).success ? field.value : null
|
||||||
}
|
}
|
||||||
dateParser={(value) =>
|
dateParser={(value) =>
|
||||||
bankDateSchema.safeParse(value).success ? value : null
|
bankDateSchema.safeParse(bankDateInputValue(value)).success
|
||||||
|
? bankDateInputValue(value)
|
||||||
|
: null
|
||||||
}
|
}
|
||||||
fixOnBlur={false}
|
fixOnBlur={false}
|
||||||
onInput={(event) => field.onChange(event.currentTarget.value)}
|
onInput={(event) =>
|
||||||
|
field.onChange(bankDateInputValue(event.currentTarget.value))
|
||||||
|
}
|
||||||
onChange={(value) => field.onChange(value ?? "")}
|
onChange={(value) => field.onChange(value ?? "")}
|
||||||
onBlur={(event) => {
|
onBlur={(event) => {
|
||||||
field.onChange(event.currentTarget.value);
|
field.onChange(bankDateInputValue(event.currentTarget.value));
|
||||||
field.onBlur();
|
field.onBlur();
|
||||||
}}
|
}}
|
||||||
maxDate={bankToday()}
|
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 { formatMoney } from "@/shared/lib";
|
||||||
import { LoadingSkeleton } from "@/shared/ui";
|
import { LoadingSkeleton } from "@/shared/ui";
|
||||||
import type { BankSummary } from "../api/queries";
|
import type { BankSummary } from "../api/queries";
|
||||||
|
import { BankOperationIcon } from "./BankOperationIcon";
|
||||||
|
|
||||||
type BankSummaryViewProps = {
|
type BankSummaryViewProps = {
|
||||||
data?: BankSummary;
|
data?: BankSummary;
|
||||||
@@ -48,7 +49,13 @@ const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
|
|||||||
{props.data.categories.length ? (
|
{props.data.categories.length ? (
|
||||||
props.data.categories.map((category) => (
|
props.data.categories.map((category) => (
|
||||||
<Paper p="sm" key={category.categoryId} withBorder>
|
<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>
|
<Group>
|
||||||
{(["expenses", "refunds", "net"] as const).map((key) => (
|
{(["expenses", "refunds", "net"] as const).map((key) => (
|
||||||
<Text key={key} style={{ overflowWrap: "anywhere" }}>
|
<Text key={key} style={{ overflowWrap: "anywhere" }}>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { useBankMutations } from "../api/mutations";
|
|||||||
import { type ExpenseCategory, useBankCategories } from "../api/queries";
|
import { type ExpenseCategory, useBankCategories } from "../api/queries";
|
||||||
import { BankFeedback } from "./BankFeedback";
|
import { BankFeedback } from "./BankFeedback";
|
||||||
import classes from "./BankForms.module.css";
|
import classes from "./BankForms.module.css";
|
||||||
|
import { BankOperationIcon } from "./BankOperationIcon";
|
||||||
import { useBankFormFocus } from "./useBankFormFocus";
|
import { useBankFormFocus } from "./useBankFormFocus";
|
||||||
|
|
||||||
type CategoryManagerProps = {
|
type CategoryManagerProps = {
|
||||||
@@ -120,6 +121,10 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
|||||||
{categories.data?.map((category) => (
|
{categories.data?.map((category) => (
|
||||||
<Group justify="space-between" key={category.id}>
|
<Group justify="space-between" key={category.id}>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
|
<BankOperationIcon
|
||||||
|
kind="expense"
|
||||||
|
categoryName={category.name}
|
||||||
|
/>
|
||||||
<Text>{category.name}</Text>
|
<Text>{category.name}</Text>
|
||||||
{category.isDefault && (
|
{category.isDefault && (
|
||||||
<Badge>{t("bank.defaultCategory")}</Badge>
|
<Badge>{t("bank.defaultCategory")}</Badge>
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ describe("CardPage", () => {
|
|||||||
expect(
|
expect(
|
||||||
await screen.findByText("Перевод на «Запасная»"),
|
await screen.findByText("Перевод на «Запасная»"),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("03.08.2026")).toBeInTheDocument();
|
||||||
expect(screen.getByText("−50,01 ₽")).toBeInTheDocument();
|
expect(screen.getByText("−50,01 ₽")).toBeInTheDocument();
|
||||||
expect(screen.getByText("-50 ₽")).toBeInTheDocument();
|
expect(screen.getByText("-50 ₽")).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
|
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ import { MonthPickerInput } from "@mantine/dates";
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { BankFeedback, BankSummaryView } from "@/entities/bank-card";
|
import {
|
||||||
|
BankFeedback,
|
||||||
|
BankOperationIcon,
|
||||||
|
BankSummaryView,
|
||||||
|
} from "@/entities/bank-card";
|
||||||
import { formatMoney } from "@/shared/lib";
|
import { formatMoney } from "@/shared/lib";
|
||||||
import { EntryCard, LoadingSkeleton, StateView } from "@/shared/ui";
|
import { EntryCard, LoadingSkeleton, StateView } from "@/shared/ui";
|
||||||
import classes from "./CardPage.module.css";
|
import classes from "./CardPage.module.css";
|
||||||
@@ -148,7 +152,14 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|
|||||||
<EntryCard
|
<EntryCard
|
||||||
title={row.title}
|
title={row.title}
|
||||||
metadata={row.metadata}
|
metadata={row.metadata}
|
||||||
|
color={row.color}
|
||||||
initial={row.title[0]}
|
initial={row.title[0]}
|
||||||
|
icon={
|
||||||
|
<BankOperationIcon
|
||||||
|
kind={row.item.kind}
|
||||||
|
categoryName={row.item.categoryName}
|
||||||
|
/>
|
||||||
|
}
|
||||||
value={row.value}
|
value={row.value}
|
||||||
valueLabel={t(`bank.kinds.${row.item.kind}`)}
|
valueLabel={t(`bank.kinds.${row.item.kind}`)}
|
||||||
tone={row.tone}
|
tone={row.tone}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
useBankSummary,
|
useBankSummary,
|
||||||
} from "@/entities/bank-card";
|
} from "@/entities/bank-card";
|
||||||
import { apiErrorCode } from "@/shared/api";
|
import { apiErrorCode } from "@/shared/api";
|
||||||
import { formatMoney } from "@/shared/lib";
|
import { formatDate, formatMoney } from "@/shared/lib";
|
||||||
|
|
||||||
function useCardPageVm() {
|
function useCardPageVm() {
|
||||||
const { cardId } = useParams();
|
const { cardId } = useParams();
|
||||||
@@ -37,13 +37,14 @@ function useCardPageVm() {
|
|||||||
(item.kind === "transfer" && item.cardId === cardId);
|
(item.kind === "transfer" && item.cardId === cardId);
|
||||||
return {
|
return {
|
||||||
item,
|
item,
|
||||||
|
color: outgoing ? "red" : "green",
|
||||||
title:
|
title:
|
||||||
item.kind === "transfer"
|
item.kind === "transfer"
|
||||||
? t(outgoing ? "bank.transferTo" : "bank.transferFrom", {
|
? t(outgoing ? "bank.transferTo" : "bank.transferFrom", {
|
||||||
name: outgoing ? item.targetCardName : item.cardName,
|
name: outgoing ? item.targetCardName : item.cardName,
|
||||||
})
|
})
|
||||||
: t(`bank.kinds.${item.kind}`),
|
: t(`bank.kinds.${item.kind}`),
|
||||||
metadata: [item.date, item.categoryName, item.description]
|
metadata: [formatDate(item.date), item.categoryName, item.description]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" · "),
|
.join(" · "),
|
||||||
value: `${outgoing ? "−" : "+"}${formatMoney(item.amount)}`,
|
value: `${outgoing ? "−" : "+"}${formatMoney(item.amount)}`,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
useBankMutations,
|
useBankMutations,
|
||||||
useBankOperation,
|
useBankOperation,
|
||||||
} from "@/entities/bank-card";
|
} from "@/entities/bank-card";
|
||||||
import { formatMoney } from "@/shared/lib";
|
import { formatDate, formatMoney } from "@/shared/lib";
|
||||||
|
|
||||||
type DeleteOperationProps = { operation: BankOperation; onClose: () => void };
|
type DeleteOperationProps = { operation: BankOperation; onClose: () => void };
|
||||||
const DeleteOperation: FC<DeleteOperationProps> = (props) => {
|
const DeleteOperation: FC<DeleteOperationProps> = (props) => {
|
||||||
@@ -64,7 +64,7 @@ const DeleteOperation: FC<DeleteOperationProps> = (props) => {
|
|||||||
<>
|
<>
|
||||||
<Text>
|
<Text>
|
||||||
{t("bank.versionHint", {
|
{t("bank.versionHint", {
|
||||||
details: `${latest.date} · ${formatMoney(latest.amount)} · ${latest.description ?? ""}`,
|
details: `${formatDate(latest.date)} · ${formatMoney(latest.amount)} · ${latest.description ?? ""}`,
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ describe("OperationForm", () => {
|
|||||||
"Остальное",
|
"Остальное",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
fireEvent.change(screen.getByLabelText(/Дата операции/), {
|
||||||
|
target: { value: "07.08.2026" },
|
||||||
|
});
|
||||||
|
fireEvent.blur(screen.getByLabelText(/Дата операции/));
|
||||||
|
expect(screen.getByLabelText(/Дата операции/)).toHaveValue("07.08.2026");
|
||||||
let body: unknown;
|
let body: unknown;
|
||||||
server.use(
|
server.use(
|
||||||
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
|
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
|
||||||
@@ -90,6 +95,7 @@ describe("OperationForm", () => {
|
|||||||
await waitFor(() => expect(close).toHaveBeenCalled());
|
await waitFor(() => expect(close).toHaveBeenCalled());
|
||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
categoryId: "default",
|
categoryId: "default",
|
||||||
|
date: "2026-08-07",
|
||||||
amount: money("92233720368547758.07"),
|
amount: money("92233720368547758.07"),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -101,7 +107,7 @@ describe("OperationForm", () => {
|
|||||||
target: { value: "0" },
|
target: { value: "0" },
|
||||||
});
|
});
|
||||||
fireEvent.change(screen.getByLabelText(/Дата операции/), {
|
fireEvent.change(screen.getByLabelText(/Дата операции/), {
|
||||||
target: { value: "2026-07-31" },
|
target: { value: "31.07.2026" },
|
||||||
});
|
});
|
||||||
const bodies: unknown[] = [];
|
const bodies: unknown[] = [];
|
||||||
server.use(
|
server.use(
|
||||||
@@ -122,7 +128,7 @@ describe("OperationForm", () => {
|
|||||||
target: { value: "150,01" },
|
target: { value: "150,01" },
|
||||||
});
|
});
|
||||||
fireEvent.change(screen.getByLabelText(/Дата операции/), {
|
fireEvent.change(screen.getByLabelText(/Дата операции/), {
|
||||||
target: { value: "2026-08-02" },
|
target: { value: "02.08.2026" },
|
||||||
});
|
});
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||||
await screen.findByText(/Не удалось подтвердить сохранение/);
|
await screen.findByText(/Не удалось подтвердить сохранение/);
|
||||||
@@ -229,7 +235,7 @@ describe("OperationForm", () => {
|
|||||||
target: { value: "500" },
|
target: { value: "500" },
|
||||||
});
|
});
|
||||||
fireEvent.change(screen.getByLabelText(/Дата операции/), {
|
fireEvent.change(screen.getByLabelText(/Дата операции/), {
|
||||||
target: { value: "2026-08-01" },
|
target: { value: "01.08.2026" },
|
||||||
});
|
});
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||||
await screen.findByText(/Не удалось подтвердить сохранение/);
|
await screen.findByText(/Не удалось подтвердить сохранение/);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
useBankOperation,
|
useBankOperation,
|
||||||
} from "@/entities/bank-card";
|
} from "@/entities/bank-card";
|
||||||
import { validationMessage } from "@/shared/i18n";
|
import { validationMessage } from "@/shared/i18n";
|
||||||
import { formatMoney } from "@/shared/lib";
|
import { formatDate, formatMoney } from "@/shared/lib";
|
||||||
import classes from "./OperationForm.module.css";
|
import classes from "./OperationForm.module.css";
|
||||||
import type { OperationValues } from "./OperationForm.types";
|
import type { OperationValues } from "./OperationForm.types";
|
||||||
import { OperationFields, RefundFields } from "./OperationForm.view";
|
import { OperationFields, RefundFields } from "./OperationForm.view";
|
||||||
@@ -249,7 +249,7 @@ const OperationForm: FC<OperationFormProps> = (props) => {
|
|||||||
expenses.data?.items.map((item) => ({
|
expenses.data?.items.map((item) => ({
|
||||||
value: item.id,
|
value: item.id,
|
||||||
label: t("bank.expenseChoice", {
|
label: t("bank.expenseChoice", {
|
||||||
date: item.date,
|
date: formatDate(item.date),
|
||||||
name: item.description || item.categoryName,
|
name: item.description || item.categoryName,
|
||||||
amount: formatMoney(item.amount),
|
amount: formatMoney(item.amount),
|
||||||
available: formatMoney(item.availableRefund),
|
available: formatMoney(item.availableRefund),
|
||||||
@@ -261,7 +261,7 @@ const OperationForm: FC<OperationFormProps> = (props) => {
|
|||||||
? {
|
? {
|
||||||
value: sourceQuery.data.id,
|
value: sourceQuery.data.id,
|
||||||
label: t("bank.expenseChoice", {
|
label: t("bank.expenseChoice", {
|
||||||
date: sourceQuery.data.date,
|
date: formatDate(sourceQuery.data.date),
|
||||||
name:
|
name:
|
||||||
sourceQuery.data.description ||
|
sourceQuery.data.description ||
|
||||||
sourceQuery.data.categoryName,
|
sourceQuery.data.categoryName,
|
||||||
@@ -366,7 +366,7 @@ const OperationForm: FC<OperationFormProps> = (props) => {
|
|||||||
<>
|
<>
|
||||||
<Text>
|
<Text>
|
||||||
{t("bank.versionHint", {
|
{t("bank.versionHint", {
|
||||||
details: `${latest.date} · ${formatMoney(latest.amount)} · ${latest.description ?? ""}`,
|
details: `${formatDate(latest.date)} · ${formatMoney(latest.amount)} · ${latest.description ?? ""}`,
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Controller, useFormContext } from "react-hook-form";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
type BankOperation,
|
type BankOperation,
|
||||||
|
bankDateInputValue,
|
||||||
bankDateSchema,
|
bankDateSchema,
|
||||||
bankToday,
|
bankToday,
|
||||||
} from "@/entities/bank-card";
|
} from "@/entities/bank-card";
|
||||||
@@ -97,18 +98,22 @@ const OperationFields: FC<OperationFieldsProps> = (props) => {
|
|||||||
{...field}
|
{...field}
|
||||||
label={t("bank.date")}
|
label={t("bank.date")}
|
||||||
required
|
required
|
||||||
valueFormat="YYYY-MM-DD"
|
valueFormat="DD.MM.YYYY"
|
||||||
value={
|
value={
|
||||||
bankDateSchema.safeParse(field.value).success ? field.value : null
|
bankDateSchema.safeParse(field.value).success ? field.value : null
|
||||||
}
|
}
|
||||||
dateParser={(value) =>
|
dateParser={(value) =>
|
||||||
bankDateSchema.safeParse(value).success ? value : null
|
bankDateSchema.safeParse(bankDateInputValue(value)).success
|
||||||
|
? bankDateInputValue(value)
|
||||||
|
: null
|
||||||
}
|
}
|
||||||
fixOnBlur={false}
|
fixOnBlur={false}
|
||||||
onInput={(event) => field.onChange(event.currentTarget.value)}
|
onInput={(event) =>
|
||||||
|
field.onChange(bankDateInputValue(event.currentTarget.value))
|
||||||
|
}
|
||||||
onChange={(value) => field.onChange(value ?? "")}
|
onChange={(value) => field.onChange(value ?? "")}
|
||||||
onBlur={(event) => {
|
onBlur={(event) => {
|
||||||
field.onChange(event.currentTarget.value);
|
field.onChange(bankDateInputValue(event.currentTarget.value));
|
||||||
field.onBlur();
|
field.onBlur();
|
||||||
}}
|
}}
|
||||||
maxDate={bankToday()}
|
maxDate={bankToday()}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ type EntryCardProps = {
|
|||||||
details?: ReactNode;
|
details?: ReactNode;
|
||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
initial: string;
|
initial: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
color?: string;
|
||||||
imageSrc?: string | null;
|
imageSrc?: string | null;
|
||||||
value: string;
|
value: string;
|
||||||
valueLabel: string;
|
valueLabel: string;
|
||||||
@@ -17,7 +19,17 @@ type EntryCardProps = {
|
|||||||
const EntryCard: FC<EntryCardProps> = (props) => {
|
const EntryCard: FC<EntryCardProps> = (props) => {
|
||||||
const [imageFailed, setImageFailed] = useState(false);
|
const [imageFailed, setImageFailed] = useState(false);
|
||||||
return (
|
return (
|
||||||
<Paper className={classes.row} withBorder>
|
<Paper
|
||||||
|
className={classes.row}
|
||||||
|
withBorder
|
||||||
|
style={
|
||||||
|
props.color
|
||||||
|
? {
|
||||||
|
background: `color-mix(in srgb, var(--mantine-color-${props.color}-0) 45%, white)`,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
alt=""
|
alt=""
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -26,7 +38,7 @@ const EntryCard: FC<EntryCardProps> = (props) => {
|
|||||||
src={imageFailed ? null : props.imageSrc}
|
src={imageFailed ? null : props.imageSrc}
|
||||||
onError={() => setImageFailed(true)}
|
onError={() => setImageFailed(true)}
|
||||||
>
|
>
|
||||||
{props.initial}
|
{props.icon ?? props.initial}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Stack className={classes.identity} gap={2}>
|
<Stack className={classes.identity} gap={2}>
|
||||||
<Text fw={700} lineClamp={1}>
|
<Text fw={700} lineClamp={1}>
|
||||||
|
|||||||
Reference in New Issue
Block a user