Files
frontend/src/pages/account-income/ui/AccountIncomePage.vm.ts
T

237 lines
6.8 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";
type IncomePeriod = { from: string; to: string };
type IncomeForm = IncomePeriod;
type IncomeDisplayModel = {
entries: { key: string; operation: IncomeAnalytics["entries"][number] }[];
};
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 = (): AccountIncomePageViewModel => {
const { accountId } = useParams();
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));
// DateInput retains raw text while its calendar is open; reset that UI state too.
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, 7)}-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 };
}
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 IncomeForm,
isDate,
moscowDate,
parseDraftDate,
toIncomeDisplayModel,
useAccountIncomePageViewModel,
};