Frontend security / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 4m57s
Conventional commits / commits (pull_request) Successful in 28s
Frontend security / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 5m10s
Repair investment recovery, draft preservation, date validation, currency presentation, accessibility, and route error recovery. Correct Docker and dependency checks and consume explicit income roles.
317 lines
9.2 KiB
TypeScript
317 lines
9.2 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 { 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<typeof useIncomeAnalytics>["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<typeof useForm<IncomeForm>>;
|
||
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<IncomeForm>({
|
||
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<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 {
|
||
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<NonNullable<IncomeMeta>["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,
|
||
};
|