feat: simplify income page filters and payments
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user