Author SHA1 Message Date
ksv741 850823e1f5 feat: clarify grouped coupon payouts (WealthLedger/frontend#162)
Frontend verify / commits (pull_request) Successful in 31s
Frontend verify / verify (pull_request) Successful in 6m27s
Frontend verify / commits (pull_request) Successful in 30s
Frontend verify / security (pull_request) Successful in 10s
2026-11-07 10:51:05 +03:00
9 changed files with 119 additions and 38 deletions
@@ -49,6 +49,10 @@ function entryLabelKey(operation: BrokerageOperation): string {
return `ledger.category.${operation.category}`; return `ledger.category.${operation.category}`;
} }
function isCouponPayment(operation: BrokerageOperation): boolean {
return isOperationType(operation.type, OperationType.Coupon);
}
function isIncomePayment(operation: BrokerageOperation): boolean { function isIncomePayment(operation: BrokerageOperation): boolean {
return [OperationType.Coupon, OperationType.Dividend].some(type => return [OperationType.Coupon, OperationType.Dividend].some(type =>
isOperationType(operation.type, type), isOperationType(operation.type, type),
@@ -131,6 +135,7 @@ function paymentPresentation(
export { export {
brokerageOperationGroupKey, brokerageOperationGroupKey,
entryLabelKey, entryLabelKey,
isCouponPayment,
isIncomePayment, isIncomePayment,
isZeroDecimal, isZeroDecimal,
operationLabelKey, operationLabelKey,
@@ -55,23 +55,27 @@ function renderGroup(
} }
describe("OperationGroup", () => { describe("OperationGroup", () => {
it("shows the header, exact total and separate card rows per account", () => { it("shows the received coupon and positive totals in flat account rows", () => {
renderGroup([ renderGroup([
operation("1", { id: "broker", name: "Брокерский" }, "0.1"), { ...operation("1", { id: "broker", name: "Брокерский" }, "0.1"), description: "Выплата купонов СФО ВТБ РКС Эталон 04" },
operation("2", { id: "iis", name: "ИИС" }, "0.2"), operation("2", { id: "iis", name: "ИИС" }, "0.2"),
]); ]);
expect(screen.getByText("Купон · BOND")).toBeInTheDocument(); expect(screen.getByText("Выплата купонов СФО ВТБ РКС Эталон 04")).toBeInTheDocument();
expect(screen.getByText("+0,3 ₽")).toBeInTheDocument(); expect(screen.getByText("+0,3 ₽")).toBeInTheDocument();
const accounts = within(screen.getByRole("list", { const accounts = within(screen.getByRole("list", {
name: /Купон · BOND, Исполнено 14\.08\.2026.*, \+0,3 ₽/, name: /Выплата купонов СФО ВТБ РКС Эталон 04, Исполнено 14\.08\.2026.*, \+0,3 ₽/,
})); }));
expect(accounts.getByRole("listitem", { name: "Брокерский, +0,1 ₽" })) expect(accounts.getByRole("listitem", { name: "Брокерский, +0,1 ₽" }))
.toHaveTextContent("Купон"); .toHaveTextContent("Купон");
expect(accounts.getByText("+0,1 ₽")).toHaveClass(/positive/);
expect(accounts.getByRole("listitem", { name: "ИИС, +0,2 ₽" })) expect(accounts.getByRole("listitem", { name: "ИИС, +0,2 ₽" }))
.toHaveTextContent("Купон"); .toHaveTextContent("Купон");
expect(accounts.getByText("+0,2 ₽")).toHaveClass(/positive/);
expect(accounts.getByRole("listitem", { name: "ИИС, +0,2 ₽" }))
.not.toHaveClass("EntryGroup_accountCardRow");
expect(accounts.queryByText(/Исполнено/)).not.toBeInTheDocument(); expect(accounts.queryByText(/Исполнено/)).not.toBeInTheDocument();
expect(accounts.queryByRole("link")).not.toBeInTheDocument(); expect(accounts.queryByRole("link")).not.toBeInTheDocument();
}); });
@@ -83,8 +87,10 @@ describe("OperationGroup", () => {
]); ]);
expect(screen.getByRole("list", { expect(screen.getByRole("list", {
name: /Купон · BOND, Исполнено .*Итог недоступен/, name: /Выплата купонов BOND, Исполнено .*Итог недоступен/,
})).toBeInTheDocument(); })).toBeInTheDocument();
expect(screen.getByText("Итог недоступен")).toBeInTheDocument();
expect(screen.getByText("—")).not.toHaveClass(/positive/);
}); });
it.each(["0", "0.000"])( it.each(["0", "0.000"])(
@@ -114,6 +120,7 @@ describe("OperationGroup", () => {
operation("2", { id: "iis", name: "ИИС" }, "-100"), operation("2", { id: "iis", name: "ИИС" }, "-100"),
]); ]);
expect(screen.getByText("0 ₽")).toBeInTheDocument(); expect(screen.getByText("0 ₽")).toBeInTheDocument();
expect(screen.getByText("−100 ₽")).toHaveClass(/negative/);
}); });
it("renders repeated ids inside one group as separate rows", () => { it("renders repeated ids inside one group as separate rows", () => {
@@ -123,7 +130,7 @@ describe("OperationGroup", () => {
]); ]);
expect(screen.getByText("+30 ₽")).toBeInTheDocument(); expect(screen.getByText("+30 ₽")).toBeInTheDocument();
const accounts = within(screen.getByRole("list", { name: /Купон/ })); const accounts = within(screen.getByRole("list", { name: /Выплата купонов/ }));
expect(accounts.getAllByText("Брокерский")).toHaveLength(1); expect(accounts.getAllByText("Брокерский")).toHaveLength(1);
expect(accounts.getAllByText("ИИС")).toHaveLength(1); expect(accounts.getAllByText("ИИС")).toHaveLength(1);
expect(accounts.getAllByRole("listitem")).toHaveLength(2); expect(accounts.getAllByRole("listitem")).toHaveLength(2);
@@ -9,12 +9,13 @@ import {
sumMoney, sumMoney,
totalText, totalText,
} from "@/shared/lib"; } from "@/shared/lib";
import { AccountCardRow, EntryGroup, EntryGroupTotal } from "@/shared/ui"; import { AccountCardRow, AccountRow, EntryGroup, EntryGroupTotal } from "@/shared/ui";
import type { BrokerageOperation } from "../api/useBrokerageOperations"; import type { BrokerageOperation } from "../api/useBrokerageOperations";
import { import {
entryLabelKey, entryLabelKey,
isCouponPayment,
isZeroDecimal, isZeroDecimal,
operationLabelKey, operationLabelKey,
paymentPresentation, paymentPresentation,
@@ -37,6 +38,8 @@ const OperationGroup: FC<OperationGroupProps> = (props) => {
? entryLabelKey(first) ? entryLabelKey(first)
: operationLabelKey(first.type)); : operationLabelKey(first.type));
const isCoupon = isCouponPayment(first);
const instrument const instrument
= first.ticker ?? first.instrumentType ?? t("ledger.noInstrument"); = first.ticker ?? first.instrumentType ?? t("ledger.noInstrument");
@@ -52,9 +55,21 @@ const OperationGroup: FC<OperationGroupProps> = (props) => {
: null; : null;
const date = formatSnapshotDay(first.occurredAt); const date = formatSnapshotDay(first.occurredAt);
const title = `${typeLabel} · ${instrument}`; const description = first.description.trim();
let title = `${typeLabel} · ${instrument}`;
if (isCoupon) {
title = description && description.toLocaleLowerCase() !== "купон"
? description
: t("ledger.couponPayment", { instrument });
}
const totalsText = totalText(totals, true); const totalsText = totalText(totals, true);
const totalTone = isCoupon && totals?.length && totals.every(total => !isZeroDecimal(total.value) && !total.value.startsWith("-"))
? "positive"
: "neutral";
const accessibleName = [ const accessibleName = [
title, title,
t("payout.executedAt", { date }), t("payout.executedAt", { date }),
@@ -63,13 +78,14 @@ const OperationGroup: FC<OperationGroupProps> = (props) => {
return ( return (
<EntryGroup <EntryGroup
flat={isCoupon}
header={( header={(
<EntryCard <EntryCard
imageSrc={instrumentLogoUrl(first.logoName)} imageSrc={instrumentLogoUrl(first.logoName)}
initial={(first.ticker?.trim()[0] ?? "?").toUpperCase()} initial={(first.ticker?.trim()[0] ?? "?").toUpperCase()}
metadata={t("payout.executedAt", { date })} metadata={t("payout.executedAt", { date })}
title={title} title={title}
tone="neutral" tone={totalTone}
value={<EntryGroupTotal isSigned totals={totals} />} value={<EntryGroupTotal isSigned totals={totals} />}
valueLabel={typeLabel} valueLabel={typeLabel}
wrap wrap
@@ -80,14 +96,15 @@ const OperationGroup: FC<OperationGroupProps> = (props) => {
{props.entries.map(({ key, operation }) => { {props.entries.map(({ key, operation }) => {
const payment = paymentPresentation(operation); const payment = paymentPresentation(operation);
return ( const accountRowProps = {
<AccountCardRow accountName: operation.account?.name ?? "—",
accountName={operation.account?.name ?? "—"} value: payment.amount,
key={key} valueLabel: t(payment.label),
value={payment.amount} };
valueLabel={t(payment.label)}
/> return isCoupon
); ? <AccountRow key={key} {...accountRowProps} tone={payment.tone} />
: <AccountCardRow key={key} {...accountRowProps} />;
})} })}
</EntryGroup> </EntryGroup>
); );
@@ -817,11 +817,11 @@ describe("income period and snapshot boundary", () => {
); );
renderPage(undefined, "all"); renderPage(undefined, "all");
expect(await screen.findByText("Купон · BOND")).toBeInTheDocument(); expect(await screen.findByText("Выплата купонов BOND")).toBeInTheDocument();
expect(screen.getByText("+30 ₽")).toBeInTheDocument(); expect(screen.getByText("+30 ₽")).toBeInTheDocument();
const accounts = within(screen.getByRole("list", { const accounts = within(screen.getByRole("list", {
name: /Купон · BOND, Исполнено/, name: /Выплата купонов BOND, Исполнено/,
})); }));
expect(accounts.getByText("Брокерский")).toBeInTheDocument(); expect(accounts.getByText("Брокерский")).toBeInTheDocument();
@@ -856,7 +856,7 @@ describe("income period and snapshot boundary", () => {
expect(await screen.findByText("+30 ₽")).toBeInTheDocument(); expect(await screen.findByText("+30 ₽")).toBeInTheDocument();
const accounts = within(screen.getByRole("list", { const accounts = within(screen.getByRole("list", {
name: /Купон · BOND/, name: /Выплата купонов BOND/,
})); }));
expect(accounts.getAllByRole("listitem")).toHaveLength(2); expect(accounts.getAllByRole("listitem")).toHaveLength(2);
@@ -889,7 +889,7 @@ describe("income period and snapshot boundary", () => {
); );
const view = renderPage(undefined, "all"); const view = renderPage(undefined, "all");
await screen.findByText("Купон · BOND"); await screen.findByText("Выплата купонов BOND");
expect(screen.queryByText("+30 ₽")).not.toBeInTheDocument(); expect(screen.queryByText("+30 ₽")).not.toBeInTheDocument();
view.unmount(); view.unmount();
@@ -787,10 +787,10 @@ describe("all-account operation groups", () => {
fireEvent.click(screen.getByRole("button", { name: "Далее" })); fireEvent.click(screen.getByRole("button", { name: "Далее" }));
const accounts = await screen.findByRole("list", { const accounts = await screen.findByRole("list", {
name: /Купон · BOND, Исполнено/, name: /Выплата купонов BOND, Исполнено/,
}); });
expect(screen.getByText("Купон · BOND")).toBeInTheDocument(); expect(screen.getByText("Выплата купонов BOND")).toBeInTheDocument();
expect(screen.getByText("+200 ₽")).toBeInTheDocument(); expect(screen.getByText("+200 ₽")).toBeInTheDocument();
expect(within(accounts).getAllByRole("listitem")).toHaveLength(2); expect(within(accounts).getAllByRole("listitem")).toHaveLength(2);
@@ -1655,7 +1655,7 @@ describe("AccountPage states", () => {
await screen.findByRole("heading", { level: 1, name: "Все счета" }); await screen.findByRole("heading", { level: 1, name: "Все счета" });
const operationGroup = screen.getByRole("list", { const operationGroup = screen.getByRole("list", {
name: /Купон · BOND, Исполнено/, name: /Выплата купонов BOND, Исполнено/,
}); });
expect(screen.getByText("+500 ₽")).toBeInTheDocument(); expect(screen.getByText("+500 ₽")).toBeInTheDocument();
+1
View File
@@ -443,6 +443,7 @@ const ru = {
transfer: "Перевод", transfer: "Перевод",
}, },
clearPeriod: "Очистить период", clearPeriod: "Очистить период",
couponPayment: "Выплата купонов {{instrument}}",
emptyText: "Измените период или фильтры.", emptyText: "Измените период или фильтры.",
emptyTitle: "Операций нет", emptyTitle: "Операций нет",
entryLabel: { entryLabel: {
@@ -4,6 +4,40 @@
background: var(--wl-color-subtle); background: var(--wl-color-subtle);
} }
.flat {
background: var(--wl-color-surface);
}
.flat > :first-child {
border: 0;
background: transparent;
}
.flat .accounts {
padding-bottom: var(--mantine-spacing-md);
}
.flat .accountRow {
padding-block: var(--mantine-spacing-md);
}
.flat .accountRow:last-child {
padding-bottom: 0;
}
.flat .accountRow > :first-child {
min-width: 0;
overflow-wrap: anywhere;
}
.positive {
color: var(--wl-color-positive);
}
.negative {
color: var(--wl-color-negative);
}
.accounts { .accounts {
padding: 0 var(--mantine-spacing-md) var(--mantine-spacing-sm); padding: 0 var(--mantine-spacing-md) var(--mantine-spacing-sm);
list-style: none; list-style: none;
@@ -38,4 +72,12 @@
.accountRow { .accountRow {
align-items: flex-start; align-items: flex-start;
} }
.flat .accountRow {
flex-wrap: wrap;
}
.flat .accountValue {
white-space: normal;
}
} }
+23 -14
View File
@@ -9,11 +9,15 @@ import classes from "./EntryGroup.module.css";
type AccountRowProps = { type AccountRowProps = {
accountName: string; accountName: string;
tone?: "negative"
| "neutral"
| "positive";
value: string; value: string;
valueLabel: string; valueLabel: string;
}; };
type EntryGroupProps = PropsWithChildren<{ type EntryGroupProps = PropsWithChildren<{
flat?: boolean;
header: ReactNode; header: ReactNode;
label: string; label: string;
}>; }>;
@@ -25,13 +29,14 @@ type EntryGroupTotalProps = {
const AccountRow: FC<AccountRowProps> = (props) => { const AccountRow: FC<AccountRowProps> = (props) => {
const accessibleName = `${props.accountName}, ${props.value}`; const accessibleName = `${props.accountName}, ${props.value}`;
const valueClass = props.tone ? classes[props.tone] : undefined;
return ( return (
<li aria-label={accessibleName} className={classes.accountRow}> <li aria-label={accessibleName} className={classes.accountRow}>
<Text>{props.accountName}</Text> <Text>{props.accountName}</Text>
<Stack className={classes.accountValue} gap={2}> <Stack className={classes.accountValue} gap={2}>
<Text fw={700}>{props.value}</Text> <Text className={valueClass} fw={700}>{props.value}</Text>
<Text c="dimmed" size="xs">{props.valueLabel}</Text> <Text c="dimmed" size="xs">{props.valueLabel}</Text>
</Stack> </Stack>
@@ -53,20 +58,24 @@ const AccountCardRow: FC<AccountRowProps> = props => (
</li> </li>
); );
const EntryGroup: FC<EntryGroupProps> = props => ( const EntryGroup: FC<EntryGroupProps> = (props) => {
<Paper className={classes.group} component="li" withBorder> const groupClass = props.flat ? `${classes.group} ${classes.flat}` : classes.group;
{props.header}
<Stack return (
aria-label={props.label} <Paper className={groupClass} component="li" withBorder>
className={classes.accounts} {props.header}
component="ul"
gap={0} <Stack
> aria-label={props.label}
{props.children} className={classes.accounts}
</Stack> component="ul"
</Paper> gap={0}
); >
{props.children}
</Stack>
</Paper>
);
};
const EntryGroupTotal: FC<EntryGroupTotalProps> = (props) => { const EntryGroupTotal: FC<EntryGroupTotalProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();