refactor: simplify calendar and form state
Frontend verify / commits (pull_request) Successful in 29s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Successful in 8m43s

This commit is contained in:
2026-09-28 20:23:02 +03:00
parent a7537da344
commit 94adbafa34
20 changed files with 449 additions and 392 deletions
@@ -0,0 +1,168 @@
import { describe, expect, it } from "vitest";
import type { IncomeAnalytics } from "@/entities/income";
import { resource } from "@/test/resource";
import { toIncomeDisplayModel } from "./display";
const analytics: IncomeAnalytics = {
accountId: "account-1",
bucketGranularity: "day",
buckets: [],
calculatedAt: "2026-08-27T00:00:00Z",
entries: [],
instruments: [],
period: { from: "2026-08-01", to: "2026-08-27" },
previous: {
period: { from: "2026-07-01", to: "2026-07-31" },
totalsByCurrency: [comparison("RUB")],
},
totalsByCurrency: [],
yearComparison: null,
};
function comparison(currency: string) {
return {
compared: summary(currency, "1", "0"),
currency,
difference: summary(currency, "9", "2"),
selected: summary(currency, "10", "2"),
};
}
function money(currency: string, value: string) {
return { currency, value };
}
function summary(currency: string, coupon: string, dividend: string) {
return {
coupon: money(currency, coupon),
currency,
dividend: money(currency, dividend),
total: money(currency, String(Number(coupon) + Number(dividend))),
};
}
describe("income comparison display", () => {
it.each([
["25", "100", "+25 %"],
["-25", "100", "-25 %"],
["0", "100", "0 %"],
["-0.000", "100", "0 %"],
["0.0001", "100", "0 %"],
["1", "3", "+33,3 %"],
["0.01", "0.04", "+25 %"],
["123456789012345678.123456789", "123456789012345678.123456789", "+100 %"],
[
"10",
"0",
null,
],
[
"0",
"0",
null,
],
[
"10",
"-0.00",
null,
],
[
"10",
"-100",
null,
],
[
"9".repeat(400),
"1",
null,
],
[
"1",
"9".repeat(400),
null,
],
])("formats change %s against base %s", (difference, base, expected) => {
const value = comparison("RUB");
value.difference.coupon = money("RUB", difference);
value.compared.coupon = money("RUB", base);
const display = toIncomeDisplayModel({
...analytics,
yearComparison: {
period: { from: "2025-08-01", to: "2025-08-27" },
totalsByCurrency: [value],
},
});
const result = display?.comparison?.values[0];
expect(
result?.percentages.coupon?.replaceAll("\u{A0}", " ")
?? null,
).toBe(expected);
expect(result?.difference.coupon.value).toBe(difference);
expect(result?.compared.coupon.value).toBe(base);
});
it("formats calendar ranges across years and preserves separate currencies", () => {
const display = toIncomeDisplayModel({
...analytics,
period: { from: "2025-12-01", to: "2026-01-09" },
yearComparison: {
period: { from: "2024-12-01", to: "2025-01-09" },
totalsByCurrency: [comparison("RUB"), comparison("USD")],
},
});
expect(display?.comparison?.selectedPeriod.replaceAll(/\s/g, " ")).toBe(
"01.12.2025 – 09.01.2026",
);
expect(display?.comparison?.comparedPeriod.replaceAll(/\s/g, " ")).toBe(
"01.12.2024 – 09.01.2025",
);
expect(display?.comparison?.values.map(value => value.currency)).toEqual([
"RUB",
"USD",
]);
});
});
it("maps comparison notices by role regardless of dependency order or resource key", () => {
const selected = { ...resource(analytics).meta, role: "selected" as const };
const previous
= {
...resource(null).meta,
resourceKey: "opaque/year-looking-key",
role: "previous" as const,
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: null,
state: "failed" as const,
},
};
const year = {
...resource(null).meta,
resourceKey: "opaque/previous-looking-key",
role: "year" as const,
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
};
for (const dependencies of [
[selected, previous, year],
[year, selected, previous],
]) {
const display = toIncomeDisplayModel(analytics, {
...resource(analytics).meta,
dependencies,
});
expect(display?.previousNotice).toBe("comparisonFailed");
expect(display?.yearNotice).toBe("yearUnavailable");
expect(display?.previousComparison).not.toBeNull();
}
});
+173
View File
@@ -0,0 +1,173 @@
import type { IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
import { formatDate } from "@/shared/lib";
import type { IncomePeriod } from "./period";
type ComparisonNotice
= | "comparisonFailed"
| "comparisonPending"
| "previousUnavailable"
| "yearUnavailable";
type IncomeDisplayModel = {
comparison: null
| {
comparedPeriod: string;
selectedPeriod: string;
values: (NonNullable<
IncomeAnalytics["yearComparison"]
>["totalsByCurrency"][number] & {
percentages: Record<"coupon"
| "dividend"
| "total", null | string>;
})[];
};
entries: { key: string; operation: IncomeAnalytics["entries"][number] }[];
previousComparison: IncomeDisplayModel["comparison"];
previousNotice: ComparisonNotice;
yearNotice: ComparisonNotice;
};
type IncomeMeta = ReturnType<typeof useIncomeAnalytics>["meta"];
function comparisonDisplay(
period: IncomePeriod,
comparison: IncomeAnalytics["yearComparison"],
): IncomeDisplayModel["comparison"] {
if (!comparison)
return null;
return {
comparedPeriod: formatComparisonPeriod(comparison.period),
selectedPeriod: formatComparisonPeriod(period),
values: comparison.totalsByCurrency.map(value => ({
...value,
percentages: {
coupon: formatIncomeChange(
value.difference.coupon.value,
value.compared.coupon.value,
),
dividend: formatIncomeChange(
value.difference.dividend.value,
value.compared.dividend.value,
),
total: formatIncomeChange(
value.difference.total.value,
value.compared.total.value,
),
},
})),
};
}
function comparisonNotice(
dependency:
NonNullable<NonNullable<IncomeMeta>["dependencies"]>[number] | undefined,
unavailable: "previousUnavailable" | "yearUnavailable",
): ComparisonNotice {
if (!dependency || dependency.unavailableReason) return unavailable;
return dependency.sync.state === "failed"
? "comparisonFailed"
: "comparisonPending";
}
function formatComparisonPeriod(period: IncomePeriod): string {
return `${formatDate(period.from)} – ${formatDate(period.to)}`;
}
function formatIncomeChange(
difference: string,
compared: string,
): null | string {
// Display-only ratio; server-provided Money values remain exact decimal strings.
const base = Number(compared);
if (!Number.isFinite(base) || base <= 0)
return null;
const ratio = Number(difference) / base;
if (!Number.isFinite(ratio))
return null;
return new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 1,
signDisplay: "exceptZero",
style: "percent",
}).format(ratio);
}
function toIncomeDisplayModel(
analytics: IncomeAnalytics,
meta?: IncomeMeta,
): IncomeDisplayModel | null {
const instruments = new Map<
string,
{
currency: string;
includedCount: number;
receiptCount: number;
}
>();
const currencies = new Set(
analytics.totalsByCurrency.map(({ currency }) => currency),
);
for (const instrument of analytics.instruments) {
if (
typeof instrument.key !== "string"
|| !instrument.key.trim()
|| instruments.has(instrument.key)
|| !currencies.has(instrument.total.currency)
)
return null;
instruments.set(instrument.key, {
currency: instrument.total.currency,
includedCount: 0,
receiptCount: instrument.receiptCount,
});
}
const entries: IncomeDisplayModel["entries"] = [];
const occurrences = new Map<string, number>();
for (const entry of analytics.entries) {
if (typeof entry.instrumentKey !== "string" || !entry.instrumentKey.trim())
return null;
const instrument = instruments.get(entry.instrumentKey);
if (!instrument || entry.payment?.currency !== instrument.currency)
return null;
const occurrenceKey = JSON.stringify([entry.instrumentKey, entry.id]);
const occurrence = occurrences.get(occurrenceKey) ?? 0;
occurrences.set(occurrenceKey, occurrence + 1);
instrument.includedCount += 1;
entries.push({
key: JSON.stringify([
entry.instrumentKey,
entry.id,
occurrence,
entry.logoName,
]),
operation: entry,
});
}
for (const instrument of instruments.values()) {
if (instrument.includedCount !== instrument.receiptCount)
return null;
}
const previous = meta?.dependencies?.find(({ role }) => role === "previous");
const year = meta?.dependencies?.find(({ role }) => role === "year");
return {
comparison: comparisonDisplay(analytics.period, analytics.yearComparison),
entries,
previousComparison: comparisonDisplay(analytics.period, analytics.previous),
previousNotice: comparisonNotice(previous, "previousUnavailable"),
yearNotice: comparisonNotice(year, "yearUnavailable"),
};
}
export { type IncomeDisplayModel, toIncomeDisplayModel };
@@ -1,8 +1,9 @@
import { z } from "zod";
import { validationMessage } from "@/shared/i18n";
import { moscowDate } from "@/shared/lib";
import { moscowDate, parseDraftDate } from "./period";
import { parseDraftDate } from "./period";
const draftDate = z
.string()
+3 -31
View File
@@ -1,47 +1,22 @@
const ISO_DATE_LENGTH = 10;
import { calendarDate } from "@/shared/lib";
const DATE_PART_COUNT = 3;
type IncomePeriod = { from: string; to: string };
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
function formatDraftDate(value: string): string {
const [year, month, day] = value.split("-", DATE_PART_COUNT);
return `${day}.${month}.${year}`;
}
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, ISO_DATE_LENGTH) === value
);
}
function moscowDate(value: Date): string {
const parts = new Intl.DateTimeFormat("en-CA", {
day: "2-digit",
month: "2-digit",
timeZone: "Europe/Moscow",
year: "numeric",
}).formatToParts(value);
const part = (type: string) =>
parts.find(item => item.type === type)?.value;
return `${part("year")}-${part("month")}-${part("day")}`;
}
function parseDraftDate(value: string): null | string {
if (!/^\d{2}\.\d{2}\.\d{4}$/.test(value))
return null;
const [day, month, year] = value.split(".", DATE_PART_COUNT);
const date = `${year}-${month}-${day}`;
return isDate(date)
return calendarDate.safeParse(date).success
? date
: null;
}
@@ -50,8 +25,5 @@ export {
formatDraftDate,
type IncomePeriod as IncomeForm,
type IncomePeriod,
isDate,
ISO_DATE_LENGTH,
moscowDate,
parseDraftDate,
};
@@ -30,10 +30,9 @@ import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { isDate } from "../../model/period";
import { toIncomeDisplayModel } from "../../model/display";
import { AccountIncomePage } from "./AccountIncomePage";
import {
toIncomeDisplayModel,
useAccountIncomePageViewModel,
} from "./AccountIncomePage.vm";
import { IncomePayments } from "./components/IncomePayments.view";
@@ -469,12 +468,6 @@ describe("AccountIncomePage", () => {
expect(within(panel).getByText("−2 ₽")).toBeInTheDocument();
});
it("rejects malformed calendar dates without throwing", () => {
expect(isDate("2026-99-99")).toBe(false);
expect(isDate("2026-02-29")).toBe(false);
expect(isDate("2024-02-29")).toBe(true);
});
it("renders realized text and comparisons without subscribing to events", async () => {
const eventsRequest = vi.fn(() => new HttpResponse(null, { status: 503 }));
server.use(
@@ -584,92 +577,6 @@ describe("AccountIncomePage", () => {
});
});
describe("income comparison display", () => {
it.each([
["25", "100", "+25 %"],
["-25", "100", "-25 %"],
["0", "100", "0 %"],
["-0.000", "100", "0 %"],
["0.0001", "100", "0 %"],
["1", "3", "+33,3 %"],
["0.01", "0.04", "+25 %"],
["123456789012345678.123456789", "123456789012345678.123456789", "+100 %"],
[
"10",
"0",
null,
],
[
"0",
"0",
null,
],
[
"10",
"-0.00",
null,
],
[
"10",
"-100",
null,
],
[
"9".repeat(400),
"1",
null,
],
[
"1",
"9".repeat(400),
null,
],
])("formats change %s against base %s", (difference, base, expected) => {
const value = comparison("RUB");
value.difference.coupon = money("RUB", difference);
value.compared.coupon = money("RUB", base);
const display = toIncomeDisplayModel({
...analytics,
yearComparison: {
period: { from: "2025-08-01", to: "2025-08-27" },
totalsByCurrency: [value],
},
});
const result = display?.comparison?.values[0];
expect(
result?.percentages.coupon?.replaceAll("\u{A0}", " ")
?? null,
).toBe(expected);
expect(result?.difference.coupon.value).toBe(difference);
expect(result?.compared.coupon.value).toBe(base);
});
it("formats calendar ranges across years and preserves separate currencies", () => {
const display = toIncomeDisplayModel({
...analytics,
period: { from: "2025-12-01", to: "2026-01-09" },
yearComparison: {
period: { from: "2024-12-01", to: "2025-01-09" },
totalsByCurrency: [comparison("RUB"), comparison("USD")],
},
});
expect(display?.comparison?.selectedPeriod.replaceAll(/\s/g, " ")).toBe(
"01.12.2025 – 09.01.2026",
);
expect(display?.comparison?.comparedPeriod.replaceAll(/\s/g, " ")).toBe(
"01.12.2024 – 09.01.2025",
);
expect(display?.comparison?.values.map(value => value.currency)).toEqual([
"RUB",
"USD",
]);
});
});
const LocationProbe: FC = () => {
const location = useLocation();
const navigate = useNavigate();
@@ -978,43 +885,6 @@ describe("income period and snapshot boundary", () => {
});
});
it("maps comparison notices by role regardless of dependency order or resource key", () => {
const selected = { ...resource(analytics).meta, role: "selected" as const };
const previous
= {
...resource(null).meta,
resourceKey: "opaque/year-looking-key",
role: "previous" as const,
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: null,
state: "failed" as const,
},
};
const year = {
...resource(null).meta,
resourceKey: "opaque/previous-looking-key",
role: "year" as const,
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
};
for (const dependencies of [
[selected, previous, year],
[year, selected, previous],
]) {
const display = toIncomeDisplayModel(analytics, {
...resource(analytics).meta,
dependencies,
});
expect(display?.previousNotice).toBe("comparisonFailed");
expect(display?.yearNotice).toBe("yearUnavailable");
expect(display?.previousComparison).not.toBeNull();
}
});
it.each([false, true])(
"shows unavailable comparisons for absent roles without positional fallback (legacy=%s)",
async (legacy) => {
@@ -5,16 +5,17 @@ import { useParams, useSearchParams } from "react-router";
import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
import { useScreenSync } from "@/features/tbank-connection";
import { formatDate } from "@/shared/lib";
import { isValidPeriod, moscowDate } from "@/shared/lib";
import {
type IncomeDisplayModel,
toIncomeDisplayModel,
} from "../../model/display";
import { incomeFormSchema } from "../../model/income.schema";
import {
formatDraftDate,
type IncomeForm,
type IncomePeriod,
isDate,
ISO_DATE_LENGTH,
moscowDate,
parseDraftDate,
} from "../../model/period";
@@ -43,33 +44,6 @@ type AccountIncomePageViewModel
}
| { state: "loading" };
type ComparisonNotice
= | "comparisonFailed"
| "comparisonPending"
| "previousUnavailable"
| "yearUnavailable";
type IncomeDisplayModel = {
comparison: null
| {
comparedPeriod: string;
selectedPeriod: string;
values: (NonNullable<
IncomeAnalytics["yearComparison"]
>["totalsByCurrency"][number] & {
percentages: Record<"coupon"
| "dividend"
| "total", null | string>;
})[];
};
entries: { key: string; operation: IncomeAnalytics["entries"][number] }[];
previousComparison: IncomeDisplayModel["comparison"];
previousNotice: ComparisonNotice;
yearNotice: ComparisonNotice;
};
type IncomeMeta = ReturnType<typeof useIncomeAnalytics>["meta"];
const useAccountIncomePageViewModel = (
scopeId?: string,
): AccountIncomePageViewModel => {
@@ -86,7 +60,7 @@ const useAccountIncomePageViewModel = (
);
const period = useMemo(
() => (isValidPeriod(requested) ? requested : defaultPeriod()),
() => (isValidIncomePeriod(requested) ? requested : defaultPeriod()),
[requested],
);
@@ -104,7 +78,7 @@ const useAccountIncomePageViewModel = (
);
useEffect(() => {
if (!isValidPeriod(requested))
if (!isValidIncomePeriod(requested))
setSearchParameters(period, { replace: true });
}, [period, requested, setSearchParameters]);
useEffect(() => {
@@ -156,48 +130,6 @@ const useAccountIncomePageViewModel = (
};
};
function comparisonDisplay(
period: IncomePeriod,
comparison: IncomeAnalytics["yearComparison"],
): IncomeDisplayModel["comparison"] {
if (!comparison)
return null;
return {
comparedPeriod: formatComparisonPeriod(comparison.period),
selectedPeriod: formatComparisonPeriod(period),
values: comparison.totalsByCurrency.map(value => ({
...value,
percentages: {
coupon: formatIncomeChange(
value.difference.coupon.value,
value.compared.coupon.value,
),
dividend: formatIncomeChange(
value.difference.dividend.value,
value.compared.dividend.value,
),
total: formatIncomeChange(
value.difference.total.value,
value.compared.total.value,
),
},
})),
};
}
function comparisonNotice(
dependency:
NonNullable<NonNullable<IncomeMeta>["dependencies"]>[number] | undefined,
unavailable: "previousUnavailable" | "yearUnavailable",
): ComparisonNotice {
if (!dependency || dependency.unavailableReason) return unavailable;
return dependency.sync.state === "failed"
? "comparisonFailed"
: "comparisonPending";
}
function defaultPeriod(): IncomePeriod {
const today = moscowDate(new Date());
@@ -208,120 +140,12 @@ function draftPeriod(period: IncomePeriod): IncomeForm {
return { from: formatDraftDate(period.from), to: formatDraftDate(period.to) };
}
function formatComparisonPeriod(period: IncomePeriod): string {
return `${formatDate(period.from)} – ${formatDate(period.to)}`;
}
function formatIncomeChange(
difference: string,
compared: string,
): null | string {
// Display-only ratio; server-provided Money values remain exact decimal strings.
const base = Number(compared);
if (!Number.isFinite(base) || base <= 0)
return null;
const ratio = Number(difference) / base;
if (!Number.isFinite(ratio))
return null;
return new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 1,
signDisplay: "exceptZero",
style: "percent",
}).format(ratio);
}
function isValidPeriod(period: IncomePeriod): period is IncomePeriod {
if (!isDate(period.from) || !isDate(period.to)) return false;
const from = new Date(`${period.from}T00:00:00Z`);
const to = new Date(`${period.to}T00:00:00Z`);
const today = new Date(`${moscowDate(new Date())}T00:00:00Z`);
return (
from.toISOString().slice(0, ISO_DATE_LENGTH) === period.from
&& to.toISOString().slice(0, ISO_DATE_LENGTH) === period.to
&& from <= to
&& to <= today
);
}
function toIncomeDisplayModel(
analytics: IncomeAnalytics,
meta?: IncomeMeta,
): IncomeDisplayModel | null {
const instruments = new Map<
string,
{
currency: string;
includedCount: number;
receiptCount: number;
}
>();
const currencies = new Set(
analytics.totalsByCurrency.map(({ currency }) => currency),
);
for (const instrument of analytics.instruments) {
if (
typeof instrument.key !== "string"
|| !instrument.key.trim()
|| instruments.has(instrument.key)
|| !currencies.has(instrument.total.currency)
)
return null;
instruments.set(instrument.key, {
currency: instrument.total.currency,
includedCount: 0,
receiptCount: instrument.receiptCount,
});
}
const entries: IncomeDisplayModel["entries"] = [];
const occurrences = new Map<string, number>();
for (const entry of analytics.entries) {
if (typeof entry.instrumentKey !== "string" || !entry.instrumentKey.trim())
return null;
const instrument = instruments.get(entry.instrumentKey);
if (!instrument || entry.payment?.currency !== instrument.currency)
return null;
const occurrenceKey = JSON.stringify([entry.instrumentKey, entry.id]);
const occurrence = occurrences.get(occurrenceKey) ?? 0;
occurrences.set(occurrenceKey, occurrence + 1);
instrument.includedCount += 1;
entries.push({
key: JSON.stringify([
entry.instrumentKey,
entry.id,
occurrence,
entry.logoName,
]),
operation: entry,
});
}
for (const instrument of instruments.values()) {
if (instrument.includedCount !== instrument.receiptCount)
return null;
}
const previous = meta?.dependencies?.find(({ role }) => role === "previous");
const year = meta?.dependencies?.find(({ role }) => role === "year");
return {
comparison: comparisonDisplay(analytics.period, analytics.yearComparison),
entries,
previousComparison: comparisonDisplay(analytics.period, analytics.previous),
previousNotice: comparisonNotice(previous, "previousUnavailable"),
yearNotice: comparisonNotice(year, "yearUnavailable"),
};
function isValidIncomePeriod(period: IncomePeriod): boolean {
return isValidPeriod(period.from, period.to)
&& period.to <= moscowDate(new Date());
}
export {
type AccountIncomePageViewModel,
type IncomeDisplayModel,
toIncomeDisplayModel,
useAccountIncomePageViewModel,
};
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import { formatMoney } from "@/shared/lib";
import type { IncomeDisplayModel } from "../AccountIncomePage.vm";
import type { IncomeDisplayModel } from "../../../model/display";
import classes from "../AccountIncomePage.module.css";
@@ -2,10 +2,11 @@ import { useState } from "react";
import { useController, useFormContext, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { moscowDate } from "@/shared/lib";
import {
formatDraftDate,
type IncomeForm,
moscowDate,
parseDraftDate,
} from "../../../../model/period";
@@ -91,7 +91,6 @@ const OperationExample: FC<ExampleProps> = (props) => {
},
],
limit: { currency: "RUB", value: "500" },
onSelect: () => {},
}}
saveDisabled={false}
sourcePage={1}
@@ -245,14 +245,19 @@ describe("OperationForm", () => {
version: 1,
};
const recent = {
...purchase,
availableRefund: money("100"),
description: "Недавняя покупка",
id: "recent",
};
server.use(
http.get("*/api/v1/bank-cards/a/expenses", ({ request }) => {
return HttpResponse.json({
hasNext: new URL(request.url).searchParams.get("page") !== "2",
items:
new URL(request.url).searchParams.get("page") === "2"
? [purchase]
: [],
[new URL(request.url).searchParams.get("page") === "2" ? purchase : recent],
page: 1,
total: 1,
});
@@ -260,9 +265,19 @@ describe("OperationForm", () => {
http.get("*/api/v1/bank-cards/operations/purchase", () =>
HttpResponse.json(purchase),
),
http.get("*/api/v1/bank-cards/operations/recent", () =>
HttpResponse.json(recent),
),
);
const close = setup();
await select(/Вид операции/, "Возврат");
await select(/Исходная покупка/, /Недавняя покупка/);
await screen.findByText("Доступно к возврату: 100 ₽");
fireEvent.change(screen.getByLabelText(/Сумма/), {
target: { value: "150" },
});
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await screen.findByText("Сумма превышает доступный возврат.");
await waitFor(() =>
expect(screen.getByRole("button", { name: "Далее" })).toBeEnabled(),
);
@@ -142,12 +142,11 @@ type RefundFieldsProps = {
editing: boolean;
expenses: Choice[];
limit: BankOperation["availableRefund"] | undefined;
onSelect: (id: string) => void;
};
const RefundFields: FC<RefundFieldsProps> = (props) => {
const { errors, isSubmitting, onExpenseChange, originalExpenseIdField, t }
= useRefundFieldsVm(props);
= useRefundFieldsVm();
return (
<Stack>
@@ -35,11 +35,6 @@ const useOperationFormVm = (props: OperationFormProps) => {
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(
@@ -64,7 +59,7 @@ const useOperationFormVm = (props: OperationFormProps) => {
originalExpenseId: props.operation?.originalExpenseId ?? "",
targetCardId: props.operation?.targetCardId ?? "",
},
resolver: zodResolver(
resolver: (values, context, options) => zodResolver(
createOperationSchema({
cards: props.cards,
categories: categories.data,
@@ -74,9 +69,16 @@ const useOperationFormVm = (props: OperationFormProps) => {
: sourceQuery.data?.availableRefund,
submittedValues,
}),
),
)(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(
@@ -242,7 +244,6 @@ const useOperationFormVm = (props: OperationFormProps) => {
limit: props.operation
? refundLimit
: sourceQuery.data?.availableRefund,
onSelect: setSourceId,
},
saveDisabled:
(kind === "expense" && !categories.data)
@@ -1,20 +1,9 @@
import { useController, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { BankOperation } from "@/entities/bank-card";
import type { OperationValues } from "./OperationForm.types";
type Choice = { label: string; value: string };
type RefundFieldsProps = {
editing: boolean;
expenses: Choice[];
limit: BankOperation["availableRefund"] | undefined;
onSelect: (id: string) => void;
};
function useRefundFieldsVm(props: RefundFieldsProps) {
function useRefundFieldsVm() {
const { t } = useTranslation();
const {
@@ -29,7 +18,6 @@ function useRefundFieldsVm(props: RefundFieldsProps) {
const onExpenseChange: (value: null | string) => void = (value) => {
originalExpenseIdField.onChange(value ?? "");
props.onSelect(value ?? "");
};
return { errors, isSubmitting, onExpenseChange, originalExpenseIdField, t };