feat: unify income payment cards and remove comparisons (WealthLedger/main#33) (#74)
feat: unify income payment cards and remove comparisons (WealthLedger/main#33)
This commit was merged in pull request #74.
This commit is contained in:
Vendored
+1
-13
@@ -1107,16 +1107,6 @@ export interface components {
|
||||
data: components["schemas"]["IncomeSummaryDto"] | null;
|
||||
meta: components["schemas"]["InvestmentResourceMetaDto"];
|
||||
};
|
||||
IncomeComparisonCurrencyDto: {
|
||||
currency: string;
|
||||
selected: components["schemas"]["IncomeCurrencySummaryDto"];
|
||||
compared: components["schemas"]["IncomeCurrencySummaryDto"];
|
||||
difference: components["schemas"]["IncomeCurrencySummaryDto"];
|
||||
};
|
||||
IncomeComparisonDto: {
|
||||
period: components["schemas"]["IncomePeriodDto"];
|
||||
totalsByCurrency: components["schemas"]["IncomeComparisonCurrencyDto"][];
|
||||
};
|
||||
IncomeBucketDto: {
|
||||
period: components["schemas"]["IncomePeriodDto"];
|
||||
totalsByCurrency: components["schemas"]["IncomeCurrencySummaryDto"][];
|
||||
@@ -1160,8 +1150,6 @@ export interface components {
|
||||
totalsByCurrency: components["schemas"]["IncomeCurrencySummaryDto"][];
|
||||
/** Format: date-time */
|
||||
calculatedAt: string;
|
||||
previous: components["schemas"]["IncomeComparisonDto"] | null;
|
||||
yearComparison: components["schemas"]["IncomeComparisonDto"] | null;
|
||||
/** @enum {string} */
|
||||
bucketGranularity: "day" | "week" | "month";
|
||||
buckets: components["schemas"]["IncomeBucketDto"][];
|
||||
@@ -1183,7 +1171,7 @@ export interface components {
|
||||
lastSuccessfulAt: string | null;
|
||||
lastAttempt: components["schemas"]["InvestmentAttemptDto"];
|
||||
/** @enum {string} */
|
||||
role: "selected" | "previous" | "year";
|
||||
role: "selected";
|
||||
};
|
||||
IncomeAnalyticsMetaDto: {
|
||||
dataRevision: string;
|
||||
|
||||
@@ -6706,47 +6706,6 @@
|
||||
"meta"
|
||||
]
|
||||
},
|
||||
"IncomeComparisonCurrencyDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"currency": {
|
||||
"type": "string"
|
||||
},
|
||||
"selected": {
|
||||
"$ref": "#/components/schemas/IncomeCurrencySummaryDto"
|
||||
},
|
||||
"compared": {
|
||||
"$ref": "#/components/schemas/IncomeCurrencySummaryDto"
|
||||
},
|
||||
"difference": {
|
||||
"$ref": "#/components/schemas/IncomeCurrencySummaryDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"currency",
|
||||
"selected",
|
||||
"compared",
|
||||
"difference"
|
||||
]
|
||||
},
|
||||
"IncomeComparisonDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"period": {
|
||||
"$ref": "#/components/schemas/IncomePeriodDto"
|
||||
},
|
||||
"totalsByCurrency": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/IncomeComparisonCurrencyDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"period",
|
||||
"totalsByCurrency"
|
||||
]
|
||||
},
|
||||
"IncomeBucketDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -6948,24 +6907,6 @@
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"previous": {
|
||||
"nullable": true,
|
||||
"type": "object",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/IncomeComparisonDto"
|
||||
}
|
||||
]
|
||||
},
|
||||
"yearComparison": {
|
||||
"nullable": true,
|
||||
"type": "object",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/IncomeComparisonDto"
|
||||
}
|
||||
]
|
||||
},
|
||||
"bucketGranularity": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -6998,8 +6939,6 @@
|
||||
"period",
|
||||
"totalsByCurrency",
|
||||
"calculatedAt",
|
||||
"previous",
|
||||
"yearComparison",
|
||||
"bucketGranularity",
|
||||
"buckets",
|
||||
"instruments",
|
||||
@@ -7056,9 +6995,7 @@
|
||||
"role": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"selected",
|
||||
"previous",
|
||||
"year"
|
||||
"selected"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -340,13 +340,8 @@ const ru = {
|
||||
},
|
||||
income: {
|
||||
apply: "Применить",
|
||||
comparisonEmpty: "За эти периоды купоны и дивиденды не найдены.",
|
||||
comparisonFailed:
|
||||
"Не удалось загрузить период сравнения. Повторите обновление.",
|
||||
comparisonPending: "Период сравнения ещё не загружен полностью.",
|
||||
coupon: "Купоны",
|
||||
datePlaceholder: "ДД.ММ.ГГГГ",
|
||||
difference: "Разница",
|
||||
dividend: "Дивиденды",
|
||||
emptyText: "За выбранный период подходящих исполненных выплат нет.",
|
||||
emptyTitle: "Купоны и дивиденды не найдены",
|
||||
@@ -356,8 +351,6 @@ const ru = {
|
||||
open: "Открыть пассивный доход",
|
||||
payments: "Выплаты",
|
||||
period: "Период",
|
||||
previousPeriod: "Предыдущий период",
|
||||
previousUnavailable: "Сравнение с предыдущим периодом недоступно.",
|
||||
refreshError:
|
||||
"Не удалось обновить пассивный доход. Показан последний загруженный результат.",
|
||||
refreshing: "Обновляем пассивный доход",
|
||||
@@ -378,8 +371,6 @@ const ru = {
|
||||
period:
|
||||
"Укажите корректный период без будущих дат: начало не позже окончания.",
|
||||
},
|
||||
yearAgo: "Годом ранее",
|
||||
yearUnavailable: "Сравнение с годом ранее недоступно.",
|
||||
},
|
||||
investment: {
|
||||
attempt: {
|
||||
@@ -459,6 +450,14 @@ const ru = {
|
||||
stocks: "Акции",
|
||||
},
|
||||
notFound: { action: "К счетам", title: "Страница не найдена" },
|
||||
payout: {
|
||||
estimate: "Оценка по позиции",
|
||||
executedAt: "Исполнено {{date}}",
|
||||
forecast: "Прогноз",
|
||||
noAmount: "Сумма неизвестна",
|
||||
noMoney: "Без движения денег",
|
||||
registryDate: "Дата реестра",
|
||||
},
|
||||
portfolio: {
|
||||
accountUnavailableText: "Вернитесь к списку и выберите доступный счет.",
|
||||
accountUnavailableTitle: "Счет недоступен",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 +1,2 @@
|
||||
export { PayoutCard } from "./PayoutCard/PayoutCard";
|
||||
export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus";
|
||||
|
||||
Reference in New Issue
Block a user