feat: simplify income page filters and payments

This commit is contained in:
2026-09-09 13:50:41 +03:00
parent ab1b53680d
commit 26f229ba3e
13 changed files with 668 additions and 513 deletions
@@ -1,36 +1,16 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { useParams, useSearchParams } from "react-router";
import { z } from "zod";
import type { BrokerageEvent } from "@/entities/brokerage-event";
import { useBrokerageEvents } from "@/entities/brokerage-event";
import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
import { validationMessage } from "@/shared/i18n";
type IncomePeriod = { from: string; to: string };
type IncomeForm = IncomePeriod;
type IncomeForecast =
| { state: "loading" | "empty" }
| { state: "error"; retry: () => void }
| {
state: "loaded";
totals: { currency: string; value: string }[];
events: BrokerageEvent[];
};
type IncomeDisplayModel = {
trend: {
currency: string;
buckets: {
period: IncomeAnalytics["buckets"][number]["period"];
total: IncomeAnalytics["totalsByCurrency"][number];
}[];
}[];
instruments: (IncomeAnalytics["instruments"][number] & {
entries: IncomeAnalytics["entries"];
})[];
entries: { key: string; operation: IncomeAnalytics["entries"][number] }[];
};
type AccountIncomePageViewModel =
@@ -40,24 +20,34 @@ type AccountIncomePageViewModel =
state: "loaded";
analytics: IncomeAnalytics;
display: IncomeDisplayModel;
forecast: IncomeForecast;
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 incomeFormSchema = z
.object({
from: z
.string()
.refine(isDate, validationMessage("income.validation.date")),
to: z.string().refine(isDate, validationMessage("income.validation.date")),
})
.refine(validPeriod, {
path: ["to"],
...validationMessage("income.validation.period")({}),
});
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();
@@ -74,19 +64,32 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
[requested],
);
const form = useForm<IncomeForm>({
defaultValues: period,
defaultValues: draftPeriod(period),
resolver: zodResolver(incomeFormSchema),
});
const [formRevision, setFormRevision] = useState(0);
const analytics = useIncomeAnalytics(accountId, period);
const events = useBrokerageEvents(accountId);
useEffect(() => {
if (!validPeriod(requested)) setSearchParams(period, { replace: true });
}, [period, requested, setSearchParams]);
useEffect(() => form.reset(period), [form, period]);
useEffect(() => {
form.reset(draftPeriod(period));
setFormRevision((revision) => revision + 1);
}, [form, period]);
const onSubmit = (values: IncomeForm) => setSearchParams(values);
const onReset = () => setSearchParams(defaultPeriod(), { replace: true });
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 };
@@ -99,29 +102,30 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
state: "loaded",
analytics: analytics.data,
display,
forecast: toForecast(events),
formRevision,
refreshing: analytics.isFetching,
refreshError: analytics.isError,
retry: () => analytics.refetch(),
form,
onSubmit,
onReset,
};
};
function toForecast(
events: ReturnType<typeof useBrokerageEvents>,
): IncomeForecast {
if (events.isLoading) return { state: "loading" };
if (events.isError) return { state: "error", retry: () => events.refetch() };
const response = events.data;
const upcoming =
response?.events.filter(
(event) => event.type === "coupon" || event.type === "dividend",
) ?? [];
if (!response || !upcoming.length) return { state: "empty" };
return {
state: "loaded",
totals: response.incomeTotalsByCurrency,
events: upcoming.slice(0, 3),
};
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 {
@@ -156,48 +160,56 @@ function toIncomeDisplayModel(
): IncomeDisplayModel | null {
const instruments = new Map<
string,
IncomeDisplayModel["instruments"][number]
{
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)
instruments.has(instrument.key) ||
!currencies.has(instrument.total.currency)
)
return null;
instruments.set(instrument.key, { ...instrument, entries: [] });
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.total.currency)
if (!instrument || entry.payment?.currency !== instrument.currency)
return null;
instrument.entries.push(entry);
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.entries.length !== instrument.receiptCount) return null;
if (instrument.includedCount !== instrument.receiptCount) return null;
}
return {
trend: analytics.totalsByCurrency.map(({ currency }) => ({
currency,
buckets: analytics.buckets.map((bucket) => ({
period: bucket.period,
total:
bucket.totalsByCurrency.find((item) => item.currency === currency) ??
zeroSummary(currency),
})),
})),
instruments: [...instruments.values()],
};
}
function zeroSummary(
currency: string,
): IncomeAnalytics["totalsByCurrency"][number] {
const zero = { currency, value: "0" };
return { currency, coupon: zero, dividend: zero, total: zero };
return { entries };
}
function moscowDate(value: Date): string {
@@ -214,7 +226,11 @@ function moscowDate(value: Date): string {
export {
type AccountIncomePageViewModel,
formatDraftDate,
type IncomeForm,
isDate,
moscowDate,
parseDraftDate,
toIncomeDisplayModel,
useAccountIncomePageViewModel,
};