feat: redesign year-over-year income comparison
This commit is contained in:
@@ -29,6 +29,123 @@
|
|||||||
overflow-wrap: anywhere;
|
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) {
|
@media (max-width: 35.999em) {
|
||||||
.fields {
|
.fields {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
|||||||
@@ -368,7 +368,13 @@ describe("AccountIncomePage", () => {
|
|||||||
const eventsRequest = vi.fn(() => new HttpResponse(null, { status: 503 }));
|
const eventsRequest = vi.fn(() => new HttpResponse(null, { status: 503 }));
|
||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
|
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),
|
http.get("*/api/v1/brokerage-accounts/account-1/events", eventsRequest),
|
||||||
);
|
);
|
||||||
@@ -385,14 +391,50 @@ describe("AccountIncomePage", () => {
|
|||||||
expect(screen.queryByText("Динамика")).not.toBeInTheDocument();
|
expect(screen.queryByText("Динамика")).not.toBeInTheDocument();
|
||||||
expect(eventsRequest).not.toHaveBeenCalled();
|
expect(eventsRequest).not.toHaveBeenCalled();
|
||||||
expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument();
|
expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument();
|
||||||
|
const selected = screen.getByRole("region", {
|
||||||
|
name: /^1–27 авг\. 2026\sг\.$/,
|
||||||
|
});
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(
|
screen.getByRole("region", { name: /^1–27 авг\. 2025\sг\.$/ }),
|
||||||
"Выбранный период: Купоны 10 ₽ · Дивиденды 2 ₽ · Итого 12 ₽",
|
).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();
|
).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("Разница: Купоны 9 ₽ · Дивиденды 2 ₽ · Итого 11 ₽"),
|
screen.queryByRole("region", { name: "Разница" }),
|
||||||
).toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/Предыдущий период/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows a recoverable realized error", async () => {
|
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 LocationProbe: FC = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|||||||
@@ -11,6 +11,15 @@ type IncomeForm = IncomePeriod;
|
|||||||
|
|
||||||
type IncomeDisplayModel = {
|
type IncomeDisplayModel = {
|
||||||
entries: { key: string; operation: IncomeAnalytics["entries"][number] }[];
|
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 =
|
type AccountIncomePageViewModel =
|
||||||
@@ -209,7 +218,60 @@ function toIncomeDisplayModel(
|
|||||||
if (instrument.includedCount !== instrument.receiptCount) return null;
|
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 {
|
function moscowDate(value: Date): string {
|
||||||
@@ -227,6 +289,7 @@ function moscowDate(value: Date): string {
|
|||||||
export {
|
export {
|
||||||
type AccountIncomePageViewModel,
|
type AccountIncomePageViewModel,
|
||||||
formatDraftDate,
|
formatDraftDate,
|
||||||
|
type IncomeDisplayModel,
|
||||||
type IncomeForm,
|
type IncomeForm,
|
||||||
isDate,
|
isDate,
|
||||||
moscowDate,
|
moscowDate,
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Alert color="blue">{t("income.taxBoundary")}</Alert>
|
<Alert color="blue">{t("income.taxBoundary")}</Alert>
|
||||||
<IncomeComparisons analytics={analytics} />
|
<IncomeComparisons comparison={display.comparison} />
|
||||||
<IncomePayments entries={display.entries} />
|
<IncomePayments entries={display.entries} />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,37 +1,32 @@
|
|||||||
import { Paper, Stack, Text, Title } from "@mantine/core";
|
import { Paper, Stack, Text, Title } from "@mantine/core";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { IncomeAnalytics } from "@/entities/income";
|
|
||||||
import { formatMoney } from "@/shared/lib";
|
import { formatMoney } from "@/shared/lib";
|
||||||
|
import classes from "../AccountIncomePage.module.css";
|
||||||
|
import type { IncomeDisplayModel } from "../AccountIncomePage.vm";
|
||||||
|
|
||||||
type IncomeComparisonsProps = { analytics: IncomeAnalytics };
|
type IncomeComparisonsProps = { comparison: IncomeDisplayModel["comparison"] };
|
||||||
type ComparisonProps = {
|
type ComparisonProps = NonNullable<IncomeDisplayModel["comparison"]>;
|
||||||
title: string;
|
type ComparisonValuesProps = {
|
||||||
values: IncomeAnalytics["previous"]["totalsByCurrency"];
|
kind: "selected" | "compared" | "difference";
|
||||||
|
label: string;
|
||||||
|
values: ComparisonProps["values"][number]["selected"];
|
||||||
|
percentages?: ComparisonProps["values"][number]["percentages"];
|
||||||
};
|
};
|
||||||
|
|
||||||
const IncomeComparisons: FC<IncomeComparisonsProps> = (props) => {
|
const IncomeComparisons: FC<IncomeComparisonsProps> = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title order={2}>{t("income.comparisons")}</Title>
|
<Title order={2}>{t("income.yearAgo")}</Title>
|
||||||
<Comparison
|
{props.comparison ? (
|
||||||
title={t("income.previous", {
|
<Comparison {...props.comparison} />
|
||||||
from: props.analytics.previous.period.from,
|
|
||||||
to: props.analytics.previous.period.to,
|
|
||||||
})}
|
|
||||||
values={props.analytics.previous.totalsByCurrency}
|
|
||||||
/>
|
|
||||||
{props.analytics.yearComparison ? (
|
|
||||||
<Comparison
|
|
||||||
title={t("income.yearAgo", {
|
|
||||||
from: props.analytics.yearComparison.period.from,
|
|
||||||
to: props.analytics.yearComparison.period.to,
|
|
||||||
})}
|
|
||||||
values={props.analytics.yearComparison.totalsByCurrency}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<Text c="dimmed">{t("income.yearUnavailable")}</Text>
|
<Paper p="sm" radius="md" className={classes.comparisonNotice}>
|
||||||
|
<Text size="sm" c="gray.7">
|
||||||
|
{t("income.yearUnavailable")}
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
@@ -40,33 +35,101 @@ const IncomeComparisons: FC<IncomeComparisonsProps> = (props) => {
|
|||||||
const Comparison: FC<ComparisonProps> = (props) => {
|
const Comparison: FC<ComparisonProps> = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Paper p="sm" radius="md" withBorder>
|
<section aria-label={t("income.yearAgo")} className={classes.comparison}>
|
||||||
<Text fw={700}>{props.title}</Text>
|
<Stack gap="md">
|
||||||
{props.values.map((value) => (
|
{props.values.map((value) => (
|
||||||
<Stack gap={2} key={value.currency}>
|
<Stack
|
||||||
<Text size="sm">{value.currency}</Text>
|
gap="xs"
|
||||||
<Text size="sm">
|
key={value.currency}
|
||||||
{t("income.selected")}: {t("income.coupon")}{" "}
|
role="group"
|
||||||
{formatMoney(value.selected.coupon)} · {t("income.dividend")}{" "}
|
aria-label={value.currency}
|
||||||
{formatMoney(value.selected.dividend)} · {t("income.total")}{" "}
|
>
|
||||||
{formatMoney(value.selected.total)}
|
<div className={classes.comparisonGrid}>
|
||||||
|
<ComparisonValues
|
||||||
|
kind="selected"
|
||||||
|
label={props.selectedPeriod}
|
||||||
|
values={value.selected}
|
||||||
|
/>
|
||||||
|
<ComparisonValues
|
||||||
|
kind="compared"
|
||||||
|
label={props.comparedPeriod}
|
||||||
|
values={value.compared}
|
||||||
|
/>
|
||||||
|
<ComparisonValues
|
||||||
|
kind="difference"
|
||||||
|
label={t("income.difference")}
|
||||||
|
values={value.difference}
|
||||||
|
percentages={value.percentages}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
{props.values.length === 0 ? (
|
||||||
|
<Text size="sm" c="gray.7">
|
||||||
|
{t("income.comparisonEmpty")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm">
|
) : null}
|
||||||
{t("income.compared")}: {t("income.coupon")}{" "}
|
</Stack>
|
||||||
{formatMoney(value.compared.coupon)} · {t("income.dividend")}{" "}
|
</section>
|
||||||
{formatMoney(value.compared.dividend)} · {t("income.total")}{" "}
|
|
||||||
{formatMoney(value.compared.total)}
|
|
||||||
</Text>
|
|
||||||
<Text size="sm">
|
|
||||||
{t("income.difference")}: {t("income.coupon")}{" "}
|
|
||||||
{formatMoney(value.difference.coupon)} · {t("income.dividend")}{" "}
|
|
||||||
{formatMoney(value.difference.dividend)} · {t("income.total")}{" "}
|
|
||||||
{formatMoney(value.difference.total)}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
))}
|
|
||||||
</Paper>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ComparisonValues: FC<ComparisonValuesProps> = (props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label={props.label}
|
||||||
|
className={classes.comparisonPanel}
|
||||||
|
data-kind={props.kind}
|
||||||
|
data-tone={
|
||||||
|
props.kind === "difference"
|
||||||
|
? differenceTone(props.values.total.value)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="sm" fw={600} className={classes.comparisonLabel}>
|
||||||
|
{props.label}
|
||||||
|
</Text>
|
||||||
|
<dl className={classes.comparisonValues}>
|
||||||
|
{(["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 (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className={
|
||||||
|
key === "total"
|
||||||
|
? classes.comparisonTotal
|
||||||
|
: classes.comparisonDetail
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<dt>{t(`income.${key}`)}</dt>
|
||||||
|
<dd data-tone={tone}>
|
||||||
|
<span>
|
||||||
|
{tone === "positive" ? "+" : ""}
|
||||||
|
{formatMoney(money)}
|
||||||
|
</span>
|
||||||
|
{percentage ? (
|
||||||
|
<span className={classes.comparisonPercent}>
|
||||||
|
{percentage}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function differenceTone(value: string): "positive" | "negative" | "neutral" {
|
||||||
|
if (!/[1-9]/.test(value)) return "neutral";
|
||||||
|
return value.startsWith("-") ? "negative" : "positive";
|
||||||
|
}
|
||||||
|
|
||||||
export { IncomeComparisons };
|
export { IncomeComparisons };
|
||||||
|
|||||||
@@ -168,12 +168,9 @@ const ru = {
|
|||||||
coupon: "Купоны",
|
coupon: "Купоны",
|
||||||
dividend: "Дивиденды",
|
dividend: "Дивиденды",
|
||||||
taxBoundary: "Налоговые операции не включены в этот результат.",
|
taxBoundary: "Налоговые операции не включены в этот результат.",
|
||||||
comparisons: "Сравнения",
|
comparisonEmpty: "За эти периоды купоны и дивиденды не найдены.",
|
||||||
previous: "Предыдущий период: {{from}} — {{to}}",
|
yearAgo: "Годом ранее",
|
||||||
yearAgo: "Годом ранее: {{from}} — {{to}}",
|
|
||||||
yearUnavailable: "Сравнение с годом ранее недоступно.",
|
yearUnavailable: "Сравнение с годом ранее недоступно.",
|
||||||
selected: "Выбранный период",
|
|
||||||
compared: "Сравниваемый период",
|
|
||||||
difference: "Разница",
|
difference: "Разница",
|
||||||
total: "Итого",
|
total: "Итого",
|
||||||
payments: "Выплаты",
|
payments: "Выплаты",
|
||||||
|
|||||||
Reference in New Issue
Block a user