chore: save pending changes
This commit is contained in:
@@ -22,6 +22,7 @@ const card: BankCard = {
|
||||
name: "Daily",
|
||||
bankName: null,
|
||||
lastFour: null,
|
||||
paymentSystem: null,
|
||||
openingBalance: { currency: "RUB", value: "100" },
|
||||
openingDate: "2026-09-01",
|
||||
balance: { currency: "RUB", value: "100" },
|
||||
|
||||
@@ -19,6 +19,7 @@ export {
|
||||
moneyCents,
|
||||
positiveMoneySchema,
|
||||
} from "./model/form-validation";
|
||||
export { BankCardFace } from "./ui/BankCardFace";
|
||||
export { BankCardForm } from "./ui/BankCardForm";
|
||||
export { BankFeedback } from "./ui/BankFeedback";
|
||||
export { BankOperationIcon } from "./ui/BankOperationIcon";
|
||||
|
||||
@@ -71,6 +71,11 @@ const bankCardSchema = z.object({
|
||||
(value) => !value || /^\d{4}$/.test(value),
|
||||
validationMessage("bank.validation.lastFour"),
|
||||
),
|
||||
paymentSystem: z
|
||||
.enum(["", "mir", "visa", "mastercard"], {
|
||||
errorMap: validationMessage("bank.validation.paymentSystem"),
|
||||
})
|
||||
.optional(),
|
||||
openingBalance: moneySchema,
|
||||
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: "Основная",
|
||||
bankName: "Банк",
|
||||
lastFour: "1234",
|
||||
paymentSystem: null,
|
||||
openingBalance: { currency: "RUB", value: "100" },
|
||||
balance: { currency: "RUB", value: "50" },
|
||||
openingDate: "2026-01-01",
|
||||
@@ -62,8 +63,30 @@ describe("BankCardForm", () => {
|
||||
name: "Основная",
|
||||
bankName: 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 () => {
|
||||
const close = setup(card);
|
||||
fireEvent.change(screen.getByLabelText(/Дата начала/), {
|
||||
|
||||
@@ -33,6 +33,7 @@ const BankCardForm: FC<BankCardFormProps> = (props) => {
|
||||
name: props.card?.name ?? "",
|
||||
bankName: props.card?.bankName ?? "",
|
||||
lastFour: props.card?.lastFour ?? "",
|
||||
paymentSystem: props.card?.paymentSystem ?? "",
|
||||
openingBalance: props.card?.openingBalance.value ?? "0",
|
||||
openingDate: props.card?.openingDate ?? bankToday(),
|
||||
},
|
||||
@@ -57,6 +58,7 @@ const BankCardForm: FC<BankCardFormProps> = (props) => {
|
||||
name: values.name,
|
||||
bankName: values.bankName || null,
|
||||
lastFour: values.lastFour || null,
|
||||
paymentSystem: values.paymentSystem || null,
|
||||
};
|
||||
const financial = {
|
||||
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 type { FC } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
@@ -37,6 +37,26 @@ const BankCardFields: FC<BankCardFieldsProps> = (props) => {
|
||||
{...register("lastFour")}
|
||||
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
|
||||
label={t("bank.openingBalance")}
|
||||
required
|
||||
|
||||
Reference in New Issue
Block a user