diff --git a/src/pages/account-income/ui/AccountIncomePage.module.css b/src/pages/account-income/ui/AccountIncomePage.module.css index 43d8209..d658516 100644 --- a/src/pages/account-income/ui/AccountIncomePage.module.css +++ b/src/pages/account-income/ui/AccountIncomePage.module.css @@ -29,6 +29,123 @@ overflow-wrap: anywhere; } +.comparison { + container-type: inline-size; +} + +.comparisonGrid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: var(--mantine-spacing-sm); +} + +.comparisonPanel { + min-width: 0; + padding: var(--mantine-spacing-md); + border-radius: var(--mantine-radius-md); + border-top: 3px solid var(--mantine-color-gray-4); + background: var(--mantine-color-gray-0); +} + +.comparisonPanel[data-kind="selected"] { + border-color: var(--mantine-color-blue-6); + background: var(--mantine-color-blue-0); +} + +.comparisonPanel[data-tone="positive"] { + border-color: var(--mantine-color-green-8); + background: var(--mantine-color-green-0); +} + +.comparisonPanel[data-tone="negative"] { + border-color: var(--mantine-color-red-8); + background: var(--mantine-color-red-0); +} + +.comparisonLabel, +.comparisonValues dt { + color: var(--mantine-color-gray-7); +} + +.comparisonValues { + margin: var(--mantine-spacing-md) 0 0; + font-size: var(--mantine-font-size-sm); +} + +.comparisonValues dd { + min-width: 0; + margin: 0; + font-weight: 600; + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} + +.comparisonTotal { + margin-bottom: var(--mantine-spacing-md); + padding-bottom: var(--mantine-spacing-md); + border-bottom: 1px solid var(--mantine-color-gray-3); +} + +.comparisonTotal dd { + margin-top: 4px; + font-size: var(--mantine-h2-font-size); + line-height: 1.3; + font-weight: 700; + letter-spacing: -0.025em; +} + +.comparisonValues dd[data-tone="positive"] { + color: color-mix( + in srgb, + var(--mantine-color-green-9) 85%, + var(--mantine-color-black) + ); +} + +.comparisonValues dd[data-tone="negative"] { + color: var(--mantine-color-red-9); +} + +.comparisonValues dd[data-tone="neutral"] { + color: var(--mantine-color-gray-7); +} + +.comparisonPanel[data-kind="selected"] .comparisonTotal dd { + color: var(--mantine-color-blue-8); +} + +.comparisonDetail { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 4px var(--mantine-spacing-xs); + margin-top: var(--mantine-spacing-xs); +} + +.comparisonPercent { + display: inline-block; + margin-left: var(--mantine-spacing-xs); + padding: 2px 6px; + border-radius: var(--mantine-radius-sm); + background: var(--mantine-color-white); + font-size: var(--mantine-font-size-xs); + font-weight: 600; + line-height: 1.5; + letter-spacing: normal; + vertical-align: middle; +} + +.comparisonNotice { + background: var(--mantine-color-gray-0); + border-left: 3px solid var(--mantine-color-gray-4); +} + +@container (min-width: 42em) { + .comparisonGrid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + @media (max-width: 35.999em) { .fields { grid-template-columns: minmax(0, 1fr); diff --git a/src/pages/account-income/ui/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage.test.tsx index 15aa57b..317971e 100644 --- a/src/pages/account-income/ui/AccountIncomePage.test.tsx +++ b/src/pages/account-income/ui/AccountIncomePage.test.tsx @@ -368,7 +368,13 @@ describe("AccountIncomePage", () => { const eventsRequest = vi.fn(() => new HttpResponse(null, { status: 503 })); server.use( http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => - HttpResponse.json(analytics), + HttpResponse.json({ + ...analytics, + yearComparison: { + period: { from: "2025-08-01", to: "2025-08-27" }, + totalsByCurrency: [comparison("RUB")], + }, + }), ), http.get("*/api/v1/brokerage-accounts/account-1/events", eventsRequest), ); @@ -385,14 +391,50 @@ describe("AccountIncomePage", () => { expect(screen.queryByText("Динамика")).not.toBeInTheDocument(); expect(eventsRequest).not.toHaveBeenCalled(); expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument(); + const selected = screen.getByRole("region", { + name: /^1–27 авг\. 2026\sг\.$/, + }); expect( - screen.getByText( - "Выбранный период: Купоны 10 ₽ · Дивиденды 2 ₽ · Итого 12 ₽", + screen.getByRole("region", { name: /^1–27 авг\. 2025\sг\.$/ }), + ).toBeInTheDocument(); + expect(screen.queryByText(/Предыдущий период/)).not.toBeInTheDocument(); + expect(screen.queryByText("Выбранный период")).not.toBeInTheDocument(); + expect(screen.queryByText("Сравниваемый период")).not.toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "Годом ранее", level: 2 }), + ).toBeInTheDocument(); + expect(screen.queryByText("Сравнения")).not.toBeInTheDocument(); + expect(screen.queryByText("RUB", { exact: true })).not.toBeInTheDocument(); + for (const amount of ["10 ₽", "2 ₽", "12 ₽"]) { + expect(within(selected).getByText(amount)).toBeInTheDocument(); + } + const difference = screen.getByRole("region", { name: "Разница" }); + for (const amount of ["+9 ₽", "+2 ₽", "+11 ₽"]) { + expect(within(difference).getByText(amount)).toBeInTheDocument(); + } + expect(within(difference).getByText("+900 %")).toBeInTheDocument(); + expect(within(difference).getByText("+1 100 %")).toBeInTheDocument(); + expect(within(difference).queryByText("нет базы")).not.toBeInTheDocument(); + expect(within(difference).getAllByText(/%$/)).toHaveLength(2); + expect( + within(difference).getByText("+2 ₽").closest("dd"), + ).toHaveTextContent(/^\+2 ₽$/); + }); + + it("shows the unavailable year state without falling back to the previous period", async () => { + server.use( + http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => + HttpResponse.json(analytics), ), + ); + renderPage(); + expect( + await screen.findByText("Сравнение с годом ранее недоступно."), ).toBeInTheDocument(); expect( - screen.getByText("Разница: Купоны 9 ₽ · Дивиденды 2 ₽ · Итого 11 ₽"), - ).toBeInTheDocument(); + screen.queryByRole("region", { name: "Разница" }), + ).not.toBeInTheDocument(); + expect(screen.queryByText(/Предыдущий период/)).not.toBeInTheDocument(); }); it("shows a recoverable realized error", async () => { @@ -411,6 +453,63 @@ describe("AccountIncomePage", () => { }); }); +describe("income comparison display", () => { + it.each([ + ["25", "100", "+25 %"], + ["-25", "100", "-25 %"], + ["0", "100", "0 %"], + ["-0.000", "100", "0 %"], + ["0.0001", "100", "0 %"], + ["1", "3", "+33,3 %"], + ["0.01", "0.04", "+25 %"], + ["123456789012345678.123456789", "123456789012345678.123456789", "+100 %"], + ["10", "0", null], + ["0", "0", null], + ["10", "-0.00", null], + ["10", "-100", null], + ["9".repeat(400), "1", null], + ["1", "9".repeat(400), null], + ])("formats change %s against base %s", (difference, base, expected) => { + const value = comparison("RUB"); + value.difference.coupon = money("RUB", difference); + value.compared.coupon = money("RUB", base); + const display = toIncomeDisplayModel({ + ...analytics, + yearComparison: { + period: { from: "2025-08-01", to: "2025-08-27" }, + totalsByCurrency: [value], + }, + }); + const result = display?.comparison?.values[0]; + expect(result?.percentages.coupon?.replaceAll("\u00a0", " ") ?? null).toBe( + expected, + ); + expect(result?.difference.coupon.value).toBe(difference); + expect(result?.compared.coupon.value).toBe(base); + }); + + it("formats calendar ranges across years and preserves separate currencies", () => { + const display = toIncomeDisplayModel({ + ...analytics, + period: { from: "2025-12-01", to: "2026-01-09" }, + yearComparison: { + period: { from: "2024-12-01", to: "2025-01-09" }, + totalsByCurrency: [comparison("RUB"), comparison("USD")], + }, + }); + expect(display?.comparison?.selectedPeriod.replaceAll(/\s/g, " ")).toBe( + "1 дек. 2025 г. – 9 янв. 2026 г.", + ); + expect(display?.comparison?.comparedPeriod.replaceAll(/\s/g, " ")).toBe( + "1 дек. 2024 г. – 9 янв. 2025 г.", + ); + expect(display?.comparison?.values.map((value) => value.currency)).toEqual([ + "RUB", + "USD", + ]); + }); +}); + const LocationProbe: FC = () => { const location = useLocation(); const navigate = useNavigate(); diff --git a/src/pages/account-income/ui/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage.vm.ts index b5e3c6b..ba0814b 100644 --- a/src/pages/account-income/ui/AccountIncomePage.vm.ts +++ b/src/pages/account-income/ui/AccountIncomePage.vm.ts @@ -11,6 +11,15 @@ 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 = @@ -209,7 +218,60 @@ function toIncomeDisplayModel( if (instrument.includedCount !== instrument.receiptCount) return null; } - return { entries }; + 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 new Intl.DateTimeFormat("ru-RU", { + dateStyle: "medium", + timeZone: "UTC", + }).formatRange( + new Date(`${period.from}T00:00:00Z`), + new Date(`${period.to}T00:00:00Z`), + ); +} + +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 { @@ -227,6 +289,7 @@ function moscowDate(value: Date): string { export { type AccountIncomePageViewModel, formatDraftDate, + type IncomeDisplayModel, type IncomeForm, isDate, moscowDate, diff --git a/src/pages/account-income/ui/components/AccountIncomePageLoaded.view.tsx b/src/pages/account-income/ui/components/AccountIncomePageLoaded.view.tsx index 37ec50a..0be4570 100644 --- a/src/pages/account-income/ui/components/AccountIncomePageLoaded.view.tsx +++ b/src/pages/account-income/ui/components/AccountIncomePageLoaded.view.tsx @@ -67,7 +67,7 @@ const AccountIncomePageLoaded: FC = (props) => { /> )} {t("income.taxBoundary")} - + ); diff --git a/src/pages/account-income/ui/components/IncomeComparisons.view.tsx b/src/pages/account-income/ui/components/IncomeComparisons.view.tsx index 9824ad6..a44473e 100644 --- a/src/pages/account-income/ui/components/IncomeComparisons.view.tsx +++ b/src/pages/account-income/ui/components/IncomeComparisons.view.tsx @@ -1,37 +1,32 @@ import { Paper, Stack, Text, Title } from "@mantine/core"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; -import type { IncomeAnalytics } from "@/entities/income"; import { formatMoney } from "@/shared/lib"; +import classes from "../AccountIncomePage.module.css"; +import type { IncomeDisplayModel } from "../AccountIncomePage.vm"; -type IncomeComparisonsProps = { analytics: IncomeAnalytics }; -type ComparisonProps = { - title: string; - values: IncomeAnalytics["previous"]["totalsByCurrency"]; +type IncomeComparisonsProps = { comparison: IncomeDisplayModel["comparison"] }; +type ComparisonProps = NonNullable; +type ComparisonValuesProps = { + kind: "selected" | "compared" | "difference"; + label: string; + values: ComparisonProps["values"][number]["selected"]; + percentages?: ComparisonProps["values"][number]["percentages"]; }; const IncomeComparisons: FC = (props) => { const { t } = useTranslation(); return ( - {t("income.comparisons")} - - {props.analytics.yearComparison ? ( - + {t("income.yearAgo")} + {props.comparison ? ( + ) : ( - {t("income.yearUnavailable")} + + + {t("income.yearUnavailable")} + + )} ); @@ -40,33 +35,101 @@ const IncomeComparisons: FC = (props) => { const Comparison: FC = (props) => { const { t } = useTranslation(); return ( - - {props.title} - {props.values.map((value) => ( - - {value.currency} - - {t("income.selected")}: {t("income.coupon")}{" "} - {formatMoney(value.selected.coupon)} · {t("income.dividend")}{" "} - {formatMoney(value.selected.dividend)} · {t("income.total")}{" "} - {formatMoney(value.selected.total)} +
+ + {props.values.map((value) => ( + +
+ + + +
+
+ ))} + {props.values.length === 0 ? ( + + {t("income.comparisonEmpty")} - - {t("income.compared")}: {t("income.coupon")}{" "} - {formatMoney(value.compared.coupon)} · {t("income.dividend")}{" "} - {formatMoney(value.compared.dividend)} · {t("income.total")}{" "} - {formatMoney(value.compared.total)} - - - {t("income.difference")}: {t("income.coupon")}{" "} - {formatMoney(value.difference.coupon)} · {t("income.dividend")}{" "} - {formatMoney(value.difference.dividend)} · {t("income.total")}{" "} - {formatMoney(value.difference.total)} - -
- ))} - + ) : null} + +
); }; +const ComparisonValues: FC = (props) => { + const { t } = useTranslation(); + return ( +
+ + {props.label} + +
+ {(["total", "coupon", "dividend"] as const).map((key) => { + const money = props.values[key]; + const percentage = props.percentages?.[key]; + const tone = + props.kind === "difference" + ? differenceTone(money.value) + : undefined; + return ( +
+
{t(`income.${key}`)}
+
+ + {tone === "positive" ? "+" : ""} + {formatMoney(money)} + + {percentage ? ( + + {percentage} + + ) : null} +
+
+ ); + })} +
+
+ ); +}; + +function differenceTone(value: string): "positive" | "negative" | "neutral" { + if (!/[1-9]/.test(value)) return "neutral"; + return value.startsWith("-") ? "negative" : "positive"; +} + export { IncomeComparisons }; diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts index 603be39..8aecdf1 100644 --- a/src/shared/i18n/locales/ru.ts +++ b/src/shared/i18n/locales/ru.ts @@ -168,12 +168,9 @@ const ru = { coupon: "Купоны", dividend: "Дивиденды", taxBoundary: "Налоговые операции не включены в этот результат.", - comparisons: "Сравнения", - previous: "Предыдущий период: {{from}} — {{to}}", - yearAgo: "Годом ранее: {{from}} — {{to}}", + comparisonEmpty: "За эти периоды купоны и дивиденды не найдены.", + yearAgo: "Годом ранее", yearUnavailable: "Сравнение с годом ранее недоступно.", - selected: "Выбранный период", - compared: "Сравниваемый период", difference: "Разница", total: "Итого", payments: "Выплаты",