refactor: clarify frontend module ownership
Frontend verify / commits (pull_request) Failing after 1m19s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped

This commit is contained in:
2026-09-29 07:07:36 +03:00
parent 7c28a423dd
commit f9f0f6921e
91 changed files with 1292 additions and 1050 deletions
@@ -0,0 +1,126 @@
import {
ActionIcon,
Anchor,
Group,
Skeleton,
Stack,
Text,
} from "@mantine/core";
import {
DeleteIcon,
EditIcon,
OperationIcon,
} from "@wealthledger/icons";
import { EntryCard, LoadingSkeleton } from "@wealthledger/ui";
import { type FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import {
bankOperationAppearance,
} from "@/entities/bank-card";
import type { CardPageVm } from "./CardPage.vm";
import classes from "./CardPage.module.css";
type CardHistoryProps = Pick<CardPageVm,
"history"
| "period"
| "rows"
>;
// eslint-disable-next-line sonarjs/function-return-type -- React renders elements, lists and empty output.
const CardHistory: FC<CardHistoryProps> = (props) => {
const vm = props;
const { t } = useTranslation();
if (vm.rows) {
if (vm.rows.length > 0) {
return vm.rows.map((row) => {
const details = row.otherCard
? (
<Anchor
component={Link}
to={`/cards/${row.otherCard}?from=${vm.period.from}&to=${vm.period.to}`}
>
{t("bank.card")}
</Anchor>
)
: undefined;
return (
<section
aria-label={`${row.title} ${row.item.date}`}
className={classes.operation}
key={row.item.id}
>
<EntryCard
actions={(
<Group gap={4} wrap="nowrap">
<ActionIcon
aria-label={t("bank.edit")}
disabled={!row.editable}
onClick={row.onEdit}
size="sm"
title={t("bank.edit")}
variant="subtle"
>
<EditIcon size={18} strokeWidth={1.5} />
</ActionIcon>
<ActionIcon
aria-label={t("bank.delete")}
color="red"
disabled={!row.editable}
onClick={row.onDelete}
size="sm"
title={t("bank.delete")}
variant="subtle"
>
<DeleteIcon size={18} strokeWidth={1.5} />
</ActionIcon>
</Group>
)}
color={row.color}
details={details}
icon={(
<OperationIcon
{...bankOperationAppearance(
row.item.kind,
row.item.categoryName,
)}
/>
)}
initial={row.title.charAt(0)}
metadata={row.metadata}
title={row.title}
tone={row.tone}
value={row.value}
valueLabel={t(`bank.kinds.${row.item.kind}`)}
wrap
/>
</section>
);
});
}
return <Text>{t("bank.noOperations")}</Text>;
}
if (vm.history.loading) {
return (
<LoadingSkeleton label={t("bank.loadingHistory")}>
<Stack>
<Skeleton h={90} />
<Skeleton h={90} />
</Stack>
</LoadingSkeleton>
);
}
return null;
};
export { CardHistory };
+3 -101
View File
@@ -1,6 +1,5 @@
import {
ActionIcon,
Anchor,
Button,
Group,
Pagination,
@@ -14,10 +13,9 @@ import {
AddIcon,
DeleteIcon,
EditIcon,
OperationIcon,
RestoreIcon,
} from "@wealthledger/icons";
import { EntryCard, LoadingSkeleton, StateView } from "@wealthledger/ui";
import { LoadingSkeleton, StateView } from "@wealthledger/ui";
import { type FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
@@ -25,114 +23,18 @@ import { Link } from "react-router";
import {
BankCardFace,
BankFeedback,
bankOperationAppearance,
BankPeriodPicker,
BankSummaryView,
} from "@/entities/bank-card";
import type { CardPageVm } from "./CardPage.vm";
import { CardHistory } from "./CardHistory.view";
import classes from "./CardPage.module.css";
import { useCardPagePresentationVm } from "./CardPagePresentation.vm";
type CardPageViewProps = { vm: CardPageVm };
// eslint-disable-next-line sonarjs/function-return-type -- React renders elements, lists and empty output.
const CardPageViewHistory: FC<CardPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
const card = vm.card;
if (!card) return;
if (vm.rows) {
if (vm.rows.length > 0) {
return vm.rows.map((row) => {
const details = row.otherCard
? (
<Anchor
component={Link}
to={`/cards/${row.otherCard}?from=${vm.period.from}&to=${vm.period.to}`}
>
{t("bank.card")}
</Anchor>
)
: undefined;
return (
<section
aria-label={`${row.title} ${row.item.date}`}
className={classes.operation}
key={row.item.id}
>
<EntryCard
actions={(
<Group gap={4} wrap="nowrap">
<ActionIcon
aria-label={t("bank.edit")}
disabled={!row.editable}
onClick={row.onEdit}
size="sm"
title={t("bank.edit")}
variant="subtle"
>
<EditIcon size={18} strokeWidth={1.5} />
</ActionIcon>
<ActionIcon
aria-label={t("bank.delete")}
color="red"
disabled={!row.editable}
onClick={row.onDelete}
size="sm"
title={t("bank.delete")}
variant="subtle"
>
<DeleteIcon size={18} strokeWidth={1.5} />
</ActionIcon>
</Group>
)}
color={row.color}
details={details}
icon={(
<OperationIcon
{...bankOperationAppearance(
row.item.kind,
row.item.categoryName,
)}
/>
)}
initial={row.title.charAt(0)}
metadata={row.metadata}
title={row.title}
tone={row.tone}
value={row.value}
valueLabel={t(`bank.kinds.${row.item.kind}`)}
wrap
/>
</section>
);
});
}
return <Text>{t("bank.noOperations")}</Text>;
}
if (vm.history.loading) {
return (
<LoadingSkeleton label={t("bank.loadingHistory")}>
<Stack>
<Skeleton h={90} />
<Skeleton h={90} />
</Stack>
</LoadingSkeleton>
);
}
return null;
};
const CardPageView: FC<CardPageViewProps> = (props) => {
const { vm } = props;
@@ -291,7 +193,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
gap="lg"
key={`${vm.period.from}-${vm.period.to}-${vm.pageSize}-${vm.history.page ?? "loading"}`}
>
<CardPageViewHistory {...props} />
<CardHistory history={vm.history} period={vm.period} rows={vm.rows} />
</Stack>
{vm.history.error && (
@@ -6,7 +6,13 @@ import { useTranslation } from "react-i18next";
import type { CardPageVm } from "./CardPage.vm";
function useCardPagePresentationVm(vm: CardPageVm) {
function useCardPagePresentationVm(vm: Pick<CardPageVm,
"card"
| "history"
| "pageSize"
| "period"
| "setPage"
>) {
const { t } = useTranslation();
const card = vm.card;
const paginationRef = useRef<HTMLDivElement>(null);
@@ -1,6 +1,3 @@
import type { DateInput } from "@mantine/dates";
import type { ComponentProps } from "react";
import { useController, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
@@ -10,10 +7,9 @@ import type {
} from "./OperationForm.types";
function useOperationFieldsVm(props: Pick<OperationFieldsProps,
"dateInputValue"
"bindDate"
| "kind"
| "refundCardId"
| "validDate"
>) {
const { t } = useTranslation();
@@ -48,38 +44,13 @@ function useOperationFieldsVm(props: Pick<OperationFieldsProps,
props.kind === "transfer" ? "bank.source" : "bank.operationCard",
);
const dateParser: ComponentProps<typeof DateInput>["dateParser"] = value =>
props.validDate(props.dateInputValue(value))
? props.dateInputValue(value)
: null;
const onDateBlur: ComponentProps<typeof DateInput>["onBlur"] = (event) => {
dateField.onChange(props.dateInputValue(event.currentTarget.value));
dateField.onBlur();
};
const onDateChange: (value: null | string) => void = value =>
dateField.onChange(value ?? "");
const onDateInput: ComponentProps<typeof DateInput>["onInput"] = event =>
dateField.onChange(props.dateInputValue(event.currentTarget.value));
const dateValue = props.validDate(dateField.value)
? dateField.value
: undefined;
return {
cardIdField,
categoryIdField,
dateField,
dateParser,
dateValue,
date: { ...dateField, ...props.bindDate(dateField) },
errors,
isSubmitting,
kindField,
onDateBlur,
onDateChange,
onDateInput,
onKindChange,
register,
sourceCardLabel,
@@ -43,9 +43,14 @@ const OperationExample: FC<ExampleProps> = (props) => {
)
}
fields={{
bindDate: field => ({
...field,
dateParser: () => "2026-08-01",
onChange: value => field.onChange(value ?? ""),
onInput: () => {},
}),
cards: [{ label: "Учебная карта", value: "demo" }],
categories: [],
dateInputValue: () => "2026-08-01",
defaultCategory: "",
editing: true,
kind,
@@ -53,7 +58,6 @@ const OperationExample: FC<ExampleProps> = (props) => {
maxDate: () => "2026-08-27",
refundCardId: "demo",
targets: [],
validDate: value => value === "2026-08-01",
}}
latest={
props.conflict
@@ -107,7 +111,7 @@ const meta = {
docs: {
description: {
component:
"Synthetic presentation без API/VM. Date callbacks фиксируют fixture 01.08.2026; денежная validation, recovery и nested category flow проверяются application tests. Actions только локальные.",
"Synthetic presentation без API/VM. денежная validation, recovery и nested category flow проверяются application tests. Actions только локальные.",
},
},
},
@@ -1,11 +1,15 @@
import type { BankCard, BankOperation } from "@/entities/bank-card";
import type {
BankCard,
bankDateField,
BankOperation,
} from "@/entities/bank-card";
type Choice = { label: string; value: string };
type OperationFieldsProps = {
bindDate: typeof bankDateField;
cards: Choice[];
categories: Choice[];
dateInputValue: (value: string) => string;
defaultCategory: string;
editing: boolean;
kind: OperationValues["kind"];
@@ -13,7 +17,6 @@ type OperationFieldsProps = {
maxDate: () => string;
refundCardId: string;
targets: Choice[];
validDate: (value: string) => boolean;
};
type OperationFormProps = {
@@ -1,6 +1,7 @@
import type { FC, ReactNode, SubmitEventHandler } from "react";
import { Button,
import {
Button,
Fieldset,
Group,
Modal,
@@ -26,15 +27,10 @@ const OperationFields: FC<OperationFieldsProps> = (props) => {
const {
cardIdField,
categoryIdField,
dateField,
dateParser,
dateValue,
date,
errors,
isSubmitting,
kindField,
onDateBlur,
onDateChange,
onDateInput,
onKindChange,
register,
sourceCardLabel,
@@ -82,21 +78,16 @@ const OperationFields: FC<OperationFieldsProps> = (props) => {
/>
<DateInput
{...dateField}
dateParser={dateParser}
{...date}
error={errors.date?.message}
fixOnBlur={false}
label={t("bank.date")}
maxDate={props.maxDate()}
onBlur={onDateBlur}
onChange={onDateChange}
onInput={onDateInput}
popoverProps={{
middlewares: { shift: { crossAxis: true } },
withinPortal: false,
}}
required
value={dateValue}
valueFormat="DD.MM.YYYY"
/>
@@ -4,14 +4,12 @@ import { useForm, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
bankDateInputValue,
bankDateSchema,
bankDateField,
bankMoneyValue,
type BankOperation,
bankOperationKinds,
bankToday,
useBankCategories,
useBankExpenses,
useBankFormFocus,
useBankOperation,
useCreateBankOperation,
@@ -25,6 +23,7 @@ import type {
} from "./OperationForm.types";
import { createOperationSchema } from "./OperationForm.schema";
import { useRefundPurchase } from "./useRefundPurchase";
const useOperationFormVm = (props: OperationFormProps) => {
useBankFormFocus();
@@ -33,7 +32,6 @@ const useOperationFormVm = (props: OperationFormProps) => {
const [categoriesOpen, setCategoriesOpen] = useState(false);
const [version, setVersion] = useState(props.operation?.version ?? 1);
const [latest, setLatest] = useState<BankOperation>();
const [sourcePage, setSourcePage] = useState(1);
const currentQuery = useBankOperation(props.operation?.id);
@@ -72,20 +70,14 @@ const useOperationFormVm = (props: OperationFormProps) => {
)(values, context, options),
});
const sourceId = useWatch({
control: methods.control,
name: "originalExpenseId",
});
const sourceQuery = useBankOperation(sourceId || undefined);
const kind = useWatch({ control: methods.control, name: "kind" });
const expenses = useBankExpenses(
kind === "refund" ? props.card.id : undefined,
{ page: sourcePage },
const purchase = useRefundPurchase(
methods.control, props.card.id, kind, Boolean(props.operation),
);
const { sourceId, sourceQuery } = purchase;
const source = useWatch({ control: methods.control, name: "cardId" });
const isPending = !opened || methods.formState.isSubmitting;
@@ -149,23 +141,6 @@ const useOperationFormVm = (props: OperationFormProps) => {
else setError(result.error);
};
const expenseChoice = (item: BankOperation) => ({
label: t("bank.expenseChoice", {
amount: formatMoney(item.amount),
available: formatMoney(item.availableRefund),
date: formatDate(item.date),
name: item.description || item.categoryName,
}),
value: item.id,
});
const expenseOptions
= expenses.data?.items.map(element => expenseChoice(element)) ?? [];
const selected = sourceQuery.data
? expenseChoice(sourceQuery.data)
: undefined;
return {
categoriesError: categories.isError
? categories.error
@@ -173,16 +148,14 @@ const useOperationFormVm = (props: OperationFormProps) => {
categoriesOpen,
closeCategories: () => setCategoriesOpen(false),
error,
expensesError: expenses.isError
? expenses.error
: null,
expensesError: purchase.error,
methods,
onCategoryCreated: (id: string) => {
methods.setValue("categoryId", id);
setCategoriesOpen(false);
},
retryCategories: () => void categories.refetch(),
retryExpenses: () => void expenses.refetch(),
retryExpenses: purchase.retry,
retrySource: () => void sourceQuery.refetch(),
sourceError: sourceQuery.isError
? sourceQuery.error
@@ -190,14 +163,12 @@ const useOperationFormVm = (props: OperationFormProps) => {
view: {
categoriesOpen,
editing: Boolean(props.operation),
emptyExpenses: Boolean(
expenses.data && expenses.data.items.length === 0 && !props.operation,
),
emptyExpenses: purchase.empty,
fields: {
bindDate: bankDateField,
cards: options,
categories:
categories.data?.map(c => ({ label: c.name, value: c.id })) ?? [],
dateInputValue: bankDateInputValue,
defaultCategory: categories.data?.find(c => c.isDefault)?.id ?? "",
editing: Boolean(props.operation),
kind,
@@ -208,7 +179,6 @@ const useOperationFormVm = (props: OperationFormProps) => {
maxDate: bankToday,
refundCardId: props.card.id,
targets: options.filter(c => c.value !== source),
validDate: (value: string) => bankDateSchema.safeParse(value).success,
},
latest: latest
? {
@@ -221,26 +191,22 @@ const useOperationFormVm = (props: OperationFormProps) => {
version: latest.version,
}
: undefined,
loadingExpenses: expenses.isLoading,
nextDisabled: !expenses.data?.hasNext || expenses.isFetching,
loadingExpenses: purchase.loading,
nextDisabled: purchase.nextDisabled,
onAddCategory: () => setCategoriesOpen(true),
onCancel: () => setOpened(false),
onClose: () => !isPending && !categoriesOpen && setOpened(false),
onExitTransitionEnd: props.onClose,
onNext: () => setSourcePage(sourcePage + 1),
onPrevious: () => setSourcePage(sourcePage - 1),
onNext: purchase.onNext,
onPrevious: purchase.onPrevious,
onReload: error && (sourceId || props.operation) ? onReload : undefined,
onSubmit,
opened,
pending: isPending,
previousDisabled: sourcePage === 1 || expenses.isFetching,
previousDisabled: purchase.previousDisabled,
refund: {
editing: Boolean(props.operation),
expenses:
selected
&& expenseOptions.every(item => item.value !== selected.value)
? [selected, ...expenseOptions]
: expenseOptions,
expenses: purchase.options,
limit: props.operation
? refundLimit
: sourceQuery.data?.availableRefund,
@@ -248,7 +214,7 @@ const useOperationFormVm = (props: OperationFormProps) => {
saveDisabled:
(kind === "expense" && !categories.data)
|| (kind === "refund" && !sourceQuery.data),
sourcePage,
sourcePage: purchase.page,
},
};
};
@@ -0,0 +1,61 @@
import type { Control } from "react-hook-form";
import { useState } from "react";
import { useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
type BankOperation,
useBankExpenses,
useBankOperation,
} from "@/entities/bank-card";
import { formatDate, formatMoney } from "@/shared/lib";
import type { OperationValues } from "./OperationForm.types";
function useRefundPurchase(
control: Control<OperationValues>,
cardId: string,
kind: OperationValues["kind"],
isEditing: boolean,
) {
const { t } = useTranslation();
const [page, setPage] = useState(1);
const sourceId = useWatch({ control, name: "originalExpenseId" });
const sourceQuery = useBankOperation(sourceId || undefined);
const expenses = useBankExpenses(kind === "refund" ? cardId : undefined, { page });
const choice = (item: BankOperation) => ({
label: t("bank.expenseChoice", {
amount: formatMoney(item.amount),
available: formatMoney(item.availableRefund),
date: formatDate(item.date),
name: item.description || item.categoryName,
}),
value: item.id,
});
const options = expenses.data?.items.map(item => choice(item)) ?? [];
const selected = sourceQuery.data ? choice(sourceQuery.data) : undefined;
return {
empty: Boolean(
!isEditing && expenses.data && expenses.data.items.length === 0,
),
error: expenses.isError ? expenses.error : null,
loading: expenses.isLoading,
nextDisabled: !expenses.data?.hasNext || expenses.isFetching,
onNext: () => setPage(page + 1),
onPrevious: () => setPage(page - 1),
options: selected && options.every(item => item.value !== selected.value)
? [selected, ...options]
: options,
page,
previousDisabled: page === 1 || expenses.isFetching,
retry: () => void expenses.refetch(),
sourceId,
sourceQuery,
};
}
export { useRefundPurchase };