Use Query cancellation and native mutation results instead of HTTP registries, access epochs and copied scope state. Keep local auth ordering and mutation identity fences. Allow staged generated files to remain excluded from ESLint. Backend contract: WealthLedger/backend@d698ed9 BREAKING CHANGE: drop investment stateVersion/progress/resources from the client contract and use data/metadata readiness instead of rewritten query status. Cross-tab auth may require re-login.
143 lines
3.9 KiB
TypeScript
143 lines
3.9 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 { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
|
|
import { useScreenSync } from "@/features/tbank-connection";
|
|
import { isValidPeriod, moscowDate } from "@/shared/lib";
|
|
|
|
import {
|
|
type IncomeDisplayModel,
|
|
toIncomeDisplayModel,
|
|
} from "../../model/display";
|
|
import { incomeFormSchema } from "../../model/income.schema";
|
|
import {
|
|
formatDraftDate,
|
|
type IncomeForm,
|
|
type IncomePeriod,
|
|
parseDraftDate,
|
|
} from "../../model/period";
|
|
|
|
const ISO_MONTH_LENGTH = 7;
|
|
|
|
type AccountIncomePageViewModel
|
|
= | {
|
|
analytics: IncomeAnalytics;
|
|
display: IncomeDisplayModel;
|
|
form: ReturnType<typeof useForm<IncomeForm>>;
|
|
formRevision: string;
|
|
lastSuccessfulAt?: null | string;
|
|
onReset: () => void;
|
|
onSubmit: (values: IncomeForm) => void;
|
|
partial?: boolean;
|
|
refreshError: boolean;
|
|
refreshing: boolean;
|
|
retry: () => Promise<unknown> | undefined;
|
|
sourceError?: boolean;
|
|
state: "loaded";
|
|
}
|
|
| {
|
|
period: IncomePeriod;
|
|
retry: () => Promise<unknown> | undefined;
|
|
state: "error";
|
|
}
|
|
| { state: "loading" };
|
|
|
|
const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
|
|
const { accountId = "" } = useParams();
|
|
const [searchParameters, setSearchParameters] = useSearchParams();
|
|
|
|
const requested = useMemo(
|
|
() => ({
|
|
from: searchParameters.get("from") ?? "",
|
|
to: searchParameters.get("to") ?? "",
|
|
}),
|
|
[searchParameters],
|
|
);
|
|
|
|
const period = useMemo(
|
|
() => (isValidIncomePeriod(requested) ? requested : defaultPeriod()),
|
|
[requested],
|
|
);
|
|
|
|
const form = useForm<IncomeForm>({
|
|
defaultValues: draftPeriod(period),
|
|
resolver: zodResolver(incomeFormSchema),
|
|
});
|
|
|
|
const [resetRevision, setResetRevision] = useState(0);
|
|
const analytics = useIncomeAnalytics(accountId, period);
|
|
|
|
const sync = useScreenSync(
|
|
{ accountId, from: period.from, screen: "income", to: period.to },
|
|
[analytics],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!isValidIncomePeriod(requested))
|
|
setSearchParameters(period, { replace: true });
|
|
}, [period, requested, setSearchParameters]);
|
|
useEffect(() => {
|
|
form.reset(draftPeriod(period));
|
|
}, [accountId, form, period]);
|
|
|
|
if (analytics.isLoading || !analytics.data) {
|
|
return analytics.isError || analytics.meta?.sync.state === "failed"
|
|
? { period, retry: sync.retry, state: "error" }
|
|
: { state: "loading" };
|
|
}
|
|
|
|
const display = toIncomeDisplayModel(analytics.data, analytics.meta);
|
|
|
|
const onSubmit = (values: IncomeForm) => {
|
|
const from = parseDraftDate(values.from);
|
|
const to = parseDraftDate(values.to);
|
|
if (from && to) setSearchParameters({ from, to });
|
|
};
|
|
|
|
const onReset = () => {
|
|
const next = defaultPeriod();
|
|
form.reset(draftPeriod(next));
|
|
// Reset the open calendar along with the form.
|
|
setResetRevision(revision => revision + 1);
|
|
setSearchParameters(next, { replace: true });
|
|
};
|
|
|
|
return {
|
|
analytics: analytics.data,
|
|
display,
|
|
form,
|
|
formRevision: `${accountId}:${period.from}:${period.to}:${resetRevision}`,
|
|
lastSuccessfulAt: analytics.meta?.lastSuccessfulAt,
|
|
onReset,
|
|
onSubmit,
|
|
partial: analytics.meta?.availability !== "complete",
|
|
refreshError: analytics.isError || sync.sourceError || sync.transportError,
|
|
refreshing: analytics.isFetching || sync.refreshing,
|
|
retry: sync.retry,
|
|
sourceError: sync.sourceError,
|
|
state: "loaded",
|
|
};
|
|
};
|
|
|
|
function defaultPeriod(): IncomePeriod {
|
|
const today = moscowDate(new Date());
|
|
|
|
return { from: `${today.slice(0, ISO_MONTH_LENGTH)}-01`, to: today };
|
|
}
|
|
|
|
function draftPeriod(period: IncomePeriod): IncomeForm {
|
|
return { from: formatDraftDate(period.from), to: formatDraftDate(period.to) };
|
|
}
|
|
|
|
function isValidIncomePeriod(period: IncomePeriod): boolean {
|
|
return isValidPeriod(period.from, period.to)
|
|
&& period.to <= moscowDate(new Date());
|
|
}
|
|
|
|
export {
|
|
type AccountIncomePageViewModel,
|
|
useAccountIncomePageViewModel,
|
|
};
|