chore: save pending changes

This commit is contained in:
2026-09-12 19:59:10 +03:00
parent 6b310db0b8
commit ae06f6e6e0
16 changed files with 403 additions and 42 deletions
@@ -22,6 +22,7 @@ const card: BankCard = {
name: "Daily", name: "Daily",
bankName: null, bankName: null,
lastFour: null, lastFour: null,
paymentSystem: null,
openingBalance: { currency: "RUB", value: "100" }, openingBalance: { currency: "RUB", value: "100" },
openingDate: "2026-09-01", openingDate: "2026-09-01",
balance: { currency: "RUB", value: "100" }, balance: { currency: "RUB", value: "100" },
+1
View File
@@ -19,6 +19,7 @@ export {
moneyCents, moneyCents,
positiveMoneySchema, positiveMoneySchema,
} from "./model/form-validation"; } from "./model/form-validation";
export { BankCardFace } from "./ui/BankCardFace";
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 { BankOperationIcon } from "./ui/BankOperationIcon";
@@ -71,6 +71,11 @@ const bankCardSchema = z.object({
(value) => !value || /^\d{4}$/.test(value), (value) => !value || /^\d{4}$/.test(value),
validationMessage("bank.validation.lastFour"), validationMessage("bank.validation.lastFour"),
), ),
paymentSystem: z
.enum(["", "mir", "visa", "mastercard"], {
errorMap: validationMessage("bank.validation.paymentSystem"),
})
.optional(),
openingBalance: moneySchema, openingBalance: moneySchema,
openingDate: bankDateSchema, openingDate: bankDateSchema,
}); });
@@ -0,0 +1,160 @@
.face {
--card-base: #242b35;
--card-light: #485666;
position: relative;
isolation: isolate;
overflow: hidden;
box-sizing: border-box;
width: 100%;
max-width: 28rem;
min-height: 15rem;
padding: clamp(1.1rem, 3vw, 1.6rem);
border-radius: 1.25rem;
color: #fff;
background: linear-gradient(125deg, var(--card-light), var(--card-base) 75%);
box-shadow:
inset 0 1px 0 #ffffff40,
0 8px 18px #18233118;
}
.face::before {
content: "";
position: absolute;
z-index: -1;
width: 24rem;
height: 24rem;
border: 1px solid #ffffff20;
border-radius: 50%;
right: -13rem;
top: -9rem;
box-shadow:
0 0 0 2.5rem #ffffff08,
0 0 0 5rem #ffffff06;
}
.face[data-tone="green"] {
--card-base: #07543c;
--card-light: #21844a;
}
.face[data-tone="yellow"] {
--card-base: #edbe26;
--card-light: #ffe478;
color: #252316;
}
.face[data-tone="red"] {
--card-base: #980f28;
--card-light: #d63244;
}
.face[data-tone="blue"] {
--card-base: #073f86;
--card-light: #226db8;
}
.top,
.bottom {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
}
.bank {
font-size: 1.05rem;
font-weight: 750;
overflow-wrap: anywhere;
}
.archive {
font-size: 0.7rem;
border: 1px solid currentColor;
border-radius: 2rem;
padding: 0.2rem 0.5rem;
flex-shrink: 0;
}
.hardware {
display: flex;
align-items: center;
gap: 0.7rem;
margin: 1.4rem 0 1.1rem;
}
.chip {
display: block;
width: 2.5rem;
height: 1.9rem;
border: 1px solid #a99054;
border-radius: 0.4rem;
background:
linear-gradient(
90deg,
transparent 30%,
#9b875a 31%,
transparent 33%,
transparent 66%,
#9b875a 67%,
transparent 69%
),
linear-gradient(
0deg,
transparent 32%,
#9b875a 33%,
transparent 36%,
transparent 65%,
#9b875a 66%,
transparent 69%
),
linear-gradient(135deg, #e5d6a6, #bfaa75 50%, #f1e6c4);
}
.number {
font-family: var(--mantine-font-family-monospace);
font-size: clamp(1rem, 2.3vw, 1.35rem);
letter-spacing: 0.06em;
white-space: nowrap;
margin-bottom: 1.25rem;
}
.details {
min-width: 0;
}
.name {
font-size: 0.8rem;
opacity: 0.9;
overflow-wrap: anywhere;
}
.balance {
font-size: clamp(1.2rem, 2.5vw, 1.55rem);
font-weight: 650;
overflow-wrap: anywhere;
margin-top: 0.2rem;
}
.network {
flex-shrink: 0;
font-weight: 850;
font-style: italic;
font-size: 1.35rem;
}
.network[data-network="mastercard"] {
font-size: 0.65rem;
font-weight: 600;
font-style: normal;
text-align: center;
}
.circles {
display: block;
width: 3.3rem;
height: 2rem;
margin: 0 auto 0.15rem;
position: relative;
}
.circles::before,
.circles::after {
content: "";
position: absolute;
top: 0;
width: 2rem;
height: 2rem;
border-radius: 50%;
}
.circles::before {
left: 0;
background: #eb302b;
}
.circles::after {
right: 0;
background: #ffad19;
opacity: 0.9;
}
@@ -0,0 +1,67 @@
import { SimpleGrid } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { BankCardFace } from "./BankCardFace";
const meta = {
title: "Product/BankCards/CardAppearance",
component: BankCardFace,
tags: ["autodocs"],
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,
version: 1,
},
},
} satisfies Meta<typeof BankCardFace>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Banks: Story = {
render: (args) => (
<SimpleGrid cols={{ base: 1, sm: 2 }} maw={920}>
{["Сбербанк", "Т-Банк", "Альфа-Банк", "ВТБ", null].map(
(bankName, index) => (
<BankCardFace
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 LongArchived: Story = {
args: {
card: {
...meta.args.card,
name: "Карта с очень длинным пользовательским названием для повседневных покупок",
bankName: "Банк с очень длинным названием без пробелов".repeat(2),
archivedAt: "2026-09-12",
paymentSystem: "mastercard",
balance: { value: "184467440737095516.14", currency: "RUB" },
},
},
};
@@ -0,0 +1,67 @@
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 };
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";
return (
<div className={classes.face} data-tone={tone}>
<div className={classes.top}>
<span className={classes.bank}>{card.bankName || t("bank.card")}</span>
{card.archivedAt && (
<span className={classes.archive}>{t("bank.archived")}</span>
)}
</div>
<div className={classes.hardware} aria-hidden="true">
<span className={classes.chip} />
<svg
aria-hidden="true"
width="24"
height="30"
viewBox="0 0 24 30"
fill="none"
>
<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"
/>
</svg>
</div>
<div
className={classes.number}
>{`•••• •••• •••• ${card.lastFour || "••••"}`}</div>
<div className={classes.bottom}>
<div className={classes.details}>
<div className={classes.name}>{card.name}</div>
<div className={classes.balance}>{formatMoney(card.balance)}</div>
</div>
{card.paymentSystem && (
<span className={classes.network} data-network={card.paymentSystem}>
{card.paymentSystem === "mastercard" && (
<span className={classes.circles} aria-hidden="true" />
)}
{t(`bank.paymentSystems.${card.paymentSystem}`)}
</span>
)}
</div>
</div>
);
};
export { BankCardFace };
@@ -14,6 +14,7 @@ const card: BankCard = {
name: "Основная", name: "Основная",
bankName: "Банк", bankName: "Банк",
lastFour: "1234", lastFour: "1234",
paymentSystem: null,
openingBalance: { currency: "RUB", value: "100" }, openingBalance: { currency: "RUB", value: "100" },
balance: { currency: "RUB", value: "50" }, balance: { currency: "RUB", value: "50" },
openingDate: "2026-01-01", openingDate: "2026-01-01",
@@ -62,8 +63,30 @@ describe("BankCardForm", () => {
name: "Основная", name: "Основная",
bankName: null, bankName: null,
lastFour: null, lastFour: null,
paymentSystem: null,
}); });
}); });
it("loads and saves the selected payment system", async () => {
const close = setup({ ...card, paymentSystem: "mir" });
expect(
screen.getByRole("combobox", { name: /Платёжная система/ }),
).toHaveValue("МИР");
fireEvent.click(
screen.getByRole("combobox", { name: /Платёжная система/ }),
);
fireEvent.click(await screen.findByRole("option", { name: "Visa" }));
let body: unknown;
server.use(
http.patch("*/api/v1/bank-cards/a", async ({ request }) => {
body = await request.json();
return HttpResponse.json({ ...card, paymentSystem: "visa" });
}),
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(close).toHaveBeenCalled());
expect(body).toMatchObject({ paymentSystem: "visa" });
});
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(/Дата начала/), {
@@ -33,6 +33,7 @@ const BankCardForm: FC<BankCardFormProps> = (props) => {
name: props.card?.name ?? "", name: props.card?.name ?? "",
bankName: props.card?.bankName ?? "", bankName: props.card?.bankName ?? "",
lastFour: props.card?.lastFour ?? "", lastFour: props.card?.lastFour ?? "",
paymentSystem: props.card?.paymentSystem ?? "",
openingBalance: props.card?.openingBalance.value ?? "0", openingBalance: props.card?.openingBalance.value ?? "0",
openingDate: props.card?.openingDate ?? bankToday(), openingDate: props.card?.openingDate ?? bankToday(),
}, },
@@ -57,6 +58,7 @@ const BankCardForm: FC<BankCardFormProps> = (props) => {
name: values.name, name: values.name,
bankName: values.bankName || null, bankName: values.bankName || null,
lastFour: values.lastFour || null, lastFour: values.lastFour || null,
paymentSystem: values.paymentSystem || null,
}; };
const financial = { const financial = {
openingBalance: { openingBalance: {
@@ -1,4 +1,4 @@
import { Stack, Text, TextInput } from "@mantine/core"; import { Select, Stack, Text, TextInput } from "@mantine/core";
import { DateInput } from "@mantine/dates"; import { DateInput } from "@mantine/dates";
import type { FC } from "react"; import type { FC } from "react";
import { Controller, useFormContext } from "react-hook-form"; import { Controller, useFormContext } from "react-hook-form";
@@ -37,6 +37,26 @@ const BankCardFields: FC<BankCardFieldsProps> = (props) => {
{...register("lastFour")} {...register("lastFour")}
error={errors.lastFour?.message} error={errors.lastFour?.message}
/> />
<Controller
control={control}
name="paymentSystem"
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 }}
error={errors.paymentSystem?.message}
/>
)}
/>
<TextInput <TextInput
label={t("bank.openingBalance")} label={t("bank.openingBalance")}
required required
+2 -14
View File
@@ -1,7 +1,6 @@
import { import {
ActionIcon, ActionIcon,
Anchor, Anchor,
Badge,
Button, Button,
Group, Group,
Paper, Paper,
@@ -15,11 +14,11 @@ 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 { import {
BankCardFace,
BankFeedback, BankFeedback,
BankOperationIcon, BankOperationIcon,
BankSummaryView, BankSummaryView,
} from "@/entities/bank-card"; } from "@/entities/bank-card";
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";
import type { CardPageVm } from "./CardPage.vm"; import type { CardPageVm } from "./CardPage.vm";
@@ -65,21 +64,10 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
<Stack gap="lg"> <Stack gap="lg">
<Paper p="md" withBorder> <Paper p="md" withBorder>
<Stack> <Stack>
<Group justify="space-between">
<Title order={1} className={classes.wrap}> <Title order={1} className={classes.wrap}>
{card.name} {card.name}
</Title> </Title>
{card.archivedAt && ( <BankCardFace card={card} />
<Badge color="gray">{t("bank.archived")}</Badge>
)}
</Group>
<Text c="dimmed">
{card.bankName}
{card.lastFour ? ` · •• ${card.lastFour}` : ""}
</Text>
<Text size="xl" fw={700} className={classes.wrap}>
{formatMoney(card.balance)}
</Text>
<Text c="dimmed">{t("bank.manualBalance")}</Text> <Text c="dimmed">{t("bank.manualBalance")}</Text>
<Group> <Group>
<Button <Button
+1
View File
@@ -21,6 +21,7 @@ const card: BankCard = {
name: "Повседневная", name: "Повседневная",
bankName: null, bankName: null,
lastFour: null, lastFour: null,
paymentSystem: null,
balance: money("0"), balance: money("0"),
openingBalance: money("0"), openingBalance: money("0"),
openingDate: "2026-01-01", openingDate: "2026-01-01",
+3
View File
@@ -2,6 +2,9 @@
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
min-width: 0; min-width: 0;
max-width: 28rem;
border-radius: 1.25rem;
background: transparent;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.card:focus-visible { .card:focus-visible {
+9 -24
View File
@@ -1,5 +1,4 @@
import { import {
Badge,
Button, Button,
Group, Group,
Paper, Paper,
@@ -14,8 +13,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 {
import { formatMoney } from "@/shared/lib"; BankCardFace,
BankFeedback,
BankSummaryView,
} from "@/entities/bank-card";
import { LoadingSkeleton, StateView } from "@/shared/ui"; import { LoadingSkeleton, StateView } from "@/shared/ui";
import classes from "./CardsPage.module.css"; import classes from "./CardsPage.module.css";
import type { CardsPageVm } from "./CardsPage.vm"; import type { CardsPageVm } from "./CardsPage.vm";
@@ -44,27 +46,10 @@ const CardsPageView: FC<CardsPageViewProps> = (props) => {
key={card.id} key={card.id}
component={Link} component={Link}
to={card.to} to={card.to}
withBorder p={0}
p="md"
className={classes.card} className={classes.card}
> >
<Stack gap="xs"> <BankCardFace card={card} />
<Group>
<Text fw={600} size="lg">
{card.name}
</Text>
{card.archivedAt && (
<Badge color="gray">{t("bank.archived")}</Badge>
)}
</Group>
<Text c="dimmed">
{card.bankName}
{card.lastFour ? ` · •• ${card.lastFour}` : ""}
</Text>
<Text size="xl" fw={650}>
{formatMoney(card.balance)}
</Text>
</Stack>
</Paper> </Paper>
))} ))}
</SimpleGrid> </SimpleGrid>
@@ -82,8 +67,8 @@ const CardsPageView: FC<CardsPageViewProps> = (props) => {
) : vm.cards.isLoading ? ( ) : vm.cards.isLoading ? (
<LoadingSkeleton label={t("bank.loading")}> <LoadingSkeleton label={t("bank.loading")}>
<SimpleGrid cols={{ base: 1, sm: 2 }}> <SimpleGrid cols={{ base: 1, sm: 2 }}>
<Skeleton h={120} /> <Skeleton h={250} />
<Skeleton h={120} /> <Skeleton h={250} />
</SimpleGrid> </SimpleGrid>
</LoadingSkeleton> </LoadingSkeleton>
) : null} ) : null}
+6
View File
@@ -718,6 +718,8 @@ export interface components {
name: string; name: string;
bankName: string | null; bankName: string | null;
lastFour: string | null; lastFour: string | null;
/** @enum {string|null} */
paymentSystem: "mir" | "visa" | "mastercard" | null;
openingBalance: components["schemas"]["MoneyDto"]; openingBalance: components["schemas"]["MoneyDto"];
openingDate: string; openingDate: string;
balance: components["schemas"]["MoneyDto"]; balance: components["schemas"]["MoneyDto"];
@@ -735,6 +737,8 @@ export interface components {
name: string; name: string;
bankName?: string | null; bankName?: string | null;
lastFour?: string | null; lastFour?: string | null;
/** @enum {string|null} */
paymentSystem?: "mir" | "visa" | "mastercard" | null;
openingBalance: components["schemas"]["RubMoneyInputDto"]; openingBalance: components["schemas"]["RubMoneyInputDto"];
/** Format: date */ /** Format: date */
openingDate: string; openingDate: string;
@@ -830,6 +834,8 @@ export interface components {
name?: string; name?: string;
bankName?: string | null; bankName?: string | null;
lastFour?: string | null; lastFour?: string | null;
/** @enum {string|null} */
paymentSystem?: "mir" | "visa" | "mastercard" | null;
openingBalance?: components["schemas"]["RubMoneyInputDto"]; openingBalance?: components["schemas"]["RubMoneyInputDto"];
/** Format: date */ /** Format: date */
openingDate?: string; openingDate?: string;
+28
View File
@@ -4780,6 +4780,15 @@
"type": "string", "type": "string",
"nullable": true "nullable": true
}, },
"paymentSystem": {
"type": "string",
"enum": [
"mir",
"visa",
"mastercard"
],
"nullable": true
},
"openingBalance": { "openingBalance": {
"$ref": "#/components/schemas/MoneyDto" "$ref": "#/components/schemas/MoneyDto"
}, },
@@ -4802,6 +4811,7 @@
"name", "name",
"bankName", "bankName",
"lastFour", "lastFour",
"paymentSystem",
"openingBalance", "openingBalance",
"openingDate", "openingDate",
"balance", "balance",
@@ -4850,6 +4860,15 @@
"nullable": true, "nullable": true,
"pattern": "^[0-9]{4}$" "pattern": "^[0-9]{4}$"
}, },
"paymentSystem": {
"type": "string",
"enum": [
"mir",
"visa",
"mastercard"
],
"nullable": true
},
"openingBalance": { "openingBalance": {
"$ref": "#/components/schemas/RubMoneyInputDto" "$ref": "#/components/schemas/RubMoneyInputDto"
}, },
@@ -5210,6 +5229,15 @@
"nullable": true, "nullable": true,
"pattern": "^[0-9]{4}$" "pattern": "^[0-9]{4}$"
}, },
"paymentSystem": {
"type": "string",
"enum": [
"mir",
"visa",
"mastercard"
],
"nullable": true
},
"openingBalance": { "openingBalance": {
"$ref": "#/components/schemas/RubMoneyInputDto" "$ref": "#/components/schemas/RubMoneyInputDto"
}, },
+4
View File
@@ -27,6 +27,9 @@ const ru = {
"Остаток до операций с выбранной даты. Валюта — рубли (RUB).", "Остаток до операций с выбранной даты. Валюта — рубли (RUB).",
name: "Название карты", name: "Название карты",
bankName: "Банк (необязательно)", bankName: "Банк (необязательно)",
paymentSystem: "Платёжная система (необязательно)",
paymentSystemUnknown: "Не указана",
paymentSystems: { mir: "МИР", visa: "Visa", mastercard: "Mastercard" },
lastFour: "Последние четыре цифры (необязательно)", lastFour: "Последние четыре цифры (необязательно)",
openingBalance: "Начальный остаток, ₽", openingBalance: "Начальный остаток, ₽",
openingDate: "Дата начала учёта", openingDate: "Дата начала учёта",
@@ -137,6 +140,7 @@ const ru = {
}, },
validation: { validation: {
name: "Введите название от 1 до 100 символов.", name: "Введите название от 1 до 100 символов.",
paymentSystem: "Выберите платёжную систему из списка.",
bankName: "Не более 100 символов.", bankName: "Не более 100 символов.",
lastFour: "Укажите ровно четыре цифры или оставьте поле пустым.", lastFour: "Укажите ровно четыре цифры или оставьте поле пустым.",
money: money: