Add stock search and detail pages with charts, dividends and shared event rows. Use verified ticker links for portfolio names and logos, numeric dates and refreshed account cards. Update the generated API contract.
298 lines
8.5 KiB
TypeScript
298 lines
8.5 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";
|
||
import { formatDate } from "@/shared/lib";
|
||
|
||
type IncomePeriod = { from: string; to: string };
|
||
type IncomeForm = IncomePeriod;
|
||
|
||
type IncomeDisplayModel = {
|
||
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;
|
||
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 = (
|
||
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);
|
||
|
||
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,
|
||
comparison: analytics.yearComparison
|
||
? {
|
||
selectedPeriod: formatComparisonPeriod(analytics.period),
|
||
comparedPeriod: formatComparisonPeriod(
|
||
analytics.yearComparison.period,
|
||
),
|
||
values: analytics.yearComparison.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,
|
||
),
|
||
},
|
||
})),
|
||
}
|
||
: null,
|
||
};
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
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 IncomeDisplayModel,
|
||
type IncomeForm,
|
||
isDate,
|
||
moscowDate,
|
||
parseDraftDate,
|
||
toIncomeDisplayModel,
|
||
useAccountIncomePageViewModel,
|
||
};
|