refactor: establish local frontend workspace boundaries
Conventional commits / commits (pull_request) Successful in 24s
Frontend security / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m18s
Frontend security / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 6m45s

OpenSpec change: evolve-frontend-stack
This commit was merged in pull request #34.
This commit is contained in:
2026-09-22 22:11:02 +03:00
parent c86be7bf73
commit 85fcfe3d07
101 changed files with 1487 additions and 666 deletions
+1 -2
View File
@@ -1,12 +1,11 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
import { HttpResponse, http } from "msw";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import { AuthProvider } from "@/features/auth";
import { cssVariablesResolver, theme } from "@/shared/theme";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import { AppRoutes } from "./AppRoutes";
+1 -2
View File
@@ -1,6 +1,5 @@
import type { FC } from "react";
import { AppBreadcrumbs } from "@/app/ui";
import { AppShell } from "@/shared/ui";
import { AppBreadcrumbs, AppShell } from "@/app/ui";
import { ProtectedDocumentTitle } from "./DocumentTitle";
const ProtectedLayout: FC = () => {
+1 -1
View File
@@ -1,11 +1,11 @@
import { MantineProvider } from "@mantine/core";
import { DatesProvider } from "@mantine/dates";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
import type { FC } from "react";
import { BrowserRouter } from "react-router";
import { AppRoutes, RouteErrorBoundary } from "@/app/routes";
import { AuthProvider } from "@/features/auth";
import { cssVariablesResolver, theme } from "@/shared/theme";
const queryClient = new QueryClient({
defaultOptions: {
@@ -2,7 +2,7 @@ import { AppShell as MantineAppShell, NavLink, Text } from "@mantine/core";
import type { FC, ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Outlet, NavLink as RouterNavLink, useLocation } from "react-router";
import { bindIconAnimation } from "../lib";
import { bindIconAnimation } from "@/shared/lib";
import classes from "./AppShell.module.css";
type AppShellProps = {
+1
View File
@@ -1 +1,2 @@
export { AppBreadcrumbs } from "./AppBreadcrumbs";
export { AppShell } from "./AppShell";
+1
View File
@@ -18,6 +18,7 @@ export {
moneyCents,
positiveMoneySchema,
} from "./model/form-validation";
export { bankOperationKinds } from "./model/operation-kinds";
export { bankPeriod } from "./model/period";
export { BankCardFace } from "./ui/BankCardFace";
export { BankCardForm } from "./ui/BankCardForm";
@@ -0,0 +1,10 @@
import type { BankOperation } from "../api/queries";
const bankOperationKinds = [
"expense",
"topup",
"transfer",
"refund",
] as const satisfies readonly BankOperation["kind"][];
export { bankOperationKinds };
@@ -1,6 +1,6 @@
import { Stack } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { EntryCard } from "@/shared/ui";
import { EntryCard } from "@wealthledger/ui";
import { BankOperationIcon } from "./BankOperationIcon";
const meta = {
@@ -1,8 +1,8 @@
import { Button, Group, Skeleton, Stack, Text, Title } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { CSSProperties, FC } from "react";
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";
import classes from "./BankSummary.module.css";
@@ -1,4 +1,5 @@
import { Anchor, Group, Text } from "@mantine/core";
import { EntryCard } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
@@ -8,7 +9,6 @@ import {
formatSnapshotTime,
instrumentLogoUrl,
} from "@/shared/lib";
import { EntryCard } from "@/shared/ui";
import type { BrokerageOperation } from "../api/useBrokerageOperations";
import { operationLabelKey, paymentPresentation } from "../model/display";
@@ -1,7 +1,7 @@
import { EntryCard } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { formatDate, formatMoney, instrumentLogoUrl } from "@/shared/lib";
import { EntryCard } from "@/shared/ui";
import type { BrokerageEvent } from "../api/useBrokerageEvents";
type BrokerageEventCardProps = { event: BrokerageEvent };
@@ -1,8 +1,8 @@
import { Alert, Button, Skeleton, Stack, Text } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { LoadingSkeleton } from "@/shared/ui";
import type { BrokerageEvent } from "../api/useBrokerageEvents";
import { BrokerageEventCard } from "./BrokerageEventCard";
@@ -8,9 +8,9 @@ import {
Text,
Title,
} from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC, PropsWithChildren, ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { LoadingSkeleton } from "@/shared/ui";
import type { SectionState } from "../model/section";
import classes from "./InstrumentDetails.module.css";
@@ -1,7 +1,7 @@
import { Alert, Button, Skeleton } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { LoadingSkeleton } from "@/shared/ui";
type TbankConnectionErrorProps = {
retry: () => void;
+1
View File
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
import "@mantine/core/styles.css";
import "@mantine/charts/styles.css";
import "@mantine/dates/styles.css";
import "@wealthledger/ui/styles.css";
import "dayjs/locale/ru";
import "./shared/i18n";
import { App } from "./app";
@@ -1,6 +1,6 @@
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { StateView } from "@/shared/ui";
const AccountEventsPageEmpty: FC = () => {
const { t } = useTranslation();
@@ -1,7 +1,7 @@
import { Button } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { StateView } from "@/shared/ui";
type AccountEventsPageErrorProps = { retry: () => void };
@@ -1,7 +1,7 @@
import { Group, Skeleton } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { LoadingSkeleton } from "@/shared/ui";
const skeletonRowIds = ["first", "second", "third", "fourth"];
+46
View File
@@ -0,0 +1,46 @@
type IncomePeriod = { from: string; to: string };
type IncomeForm = IncomePeriod;
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
function formatDraftDate(value: string): string {
const [year, month, day] = value.split("-");
return `${day}.${month}.${year}`;
}
function parseDraftDate(value: string): string | null {
if (!/^\d{2}\.\d{2}\.\d{4}$/.test(value)) return null;
const [day, month, year] = value.split(".");
const date = `${year}-${month}-${day}`;
return isDate(date) ? date : null;
}
function isDate(value: string): boolean {
if (!datePattern.test(value)) return false;
const parsed = new Date(`${value}T00:00:00Z`);
return (
Number.isFinite(parsed.valueOf()) &&
parsed.toISOString().slice(0, 10) === value
);
}
function moscowDate(value: Date): string {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Moscow",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(value);
const part = (type: string) =>
parts.find((item) => item.type === type)?.value;
return `${part("year")}-${part("month")}-${part("day")}`;
}
export {
formatDraftDate,
type IncomeForm,
type IncomePeriod,
isDate,
moscowDate,
parseDraftDate,
};
@@ -23,8 +23,9 @@ import type { IncomeAnalytics } from "@/entities/income";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server";
import { isDate } from "../model/period";
import { AccountIncomePage } from "./AccountIncomePage";
import { isDate, toIncomeDisplayModel } from "./AccountIncomePage.vm";
import { toIncomeDisplayModel } from "./AccountIncomePage.vm";
import { IncomePayments } from "./components/IncomePayments.view";
const analytics = {
@@ -6,12 +6,17 @@ import { z } from "zod";
import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
import { validationMessage } from "@/shared/i18n";
import { formatDate } from "@/shared/lib";
import {
formatDraftDate,
type IncomeForm,
type IncomePeriod,
isDate,
moscowDate,
parseDraftDate,
} from "../model/period";
const ISO_MONTH_LENGTH = 7;
type IncomePeriod = { from: string; to: string };
type IncomeForm = IncomePeriod;
type IncomeDisplayModel = {
entries: { key: string; operation: IncomeAnalytics["entries"][number] }[];
comparison: {
@@ -41,7 +46,6 @@ type AccountIncomePageViewModel =
onSubmit: (values: IncomeForm) => void;
};
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
const draftDate = z
.string()
.refine((value) => parseDraftDate(value) !== null, {
@@ -129,18 +133,6 @@ const useAccountIncomePageViewModel = (
};
};
function formatDraftDate(value: string): string {
const [year, month, day] = value.split("-");
return `${day}.${month}.${year}`;
}
function parseDraftDate(value: string): string | null {
if (!/^\d{2}\.\d{2}\.\d{4}$/.test(value)) return null;
const [day, month, year] = value.split(".");
const date = `${year}-${month}-${day}`;
return isDate(date) ? date : null;
}
function draftPeriod(period: IncomePeriod): IncomeForm {
return { from: formatDraftDate(period.from), to: formatDraftDate(period.to) };
}
@@ -163,15 +155,6 @@ function validPeriod(period: IncomePeriod): period is IncomePeriod {
);
}
function isDate(value: string): boolean {
if (!datePattern.test(value)) return false;
const parsed = new Date(`${value}T00:00:00Z`);
return (
Number.isFinite(parsed.valueOf()) &&
parsed.toISOString().slice(0, 10) === value
);
}
function toIncomeDisplayModel(
analytics: IncomeAnalytics,
): IncomeDisplayModel | null {
@@ -276,26 +259,9 @@ function formatIncomeChange(
}).format(ratio);
}
function moscowDate(value: Date): string {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Moscow",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(value);
const part = (type: string) =>
parts.find((item) => item.type === type)?.value;
return `${part("year")}-${part("month")}-${part("day")}`;
}
export {
type AccountIncomePageViewModel,
formatDraftDate,
type IncomeDisplayModel,
type IncomeForm,
isDate,
moscowDate,
parseDraftDate,
toIncomeDisplayModel,
useAccountIncomePageViewModel,
};
@@ -1,7 +1,7 @@
import { Button } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { StateView } from "@/shared/ui";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
type ErrorProps = Extract<AccountIncomePageViewModel, { state: "error" }>;
@@ -7,10 +7,10 @@ import {
Text,
Title,
} from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { FormProvider } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { StateView } from "@/shared/ui";
import classes from "../AccountIncomePage.module.css";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
import { IncomeComparisons } from "./IncomeComparisons.view";
@@ -1,7 +1,7 @@
import { Paper, Skeleton, Stack } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { LoadingSkeleton } from "@/shared/ui";
import classes from "../AccountIncomePage.module.css";
const AccountIncomePageLoading: FC = () => {
@@ -0,0 +1,58 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { FC } from "react";
import { FormProvider, useForm, useFormState } from "react-hook-form";
import type { IncomeForm } from "../../model/period";
import { IncomePeriodForm } from "./IncomePeriodForm.view";
const period = { from: "01.08.2026", to: "27.08.2026" };
const errors = {
to: { type: "manual", message: "Синтетическая ошибка периода" },
};
type PeriodExampleProps = { invalid?: boolean };
const PeriodExample: FC<PeriodExampleProps> = (props) => {
const methods = useForm<IncomeForm>({
defaultValues: period,
errors: props.invalid ? errors : undefined,
});
const { isSubmitSuccessful } = useFormState({ control: methods.control });
const submitted = isSubmitSuccessful ? methods.getValues() : null;
return (
<FormProvider {...methods}>
<IncomePeriodForm
onSubmit={methods.handleSubmit(() => undefined)}
onReset={() => {
methods.reset(period);
}}
/>
<output aria-live="polite">
{submitted
? `Применено: ${submitted.from} – ${submitted.to}`
: "Период не применён"}
</output>
</FormProvider>
);
};
const meta = {
title: "Product/IncomePeriodForm",
component: PeriodExample,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component:
"Изолированная форма с синтетическим периодом. Submit показывает значения локально; reset возвращает fixture. Ошибка задана явно: query, URL и production validation остаются у page VM, API и router providers не используются.",
},
},
},
render: () => <PeriodExample />,
} satisfies Meta<typeof PeriodExample>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const ValidationError: Story = {
render: () => <PeriodExample invalid />,
};
@@ -3,13 +3,13 @@ import { DatePickerInput } from "@mantine/dates";
import type { FC, FormEventHandler } from "react";
import { useController, useFormContext, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import classes from "../AccountIncomePage.module.css";
import {
formatDraftDate,
type IncomeForm,
moscowDate,
parseDraftDate,
} from "../AccountIncomePage.vm";
} from "../../model/period";
import classes from "../AccountIncomePage.module.css";
type IncomePeriodFormProps = {
onSubmit: FormEventHandler<HTMLFormElement>;
@@ -1,7 +1,7 @@
import { Button } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { StateView } from "@/shared/ui";
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
type AccountOperationsPageErrorProps = Extract<
@@ -8,6 +8,7 @@ import {
Title,
} from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -15,7 +16,6 @@ import {
type OperationCategory,
operationCategories,
} from "@/entities/broker-operation";
import { StateView } from "@/shared/ui";
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
type AccountOperationsPageLoadedProps = Extract<
@@ -1,7 +1,7 @@
import { Group, Skeleton, Stack } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { LoadingSkeleton } from "@/shared/ui";
const skeletonRowIds = ["first", "second", "third", "fourth"];
@@ -1,9 +1,9 @@
import { Stack } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
import { StateView } from "@/shared/ui";
import type {
AccountPageIncomeSummary,
AccountPageRecentEvents,
@@ -1,8 +1,8 @@
import { Button } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { StateView } from "@/shared/ui";
type AccountPageErrorProps = { code?: string; retry: () => void };
@@ -1,7 +1,7 @@
import { Paper, SimpleGrid, Skeleton } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { LoadingSkeleton } from "@/shared/ui";
import classes from "../AccountPage.module.css";
const AccountPageLoading: FC = () => {
@@ -1,10 +1,10 @@
import { Button, Paper, Stack, Text } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { IncomeSummary as IncomeSummaryData } from "@/entities/income";
import { formatMoney } from "@/shared/lib";
import { StateView } from "@/shared/ui";
type IncomeSummaryProps =
| { state: "loading"; to: string }
@@ -1,9 +1,9 @@
import { Alert, Button, Skeleton, Stack, Text } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { LedgerEntryRow } from "@/entities/broker-operation";
import { LoadingSkeleton } from "@/shared/ui";
import type { AccountPageRecentOperations } from "../AccountPage.vm";
type RecentOperationsProps = {
@@ -1,7 +1,7 @@
import { Stack } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { StateView } from "@/shared/ui";
import {
AccountsRefreshState,
type AccountsRefreshStateProps,
@@ -1,8 +1,8 @@
import { Button, Stack } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { StateView } from "@/shared/ui";
type AccountsPageErrorProps = { code?: string; retry?: () => void };
@@ -1,7 +1,7 @@
import { Card, Group, SimpleGrid, Skeleton, Stack } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { LoadingSkeleton } from "@/shared/ui";
const AccountsPageLoading: FC = () => {
const { t } = useTranslation();
+1 -1
View File
@@ -1,8 +1,8 @@
import { Anchor, Button, Skeleton, Stack, Text, Title } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link, Navigate } from "react-router";
import { LoadingSkeleton } from "@/shared/ui";
import { BondPageView } from "./BondPage.view";
import { useBondPageVm } from "./BondPage.vm";
+1 -1
View File
@@ -12,6 +12,7 @@ import {
Text,
Title,
} from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -26,7 +27,6 @@ import {
formatMoney,
formatSnapshotTime,
} from "@/shared/lib";
import { LoadingSkeleton } from "@/shared/ui";
import classes from "./BondPage.module.css";
import type { BondPageVm } from "./BondPage.vm";
+1 -1
View File
@@ -12,6 +12,7 @@ import {
TextInput,
Title,
} from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
@@ -21,7 +22,6 @@ import {
formatMoney,
formatSnapshotTime,
} from "@/shared/lib";
import { LoadingSkeleton } from "@/shared/ui";
import classes from "./BondsPage.module.css";
import type { BondsPageVm } from "./BondsPage.vm";
+23 -71
View File
@@ -10,6 +10,8 @@ import {
Text,
Title,
} from "@mantine/core";
import { AddIcon, DeleteIcon, EditIcon } from "@wealthledger/icons";
import { EntryCard, LoadingSkeleton, StateView } from "@wealthledger/ui";
import { type FC, useLayoutEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
@@ -20,7 +22,6 @@ import {
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";
@@ -106,19 +107,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
title={t("bank.editCard")}
onClick={() => vm.setEditingCard(true)}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="m16 3 5 5-12 12-6 1 1-6L16 3Z M13 6l5 5" />
</svg>
<EditIcon size={18} strokeWidth={1.5} />
</ActionIcon>
<ActionIcon
variant="subtle"
@@ -130,25 +119,23 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
loading={vm.archivePending}
onClick={() => void vm.toggleArchive()}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path
d={
card.archivedAt
? "M3 3h18v4H3z M5 7v14h14V7 M12 17V10m-3 3 3-3 3 3"
: "M3 6h18 M9 6V3h6v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7"
}
/>
</svg>
{card.archivedAt ? (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M3 3h18v4H3z M5 7v14h14V7 M12 17V10m-3 3 3-3 3 3" />
</svg>
) : (
<DeleteIcon size={18} strokeWidth={1.5} />
)}
</ActionIcon>
</Group>
</div>
@@ -196,18 +183,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
disabled={Boolean(card.archivedAt) || !vm.cards.data}
onClick={() => vm.setOperation("new")}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
aria-hidden="true"
>
<path d="M12 5v14 M5 12h14" />
</svg>
<AddIcon size={20} strokeWidth={1.5} />
</ActionIcon>
</Group>
<Select
@@ -279,19 +255,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
title={t("bank.edit")}
onClick={() => vm.setOperation(row.item)}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="m16 3 5 5-12 12-6 1 1-6L16 3Z M13 6l5 5" />
</svg>
<EditIcon size={18} strokeWidth={1.5} />
</ActionIcon>
<ActionIcon
variant="subtle"
@@ -306,19 +270,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
title={t("bank.delete")}
onClick={() => vm.setDeleting(row.item)}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M3 6h18 M9 6V3h6v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7" />
</svg>
<DeleteIcon size={18} strokeWidth={1.5} />
</ActionIcon>
</Group>
}
+113
View File
@@ -0,0 +1,113 @@
import { Alert } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { type FC, useState } from "react";
import { FormProvider, useForm, useWatch } from "react-hook-form";
import type { OperationValues } from "./OperationForm.types";
import { OperationFormView } from "./OperationForm.view";
type ExampleProps = { pending?: boolean; conflict?: boolean };
const OperationExample: FC<ExampleProps> = (props) => {
const [opened, setOpened] = useState(true);
const [action, setAction] = useState("");
const methods = useForm<OperationValues>({
defaultValues: {
kind: "refund",
cardId: "demo",
targetCardId: "",
categoryId: "",
originalExpenseId: "purchase",
amount: "500",
date: "2026-08-01",
description: "Синтетический возврат",
},
});
const kind = useWatch({ control: methods.control, name: "kind" });
return (
<FormProvider {...methods}>
<OperationFormView
opened={opened}
pending={Boolean(props.pending)}
editing
categoriesOpen={false}
onClose={() => !props.pending && setOpened(false)}
onCancel={() => setOpened(false)}
onExitTransitionEnd={() => setAction("Форма закрыта")}
onSubmit={(event) => {
event.preventDefault();
setAction("Локальный submit");
}}
fields={{
editing: true,
refundCardId: "demo",
kind,
cards: [{ value: "demo", label: "Учебная карта" }],
targets: [],
categories: [],
defaultCategory: "",
kinds: [{ value: "refund", label: "Возврат" }],
dateInputValue: () => "2026-08-01",
validDate: (value) => value === "2026-08-01",
maxDate: () => "2026-08-27",
}}
refund={{
editing: true,
expenses: [
{
value: "purchase",
label: "01.07.2026 · Учебная покупка · 500 ₽",
},
],
onSelect: () => undefined,
limit: { currency: "RUB", value: "500" },
}}
loadingExpenses={false}
emptyExpenses={false}
sourcePage={1}
previousDisabled
nextDisabled
onPrevious={() => undefined}
onNext={() => undefined}
onAddCategory={() => undefined}
saveDisabled={false}
feedback={
props.conflict && (
<Alert color="orange" role="alert">
Синтетический конфликт версии
</Alert>
)
}
onReload={
props.conflict ? () => setAction("Локальный reload") : undefined
}
latest={
props.conflict
? {
version: 2,
details: "Учебная версия",
onAdopt: () => setAction("Локальное принятие версии"),
}
: undefined
}
/>
<output aria-live="polite">{action}</output>
</FormProvider>
);
};
const meta = {
title: "Product/OperationForm",
component: OperationExample,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component:
"Synthetic presentation без API/VM. Date callbacks фиксируют fixture 01.08.2026; денежная validation, recovery и nested category flow проверяются application tests. Actions только локальные.",
},
},
},
} satisfies Meta<typeof OperationExample>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Refund: Story = {};
export const Pending: Story = { args: { pending: true } };
export const Conflict: Story = { args: { conflict: true } };
+38
View File
@@ -70,6 +70,43 @@ async function select(label: string, value: string | RegExp) {
fireEvent.click(await screen.findByRole("option", { name: value }));
}
describe("OperationForm", () => {
it("blocks closing and editing while save is pending, then closes after success", async () => {
let finish: () => void = () => {};
const response = new Promise<void>((resolve) => {
finish = resolve;
});
const received = vi.fn();
server.use(
http.post("*/api/v1/bank-cards/operations", async () => {
received();
await response;
return HttpResponse.json({}, { status: 201 });
}),
);
const close = setup();
await waitFor(() =>
expect(screen.getByRole("combobox", { name: /Категория/ })).toHaveValue(
"Остальное",
),
);
fireEvent.change(screen.getByLabelText(/Сумма/), {
target: { value: "10" },
});
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(received).toHaveBeenCalledOnce());
try {
expect(screen.getByLabelText(/Сумма/)).toBeDisabled();
expect(screen.getByRole("button", { name: "Закрыть" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Отмена" })).toBeDisabled();
fireEvent.keyDown(document, { key: "Escape" });
expect(close).not.toHaveBeenCalled();
expect(screen.getByRole("dialog")).toBeInTheDocument();
} finally {
finish();
}
await waitFor(() => expect(close).toHaveBeenCalledOnce());
});
it("uses renamed default category and exact maximum positive money despite zero balance", async () => {
const close = setup();
await waitFor(() =>
@@ -95,6 +132,7 @@ describe("OperationForm", () => {
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(close).toHaveBeenCalled());
expect(body).toMatchObject({
kind: "expense",
categoryId: "default",
date: "2026-08-07",
amount: money("92233720368547758.07"),
+42 -411
View File
@@ -1,420 +1,51 @@
import { zodResolver } from "@hookform/resolvers/zod";
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 { z } from "zod";
import {
type BankCard,
BankFeedback,
type BankOperation,
bankDateSchema,
bankMoneyValue,
bankToday,
CategoryManager,
moneyCents,
positiveMoneySchema,
useBankCategories,
useBankExpenses,
useBankFormFocus,
useBankMutations,
useBankOperation,
} from "@/entities/bank-card";
import { validationMessage } from "@/shared/i18n";
import { formatDate, formatMoney } from "@/shared/lib";
import classes from "./OperationForm.module.css";
import type { OperationValues } from "./OperationForm.types";
import { OperationFields, RefundFields } from "./OperationForm.view";
import type { FC } from "react";
import { FormProvider } from "react-hook-form";
import { BankFeedback, CategoryManager } from "@/entities/bank-card";
import type { OperationFormProps } from "./OperationForm.types";
import { OperationFormView } from "./OperationForm.view";
import { useOperationFormVm } from "./OperationForm.vm";
const MAX_DESCRIPTION_LENGTH = 500;
const operationSchema = z.object({
kind: z.enum(["expense", "topup", "transfer", "refund"]),
cardId: z.string(),
targetCardId: z.string(),
categoryId: z.string(),
originalExpenseId: z.string(),
amount: positiveMoneySchema,
date: bankDateSchema,
description: z
.string()
.max(
MAX_DESCRIPTION_LENGTH,
validationMessage("bank.validation.description")(undefined),
),
});
type OperationFormProps = {
card: BankCard;
cards: BankCard[];
operation?: BankOperation;
onClose: () => void;
};
const OperationForm: FC<OperationFormProps> = (props) => {
useBankFormFocus();
const [opened, setOpened] = useState(true);
const { t } = useTranslation();
const [categoriesOpen, setCategoriesOpen] = useState(false);
const [version, setVersion] = useState(props.operation?.version ?? 1);
const [latest, setLatest] = useState<BankOperation>();
const [sourcePage, setSourcePage] = useState(1);
const [sourceId, setSourceId] = useState(
props.operation?.originalExpenseId ?? "",
);
const sourceQuery = useBankOperation(sourceId || undefined);
const currentQuery = useBankOperation(props.operation?.id);
const [refundLimit, setRefundLimit] = useState(
props.operation?.availableRefund,
);
const [intent] = useState(() => crypto.randomUUID());
const [submittedValues, setSubmittedValues] = useState<OperationValues>();
const [error, setError] = useState<unknown>();
const categories = useBankCategories();
const mutation = useBankMutations();
const methods = useForm<OperationValues>({
resolver: zodResolver(
operationSchema.superRefine((values, ctx) => {
// Repeated intent reaches server idempotency even after its source changed.
if (
submittedValues &&
JSON.stringify(values) === JSON.stringify(submittedValues)
)
return;
const source = props.cards.find((c) => c.id === values.cardId);
const target = props.cards.find((c) => c.id === values.targetCardId);
if (!source || source.archivedAt)
ctx.addIssue({
code: "custom",
path: ["cardId"],
...validationMessage("bank.validation.required")(undefined),
});
if (
values.kind === "transfer" &&
(!target || target.archivedAt || target.id === values.cardId)
)
ctx.addIssue({
code: "custom",
path: ["targetCardId"],
...validationMessage("bank.validation.destination")(undefined),
});
if (
(source && values.date < source.openingDate) ||
(values.kind === "transfer" &&
target &&
values.date < target.openingDate)
)
ctx.addIssue({
code: "custom",
path: ["date"],
...validationMessage("bank.validation.start")(undefined),
});
if (values.kind === "refund") {
const expense = sourceQuery.data;
if (!expense || expense.cardId !== values.cardId)
ctx.addIssue({
code: "custom",
path: ["originalExpenseId"],
...validationMessage("bank.validation.required")(undefined),
});
if (expense && values.date < expense.date)
ctx.addIssue({
code: "custom",
path: ["date"],
...validationMessage("bank.validation.start")(undefined),
});
const limit = props.operation
? refundLimit
: expense?.availableRefund;
const amount = moneyCents(values.amount);
if (
limit &&
amount !== null &&
amount > (moneyCents(limit.value) ?? 0n)
)
ctx.addIssue({
code: "custom",
path: ["amount"],
...validationMessage("bank.validation.refund")(undefined),
});
}
if (
values.kind === "expense" &&
!categories.data?.some(
(c) =>
c.id ===
(values.categoryId ||
categories.data?.find((c) => c.isDefault)?.id),
)
)
ctx.addIssue({
code: "custom",
path: ["categoryId"],
...validationMessage("bank.validation.required")(undefined),
});
}),
),
defaultValues: {
kind: props.operation?.kind ?? "expense",
cardId: props.operation?.cardId ?? props.card.id,
targetCardId: props.operation?.targetCardId ?? "",
categoryId: props.operation?.categoryId ?? "",
originalExpenseId: props.operation?.originalExpenseId ?? "",
amount: props.operation?.amount.value ?? "",
date: props.operation?.date ?? bankToday(),
description: props.operation?.description ?? "",
},
});
const kind = methods.watch("kind");
const expenses = useBankExpenses(
kind === "refund" ? props.card.id : undefined,
{ page: sourcePage },
);
const source = methods.watch("cardId");
const pending = !opened || methods.formState.isSubmitting;
const options = props.cards
.filter((c) => !c.archivedAt)
.map((c) => ({ value: c.id, label: c.name }));
const vm = useOperationFormVm(props);
return (
<>
<Modal
opened={opened}
onExitTransitionEnd={props.onClose}
onClose={() => !pending && !categoriesOpen && setOpened(false)}
title={t(props.operation ? "bank.editOperation" : "bank.addOperation")}
closeOnEscape={!pending && !categoriesOpen}
closeOnClickOutside={!pending && !categoriesOpen}
closeButtonProps={{ "aria-label": t("bank.close"), disabled: pending }}
>
<FormProvider {...methods}>
<form
onSubmit={methods.handleSubmit(async (values) => {
setSubmittedValues(values);
setError(undefined);
try {
const body = {
kind: values.kind,
cardId: values.cardId,
...(values.kind === "transfer"
? { targetCardId: values.targetCardId }
: {}),
...(values.kind === "expense"
? {
categoryId:
values.categoryId ||
categories.data?.find((c) => c.isDefault)?.id,
}
: {}),
...(values.kind === "refund"
? { originalExpenseId: values.originalExpenseId }
: {}),
amount: {
currency: "RUB" as const,
value: bankMoneyValue(values.amount),
},
date: values.date,
description: values.description,
};
if (props.operation)
await mutation.updateOperation.mutateAsync({
id: props.operation.id,
body: { ...body, version },
});
else
await mutation.createOperation.mutateAsync({
id: intent,
...body,
});
setOpened(false);
} catch (failure) {
setError(failure);
}
})}
>
<fieldset disabled={pending} className={classes.fieldset}>
<Stack>
<OperationFields
refundCardId={props.card.id}
editing={Boolean(props.operation)}
kind={kind}
cards={options}
targets={options.filter((c) => c.value !== source)}
categories={
categories.data?.map((c) => ({
value: c.id,
label: c.name,
})) ?? []
}
defaultCategory={
categories.data?.find((c) => c.isDefault)?.id ?? ""
}
<FormProvider {...vm.methods}>
<OperationFormView
{...vm.view}
refundFeedback={
<>
{vm.expensesError && (
<BankFeedback
loadingError
error={vm.expensesError}
retry={vm.retryExpenses}
/>
{kind === "refund" && (
<>
<RefundFields
editing={Boolean(props.operation)}
expenses={
expenses.data?.items.map((item) => ({
value: item.id,
label: t("bank.expenseChoice", {
date: formatDate(item.date),
name: item.description || item.categoryName,
amount: formatMoney(item.amount),
available: formatMoney(item.availableRefund),
}),
})) ?? []
}
selected={
sourceQuery.data
? {
value: sourceQuery.data.id,
label: t("bank.expenseChoice", {
date: formatDate(sourceQuery.data.date),
name:
sourceQuery.data.description ||
sourceQuery.data.categoryName,
amount: formatMoney(sourceQuery.data.amount),
available: formatMoney(
sourceQuery.data.availableRefund,
),
}),
}
: undefined
}
onSelect={setSourceId}
limit={
props.operation
? refundLimit
: sourceQuery.data?.availableRefund
}
/>
{expenses.isLoading && (
<Text role="status">{t("bank.loadingHistory")}</Text>
)}
{expenses.data &&
!expenses.data.items.length &&
!props.operation && <Text>{t("bank.noPurchases")}</Text>}
{!props.operation && (
<Group>
<Button
variant="default"
disabled={sourcePage === 1 || expenses.isFetching}
onClick={() => setSourcePage(sourcePage - 1)}
>
{t("bank.previous")}
</Button>
<Text>{t("bank.page", { page: sourcePage })}</Text>
<Button
variant="default"
disabled={
!expenses.data?.hasNext || expenses.isFetching
}
onClick={() => setSourcePage(sourcePage + 1)}
>
{t("bank.next")}
</Button>
</Group>
)}
{expenses.isError && (
<BankFeedback
loadingError
error={expenses.error}
retry={() => void expenses.refetch()}
/>
)}{" "}
{sourceQuery.isError && (
<BankFeedback
loadingError
error={sourceQuery.error}
retry={() => void sourceQuery.refetch()}
/>
)}
</>
)}
{kind === "expense" && (
<Button
variant="subtle"
onClick={() => setCategoriesOpen(true)}
>
{t("bank.addCategory")}
</Button>
)}
{categories.isError && (
<BankFeedback
loadingError
error={categories.error}
retry={() => void categories.refetch()}
/>
)}
{error ? (
<>
<BankFeedback error={error} />
{(props.operation || sourceId) && (
<Button
variant="default"
onClick={async () => {
const query = props.operation
? currentQuery
: sourceQuery;
const result = await query.refetch();
if (result.data && !result.error) {
if (props.operation) {
setLatest(result.data);
setRefundLimit(result.data.availableRefund);
} else setError(undefined);
} else setError(result.error);
}}
>
{t("bank.reloadRecord")}
</Button>
)}
</>
) : null}
{latest && (
<>
<Text>
{t("bank.versionHint", {
details: `${formatDate(latest.date)} · ${formatMoney(latest.amount)} · ${latest.description ?? ""}`,
})}
</Text>
<Button
variant="default"
onClick={() => {
setVersion(latest.version);
setLatest(undefined);
setError(undefined);
}}
>
{t("bank.adoptVersion", { version: latest.version })}
</Button>
</>
)}
<Group>
<Button
type="submit"
loading={pending}
disabled={
(kind === "expense" && !categories.data) ||
(kind === "refund" && !sourceQuery.data)
}
>
{t("bank.save")}
</Button>
<Button variant="default" onClick={() => setOpened(false)}>
{t("bank.cancel")}
</Button>
</Group>
</Stack>
</fieldset>
</form>
</FormProvider>
</Modal>
{categoriesOpen && (
)}{" "}
{vm.sourceError && (
<BankFeedback
loadingError
error={vm.sourceError}
retry={vm.retrySource}
/>
)}
</>
}
categoriesFeedback={
vm.categoriesError && (
<BankFeedback
loadingError
error={vm.categoriesError}
retry={vm.retryCategories}
/>
)
}
feedback={vm.error ? <BankFeedback error={vm.error} /> : null}
/>
</FormProvider>
{vm.categoriesOpen && (
<CategoryManager
onClose={() => setCategoriesOpen(false)}
onCreated={(id) => {
methods.setValue("categoryId", id);
setCategoriesOpen(false);
}}
onClose={vm.closeCategories}
onCreated={vm.onCategoryCreated}
/>
)}
</>
+9 -2
View File
@@ -1,4 +1,4 @@
import type { BankOperation } from "@/entities/bank-card";
import type { BankCard, BankOperation } from "@/entities/bank-card";
type OperationValues = {
kind: BankOperation["kind"];
@@ -11,4 +11,11 @@ type OperationValues = {
description: string;
};
export type { OperationValues };
type OperationFormProps = {
card: BankCard;
cards: BankCard[];
operation?: BankOperation;
onClose: () => void;
};
export type { OperationFormProps, OperationValues };
+141 -29
View File
@@ -1,15 +1,20 @@
import { Select, Stack, Text, Textarea, TextInput } from "@mantine/core";
import {
Button,
Group,
Modal,
Select,
Stack,
Text,
Textarea,
TextInput,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import type { FC } from "react";
import type { FC, FormEventHandler, ReactNode } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
type BankOperation,
bankDateInputValue,
bankDateSchema,
bankToday,
} from "@/entities/bank-card";
import type { BankOperation } from "@/entities/bank-card";
import { formatMoney } from "@/shared/lib";
import classes from "./OperationForm.module.css";
import type { OperationValues } from "./OperationForm.types";
type Choice = { value: string; label: string };
@@ -21,6 +26,10 @@ type OperationFieldsProps = {
targets: Choice[];
categories: Choice[];
defaultCategory: string;
kinds: Choice[];
dateInputValue: (value: string) => string;
validDate: (value: string) => boolean;
maxDate: () => string;
};
const OperationFields: FC<OperationFieldsProps> = (props) => {
const { t } = useTranslation();
@@ -39,9 +48,7 @@ const OperationFields: FC<OperationFieldsProps> = (props) => {
<Select
label={t("bank.kind")}
required
data={(["expense", "topup", "transfer", "refund"] as const).map(
(value) => ({ value, label: t(`bank.kinds.${value}`) }),
)}
data={props.kinds}
{...field}
disabled={isSubmitting || props.editing}
onChange={(value) => {
@@ -99,24 +106,22 @@ const OperationFields: FC<OperationFieldsProps> = (props) => {
label={t("bank.date")}
required
valueFormat="DD.MM.YYYY"
value={
bankDateSchema.safeParse(field.value).success ? field.value : null
}
value={props.validDate(field.value) ? field.value : null}
dateParser={(value) =>
bankDateSchema.safeParse(bankDateInputValue(value)).success
? bankDateInputValue(value)
props.validDate(props.dateInputValue(value))
? props.dateInputValue(value)
: null
}
fixOnBlur={false}
onInput={(event) =>
field.onChange(bankDateInputValue(event.currentTarget.value))
field.onChange(props.dateInputValue(event.currentTarget.value))
}
onChange={(value) => field.onChange(value ?? "")}
onBlur={(event) => {
field.onChange(bankDateInputValue(event.currentTarget.value));
field.onChange(props.dateInputValue(event.currentTarget.value));
field.onBlur();
}}
maxDate={bankToday()}
maxDate={props.maxDate()}
popoverProps={{
withinPortal: false,
middlewares: { shift: { crossAxis: true } },
@@ -153,7 +158,6 @@ const OperationFields: FC<OperationFieldsProps> = (props) => {
type RefundFieldsProps = {
editing: boolean;
expenses: Choice[];
selected?: Choice;
onSelect: (id: string) => void;
limit: BankOperation["availableRefund"] | undefined;
};
@@ -172,14 +176,7 @@ const RefundFields: FC<RefundFieldsProps> = (props) => {
<Select
label={t("bank.expense")}
required
data={
props.selected &&
!props.expenses.some(
(item) => item.value === props.selected?.value,
)
? [props.selected, ...props.expenses]
: props.expenses
}
data={props.expenses}
{...field}
onChange={(value) => {
field.onChange(value ?? "");
@@ -200,4 +197,119 @@ const RefundFields: FC<RefundFieldsProps> = (props) => {
);
};
export { OperationFields, RefundFields };
type OperationFormViewProps = {
opened: boolean;
pending: boolean;
categoriesOpen: boolean;
editing: boolean;
onClose: () => void;
onExitTransitionEnd: () => void;
onCancel: () => void;
onSubmit: FormEventHandler<HTMLFormElement>;
fields: OperationFieldsProps;
refund: RefundFieldsProps;
loadingExpenses: boolean;
emptyExpenses: boolean;
sourcePage: number;
previousDisabled: boolean;
nextDisabled: boolean;
onPrevious: () => void;
onNext: () => void;
onAddCategory: () => void;
onReload?: () => void;
latest?: { version: number; details: string; onAdopt: () => void };
saveDisabled: boolean;
refundFeedback?: ReactNode;
categoriesFeedback?: ReactNode;
feedback?: ReactNode;
};
const OperationFormView: FC<OperationFormViewProps> = (props) => {
const { t } = useTranslation();
return (
<Modal
opened={props.opened}
onExitTransitionEnd={props.onExitTransitionEnd}
onClose={props.onClose}
title={t(props.editing ? "bank.editOperation" : "bank.addOperation")}
closeOnEscape={!props.pending && !props.categoriesOpen}
closeOnClickOutside={!props.pending && !props.categoriesOpen}
closeButtonProps={{
"aria-label": t("bank.close"),
disabled: props.pending,
}}
>
<form onSubmit={props.onSubmit}>
<fieldset disabled={props.pending} className={classes.fieldset}>
<Stack>
<OperationFields {...props.fields} />
{props.fields.kind === "refund" && (
<>
<RefundFields {...props.refund} />
{props.loadingExpenses && (
<Text role="status">{t("bank.loadingHistory")}</Text>
)}
{props.emptyExpenses && <Text>{t("bank.noPurchases")}</Text>}
{!props.editing && (
<Group>
<Button
variant="default"
disabled={props.previousDisabled}
onClick={props.onPrevious}
>
{t("bank.previous")}
</Button>
<Text>{t("bank.page", { page: props.sourcePage })}</Text>
<Button
variant="default"
disabled={props.nextDisabled}
onClick={props.onNext}
>
{t("bank.next")}
</Button>
</Group>
)}
{props.refundFeedback}
</>
)}
{props.fields.kind === "expense" && (
<Button variant="subtle" onClick={props.onAddCategory}>
{t("bank.addCategory")}
</Button>
)}
{props.categoriesFeedback}
{props.feedback}
{props.onReload && (
<Button variant="default" onClick={props.onReload}>
{t("bank.reloadRecord")}
</Button>
)}
{props.latest && (
<>
<Text>
{t("bank.versionHint", { details: props.latest.details })}
</Text>
<Button variant="default" onClick={props.latest.onAdopt}>
{t("bank.adoptVersion", { version: props.latest.version })}
</Button>
</>
)}
<Group>
<Button
type="submit"
loading={props.pending}
disabled={props.saveDisabled}
>
{t("bank.save")}
</Button>
<Button variant="default" onClick={props.onCancel}>
{t("bank.cancel")}
</Button>
</Group>
</Stack>
</fieldset>
</form>
</Modal>
);
};
export { OperationFormView };
+318
View File
@@ -0,0 +1,318 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import {
type BankOperation,
bankDateInputValue,
bankDateSchema,
bankMoneyValue,
bankOperationKinds,
bankToday,
moneyCents,
positiveMoneySchema,
useBankCategories,
useBankExpenses,
useBankFormFocus,
useBankMutations,
useBankOperation,
} from "@/entities/bank-card";
import { validationMessage } from "@/shared/i18n";
import { formatDate, formatMoney } from "@/shared/lib";
import type {
OperationFormProps,
OperationValues,
} from "./OperationForm.types";
const MAX_DESCRIPTION_LENGTH = 500;
const operationSchema = z.object({
kind: z.enum(bankOperationKinds),
cardId: z.string(),
targetCardId: z.string(),
categoryId: z.string(),
originalExpenseId: z.string(),
amount: positiveMoneySchema,
date: bankDateSchema,
description: z
.string()
.max(
MAX_DESCRIPTION_LENGTH,
validationMessage("bank.validation.description")(undefined),
),
});
const useOperationFormVm = (props: OperationFormProps) => {
useBankFormFocus();
const [opened, setOpened] = useState(true);
const { t } = useTranslation();
const [categoriesOpen, setCategoriesOpen] = useState(false);
const [version, setVersion] = useState(props.operation?.version ?? 1);
const [latest, setLatest] = useState<BankOperation>();
const [sourcePage, setSourcePage] = useState(1);
const [sourceId, setSourceId] = useState(
props.operation?.originalExpenseId ?? "",
);
const sourceQuery = useBankOperation(sourceId || undefined);
const currentQuery = useBankOperation(props.operation?.id);
const [refundLimit, setRefundLimit] = useState(
props.operation?.availableRefund,
);
const [intent] = useState(() => crypto.randomUUID());
const [submittedValues, setSubmittedValues] = useState<OperationValues>();
const [error, setError] = useState<unknown>();
const categories = useBankCategories();
const mutation = useBankMutations();
const methods = useForm<OperationValues>({
resolver: zodResolver(
operationSchema.superRefine((values, ctx) => {
// Repeated intent reaches server idempotency even after its source changed.
if (
submittedValues &&
JSON.stringify(values) === JSON.stringify(submittedValues)
)
return;
const source = props.cards.find((c) => c.id === values.cardId);
const target = props.cards.find((c) => c.id === values.targetCardId);
if (!source || source.archivedAt)
ctx.addIssue({
code: "custom",
path: ["cardId"],
...validationMessage("bank.validation.required")(undefined),
});
if (
values.kind === "transfer" &&
(!target || target.archivedAt || target.id === values.cardId)
)
ctx.addIssue({
code: "custom",
path: ["targetCardId"],
...validationMessage("bank.validation.destination")(undefined),
});
if (
(source && values.date < source.openingDate) ||
(values.kind === "transfer" &&
target &&
values.date < target.openingDate)
)
ctx.addIssue({
code: "custom",
path: ["date"],
...validationMessage("bank.validation.start")(undefined),
});
if (values.kind === "refund") {
const expense = sourceQuery.data;
if (!expense || expense.cardId !== values.cardId)
ctx.addIssue({
code: "custom",
path: ["originalExpenseId"],
...validationMessage("bank.validation.required")(undefined),
});
if (expense && values.date < expense.date)
ctx.addIssue({
code: "custom",
path: ["date"],
...validationMessage("bank.validation.start")(undefined),
});
const limit = props.operation
? refundLimit
: expense?.availableRefund;
const amount = moneyCents(values.amount);
if (
limit &&
amount !== null &&
amount > (moneyCents(limit.value) ?? 0n)
)
ctx.addIssue({
code: "custom",
path: ["amount"],
...validationMessage("bank.validation.refund")(undefined),
});
}
if (
values.kind === "expense" &&
!categories.data?.some(
(c) =>
c.id ===
(values.categoryId ||
categories.data?.find((c) => c.isDefault)?.id),
)
)
ctx.addIssue({
code: "custom",
path: ["categoryId"],
...validationMessage("bank.validation.required")(undefined),
});
}),
),
defaultValues: {
kind: props.operation?.kind ?? "expense",
cardId: props.operation?.cardId ?? props.card.id,
targetCardId: props.operation?.targetCardId ?? "",
categoryId: props.operation?.categoryId ?? "",
originalExpenseId: props.operation?.originalExpenseId ?? "",
amount: props.operation?.amount.value ?? "",
date: props.operation?.date ?? bankToday(),
description: props.operation?.description ?? "",
},
});
const kind = methods.watch("kind");
const expenses = useBankExpenses(
kind === "refund" ? props.card.id : undefined,
{ page: sourcePage },
);
const source = methods.watch("cardId");
const pending = !opened || methods.formState.isSubmitting;
const options = props.cards
.filter((c) => !c.archivedAt)
.map((c) => ({ value: c.id, label: c.name }));
const onSubmit = methods.handleSubmit(async (values) => {
setSubmittedValues(values);
setError(undefined);
try {
const body = {
kind: values.kind,
cardId: values.cardId,
...(values.kind === "transfer"
? { targetCardId: values.targetCardId }
: {}),
...(values.kind === "expense"
? {
categoryId:
values.categoryId ||
categories.data?.find((c) => c.isDefault)?.id,
}
: {}),
...(values.kind === "refund"
? { originalExpenseId: values.originalExpenseId }
: {}),
amount: {
currency: "RUB" as const,
value: bankMoneyValue(values.amount),
},
date: values.date,
description: values.description,
};
if (props.operation)
await mutation.updateOperation.mutateAsync({
id: props.operation.id,
body: { ...body, version },
});
else
await mutation.createOperation.mutateAsync({
id: intent,
...body,
});
setOpened(false);
} catch (failure) {
setError(failure);
}
});
const onReload = async () => {
const query = props.operation ? currentQuery : sourceQuery;
const result = await query.refetch();
if (result.data && !result.error) {
if (props.operation) {
setLatest(result.data);
setRefundLimit(result.data.availableRefund);
} else setError(undefined);
} else setError(result.error);
};
const expenseChoice = (item: BankOperation) => ({
value: item.id,
label: t("bank.expenseChoice", {
date: formatDate(item.date),
name: item.description || item.categoryName,
amount: formatMoney(item.amount),
available: formatMoney(item.availableRefund),
}),
});
const expenseOptions = expenses.data?.items.map(expenseChoice) ?? [];
const selected = sourceQuery.data
? expenseChoice(sourceQuery.data)
: undefined;
return {
methods,
categoriesOpen,
error,
categoriesError: categories.isError ? categories.error : null,
expensesError: expenses.isError ? expenses.error : null,
sourceError: sourceQuery.isError ? sourceQuery.error : null,
retryCategories: () => void categories.refetch(),
retryExpenses: () => void expenses.refetch(),
retrySource: () => void sourceQuery.refetch(),
closeCategories: () => setCategoriesOpen(false),
onCategoryCreated: (id: string) => {
methods.setValue("categoryId", id);
setCategoriesOpen(false);
},
view: {
opened,
pending,
categoriesOpen,
editing: Boolean(props.operation),
onClose: () => !pending && !categoriesOpen && setOpened(false),
onExitTransitionEnd: props.onClose,
onCancel: () => setOpened(false),
onSubmit,
fields: {
refundCardId: props.card.id,
editing: Boolean(props.operation),
kind,
cards: options,
targets: options.filter((c) => c.value !== source),
categories:
categories.data?.map((c) => ({ value: c.id, label: c.name })) ?? [],
defaultCategory: categories.data?.find((c) => c.isDefault)?.id ?? "",
kinds: bankOperationKinds.map((value) => ({
value,
label: t(`bank.kinds.${value}`),
})),
dateInputValue: bankDateInputValue,
validDate: (value: string) => bankDateSchema.safeParse(value).success,
maxDate: bankToday,
},
refund: {
editing: Boolean(props.operation),
expenses:
selected &&
!expenseOptions.some((item) => item.value === selected.value)
? [selected, ...expenseOptions]
: expenseOptions,
onSelect: setSourceId,
limit: props.operation
? refundLimit
: sourceQuery.data?.availableRefund,
},
loadingExpenses: expenses.isLoading,
emptyExpenses: Boolean(
expenses.data && !expenses.data.items.length && !props.operation,
),
sourcePage,
previousDisabled: sourcePage === 1 || expenses.isFetching,
nextDisabled: !expenses.data?.hasNext || expenses.isFetching,
onPrevious: () => setSourcePage(sourcePage - 1),
onNext: () => setSourcePage(sourcePage + 1),
onAddCategory: () => setCategoriesOpen(true),
onReload: error && (props.operation || sourceId) ? onReload : undefined,
latest: latest
? {
version: latest.version,
details: `${formatDate(latest.date)} · ${formatMoney(latest.amount)} · ${latest.description ?? ""}`,
onAdopt: () => {
setVersion(latest.version);
setLatest(undefined);
setError(undefined);
},
}
: undefined,
saveDisabled:
(kind === "expense" && !categories.data) ||
(kind === "refund" && !sourceQuery.data),
},
};
};
export { useOperationFormVm };
+3 -13
View File
@@ -10,6 +10,8 @@ import {
Text,
Title,
} from "@mantine/core";
import { AddIcon } from "@wealthledger/icons";
import { LoadingSkeleton, StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
@@ -19,7 +21,6 @@ import {
BankPeriodPicker,
BankSummaryView,
} from "@/entities/bank-card";
import { LoadingSkeleton, StateView } from "@/shared/ui";
import classes from "./CardsPage.module.css";
import type { CardsPageVm } from "./CardsPage.vm";
@@ -38,18 +39,7 @@ const CardsPageView: FC<CardsPageViewProps> = (props) => {
title={t("bank.addCard")}
onClick={() => vm.setAdding(true)}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden="true"
>
<path d="M12 5v14M5 12h14" />
</svg>
<AddIcon size={20} strokeWidth={2} />
</ActionIcon>
</Group>
<Switch
+1 -1
View File
@@ -1,9 +1,9 @@
import { Button, Stack, Title } from "@mantine/core";
import { useDocumentTitle } from "@mantine/hooks";
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { StateView } from "@/shared/ui";
const NotFoundPage: FC = () => {
const { t } = useTranslation();
+1 -1
View File
@@ -1,8 +1,8 @@
import { Anchor, Button, Skeleton, Stack, Text, Title } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link, Navigate } from "react-router";
import { LoadingSkeleton } from "@/shared/ui";
import { StockPageView } from "./StockPage.view";
import { useStockPageVm } from "./StockPage.vm";
+1 -1
View File
@@ -10,6 +10,7 @@ import {
Text,
Title,
} from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -24,7 +25,6 @@ import {
formatMoney,
formatSnapshotTime,
} from "@/shared/lib";
import { LoadingSkeleton } from "@/shared/ui";
import { StockDividends } from "./components/StockEvents.view";
import classes from "./StockPage.module.css";
import type { StockPageVm } from "./StockPage.vm";
+1 -1
View File
@@ -12,11 +12,11 @@ import {
TextInput,
Title,
} from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { formatMoney, formatSnapshotTime } from "@/shared/lib";
import { LoadingSkeleton } from "@/shared/ui";
import classes from "./StocksPage.module.css";
import type { StocksPageVm } from "./StocksPage.vm";
+3 -3
View File
@@ -10,8 +10,8 @@ import {
useMantineTheme,
} from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { colorRoles, designTokens } from "@wealthledger/ui/theme";
import type { FC } from "react";
import { colorRoles, designTokens } from "./index";
const ColorsView: FC = () => {
const theme = useMantineTheme();
@@ -20,7 +20,7 @@ const ColorsView: FC = () => {
<Title order={1}>Цвета и семантические роли</Title>
<Text>
Светлая схема WealthLedger. Источник цветов и ролей —
shared/theme/tokens.json. Цвет не заменяет подпись состояния.
@wealthledger/tokens/tokens.json. Цвет не заменяет подпись состояния.
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
{Object.entries(designTokens.color.roles).map(([role, token]) => (
@@ -168,7 +168,7 @@ const meta = {
docs: {
description: {
component:
"Единый источник shared/theme/tokens.json; theme.ts адаптирует его к Mantine. Все образцы читают effective theme; описания ролей берутся из JSON. Показаны используемые шкалы и роли, а не весь Mantine. Светлая схема; контраст, смысловые заголовки и отсутствие передачи статуса только цветом проверяются в конкретной композиции.",
"Единый источник @wealthledger/tokens/tokens.json; theme.ts адаптирует его к Mantine. Все образцы читают effective theme; описания ролей берутся из JSON. Показаны используемые шкалы и роли, а не весь Mantine. Светлая схема; контраст, смысловые заголовки и отсутствие передачи статуса только цветом проверяются в конкретной композиции.",
},
},
},
+1 -1
View File
@@ -1,5 +1,5 @@
import tokens from "@wealthledger/tokens/tokens.json";
import { describe, expect, it } from "vitest";
import tokens from "./tokens.json";
function luminance(color: string) {
const channels = [1, 3, 5]
-3
View File
@@ -1,3 +0,0 @@
import "./styles.css";
export { colorRoles, cssVariablesResolver, designTokens, theme } from "./theme";
-39
View File
@@ -1,39 +0,0 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--wl-color-canvas);
color: var(--wl-color-text);
}
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;
}
}
-29
View File
@@ -1,29 +0,0 @@
import { DEFAULT_THEME, mergeMantineTheme } from "@mantine/core";
import { describe, expect, it } from "vitest";
import { colorRoles, cssVariablesResolver, theme } from "./theme";
import source from "./tokens.json";
describe("design theme adapter", () => {
it("preserves scale-aware sizes, unscaled breakpoints and shadow layers", () => {
expect(theme.spacing?.md).toBe("calc(1rem * var(--mantine-scale))");
expect(theme.fontSizes?.sm).toBe("calc(0.875rem * var(--mantine-scale))");
expect(theme.breakpoints?.sm).toBe("48em");
expect(theme.shadows?.sm).toContain(
"calc(-0.3125rem * var(--mantine-scale))",
);
expect(theme.shadows?.sm).toContain("rgba(0, 0, 0, 0.04)");
});
it("keeps default-shade roles attached to native filled variables", () => {
const resolved = mergeMantineTheme(DEFAULT_THEME, theme);
const variables = cssVariablesResolver(resolved).variables;
expect(colorRoles.primary).toBe("blue");
expect(variables["--wl-color-primary"]).toBe(
"var(--mantine-color-blue-filled)",
);
expect(variables["--wl-color-canvas"]).toBe("var(--mantine-color-gray-2)");
expect(Object.keys(variables)).toHaveLength(
Object.keys(source.color.roles).length,
);
});
});
-85
View File
@@ -1,85 +0,0 @@
import {
type CSSVariablesResolver,
colorsTuple,
createTheme,
getThemeColor,
type MantinePrimaryShade,
Modal,
rem,
rgba,
} from "@mantine/core";
import designTokens from "./tokens.json";
const { color, typography, spacing, radius, shadows, breakpoints } =
designTokens;
const mapValues = <T, U>(values: Record<string, T>, convert: (value: T) => U) =>
Object.fromEntries(
Object.entries(values).map(([name, value]) => [name, convert(value)]),
);
const colorRoles = mapValues(color.roles, (role) =>
role.shade === null ? role.color : `${role.color}.${role.shade}`,
);
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,
white: color.base.white,
black: color.base.black,
colors: mapValues(color.palettes, colorsTuple),
fontFamily: typography.fontFamily,
fontFamilyMonospace: typography.fontFamilyMonospace,
fontSizes: mapValues(typography.fontSizes, (value) => rem(value)),
fontWeights: mapValues(typography.fontWeights, String),
lineHeights: mapValues(typography.lineHeights, String),
headings: {
fontFamily: typography.fontFamily,
fontWeight: String(typography.fontWeights.bold),
sizes: mapValues(typography.headings, (value) => ({
fontSize: rem(value.fontSize),
lineHeight: String(value.lineHeight),
})),
},
spacing: mapValues(spacing.values, (value) => rem(value)),
radius: mapValues(radius.values, (value) => rem(value)),
shadows: mapValues(shadows.values, (layers) =>
layers
.map(
(layer) =>
`${rem(layer.offsetX)} ${rem(layer.offsetY)} ${rem(layer.blur)} ${rem(layer.spread)} ${rgba(color.base[layer.color as keyof typeof color.base], layer.opacity)}`,
)
.join(", "),
),
breakpoints: mapValues(
breakpoints.values,
(value) => `${value}${breakpoints.unit}`,
),
});
const cssVariablesResolver: CSSVariablesResolver = (resolvedTheme) => ({
variables: Object.fromEntries(
Object.entries(colorRoles).map(([role, reference]) => [
`--wl-color-${role}`,
getThemeColor(reference, resolvedTheme),
]),
),
light: {},
dark: {},
});
export { colorRoles, cssVariablesResolver, designTokens, theme };
-377
View File
@@ -1,377 +0,0 @@
{
"formatVersion": 1,
"description": "Переносимые токены WealthLedger. Размеры px адаптируются к масштабу runtime; breakpoints em не масштабируются. Светлая схема; роли с shade null используют primaryShade.light для палитры или base color без оттенка.",
"color": {
"base": {
"white": "#fff",
"black": "#000"
},
"palettes": {
"blue": [
"#eff6ff",
"#dbeafe",
"#bfdbfe",
"#93c5fd",
"#60a5fa",
"#3b82f6",
"#2563eb",
"#1d4ed8",
"#1e40af",
"#1e3a8a"
],
"gray": [
"#f8f9fa",
"#f1f3f5",
"#e9ecef",
"#dee2e6",
"#ced4da",
"#adb5bd",
"#868e96",
"#495057",
"#343a40",
"#212529"
],
"dark": [
"#C9C9C9",
"#b8b8b8",
"#828282",
"#696969",
"#424242",
"#3b3b3b",
"#2e2e2e",
"#242424",
"#1f1f1f",
"#141414"
],
"green": [
"#ebfbee",
"#d3f9d8",
"#b2f2bb",
"#8ce99a",
"#69db7c",
"#51cf66",
"#40c057",
"#37b24d",
"#2f9e44",
"#237032"
],
"red": [
"#fff5f5",
"#ffe3e3",
"#ffc9c9",
"#ffa8a8",
"#ff8787",
"#ff6b6b",
"#fa5252",
"#f03e3e",
"#e03131",
"#c92a2a"
],
"yellow": [
"#fff9db",
"#fff3bf",
"#ffec99",
"#ffe066",
"#ffd43b",
"#fcc419",
"#fab005",
"#f59f00",
"#f08c00",
"#e67700"
],
"grape": [
"#f8f0fc",
"#f3d9fa",
"#eebefa",
"#e599f7",
"#da77f2",
"#cc5de8",
"#be4bdb",
"#ae3ec9",
"#9c36b5",
"#862e9c"
],
"orange": [
"#fff4e6",
"#ffe8cc",
"#ffd8a8",
"#ffc078",
"#ffa94d",
"#ff922b",
"#fd7e14",
"#f76707",
"#e8590c",
"#d9480f"
]
},
"primaryShade": {
"light": 6,
"dark": 8
},
"roles": {
"canvas": {
"color": "gray",
"shade": 2,
"description": "Фон приложения"
},
"surface": {
"color": "white",
"shade": null,
"description": "Поверхность Paper / формы"
},
"subtle": {
"color": "gray",
"shade": 0,
"description": "Приглушённая поверхность сводки"
},
"text": {
"color": "dark",
"shade": 7,
"description": "Основной текст страницы"
},
"muted": {
"color": "gray",
"shade": 6,
"description": "Вторичный текст; проверяйте контраст при выборе фона"
},
"border": {
"color": "gray",
"shade": 3,
"description": "Граница поверхности"
},
"primary": {
"color": "blue",
"shade": null,
"description": "Основное действие и активная навигация"
},
"info": {
"color": "blue",
"shade": 6,
"description": "Информационное сообщение"
},
"positive": {
"color": "green",
"shade": 9,
"description": "Положительный результат; дополните знаком и текстом"
},
"negative": {
"color": "red",
"shade": 9,
"description": "Ошибка / отрицательный результат; дополните текстом"
},
"warning": {
"color": "yellow",
"shade": 6,
"description": "Предупреждение; не мелкий текст на белом фоне"
}
}
},
"typography": {
"fontFamily": "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif",
"fontFamilyMonospace": "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace",
"unit": "px",
"fontSizes": {
"xs": 12.0,
"sm": 14.0,
"md": 16.0,
"lg": 18.0,
"xl": 20.0
},
"fontWeights": {
"regular": 400,
"medium": 600,
"bold": 700
},
"lineHeights": {
"xs": 1.4,
"sm": 1.45,
"md": 1.55,
"lg": 1.6,
"xl": 1.65
},
"headings": {
"h1": {
"fontSize": 34.0,
"lineHeight": 1.3
},
"h2": {
"fontSize": 26.0,
"lineHeight": 1.35
},
"h3": {
"fontSize": 22.0,
"lineHeight": 1.4
},
"h4": {
"fontSize": 18.0,
"lineHeight": 1.45
},
"h5": {
"fontSize": 16.0,
"lineHeight": 1.5
},
"h6": {
"fontSize": 14.0,
"lineHeight": 1.5
}
}
},
"spacing": {
"unit": "px",
"values": {
"xs": 10.0,
"sm": 12.0,
"md": 16.0,
"lg": 20.0,
"xl": 32.0
}
},
"radius": {
"unit": "px",
"default": "md",
"values": {
"xs": 2.0,
"sm": 4.0,
"md": 8.0,
"lg": 16.0,
"xl": 32.0
}
},
"shadows": {
"unit": "px",
"values": {
"xs": [
{
"offsetX": 0.0,
"offsetY": 1.0,
"blur": 3.0,
"spread": 0,
"color": "black",
"opacity": 0.05
},
{
"offsetX": 0.0,
"offsetY": 1.0,
"blur": 2.0,
"spread": 0,
"color": "black",
"opacity": 0.1
}
],
"sm": [
{
"offsetX": 0.0,
"offsetY": 1.0,
"blur": 3.0,
"spread": 0,
"color": "black",
"opacity": 0.05
},
{
"offsetX": 0.0,
"offsetY": 10.0,
"blur": 15.0,
"spread": -5.0,
"color": "black",
"opacity": 0.05
},
{
"offsetX": 0.0,
"offsetY": 7.0,
"blur": 7.0,
"spread": -5.0,
"color": "black",
"opacity": 0.04
}
],
"md": [
{
"offsetX": 0.0,
"offsetY": 1.0,
"blur": 3.0,
"spread": 0,
"color": "black",
"opacity": 0.05
},
{
"offsetX": 0.0,
"offsetY": 20.0,
"blur": 25.0,
"spread": -5.0,
"color": "black",
"opacity": 0.05
},
{
"offsetX": 0.0,
"offsetY": 10.0,
"blur": 10.0,
"spread": -5.0,
"color": "black",
"opacity": 0.04
}
],
"lg": [
{
"offsetX": 0.0,
"offsetY": 1.0,
"blur": 3.0,
"spread": 0,
"color": "black",
"opacity": 0.05
},
{
"offsetX": 0.0,
"offsetY": 28.0,
"blur": 23.0,
"spread": -7.0,
"color": "black",
"opacity": 0.05
},
{
"offsetX": 0.0,
"offsetY": 12.0,
"blur": 12.0,
"spread": -7.0,
"color": "black",
"opacity": 0.04
}
],
"xl": [
{
"offsetX": 0.0,
"offsetY": 1.0,
"blur": 3.0,
"spread": 0,
"color": "black",
"opacity": 0.05
},
{
"offsetX": 0.0,
"offsetY": 36.0,
"blur": 28.0,
"spread": -7.0,
"color": "black",
"opacity": 0.05
},
{
"offsetX": 0.0,
"offsetY": 17.0,
"blur": 17.0,
"spread": -7.0,
"color": "black",
"opacity": 0.04
}
]
}
},
"breakpoints": {
"unit": "em",
"values": {
"xs": 36.0,
"sm": 48.0,
"md": 62.0,
"lg": 75.0,
"xl": 88.0
}
}
}
-95
View File
@@ -1,95 +0,0 @@
.row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: var(--mantine-spacing-sm);
align-items: center;
padding: var(--mantine-spacing-md);
border-color: var(--mantine-color-gray-3);
background: var(--mantine-color-gray-0);
}
.identity,
.value {
min-width: 0;
}
.value {
text-align: right;
white-space: nowrap;
}
.positive {
color: var(--wl-color-positive);
}
.negative {
color: var(--wl-color-negative);
}
.neutral {
color: var(--mantine-color-gray-7);
}
@media (max-width: 36em) {
.row {
grid-template-columns: auto minmax(0, 1fr);
}
.value {
grid-column: 2;
text-align: left;
}
}
.valueFooter {
display: grid;
align-items: center;
justify-items: end;
min-height: 28px;
}
.valueLabel,
.actions {
grid-area: 1 / 1;
transition: opacity 180ms ease;
}
.valueLabel {
opacity: 0;
}
.actions {
display: flex;
}
@media (hover: hover) and (pointer: fine) {
.valueLabel {
opacity: 1;
}
.actions {
opacity: 0;
pointer-events: none;
}
.row:hover .actions,
.row:focus-within .actions {
opacity: 1;
pointer-events: auto;
}
.row:hover .valueLabel,
.row:focus-within .valueLabel {
opacity: 0;
}
}
@media (max-width: 36em) {
.valueFooter {
justify-items: start;
}
}
@media (pointer: coarse) {
.actions button {
min-width: 36px;
min-height: 36px;
}
}
@media (prefers-reduced-motion: reduce) {
.valueLabel,
.actions {
transition: none;
}
}
-72
View File
@@ -1,72 +0,0 @@
import { Avatar, Paper, Stack, Text } from "@mantine/core";
import { type FC, type ReactNode, useState } from "react";
import classes from "./EntryCard.module.css";
type EntryCardProps = {
title: string;
metadata: string;
details?: ReactNode;
actions?: ReactNode;
initial: string;
icon?: ReactNode;
color?: string;
imageSrc?: string | null;
value: string;
valueLabel: string;
tone: "positive" | "negative" | "neutral";
};
const EntryCard: FC<EntryCardProps> = (props) => {
const [imageFailed, setImageFailed] = useState(false);
return (
<Paper
className={classes.row}
withBorder
style={
props.color
? {
background: `color-mix(in srgb, var(--mantine-color-${props.color}-0) 45%, white)`,
}
: undefined
}
>
<Avatar
alt=""
aria-hidden="true"
radius="md"
size={40}
src={imageFailed ? null : props.imageSrc}
onError={() => setImageFailed(true)}
>
{props.icon ?? props.initial}
</Avatar>
<Stack className={classes.identity} gap={2}>
<Text fw={700} lineClamp={1}>
{props.title}
</Text>
<Text c="dimmed" size="sm">
{props.metadata}
</Text>
{props.details}
</Stack>
<Stack className={classes.value} gap={2}>
<Text className={classes[props.tone]} fw={700}>
{props.value}
</Text>
<div className={props.actions ? classes.valueFooter : undefined}>
<Text
className={`${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`}
size="sm"
>
{props.valueLabel}
</Text>
{props.actions && (
<div className={classes.actions}>{props.actions}</div>
)}
</div>
</Stack>
</Paper>
);
};
export { EntryCard };
-35
View File
@@ -1,35 +0,0 @@
import { Paper, Skeleton } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { LoadingSkeleton } from "./LoadingSkeleton";
const meta = {
title: "Design System/Components/LoadingSkeleton",
component: LoadingSkeleton,
tags: ["autodocs"],
args: { label: "Загрузка содержимого" },
argTypes: {
label: { control: "text" },
children: { table: { disable: true } },
},
parameters: {
docs: {
description: {
component:
"Общая status/busy область для initial или partial loading. Передайте доступную метку и только декоративные Skeleton shapes без интерактивных элементов. Геометрию определяет владелец section/page (см. Product/AccountPageLoading). При refetch сохраняйте уже видимые данные; для действия используйте Button loading. Этот пример не запускает запрос и не демонстрирует query lifecycle.",
},
},
},
render: (args) => (
<Paper p="md" withBorder>
<LoadingSkeleton {...args}>
<Skeleton h={24} w="60%" />
<Skeleton h={72} />
<Skeleton h={16} w="80%" />
</LoadingSkeleton>
</Paper>
),
} satisfies Meta<typeof LoadingSkeleton>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Initial: Story = { name: "Загрузка области" };
-18
View File
@@ -1,18 +0,0 @@
import { Stack } from "@mantine/core";
import type { FC, PropsWithChildren } from "react";
type LoadingSkeletonProps = PropsWithChildren<{
label: string;
}>;
const LoadingSkeleton: FC<LoadingSkeletonProps> = (props) => {
const { children, label } = props;
return (
<Stack aria-busy="true" aria-label={label} gap="md" role="status">
{children}
</Stack>
);
};
export { LoadingSkeleton };
-78
View File
@@ -1,78 +0,0 @@
import { Button } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ComponentProps } from "react";
import { fn } from "storybook/test";
import { StateView } from "./StateView";
type StateStoryArgs = ComponentProps<typeof StateView> & {
withAction: boolean;
onAction: () => void;
};
const meta = {
title: "Design System/Components/StateView",
component: StateView,
tags: ["autodocs"],
args: {
title: "Нет учебных данных",
text: "Синтетическое пустое состояние. Пользовательские данные не загружаются.",
error: false,
withAction: false,
onAction: fn(),
},
argTypes: {
title: { control: "text" },
text: { control: "text" },
error: { control: "boolean" },
action: { table: { disable: true } },
withAction: {
control: "boolean",
description: "Показать необязательное действие StateView",
},
onAction: { control: false },
},
parameters: {
docs: {
description: {
component:
"Общее empty/error состояние WealthLedger с необязательным действием. Error сохраняет alert semantics. Для initial loading используйте skeleton страницы, для refetch — локальное предупреждение рядом с сохранёнными данными. Кнопка вызывает только Actions; API recovery и полный пользовательский сценарий здесь не проверяются.",
},
},
},
render: (args) => {
const { withAction, onAction, ...stateProps } = args;
return (
<StateView
{...stateProps}
action={
withAction ? (
<Button onClick={onAction} variant="default">
{args.error ? "Повторить локально" : "Выполнить пример"}
</Button>
) : undefined
}
/>
);
},
} satisfies Meta<StateStoryArgs>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Empty: Story = { name: "Пустое состояние" };
export const EmptyWithAction: Story = {
name: "Пустое состояние с действием",
args: { withAction: true },
};
export const Failed: Story = {
name: "Ошибка",
args: {
error: true,
title: "Учебная ошибка",
text: "Демонстрация недоступных данных без вызова API.",
},
};
export const ErrorWithAction: Story = {
name: "Ошибка с retry",
args: { ...Failed.args, withAction: true },
};
-37
View File
@@ -1,37 +0,0 @@
import { MantineProvider } from "@mantine/core";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { LoadingSkeleton } from "./LoadingSkeleton";
import { StateView } from "./StateView";
describe("StateView", () => {
it("renders an accessible structural loading region", () => {
render(
<MantineProvider>
<LoadingSkeleton label="Загрузка">
<div />
</LoadingSkeleton>
</MantineProvider>,
);
expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute(
"aria-busy",
"true",
);
});
it("renders an accessible error state", () => {
render(
<MantineProvider>
<StateView
error
text="Попробуйте обновить страницу позже."
title="Что-то пошло не так"
/>
,
</MantineProvider>,
);
expect(screen.getByRole("alert")).toHaveTextContent("Что-то пошло не так");
});
});
-26
View File
@@ -1,26 +0,0 @@
import { Paper, Stack, Text, Title } from "@mantine/core";
import type { FC, ReactNode } from "react";
type StateViewProps = {
title: string;
text: string;
action?: ReactNode;
error?: boolean;
};
const StateView: FC<StateViewProps> = (props) => {
const { title, text, action, error = false } = props;
const role = error ? "alert" : undefined;
return (
<Paper p="xl" radius="md" shadow="xs" withBorder role={role}>
<Stack align="flex-start" gap="sm">
<Title order={2}>{title}</Title>
<Text c="dimmed">{text}</Text>
{action}
</Stack>
</Paper>
);
};
export { StateView };
-4
View File
@@ -1,4 +0,0 @@
export { AppShell } from "./AppShell";
export { EntryCard } from "./EntryCard";
export { LoadingSkeleton } from "./LoadingSkeleton";
export { StateView } from "./StateView";
+8 -1
View File
@@ -3,12 +3,16 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { fn } from "storybook/test";
import { afterEach, describe, expect, it, vi } from "vitest";
import preview from "../../.storybook/preview";
import * as UiLoadingStories from "../../packages/ui/src/LoadingSkeleton.stories";
import * as StateStories from "../../packages/ui/src/StateView.stories";
import * as AccountStories from "../entities/brokerage-account/ui/BrokerageAccountList.stories";
import * as EventStories from "../entities/brokerage-event/ui/BrokerageEventsPreview.stories";
import * as LoadingStories from "../pages/account/ui/components/AccountPageLoading.stories";
import * as RefreshStories from "../pages/account/ui/components/PortfolioRefreshState.stories";
import * as PositionStories from "../pages/account/ui/components/PositionCard.stories";
import * as StateStories from "../shared/ui/StateView.stories";
import * as IncomePeriodStories from "../pages/account-income/ui/components/IncomePeriodForm.stories";
import * as OperationStories from "../pages/card/ui/OperationForm.stories";
afterEach(() => vi.restoreAllMocks());
@@ -30,6 +34,9 @@ describe("Storybook isolated product examples", () => {
...Object.values(composeStories(PositionStories, preview)),
...Object.values(composeStories(EventStories, preview)),
...Object.values(composeStories(StateStories, preview)),
...Object.values(composeStories(UiLoadingStories, preview)),
...Object.values(composeStories(IncomePeriodStories, preview)),
...Object.values(composeStories(OperationStories, preview)),
];
for (const Story of stories) {
render(<Story />);