From aba4cf1ce83ea62082f35be358c2081d797f6645 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sat, 12 Sep 2026 22:34:27 +0300 Subject: [PATCH] feat(cards): refine controls and modal transitions --- src/app/routes/AppRoutes.test.tsx | 4 +- src/entities/bank-card/index.ts | 3 +- src/entities/bank-card/model/period.ts | 23 +++++++ .../bank-card/ui/BankCardForm.test.tsx | 6 ++ src/entities/bank-card/ui/BankCardForm.tsx | 14 ++-- .../bank-card/ui/BankPeriodPicker.tsx | 38 ++++++++++ src/entities/bank-card/ui/CategoryManager.tsx | 10 +-- src/pages/card/ui/CardPage.view.tsx | 39 +---------- src/pages/card/ui/CardPage.vm.ts | 22 +----- src/pages/card/ui/DeleteOperation.tsx | 18 +++-- src/pages/card/ui/OperationForm.tsx | 12 ++-- src/pages/cards/ui/CardsPage.test.tsx | 38 +++++++++- src/pages/cards/ui/CardsPage.view.tsx | 69 +++++++++++++------ src/pages/cards/ui/CardsPage.vm.ts | 14 ++-- src/shared/theme/styles.css | 27 ++++++++ src/shared/theme/theme.ts | 15 ++++ 16 files changed, 245 insertions(+), 107 deletions(-) create mode 100644 src/entities/bank-card/model/period.ts create mode 100644 src/entities/bank-card/ui/BankPeriodPicker.tsx diff --git a/src/app/routes/AppRoutes.test.tsx b/src/app/routes/AppRoutes.test.tsx index 84fcedb..fd796f1 100644 --- a/src/app/routes/AppRoutes.test.tsx +++ b/src/app/routes/AppRoutes.test.tsx @@ -126,7 +126,9 @@ describe("AppRoutes", () => { }); fireEvent.click(screen.getByRole("button", { name: "Вход" })); await screen.findByRole("heading", { name: "Добавьте первую карту" }); - expect(screen.getByLabelText("Месяц")).toHaveTextContent("August 2026"); + expect(screen.getByLabelText("Период")).toHaveTextContent( + "01.08.2026 – 31.08.2026", + ); expect(document.title).toBe("Карты — WealthLedger"); }); diff --git a/src/entities/bank-card/index.ts b/src/entities/bank-card/index.ts index ec22e8d..ba1c14f 100644 --- a/src/entities/bank-card/index.ts +++ b/src/entities/bank-card/index.ts @@ -14,15 +14,16 @@ export { bankDateInputValue, bankDateSchema, bankMoneyValue, - bankMonth, bankToday, moneyCents, positiveMoneySchema, } from "./model/form-validation"; +export { bankPeriod } from "./model/period"; export { BankCardFace } from "./ui/BankCardFace"; export { BankCardForm } from "./ui/BankCardForm"; export { BankFeedback } from "./ui/BankFeedback"; export { BankOperationIcon } from "./ui/BankOperationIcon"; +export { BankPeriodPicker } from "./ui/BankPeriodPicker"; export { BankSummaryView } from "./ui/BankSummary.view"; export { CategoryManager } from "./ui/CategoryManager"; export { useBankFormFocus } from "./ui/useBankFormFocus"; diff --git a/src/entities/bank-card/model/period.ts b/src/entities/bank-card/model/period.ts new file mode 100644 index 0000000..88ff129 --- /dev/null +++ b/src/entities/bank-card/model/period.ts @@ -0,0 +1,23 @@ +import dayjs from "dayjs"; +import { z } from "zod"; +import { bankMonth } from "./form-validation"; + +const calendarDate = z.string().date(); +function bankPeriod(params: URLSearchParams) { + const month = bankMonth(params.get("month")); + const from = params.get("from"); + const to = params.get("to"); + return from && + to && + calendarDate.safeParse(from).success && + calendarDate.safeParse(to).success && + from >= "0001-01-01" && + from <= to + ? { from, to } + : { + from: `${month}-01`, + to: dayjs(`${month}-01`).endOf("month").format("YYYY-MM-DD"), + }; +} + +export { bankPeriod }; diff --git a/src/entities/bank-card/ui/BankCardForm.test.tsx b/src/entities/bank-card/ui/BankCardForm.test.tsx index 5bf9777..d26ac14 100644 --- a/src/entities/bank-card/ui/BankCardForm.test.tsx +++ b/src/entities/bank-card/ui/BankCardForm.test.tsx @@ -39,6 +39,12 @@ function setup(value: BankCard) { return close; } describe("BankCardForm", () => { + it("waits for the exit transition before notifying the parent", async () => { + const close = setup(card); + fireEvent.click(screen.getByRole("button", { name: "Отмена" })); + expect(close).not.toHaveBeenCalled(); + await waitFor(() => expect(close).toHaveBeenCalledTimes(1)); + }); it("clears optional metadata on archive while omitting protected financial fields", async () => { const close = setup({ ...card, archivedAt: "2026-09-01" }); expect(screen.getByLabelText(/Начальный остаток/)).toHaveAttribute( diff --git a/src/entities/bank-card/ui/BankCardForm.tsx b/src/entities/bank-card/ui/BankCardForm.tsx index d2104be..c0c11d4 100644 --- a/src/entities/bank-card/ui/BankCardForm.tsx +++ b/src/entities/bank-card/ui/BankCardForm.tsx @@ -20,6 +20,7 @@ import { useBankFormFocus } from "./useBankFormFocus"; type BankCardFormProps = { card?: BankCard; onClose: () => void }; const BankCardForm: FC = (props) => { useBankFormFocus(); + const [opened, setOpened] = useState(true); const { t } = useTranslation(); const [intent] = useState(() => crypto.randomUUID()); const [version, setVersion] = useState(props.card?.version ?? 1); @@ -39,11 +40,14 @@ const BankCardForm: FC = (props) => { }, }); const pending = - methods.formState.isSubmitting || (fresh.isFetching && Boolean(error)); + !opened || + methods.formState.isSubmitting || + (fresh.isFetching && Boolean(error)); return ( !pending && props.onClose()} + opened={opened} + onExitTransitionEnd={props.onClose} + onClose={() => !pending && setOpened(false)} title={t(props.card ? "bank.editCard" : "bank.addCard")} closeButtonProps={{ "aria-label": t("bank.close"), disabled: pending }} closeOnClickOutside={!pending} @@ -82,7 +86,7 @@ const BankCardForm: FC = (props) => { ...metadata, ...financial, }); - props.onClose(); + setOpened(false); } catch (failure) { setError(failure); } @@ -132,7 +136,7 @@ const BankCardForm: FC = (props) => { - diff --git a/src/entities/bank-card/ui/BankPeriodPicker.tsx b/src/entities/bank-card/ui/BankPeriodPicker.tsx new file mode 100644 index 0000000..288e605 --- /dev/null +++ b/src/entities/bank-card/ui/BankPeriodPicker.tsx @@ -0,0 +1,38 @@ +import { DatePickerInput } from "@mantine/dates"; +import { type FC, useState } from "react"; +import { useTranslation } from "react-i18next"; + +type BankPeriodPickerProps = { + period: { from: string; to: string }; + onChange: (value: [string | null, string | null]) => void; +}; +const BankPeriodPicker: FC = (props) => { + const { period, onChange } = props; + const { t } = useTranslation(); + const scope = `${period.from}-${period.to}`; + const [draft, setDraft] = useState<{ + scope: string; + value: [string | null, string | null]; + }>(); + const value: [string | null, string | null] = + draft?.scope === scope ? draft.value : [period.from, period.to]; + return ( + { + setDraft({ scope, value: next }); + onChange(next); + }} + popoverProps={{ + returnFocus: true, + middlewares: { shift: { crossAxis: true } }, + }} + /> + ); +}; + +export { BankPeriodPicker }; diff --git a/src/entities/bank-card/ui/CategoryManager.tsx b/src/entities/bank-card/ui/CategoryManager.tsx index f2281dc..f4d4813 100644 --- a/src/entities/bank-card/ui/CategoryManager.tsx +++ b/src/entities/bank-card/ui/CategoryManager.tsx @@ -53,6 +53,7 @@ const CategoryFields: FC = () => { }; const CategoryManager: FC = (props) => { useBankFormFocus(); + const [opened, setOpened] = useState(true); const { t } = useTranslation(); const categories = useBankCategories(); const mutation = useBankMutations(); @@ -83,11 +84,12 @@ const CategoryManager: FC = (props) => { ), defaultValues: { name: "" }, }); - const pending = methods.formState.isSubmitting; + const pending = !opened || methods.formState.isSubmitting; return ( !pending && props.onClose()} + opened={opened} + onExitTransitionEnd={props.onClose} + onClose={() => !pending && setOpened(false)} title={t("bank.categories")} closeOnClickOutside={!pending} closeOnEscape={!pending} @@ -205,7 +207,7 @@ const CategoryManager: FC = (props) => { {t("bank.cancel")} )} - diff --git a/src/pages/card/ui/CardPage.view.tsx b/src/pages/card/ui/CardPage.view.tsx index 61305c1..b04d6c1 100644 --- a/src/pages/card/ui/CardPage.view.tsx +++ b/src/pages/card/ui/CardPage.view.tsx @@ -10,53 +10,20 @@ import { Text, Title, } from "@mantine/core"; -import { DatePickerInput } from "@mantine/dates"; -import { type FC, useLayoutEffect, useRef, useState } from "react"; +import { type FC, useLayoutEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import { BankCardFace, BankFeedback, BankOperationIcon, + BankPeriodPicker, BankSummaryView, } from "@/entities/bank-card"; import { EntryCard, LoadingSkeleton, StateView } from "@/shared/ui"; import classes from "./CardPage.module.css"; import type { CardPageVm } from "./CardPage.vm"; -type PeriodPickerProps = { - period: CardPageVm["period"]; - onChange: CardPageVm["setPeriod"]; -}; -const PeriodPicker: FC = (props) => { - const { period, onChange } = props; - const { t } = useTranslation(); - const scope = `${period.from}-${period.to}`; - const [draft, setDraft] = useState<{ - scope: string; - value: [string | null, string | null]; - }>(); - const value: [string | null, string | null] = - draft?.scope === scope ? draft.value : [period.from, period.to]; - return ( - { - setDraft({ scope, value: next }); - onChange(next); - }} - popoverProps={{ - returnFocus: true, - middlewares: { shift: { crossAxis: true } }, - }} - /> - ); -}; - type CardPageViewProps = { vm: CardPageVm }; const CardPageView: FC = (props) => { const { vm } = props; @@ -210,7 +177,7 @@ const CardPageView: FC = (props) => { retry={() => void vm.cards.refetch()} /> )} - + = "0001-01-01" && - from <= to - ? { from, to } - : { - from: `${month}-01`, - to: dayjs(`${month}-01`).endOf("month").format("YYYY-MM-DD"), - }; + const period = bankPeriod(params); const requestedSize = params.get("limit") ?? "10"; const pageSize = PAGE_SIZES.includes(requestedSize) ? requestedSize : "10"; const rawPage = Number(params.get("page") ?? 1); diff --git a/src/pages/card/ui/DeleteOperation.tsx b/src/pages/card/ui/DeleteOperation.tsx index 5e3fdbb..4e59973 100644 --- a/src/pages/card/ui/DeleteOperation.tsx +++ b/src/pages/card/ui/DeleteOperation.tsx @@ -13,6 +13,7 @@ import { formatDate, formatMoney } from "@/shared/lib"; type DeleteOperationProps = { operation: BankOperation; onClose: () => void }; const DeleteOperation: FC = (props) => { useBankFormFocus(); + const [opened, setOpened] = useState(true); const { t } = useTranslation(); const [record, setRecord] = useState(props.operation); const [latest, setLatest] = useState(); @@ -20,11 +21,14 @@ const DeleteOperation: FC = (props) => { const query = useBankOperation(record.id); const { deleteOperation } = useBankMutations(); const pending = - deleteOperation.isPending || (query.isFetching && Boolean(error)); + !opened || + deleteOperation.isPending || + (query.isFetching && Boolean(error)); return ( !pending && props.onClose()} + opened={opened} + onExitTransitionEnd={props.onClose} + onClose={() => !pending && setOpened(false)} title={t("bank.deleteTitle")} closeOnEscape={!pending} closeOnClickOutside={!pending} @@ -91,7 +95,7 @@ const DeleteOperation: FC = (props) => { id: record.id, version: record.version, }); - props.onClose(); + setOpened(false); } catch (failure) { setError(failure); } @@ -99,7 +103,11 @@ const DeleteOperation: FC = (props) => { > {t("bank.delete")} - diff --git a/src/pages/card/ui/OperationForm.tsx b/src/pages/card/ui/OperationForm.tsx index db8e161..869e1c0 100644 --- a/src/pages/card/ui/OperationForm.tsx +++ b/src/pages/card/ui/OperationForm.tsx @@ -47,6 +47,7 @@ type OperationFormProps = { }; const OperationForm: FC = (props) => { useBankFormFocus(); + const [opened, setOpened] = useState(true); const { t } = useTranslation(); const [categoriesOpen, setCategoriesOpen] = useState(false); const [version, setVersion] = useState(props.operation?.version ?? 1); @@ -164,15 +165,16 @@ const OperationForm: FC = (props) => { { page: sourcePage }, ); const source = methods.watch("cardId"); - const pending = methods.formState.isSubmitting; + const pending = !opened || methods.formState.isSubmitting; const options = props.cards .filter((c) => !c.archivedAt) .map((c) => ({ value: c.id, label: c.name })); return ( <> !pending && !categoriesOpen && props.onClose()} + opened={opened} + onExitTransitionEnd={props.onClose} + onClose={() => !pending && !categoriesOpen && setOpened(false)} title={t(props.operation ? "bank.editOperation" : "bank.addOperation")} closeOnEscape={!pending && !categoriesOpen} closeOnClickOutside={!pending && !categoriesOpen} @@ -217,7 +219,7 @@ const OperationForm: FC = (props) => { id: intent, ...body, }); - props.onClose(); + setOpened(false); } catch (failure) { setError(failure); } @@ -392,7 +394,7 @@ const OperationForm: FC = (props) => { > {t("bank.save")} - diff --git a/src/pages/cards/ui/CardsPage.test.tsx b/src/pages/cards/ui/CardsPage.test.tsx index 0bb595a..6ceec94 100644 --- a/src/pages/cards/ui/CardsPage.test.tsx +++ b/src/pages/cards/ui/CardsPage.test.tsx @@ -42,7 +42,7 @@ function renderCards(fail = false, categoryFail = false) { ); return render( - + @@ -138,10 +138,42 @@ describe("CardsPage", () => { fireEvent.click(screen.getByLabelText("Показать архив")); await screen.findByText("Запасная"); expect(requests).toContain("?includeArchived=true"); - expect(screen.getByLabelText("Месяц")).toHaveTextContent("September 2026"); + expect(screen.getByLabelText("Период")).toHaveTextContent( + "01.09.2026 – 30.09.2026", + ); expect(screen.getByRole("link", { name: /Запасная/ })).toHaveAttribute( "href", - "/cards/demo?month=2026-09", + "/cards/demo?from=2026-09-01&to=2026-09-30", + ); + }); + it("applies a completed custom period to the summary", async () => { + renderCards(); + await screen.findByText("За этот период операций по расходам нет."); + await screen.findByRole("heading", { name: "Добавьте первую карту" }); + const requests: string[] = []; + server.use( + http.get("*/api/v1/bank-cards/summary", ({ request }) => { + requests.push(new URL(request.url).search); + return HttpResponse.json({ + cardId: null, + expenses: money("0"), + refunds: money("0"), + net: money("0"), + categories: [], + }); + }), + ); + fireEvent.click(screen.getByLabelText("Период")); + fireEvent.click( + await screen.findByRole("button", { name: "10 September 2026" }), + ); + expect(requests).toHaveLength(0); + + fireEvent.click( + await screen.findByRole("button", { name: "20 September 2026" }), + ); + await waitFor(() => + expect(requests.at(-1)).toBe("?from=2026-09-10&to=2026-09-20"), ); }); it("shows retry after failure instead of presenting an empty list", async () => { diff --git a/src/pages/cards/ui/CardsPage.view.tsx b/src/pages/cards/ui/CardsPage.view.tsx index 4e5a7dd..1db4848 100644 --- a/src/pages/cards/ui/CardsPage.view.tsx +++ b/src/pages/cards/ui/CardsPage.view.tsx @@ -1,4 +1,5 @@ import { + ActionIcon, Button, Group, Paper, @@ -9,13 +10,13 @@ import { Text, Title, } from "@mantine/core"; -import { MonthPickerInput } from "@mantine/dates"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import { BankCardFace, BankFeedback, + BankPeriodPicker, BankSummaryView, } from "@/entities/bank-card"; import { LoadingSkeleton, StateView } from "@/shared/ui"; @@ -28,11 +29,29 @@ const CardsPageView: FC = (props) => { const { t } = useTranslation(); return ( - + {t("bank.title")} - + vm.setAdding(true)} + > + + - {t("bank.manualBalance")} = (props) => { {vm.cards.data && vm.cards.isFetching && ( {t("bank.refreshing")} )} - { - if (value) vm.setMonth(value.slice(0, 7)); - }} - popoverProps={{ middlewares: { shift: { crossAxis: true } } }} - /> + + + {Boolean(vm.categories.data?.length || vm.cards.data?.length) && ( + + )} + = (props) => { retry={() => void vm.categories.refetch()} /> )} - {Boolean(vm.categories.data?.length || vm.cards.data?.length) && ( - - )} ); }; diff --git a/src/pages/cards/ui/CardsPage.vm.ts b/src/pages/cards/ui/CardsPage.vm.ts index 84888fe..c750dba 100644 --- a/src/pages/cards/ui/CardsPage.vm.ts +++ b/src/pages/cards/ui/CardsPage.vm.ts @@ -1,7 +1,7 @@ import { useState } from "react"; import { useSearchParams } from "react-router"; import { - bankMonth, + bankPeriod, useBankCards, useBankCategories, useBankSummary, @@ -9,30 +9,30 @@ import { function useCardsPageVm() { const [params, setParams] = useSearchParams(); - const month = bankMonth(params.get("month")); + const period = bankPeriod(params); const [archive, setArchive] = useState(false); const categories = useBankCategories(); const [managingCategories, setManagingCategories] = useState(false); const [adding, setAdding] = useState(false); const cards = useBankCards(archive); - const summary = useBankSummary(month); + const summary = useBankSummary(period); return { categories, managingCategories, setManagingCategories, - month, + period, archive, setArchive, adding, setAdding, cards, summary, - setMonth: (value: string) => { - if (value) setParams({ month: bankMonth(value) }); + setPeriod: (value: [string | null, string | null]) => { + if (value[0] && value[1]) setParams({ from: value[0], to: value[1] }); }, items: cards.data?.map((card) => ({ ...card, - to: `/cards/${card.id}?month=${month}`, + to: `/cards/${card.id}?${new URLSearchParams(period)}`, })), }; } diff --git a/src/shared/theme/styles.css b/src/shared/theme/styles.css index 222205d..3f0afd5 100644 --- a/src/shared/theme/styles.css +++ b/src/shared/theme/styles.css @@ -10,3 +10,30 @@ a { color: inherit; text-decoration: none; } + +@media (prefers-reduced-motion: no-preference) { + .mantine-Modal-content { + animation: modal-appear 220ms ease-out; + } + + .mantine-Modal-overlay { + animation: modal-backdrop-appear 220ms ease-out; + } +} + +@keyframes modal-appear { + from { + opacity: 0; + transform: translateY(8px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes modal-backdrop-appear { + from { + opacity: 0; + } +} diff --git a/src/shared/theme/theme.ts b/src/shared/theme/theme.ts index c1bde40..07737b3 100644 --- a/src/shared/theme/theme.ts +++ b/src/shared/theme/theme.ts @@ -4,6 +4,7 @@ import { createTheme, getThemeColor, type MantinePrimaryShade, + Modal, rem, rgba, } from "@mantine/core"; @@ -21,6 +22,20 @@ const colorRoles = mapValues(color.roles, (role) => ); const theme = createTheme({ + respectReducedMotion: true, + components: { + Modal: Modal.extend({ + defaultProps: { + overlayProps: { blur: 3 }, + transitionProps: { + transition: "fade-down", + duration: 220, + exitDuration: 180, + timingFunction: "ease-out", + }, + }, + }), + }, primaryColor: color.roles.primary.color, primaryShade: color.primaryShade as MantinePrimaryShade, defaultRadius: radius.default,