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>; 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({ 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(); 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, };