feat: unify income payment cards and remove comparisons (WealthLedger/main#33) (#74)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 7m18s

feat: unify income payment cards and remove comparisons (WealthLedger/main#33)
This commit was merged in pull request #74.
This commit is contained in:
2026-10-13 18:17:35 +03:00
parent b357743c10
commit 43b0f5574a
27 changed files with 474 additions and 1092 deletions
@@ -0,0 +1,85 @@
import type { FC } from "react";
import { MantineProvider } from "@mantine/core";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { PayoutCard } from "./PayoutCard";
const Wrapper: FC<{ children: React.ReactNode }> = props => (
<MantineProvider env="test">{props.children}</MantineProvider>
);
describe("PayoutCard", () => {
it("renders an executed payout with signed source money", () => {
render(
<Wrapper>
<PayoutCard
initial="К"
kind="executed"
occurredAt="01.08.2026 12:00"
payment={{ currency: "RUB", value: "-10.25" }}
title="Купон"
typeLabel="Купон"
/>
</Wrapper>,
);
expect(screen.getByText("−10,25 ₽")).toBeInTheDocument();
expect(screen.getByText(/Исполнено 01.08.2026 12:00/)).toBeInTheDocument();
});
it("keeps zero distinct from an unknown executed amount", () => {
render(
<Wrapper>
<PayoutCard
initial="Д"
kind="executed"
occurredAt="01.08.2026"
payment={{ currency: "RUB", value: "0.000" }}
title="Дивиденд"
typeLabel="Дивиденд"
/>
</Wrapper>,
);
expect(screen.getByText("0,000 ₽")).toBeInTheDocument();
expect(screen.getByText("Без движения денег")).toBeInTheDocument();
});
it("marks a forecast payout and a dividend registry date without inventing an amount", () => {
render(
<Wrapper>
<PayoutCard
date="15.08.2026"
dateKind="registry"
initial="S"
kind="forecast"
projectedAmount={null}
title="Дивиденд · SBER"
/>
</Wrapper>,
);
expect(screen.getByText("Прогноз")).toBeInTheDocument();
expect(screen.getByText(/15.08.2026 · Дата реестра/)).toBeInTheDocument();
expect(screen.getByText("Сумма неизвестна")).toBeInTheDocument();
});
it("shows a schedule payout with per-security label and separate estimate", () => {
render(
<Wrapper>
<PayoutCard
estimate={{ currency: "RUB", value: "125" }}
initial="S"
kind="schedule"
metadata="Дата выплаты 04.10.2026"
perSecurity={{ currency: "RUB", value: "12.5" }}
perSecurityLabel="На облигацию"
title="Купон · SU26238RMFS4"
/>
</Wrapper>,
);
expect(screen.getByText("12,5 ₽")).toBeInTheDocument();
expect(screen.getByText("На облигацию")).toBeInTheDocument();
expect(screen.getByText("Оценка по позиции")).toBeInTheDocument();
expect(screen.getByText("125 ₽")).toBeInTheDocument();
});
});
+166
View File
@@ -0,0 +1,166 @@
import type { FC, ReactNode } from "react";
import { Group, Text } from "@mantine/core";
import { EntryCard } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import { formatMoney } from "@/shared/lib";
type ExecutedPayout = {
details?: ReactNode;
extraMetadata?: string;
imageSrc?: null | string;
initial: string;
kind: "executed";
occurredAt: string;
payment: null | PayoutMoney;
title: string;
typeLabel: string;
wrap?: boolean;
};
type ForecastPayout = {
date: string;
dateKind?: "event" | "registry";
extraMetadata?: string;
imageSrc?: null | string;
initial: string;
kind: "forecast";
projectedAmount: null | PayoutMoney;
title: string;
};
type PayoutCardProps
= | ExecutedPayout
| ForecastPayout
| SchedulePayout;
type PayoutMoney = { currency: string; value: string };
type PayoutTone
= | "negative"
| "neutral"
| "positive";
type SchedulePayout = {
conditions?: ReactNode;
estimate: null | PayoutMoney;
imageSrc?: null | string;
initial: string;
kind: "schedule";
metadata: string;
perSecurity: null | PayoutMoney;
perSecurityLabel: string;
title: string;
};
const PayoutCard: FC<PayoutCardProps> = (props) => {
const { t } = useTranslation();
const common = {
imageSrc: props.imageSrc,
initial: props.initial,
title: props.title,
wrap: true,
};
if (props.kind === "executed") {
const payment = props.payment;
const presentation = executedPresentation(payment);
const isKnown = payment !== null && !isZeroDecimal(payment.value);
const metadata = [
t("payout.executedAt", { date: props.occurredAt }),
props.extraMetadata,
].filter(Boolean).join(" · ");
const valueLabel = isKnown ? props.typeLabel : t("payout.noMoney");
return (
<EntryCard
{...common}
details={props.details}
metadata={metadata}
tone={presentation.tone}
value={presentation.amount}
valueLabel={valueLabel}
wrap={props.wrap}
/>
);
}
if (props.kind === "forecast") {
const metadata = [
props.date,
props.dateKind === "registry" ? t("payout.registryDate") : null,
props.extraMetadata,
].filter(Boolean).join(" · ");
const value = props.projectedAmount
? formatMoney(props.projectedAmount)
: t("payout.noAmount");
return (
<EntryCard
{...common}
metadata={metadata}
tone="neutral"
value={value}
valueLabel={t("payout.forecast")}
/>
);
}
const details = (
<>
{props.estimate
? (
<Group gap="sm">
<Text c="dimmed" size="xs">{t("payout.estimate")}</Text>
<Text fw={600} size="xs">{formatMoney(props.estimate)}</Text>
</Group>
)
: null}
{props.conditions}
</>
);
const value = props.perSecurity
? formatMoney(props.perSecurity)
: t("payout.noAmount");
return (
<EntryCard
{...common}
details={details}
metadata={props.metadata}
tone="neutral"
value={value}
valueLabel={props.perSecurityLabel}
/>
);
};
function executedPresentation(payment: null | PayoutMoney): {
amount: string;
tone: PayoutTone;
} {
if (!payment) return { amount: "—", tone: "neutral" };
if (isZeroDecimal(payment.value))
return { amount: formatMoney(payment), tone: "neutral" };
if (payment.value.startsWith("-"))
return {
amount: `−${formatMoney({ ...payment, value: payment.value.slice(1) })}`,
tone: "negative",
};
return { amount: `+${formatMoney(payment)}`, tone: "positive" };
}
function isZeroDecimal(value: string): boolean {
// eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal check over a bounded Money string, with no nested repetition.
return /^-?0(?:\.0+)?$/.test(value);
}
export { PayoutCard };
+1
View File
@@ -1 +1,2 @@
export { PayoutCard } from "./PayoutCard/PayoutCard";
export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus";