refactor: simplify calendar and form state
This commit is contained in:
+1
-1
@@ -23,7 +23,7 @@
|
|||||||
"openapi:check": "node scripts/check-openapi.mjs",
|
"openapi:check": "node scripts/check-openapi.mjs",
|
||||||
"knip": "npm run build:packages && knip",
|
"knip": "npm run build:packages && knip",
|
||||||
"deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long",
|
"deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long",
|
||||||
"verify": "npm run build:packages && npm run openapi:check && tsc -b --noEmit && npm run lint && npm run test:lint && vitest run --coverage && knip && depcruise src packages --config dependency-cruiser.cjs --output-type err-long && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long && tsc -b && vite build && storybook build --output-dir storybook-static --disable-telemetry",
|
"verify": "npm run build:packages && npm run openapi:check && tsc -b --noEmit && eslint . --max-warnings=0 && npm run test:lint && vitest run --coverage && knip && depcruise src packages --config dependency-cruiser.cjs --output-type err-long && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long && tsc -b && vite build && storybook build --output-dir storybook-static --disable-telemetry",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"deps:storybook": "npm run build:packages && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long",
|
"deps:storybook": "npm run build:packages && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long",
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ describe("bank form input", () => {
|
|||||||
expect(bankDateSchema.safeParse("2026-09-01").success).toBe(true);
|
expect(bankDateSchema.safeParse("2026-09-01").success).toBe(true);
|
||||||
expect(bankDateSchema.safeParse("2026-09-02").success).toBe(false);
|
expect(bankDateSchema.safeParse("2026-09-02").success).toBe(false);
|
||||||
expect(bankDateSchema.safeParse("2026-02-30").success).toBe(false);
|
expect(bankDateSchema.safeParse("2026-02-30").success).toBe(false);
|
||||||
|
expect(bankDateSchema.safeParse("0000-01-01").success).toBe(false);
|
||||||
|
expect(bankDateSchema.safeParse("0001-01-01").success).toBe(true);
|
||||||
|
expect(bankDateSchema.safeParse("0099-12-31").success).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { moscowDate } from "@/shared/lib";
|
||||||
|
|
||||||
const DECIMAL_PART_COUNT = 2;
|
const DECIMAL_PART_COUNT = 2;
|
||||||
const CENTS_PRECISION = 2;
|
const CENTS_PRECISION = 2;
|
||||||
|
|
||||||
@@ -21,12 +23,7 @@ function bankMonth(value: null | string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function bankToday() {
|
function bankToday() {
|
||||||
return new Intl.DateTimeFormat("en-CA", {
|
return moscowDate(new Date());
|
||||||
day: "2-digit",
|
|
||||||
month: "2-digit",
|
|
||||||
timeZone: "Europe/Moscow",
|
|
||||||
year: "numeric",
|
|
||||||
}).format(new Date());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function moneyCents(value: string): bigint | null {
|
function moneyCents(value: string): bigint | null {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { validationMessage } from "@/shared/i18n";
|
import { validationMessage } from "@/shared/i18n";
|
||||||
|
import { calendarDate } from "@/shared/lib";
|
||||||
|
|
||||||
import { bankToday, moneyCents } from "./form-validation";
|
import { bankToday, moneyCents } from "./form-validation";
|
||||||
const ISO_DATE_LENGTH = 10;
|
|
||||||
const MAX_CARD_NAME_LENGTH = 100;
|
const MAX_CARD_NAME_LENGTH = 100;
|
||||||
const MIN_BALANCE_KOPECKS = -9_223_372_036_854_775_808n;
|
const MIN_BALANCE_KOPECKS = -9_223_372_036_854_775_808n;
|
||||||
const MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n;
|
const MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n;
|
||||||
@@ -34,11 +34,8 @@ const bankDateSchema = z
|
|||||||
.string()
|
.string()
|
||||||
.refine(
|
.refine(
|
||||||
value =>
|
value =>
|
||||||
/^\d{4}-\d{2}-\d{2}$/.test(value)
|
calendarDate.safeParse(value).success
|
||||||
&& value >= "0001-01-01"
|
&& value >= "0001-01-01"
|
||||||
&& !Number.isNaN(Date.parse(`${value}T00:00:00Z`))
|
|
||||||
&& new Date(`${value}T00:00:00Z`).toISOString().slice(0, ISO_DATE_LENGTH)
|
|
||||||
=== value
|
|
||||||
&& value <= bankToday(),
|
&& value <= bankToday(),
|
||||||
{ error: validationMessage("bank.validation.date") },
|
{ error: validationMessage("bank.validation.date") },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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 { z } from "zod";
|
||||||
|
|
||||||
import { validationMessage } from "@/shared/i18n";
|
import { validationMessage } from "@/shared/i18n";
|
||||||
|
import { moscowDate } from "@/shared/lib";
|
||||||
|
|
||||||
import { moscowDate, parseDraftDate } from "./period";
|
import { parseDraftDate } from "./period";
|
||||||
|
|
||||||
const draftDate = z
|
const draftDate = z
|
||||||
.string()
|
.string()
|
||||||
|
|||||||
@@ -1,47 +1,22 @@
|
|||||||
const ISO_DATE_LENGTH = 10;
|
import { calendarDate } from "@/shared/lib";
|
||||||
|
|
||||||
const DATE_PART_COUNT = 3;
|
const DATE_PART_COUNT = 3;
|
||||||
|
|
||||||
type IncomePeriod = { from: string; to: string };
|
type IncomePeriod = { from: string; to: string };
|
||||||
|
|
||||||
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
|
|
||||||
|
|
||||||
function formatDraftDate(value: string): string {
|
function formatDraftDate(value: string): string {
|
||||||
const [year, month, day] = value.split("-", DATE_PART_COUNT);
|
const [year, month, day] = value.split("-", DATE_PART_COUNT);
|
||||||
|
|
||||||
return `${day}.${month}.${year}`;
|
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 {
|
function parseDraftDate(value: string): null | string {
|
||||||
if (!/^\d{2}\.\d{2}\.\d{4}$/.test(value))
|
if (!/^\d{2}\.\d{2}\.\d{4}$/.test(value))
|
||||||
return null;
|
return null;
|
||||||
const [day, month, year] = value.split(".", DATE_PART_COUNT);
|
const [day, month, year] = value.split(".", DATE_PART_COUNT);
|
||||||
const date = `${year}-${month}-${day}`;
|
const date = `${year}-${month}-${day}`;
|
||||||
|
|
||||||
return isDate(date)
|
return calendarDate.safeParse(date).success
|
||||||
? date
|
? date
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
@@ -50,8 +25,5 @@ export {
|
|||||||
formatDraftDate,
|
formatDraftDate,
|
||||||
type IncomePeriod as IncomeForm,
|
type IncomePeriod as IncomeForm,
|
||||||
type IncomePeriod,
|
type IncomePeriod,
|
||||||
isDate,
|
|
||||||
ISO_DATE_LENGTH,
|
|
||||||
moscowDate,
|
|
||||||
parseDraftDate,
|
parseDraftDate,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,10 +30,9 @@ import { createTestQueryClient } from "@/test/query";
|
|||||||
import { resource } from "@/test/resource";
|
import { resource } from "@/test/resource";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
|
|
||||||
import { isDate } from "../../model/period";
|
import { toIncomeDisplayModel } from "../../model/display";
|
||||||
import { AccountIncomePage } from "./AccountIncomePage";
|
import { AccountIncomePage } from "./AccountIncomePage";
|
||||||
import {
|
import {
|
||||||
toIncomeDisplayModel,
|
|
||||||
useAccountIncomePageViewModel,
|
useAccountIncomePageViewModel,
|
||||||
} from "./AccountIncomePage.vm";
|
} from "./AccountIncomePage.vm";
|
||||||
import { IncomePayments } from "./components/IncomePayments.view";
|
import { IncomePayments } from "./components/IncomePayments.view";
|
||||||
@@ -469,12 +468,6 @@ describe("AccountIncomePage", () => {
|
|||||||
expect(within(panel).getByText("−2 ₽")).toBeInTheDocument();
|
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 () => {
|
it("renders realized text and comparisons without subscribing to events", async () => {
|
||||||
const eventsRequest = vi.fn(() => new HttpResponse(null, { status: 503 }));
|
const eventsRequest = vi.fn(() => new HttpResponse(null, { status: 503 }));
|
||||||
server.use(
|
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 LocationProbe: FC = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
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])(
|
it.each([false, true])(
|
||||||
"shows unavailable comparisons for absent roles without positional fallback (legacy=%s)",
|
"shows unavailable comparisons for absent roles without positional fallback (legacy=%s)",
|
||||||
async (legacy) => {
|
async (legacy) => {
|
||||||
|
|||||||
@@ -5,16 +5,17 @@ import { useParams, useSearchParams } from "react-router";
|
|||||||
|
|
||||||
import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
|
import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
|
||||||
import { useScreenSync } from "@/features/tbank-connection";
|
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 { incomeFormSchema } from "../../model/income.schema";
|
||||||
import {
|
import {
|
||||||
formatDraftDate,
|
formatDraftDate,
|
||||||
type IncomeForm,
|
type IncomeForm,
|
||||||
type IncomePeriod,
|
type IncomePeriod,
|
||||||
isDate,
|
|
||||||
ISO_DATE_LENGTH,
|
|
||||||
moscowDate,
|
|
||||||
parseDraftDate,
|
parseDraftDate,
|
||||||
} from "../../model/period";
|
} from "../../model/period";
|
||||||
|
|
||||||
@@ -43,33 +44,6 @@ type AccountIncomePageViewModel
|
|||||||
}
|
}
|
||||||
| { state: "loading" };
|
| { 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 = (
|
const useAccountIncomePageViewModel = (
|
||||||
scopeId?: string,
|
scopeId?: string,
|
||||||
): AccountIncomePageViewModel => {
|
): AccountIncomePageViewModel => {
|
||||||
@@ -86,7 +60,7 @@ const useAccountIncomePageViewModel = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const period = useMemo(
|
const period = useMemo(
|
||||||
() => (isValidPeriod(requested) ? requested : defaultPeriod()),
|
() => (isValidIncomePeriod(requested) ? requested : defaultPeriod()),
|
||||||
[requested],
|
[requested],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -104,7 +78,7 @@ const useAccountIncomePageViewModel = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isValidPeriod(requested))
|
if (!isValidIncomePeriod(requested))
|
||||||
setSearchParameters(period, { replace: true });
|
setSearchParameters(period, { replace: true });
|
||||||
}, [period, requested, setSearchParameters]);
|
}, [period, requested, setSearchParameters]);
|
||||||
useEffect(() => {
|
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 {
|
function defaultPeriod(): IncomePeriod {
|
||||||
const today = moscowDate(new Date());
|
const today = moscowDate(new Date());
|
||||||
|
|
||||||
@@ -208,120 +140,12 @@ function draftPeriod(period: IncomePeriod): IncomeForm {
|
|||||||
return { from: formatDraftDate(period.from), to: formatDraftDate(period.to) };
|
return { from: formatDraftDate(period.from), to: formatDraftDate(period.to) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatComparisonPeriod(period: IncomePeriod): string {
|
function isValidIncomePeriod(period: IncomePeriod): boolean {
|
||||||
return `${formatDate(period.from)} – ${formatDate(period.to)}`;
|
return isValidPeriod(period.from, period.to)
|
||||||
}
|
&& period.to <= moscowDate(new Date());
|
||||||
|
|
||||||
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"),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
type AccountIncomePageViewModel,
|
type AccountIncomePageViewModel,
|
||||||
type IncomeDisplayModel,
|
|
||||||
toIncomeDisplayModel,
|
|
||||||
useAccountIncomePageViewModel,
|
useAccountIncomePageViewModel,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { formatMoney } from "@/shared/lib";
|
import { formatMoney } from "@/shared/lib";
|
||||||
|
|
||||||
import type { IncomeDisplayModel } from "../AccountIncomePage.vm";
|
import type { IncomeDisplayModel } from "../../../model/display";
|
||||||
|
|
||||||
import classes from "../AccountIncomePage.module.css";
|
import classes from "../AccountIncomePage.module.css";
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -2,10 +2,11 @@ import { useState } from "react";
|
|||||||
import { useController, useFormContext, useWatch } from "react-hook-form";
|
import { useController, useFormContext, useWatch } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { moscowDate } from "@/shared/lib";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
formatDraftDate,
|
formatDraftDate,
|
||||||
type IncomeForm,
|
type IncomeForm,
|
||||||
moscowDate,
|
|
||||||
parseDraftDate,
|
parseDraftDate,
|
||||||
} from "../../../../model/period";
|
} from "../../../../model/period";
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,6 @@ const OperationExample: FC<ExampleProps> = (props) => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
limit: { currency: "RUB", value: "500" },
|
limit: { currency: "RUB", value: "500" },
|
||||||
onSelect: () => {},
|
|
||||||
}}
|
}}
|
||||||
saveDisabled={false}
|
saveDisabled={false}
|
||||||
sourcePage={1}
|
sourcePage={1}
|
||||||
|
|||||||
@@ -245,14 +245,19 @@ describe("OperationForm", () => {
|
|||||||
version: 1,
|
version: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const recent = {
|
||||||
|
...purchase,
|
||||||
|
availableRefund: money("100"),
|
||||||
|
description: "Недавняя покупка",
|
||||||
|
id: "recent",
|
||||||
|
};
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/bank-cards/a/expenses", ({ request }) => {
|
http.get("*/api/v1/bank-cards/a/expenses", ({ request }) => {
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
hasNext: new URL(request.url).searchParams.get("page") !== "2",
|
hasNext: new URL(request.url).searchParams.get("page") !== "2",
|
||||||
items:
|
items:
|
||||||
new URL(request.url).searchParams.get("page") === "2"
|
[new URL(request.url).searchParams.get("page") === "2" ? purchase : recent],
|
||||||
? [purchase]
|
|
||||||
: [],
|
|
||||||
page: 1,
|
page: 1,
|
||||||
total: 1,
|
total: 1,
|
||||||
});
|
});
|
||||||
@@ -260,9 +265,19 @@ describe("OperationForm", () => {
|
|||||||
http.get("*/api/v1/bank-cards/operations/purchase", () =>
|
http.get("*/api/v1/bank-cards/operations/purchase", () =>
|
||||||
HttpResponse.json(purchase),
|
HttpResponse.json(purchase),
|
||||||
),
|
),
|
||||||
|
http.get("*/api/v1/bank-cards/operations/recent", () =>
|
||||||
|
HttpResponse.json(recent),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const close = setup();
|
const close = setup();
|
||||||
await select(/Вид операции/, "Возврат");
|
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(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByRole("button", { name: "Далее" })).toBeEnabled(),
|
expect(screen.getByRole("button", { name: "Далее" })).toBeEnabled(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -142,12 +142,11 @@ type RefundFieldsProps = {
|
|||||||
editing: boolean;
|
editing: boolean;
|
||||||
expenses: Choice[];
|
expenses: Choice[];
|
||||||
limit: BankOperation["availableRefund"] | undefined;
|
limit: BankOperation["availableRefund"] | undefined;
|
||||||
onSelect: (id: string) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const RefundFields: FC<RefundFieldsProps> = (props) => {
|
const RefundFields: FC<RefundFieldsProps> = (props) => {
|
||||||
const { errors, isSubmitting, onExpenseChange, originalExpenseIdField, t }
|
const { errors, isSubmitting, onExpenseChange, originalExpenseIdField, t }
|
||||||
= useRefundFieldsVm(props);
|
= useRefundFieldsVm();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
|
|||||||
@@ -35,11 +35,6 @@ const useOperationFormVm = (props: OperationFormProps) => {
|
|||||||
const [latest, setLatest] = useState<BankOperation>();
|
const [latest, setLatest] = useState<BankOperation>();
|
||||||
const [sourcePage, setSourcePage] = useState(1);
|
const [sourcePage, setSourcePage] = useState(1);
|
||||||
|
|
||||||
const [sourceId, setSourceId] = useState(
|
|
||||||
props.operation?.originalExpenseId ?? "",
|
|
||||||
);
|
|
||||||
|
|
||||||
const sourceQuery = useBankOperation(sourceId || undefined);
|
|
||||||
const currentQuery = useBankOperation(props.operation?.id);
|
const currentQuery = useBankOperation(props.operation?.id);
|
||||||
|
|
||||||
const [refundLimit, setRefundLimit] = useState(
|
const [refundLimit, setRefundLimit] = useState(
|
||||||
@@ -64,7 +59,7 @@ const useOperationFormVm = (props: OperationFormProps) => {
|
|||||||
originalExpenseId: props.operation?.originalExpenseId ?? "",
|
originalExpenseId: props.operation?.originalExpenseId ?? "",
|
||||||
targetCardId: props.operation?.targetCardId ?? "",
|
targetCardId: props.operation?.targetCardId ?? "",
|
||||||
},
|
},
|
||||||
resolver: zodResolver(
|
resolver: (values, context, options) => zodResolver(
|
||||||
createOperationSchema({
|
createOperationSchema({
|
||||||
cards: props.cards,
|
cards: props.cards,
|
||||||
categories: categories.data,
|
categories: categories.data,
|
||||||
@@ -74,9 +69,16 @@ const useOperationFormVm = (props: OperationFormProps) => {
|
|||||||
: sourceQuery.data?.availableRefund,
|
: sourceQuery.data?.availableRefund,
|
||||||
submittedValues,
|
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 kind = useWatch({ control: methods.control, name: "kind" });
|
||||||
|
|
||||||
const expenses = useBankExpenses(
|
const expenses = useBankExpenses(
|
||||||
@@ -242,7 +244,6 @@ const useOperationFormVm = (props: OperationFormProps) => {
|
|||||||
limit: props.operation
|
limit: props.operation
|
||||||
? refundLimit
|
? refundLimit
|
||||||
: sourceQuery.data?.availableRefund,
|
: sourceQuery.data?.availableRefund,
|
||||||
onSelect: setSourceId,
|
|
||||||
},
|
},
|
||||||
saveDisabled:
|
saveDisabled:
|
||||||
(kind === "expense" && !categories.data)
|
(kind === "expense" && !categories.data)
|
||||||
|
|||||||
@@ -1,20 +1,9 @@
|
|||||||
import { useController, useFormContext } from "react-hook-form";
|
import { useController, useFormContext } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { BankOperation } from "@/entities/bank-card";
|
|
||||||
|
|
||||||
import type { OperationValues } from "./OperationForm.types";
|
import type { OperationValues } from "./OperationForm.types";
|
||||||
|
|
||||||
type Choice = { label: string; value: string };
|
function useRefundFieldsVm() {
|
||||||
|
|
||||||
type RefundFieldsProps = {
|
|
||||||
editing: boolean;
|
|
||||||
expenses: Choice[];
|
|
||||||
limit: BankOperation["availableRefund"] | undefined;
|
|
||||||
onSelect: (id: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function useRefundFieldsVm(props: RefundFieldsProps) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -29,7 +18,6 @@ function useRefundFieldsVm(props: RefundFieldsProps) {
|
|||||||
|
|
||||||
const onExpenseChange: (value: null | string) => void = (value) => {
|
const onExpenseChange: (value: null | string) => void = (value) => {
|
||||||
originalExpenseIdField.onChange(value ?? "");
|
originalExpenseIdField.onChange(value ?? "");
|
||||||
props.onSelect(value ?? "");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return { errors, isSubmitting, onExpenseChange, originalExpenseIdField, t };
|
return { errors, isSubmitting, onExpenseChange, originalExpenseIdField, t };
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { isValidPeriod, moscowDate } from "./calendar";
|
||||||
|
import { calendarDate } from "./calendar.schema";
|
||||||
|
|
||||||
|
describe("calendar dates", () => {
|
||||||
|
it.each([
|
||||||
|
["0000-02-29", true],
|
||||||
|
["0001-01-01", true],
|
||||||
|
["0099-12-31", true],
|
||||||
|
["1900-02-29", false],
|
||||||
|
["2000-02-29", true],
|
||||||
|
["2024-02-29", true],
|
||||||
|
["2026-02-29", false],
|
||||||
|
["2026-99-99", false],
|
||||||
|
["2026-1-01", false],
|
||||||
|
["", false],
|
||||||
|
])("validates %s without Date normalization", (value, valid) => {
|
||||||
|
expect(calendarDate.safeParse(value).success).toBe(valid);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts open or single-day periods and rejects reversed ranges", () => {
|
||||||
|
expect(isValidPeriod(null, null)).toBe(true);
|
||||||
|
expect(isValidPeriod(null, "2026-09-01")).toBe(true);
|
||||||
|
expect(isValidPeriod("2026-09-01", null)).toBe(true);
|
||||||
|
expect(isValidPeriod("2026-09-01", "2026-09-01")).toBe(true);
|
||||||
|
expect(isValidPeriod("2026-09-02", "2026-09-01")).toBe(false);
|
||||||
|
expect(isValidPeriod("", "2026-09-01")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes the calendar day at Moscow midnight across a year boundary", () => {
|
||||||
|
expect(moscowDate(new Date("2026-12-31T20:59:59Z"))).toBe("2026-12-31");
|
||||||
|
expect(moscowDate(new Date("2026-12-31T21:00:00Z"))).toBe("2027-01-01");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,4 +9,18 @@ function isValidPeriod(from: null | string, to: null | string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { isValidPeriod };
|
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")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { isValidPeriod, moscowDate };
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export { isValidPeriod } from "./calendar";
|
export { isValidPeriod, moscowDate } from "./calendar";
|
||||||
export { calendarDate } from "./calendar.schema";
|
export { calendarDate } from "./calendar.schema";
|
||||||
export {
|
export {
|
||||||
formatChartValue,
|
formatChartValue,
|
||||||
|
|||||||
Reference in New Issue
Block a user