300 lines
8.5 KiB
TypeScript
300 lines
8.5 KiB
TypeScript
import { zodResolver } from "@hookform/resolvers/zod";
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { useForm } from "react-hook-form";
|
||
import { useParams, useSearchParams } from "react-router";
|
||
import { z } from "zod";
|
||
import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
|
||
import { validationMessage } from "@/shared/i18n";
|
||
import { formatDate } from "@/shared/lib";
|
||
|
||
const ISO_MONTH_LENGTH = 7;
|
||
|
||
type IncomePeriod = { from: string; to: string };
|
||
type IncomeForm = IncomePeriod;
|
||
|
||
type IncomeDisplayModel = {
|
||
entries: { key: string; operation: IncomeAnalytics["entries"][number] }[];
|
||
comparison: {
|
||
selectedPeriod: string;
|
||
comparedPeriod: string;
|
||
values: (NonNullable<
|
||
IncomeAnalytics["yearComparison"]
|
||
>["totalsByCurrency"][number] & {
|
||
percentages: Record<"total" | "coupon" | "dividend", string | null>;
|
||
})[];
|
||
} | null;
|
||
};
|
||
|
||
type AccountIncomePageViewModel =
|
||
| { state: "loading" }
|
||
| { state: "error"; retry: () => void; period: IncomePeriod }
|
||
| {
|
||
state: "loaded";
|
||
analytics: IncomeAnalytics;
|
||
display: IncomeDisplayModel;
|
||
formRevision: number;
|
||
refreshing: boolean;
|
||
refreshError: boolean;
|
||
retry: () => void;
|
||
form: ReturnType<typeof useForm<IncomeForm>>;
|
||
onReset: () => void;
|
||
onSubmit: (values: IncomeForm) => void;
|
||
};
|
||
|
||
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
|
||
const draftDate = z
|
||
.string()
|
||
.refine(
|
||
(value) => parseDraftDate(value) !== null,
|
||
validationMessage("income.validation.date"),
|
||
)
|
||
.refine((value) => {
|
||
const date = parseDraftDate(value);
|
||
return !date || date <= moscowDate(new Date());
|
||
}, validationMessage("income.validation.future"));
|
||
const incomeFormSchema = z.object({ from: draftDate, to: draftDate }).refine(
|
||
({ from, to }) => {
|
||
const start = parseDraftDate(from);
|
||
const end = parseDraftDate(to);
|
||
return !start || !end || start <= end;
|
||
},
|
||
{ path: ["to"], ...validationMessage("income.validation.period")({}) },
|
||
);
|
||
|
||
const useAccountIncomePageViewModel = (
|
||
scopeId?: string,
|
||
): AccountIncomePageViewModel => {
|
||
const { accountId: routeAccountId } = useParams();
|
||
const accountId = scopeId ?? routeAccountId;
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
const requested = useMemo(
|
||
() => ({
|
||
from: searchParams.get("from") ?? "",
|
||
to: searchParams.get("to") ?? "",
|
||
}),
|
||
[searchParams],
|
||
);
|
||
const period = useMemo(
|
||
() => (validPeriod(requested) ? requested : defaultPeriod()),
|
||
[requested],
|
||
);
|
||
const form = useForm<IncomeForm>({
|
||
defaultValues: draftPeriod(period),
|
||
resolver: zodResolver(incomeFormSchema),
|
||
});
|
||
const [formRevision, setFormRevision] = useState(0);
|
||
const analytics = useIncomeAnalytics(accountId, period);
|
||
|
||
useEffect(() => {
|
||
if (!validPeriod(requested)) setSearchParams(period, { replace: true });
|
||
}, [period, requested, setSearchParams]);
|
||
useEffect(() => {
|
||
form.reset(draftPeriod(period));
|
||
setFormRevision((revision) => revision + 1);
|
||
}, [form, period]);
|
||
|
||
const onSubmit = (values: IncomeForm) => {
|
||
const from = parseDraftDate(values.from);
|
||
const to = parseDraftDate(values.to);
|
||
if (from && to) setSearchParams({ from, to });
|
||
};
|
||
const onReset = () => {
|
||
const next = defaultPeriod();
|
||
form.reset(draftPeriod(next));
|
||
// Reset the open calendar along with the form.
|
||
setFormRevision((revision) => revision + 1);
|
||
setSearchParams(next, { replace: true });
|
||
};
|
||
if (analytics.isLoading || !analytics.data) {
|
||
if (analytics.isError)
|
||
return { state: "error", retry: () => analytics.refetch(), period };
|
||
return { state: "loading" };
|
||
}
|
||
const display = toIncomeDisplayModel(analytics.data);
|
||
if (!display)
|
||
return { state: "error", retry: () => analytics.refetch(), period };
|
||
return {
|
||
state: "loaded",
|
||
analytics: analytics.data,
|
||
display,
|
||
formRevision,
|
||
refreshing: analytics.isFetching,
|
||
refreshError: analytics.isError,
|
||
retry: () => analytics.refetch(),
|
||
form,
|
||
onSubmit,
|
||
onReset,
|
||
};
|
||
};
|
||
|
||
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) };
|
||
}
|
||
|
||
function defaultPeriod(): IncomePeriod {
|
||
const today = moscowDate(new Date());
|
||
return { from: `${today.slice(0, ISO_MONTH_LENGTH)}-01`, to: today };
|
||
}
|
||
|
||
function validPeriod(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, 10) === period.from &&
|
||
to.toISOString().slice(0, 10) === period.to &&
|
||
from <= to &&
|
||
to <= today
|
||
);
|
||
}
|
||
|
||
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 {
|
||
const instruments = new Map<
|
||
string,
|
||
{
|
||
currency: string;
|
||
receiptCount: number;
|
||
includedCount: 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,
|
||
receiptCount: instrument.receiptCount,
|
||
includedCount: 0,
|
||
});
|
||
}
|
||
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;
|
||
}
|
||
|
||
return {
|
||
entries,
|
||
comparison: analytics.yearComparison
|
||
? {
|
||
selectedPeriod: formatComparisonPeriod(analytics.period),
|
||
comparedPeriod: formatComparisonPeriod(
|
||
analytics.yearComparison.period,
|
||
),
|
||
values: analytics.yearComparison.totalsByCurrency.map((value) => ({
|
||
...value,
|
||
percentages: {
|
||
total: formatIncomeChange(
|
||
value.difference.total.value,
|
||
value.compared.total.value,
|
||
),
|
||
coupon: formatIncomeChange(
|
||
value.difference.coupon.value,
|
||
value.compared.coupon.value,
|
||
),
|
||
dividend: formatIncomeChange(
|
||
value.difference.dividend.value,
|
||
value.compared.dividend.value,
|
||
),
|
||
},
|
||
})),
|
||
}
|
||
: null,
|
||
};
|
||
}
|
||
|
||
function formatComparisonPeriod(period: IncomePeriod): string {
|
||
return `${formatDate(period.from)} – ${formatDate(period.to)}`;
|
||
}
|
||
|
||
function formatIncomeChange(
|
||
difference: string,
|
||
compared: string,
|
||
): string | null {
|
||
// 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", {
|
||
style: "percent",
|
||
maximumFractionDigits: 1,
|
||
signDisplay: "exceptZero",
|
||
}).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,
|
||
};
|