From cc45352243ebf4f34295415de2356a2af163f27a Mon Sep 17 00:00:00 2001 From: codex-bot Date: Sat, 7 Nov 2026 11:18:07 +0300 Subject: [PATCH] feat: clarify grouped coupon payouts (#162) (#165) --- .../broker-operation/model/display.ts | 5 +++ .../ui/OperationGroup.test.tsx | 19 ++++++--- .../broker-operation/ui/OperationGroup.tsx | 39 ++++++++++++----- .../AccountIncomePage.test.tsx | 8 ++-- .../AccountOperationsPage.test.tsx | 4 +- .../ui/AccountPage/AccountPage.test.tsx | 2 +- src/shared/i18n/locales/ru.ts | 1 + .../ui/EntryGroup/EntryGroup.module.css | 42 +++++++++++++++++++ src/shared/ui/EntryGroup/EntryGroup.tsx | 37 +++++++++------- 9 files changed, 119 insertions(+), 38 deletions(-) diff --git a/src/entities/broker-operation/model/display.ts b/src/entities/broker-operation/model/display.ts index 53e86e1..4cadbc1 100644 --- a/src/entities/broker-operation/model/display.ts +++ b/src/entities/broker-operation/model/display.ts @@ -49,6 +49,10 @@ function entryLabelKey(operation: BrokerageOperation): string { return `ledger.category.${operation.category}`; } +function isCouponPayment(operation: BrokerageOperation): boolean { + return isOperationType(operation.type, OperationType.Coupon); +} + function isIncomePayment(operation: BrokerageOperation): boolean { return [OperationType.Coupon, OperationType.Dividend].some(type => isOperationType(operation.type, type), @@ -131,6 +135,7 @@ function paymentPresentation( export { brokerageOperationGroupKey, entryLabelKey, + isCouponPayment, isIncomePayment, isZeroDecimal, operationLabelKey, diff --git a/src/entities/broker-operation/ui/OperationGroup.test.tsx b/src/entities/broker-operation/ui/OperationGroup.test.tsx index 7c830fa..f2c6099 100644 --- a/src/entities/broker-operation/ui/OperationGroup.test.tsx +++ b/src/entities/broker-operation/ui/OperationGroup.test.tsx @@ -55,23 +55,27 @@ function renderGroup( } 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([ - operation("1", { id: "broker", name: "Брокерский" }, "0.1"), + { ...operation("1", { id: "broker", name: "Брокерский" }, "0.1"), description: "Выплата купонов СФО ВТБ РКС Эталон 04" }, operation("2", { id: "iis", name: "ИИС" }, "0.2"), ]); - expect(screen.getByText("Купон · BOND")).toBeInTheDocument(); + expect(screen.getByText("Выплата купонов СФО ВТБ РКС Эталон 04")).toBeInTheDocument(); expect(screen.getByText("+0,3 ₽")).toBeInTheDocument(); 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 ₽" })) .toHaveTextContent("Купон"); + expect(accounts.getByText("+0,1 ₽")).toHaveClass(/positive/); expect(accounts.getByRole("listitem", { name: "ИИС, +0,2 ₽" })) .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.queryByRole("link")).not.toBeInTheDocument(); }); @@ -83,8 +87,10 @@ describe("OperationGroup", () => { ]); expect(screen.getByRole("list", { - name: /Купон · BOND, Исполнено .*Итог недоступен/, + name: /Выплата купонов BOND, Исполнено .*Итог недоступен/, })).toBeInTheDocument(); + expect(screen.getByText("Итог недоступен")).toBeInTheDocument(); + expect(screen.getByText("—")).not.toHaveClass(/positive/); }); it.each(["0", "0.000"])( @@ -114,6 +120,7 @@ describe("OperationGroup", () => { operation("2", { id: "iis", name: "ИИС" }, "-100"), ]); expect(screen.getByText("0 ₽")).toBeInTheDocument(); + expect(screen.getByText("−100 ₽")).toHaveClass(/negative/); }); it("renders repeated ids inside one group as separate rows", () => { @@ -123,7 +130,7 @@ describe("OperationGroup", () => { ]); 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.getAllByRole("listitem")).toHaveLength(2); diff --git a/src/entities/broker-operation/ui/OperationGroup.tsx b/src/entities/broker-operation/ui/OperationGroup.tsx index a5d73fb..fa36ce1 100644 --- a/src/entities/broker-operation/ui/OperationGroup.tsx +++ b/src/entities/broker-operation/ui/OperationGroup.tsx @@ -9,12 +9,13 @@ import { sumMoney, totalText, } 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 { entryLabelKey, + isCouponPayment, isZeroDecimal, operationLabelKey, paymentPresentation, @@ -37,6 +38,8 @@ const OperationGroup: FC = (props) => { ? entryLabelKey(first) : operationLabelKey(first.type)); + const isCoupon = isCouponPayment(first); + const instrument = first.ticker ?? first.instrumentType ?? t("ledger.noInstrument"); @@ -52,9 +55,21 @@ const OperationGroup: FC = (props) => { : null; 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 totalTone = isCoupon && totals?.length && totals.every(total => !isZeroDecimal(total.value) && !total.value.startsWith("-")) + ? "positive" + : "neutral"; + const accessibleName = [ title, t("payout.executedAt", { date }), @@ -63,13 +78,14 @@ const OperationGroup: FC = (props) => { return ( } valueLabel={typeLabel} wrap @@ -80,14 +96,15 @@ const OperationGroup: FC = (props) => { {props.entries.map(({ key, operation }) => { const payment = paymentPresentation(operation); - return ( - - ); + const accountRowProps = { + accountName: operation.account?.name ?? "—", + value: payment.amount, + valueLabel: t(payment.label), + }; + + return isCoupon + ? + : ; })} ); diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx index 2af0697..f0ae8d8 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx @@ -817,11 +817,11 @@ describe("income period and snapshot boundary", () => { ); renderPage(undefined, "all"); - expect(await screen.findByText("Купон · BOND")).toBeInTheDocument(); + expect(await screen.findByText("Выплата купонов BOND")).toBeInTheDocument(); expect(screen.getByText("+30 ₽")).toBeInTheDocument(); const accounts = within(screen.getByRole("list", { - name: /Купон · BOND, Исполнено/, + name: /Выплата купонов BOND, Исполнено/, })); expect(accounts.getByText("Брокерский")).toBeInTheDocument(); @@ -856,7 +856,7 @@ describe("income period and snapshot boundary", () => { expect(await screen.findByText("+30 ₽")).toBeInTheDocument(); const accounts = within(screen.getByRole("list", { - name: /Купон · BOND/, + name: /Выплата купонов BOND/, })); expect(accounts.getAllByRole("listitem")).toHaveLength(2); @@ -889,7 +889,7 @@ describe("income period and snapshot boundary", () => { ); const view = renderPage(undefined, "all"); - await screen.findByText("Купон · BOND"); + await screen.findByText("Выплата купонов BOND"); expect(screen.queryByText("+30 ₽")).not.toBeInTheDocument(); view.unmount(); diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx index 7224ca8..e411897 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx @@ -787,10 +787,10 @@ describe("all-account operation groups", () => { fireEvent.click(screen.getByRole("button", { name: "Далее" })); 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(within(accounts).getAllByRole("listitem")).toHaveLength(2); diff --git a/src/pages/account/ui/AccountPage/AccountPage.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.test.tsx index 1a6797f..5ce9057 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.test.tsx @@ -1655,7 +1655,7 @@ describe("AccountPage states", () => { await screen.findByRole("heading", { level: 1, name: "Все счета" }); const operationGroup = screen.getByRole("list", { - name: /Купон · BOND, Исполнено/, + name: /Выплата купонов BOND, Исполнено/, }); expect(screen.getByText("+500 ₽")).toBeInTheDocument(); diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts index a76db70..0970b59 100644 --- a/src/shared/i18n/locales/ru.ts +++ b/src/shared/i18n/locales/ru.ts @@ -443,6 +443,7 @@ const ru = { transfer: "Перевод", }, clearPeriod: "Очистить период", + couponPayment: "Выплата купонов {{instrument}}", emptyText: "Измените период или фильтры.", emptyTitle: "Операций нет", entryLabel: { diff --git a/src/shared/ui/EntryGroup/EntryGroup.module.css b/src/shared/ui/EntryGroup/EntryGroup.module.css index 527ed80..6d4f613 100644 --- a/src/shared/ui/EntryGroup/EntryGroup.module.css +++ b/src/shared/ui/EntryGroup/EntryGroup.module.css @@ -4,6 +4,40 @@ 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 { padding: 0 var(--mantine-spacing-md) var(--mantine-spacing-sm); list-style: none; @@ -38,4 +72,12 @@ .accountRow { align-items: flex-start; } + + .flat .accountRow { + flex-wrap: wrap; + } + + .flat .accountValue { + white-space: normal; + } } diff --git a/src/shared/ui/EntryGroup/EntryGroup.tsx b/src/shared/ui/EntryGroup/EntryGroup.tsx index 9f44271..c1c9b93 100644 --- a/src/shared/ui/EntryGroup/EntryGroup.tsx +++ b/src/shared/ui/EntryGroup/EntryGroup.tsx @@ -9,11 +9,15 @@ import classes from "./EntryGroup.module.css"; type AccountRowProps = { accountName: string; + tone?: "negative" + | "neutral" + | "positive"; value: string; valueLabel: string; }; type EntryGroupProps = PropsWithChildren<{ + flat?: boolean; header: ReactNode; label: string; }>; @@ -25,13 +29,14 @@ type EntryGroupTotalProps = { const AccountRow: FC = (props) => { const accessibleName = `${props.accountName}, ${props.value}`; + const valueClass = props.tone ? classes[props.tone] : undefined; return (
  • {props.accountName} - {props.value} + {props.value} {props.valueLabel} @@ -53,20 +58,24 @@ const AccountCardRow: FC = props => (
  • ); -const EntryGroup: FC = props => ( - - {props.header} +const EntryGroup: FC = (props) => { + const groupClass = props.flat ? `${classes.group} ${classes.flat}` : classes.group; - - {props.children} - - -); + return ( + + {props.header} + + + {props.children} + + + ); +}; const EntryGroupTotal: FC = (props) => { const { t } = useTranslation();