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 { useScreenSync } from "@/features/tbank-connection"; import { validationMessage } from "@/shared/i18n"; import { formatDate } from "@/shared/lib"; import { formatDraftDate, type IncomeForm, type IncomePeriod, isDate, moscowDate, parseDraftDate, } from "../../model/period"; const ISO_MONTH_LENGTH = 7; type IncomeMeta = ReturnType["meta"]; type ComparisonNotice = | "comparisonPending" | "comparisonFailed" | "previousUnavailable" | "yearUnavailable"; type IncomeDisplayModel = { previousComparison: IncomeDisplayModel["comparison"]; previousNotice: ComparisonNotice; yearNotice: ComparisonNotice; 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; sourceError?: boolean; lastSuccessfulAt?: string | null; partial?: boolean; refreshing: boolean; refreshError: boolean; retry: () => void; form: ReturnType>; onReset: () => void; onSubmit: (values: IncomeForm) => void; }; const draftDate = z .string() .refine((value) => parseDraftDate(value) !== null, { error: validationMessage("income.validation.date"), }) .refine( (value) => { const date = parseDraftDate(value); return !date || date <= moscowDate(new Date()); }, { error: 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); const sync = useScreenSync( { screen: "income", accountId, from: period.from, to: period.to }, [analytics], ); 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 || analytics.meta?.sync.state === "failed") return { state: "error", retry: sync.retry, period }; return { state: "loading" }; } const display = toIncomeDisplayModel(analytics.data, analytics.meta); if (!display) return { state: "error", retry: () => { void analytics.observe(); }, period, }; return { state: "loaded", analytics: analytics.data, display, formRevision, sourceError: sync.sourceError, lastSuccessfulAt: analytics.meta?.lastSuccessfulAt, partial: analytics.meta?.availability !== "complete", refreshing: analytics.isFetching || sync.refreshing, refreshError: analytics.isError || sync.sourceError || sync.transportError, retry: sync.retry, form, onSubmit, onReset, }; }; 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 toIncomeDisplayModel( analytics: IncomeAnalytics, meta?: IncomeMeta, ): 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; } const previous = meta?.dependencies?.find(({ role }) => role === "previous"); const year = meta?.dependencies?.find(({ role }) => role === "year"); return { entries, previousComparison: comparisonDisplay(analytics.period, analytics.previous), comparison: comparisonDisplay(analytics.period, analytics.yearComparison), previousNotice: comparisonNotice(previous, "previousUnavailable"), yearNotice: comparisonNotice(year, "yearUnavailable"), }; } function comparisonNotice( dependency: | NonNullable["dependencies"]>[number] | undefined, unavailable: "previousUnavailable" | "yearUnavailable", ): ComparisonNotice { if (!dependency || dependency.unavailableReason) return unavailable; return dependency.sync.state === "failed" ? "comparisonFailed" : "comparisonPending"; } function comparisonDisplay( period: IncomePeriod, comparison: IncomeAnalytics["yearComparison"], ): IncomeDisplayModel["comparison"] { if (!comparison) return null; return { selectedPeriod: formatComparisonPeriod(period), comparedPeriod: formatComparisonPeriod(comparison.period), values: comparison.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, ), }, })), }; } 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); } export { type AccountIncomePageViewModel, type IncomeDisplayModel, toIncomeDisplayModel, useAccountIncomePageViewModel, };