From 71126c91f0b30f4d65a313ddc7d7f86963340455 Mon Sep 17 00:00:00 2001 From: codex-bot Date: Fri, 30 Oct 2026 07:28:19 +0300 Subject: [PATCH] feat: group all-account rows by instrument and day (WealthLedger/main#47) (#126) --- packages/ui/src/EntryCard/EntryCard.tsx | 9 +- src/entities/broker-operation/index.ts | 2 + .../broker-operation/model/display.test.ts | 75 +++++++++- .../broker-operation/model/display.ts | 24 +++- .../ui/OperationGroup.test.tsx | 131 ++++++++++++++++++ .../broker-operation/ui/OperationGroup.tsx | 96 +++++++++++++ src/entities/brokerage-event/index.ts | 3 +- .../brokerage-event/model/event-key.test.ts | 57 ++++++++ .../brokerage-event/model/event-key.ts | 10 +- .../ui/BrokerageEventGroup.test.tsx | 122 ++++++++++++++++ .../ui/BrokerageEventGroup.tsx | 87 ++++++++++++ .../BrokerageEventsPreview.tsx | 40 ++++-- .../AccountEventsPage.test.tsx | 75 ++++++++++ .../AccountEventsPage/AccountEventsPage.vm.ts | 4 + .../AccountEventsPageLoaded.view.tsx | 35 ++++- .../AccountIncomePage.test.tsx | 113 ++++++++++++++- .../AccountIncomePage/AccountIncomePage.vm.ts | 2 + .../AccountIncomePageLoaded.view.tsx | 6 +- .../components/IncomePayments.view.tsx | 36 ++++- .../AccountOperationsPage.test.tsx | 95 ++++++++++++- .../AccountOperationsPage.vm.ts | 4 + .../AccountOperationsPageLoaded.view.tsx | 51 +++++-- src/pages/account/model/positions.ts | 51 ++----- src/pages/account/model/previews.ts | 11 +- .../ui/AccountPage/AccountPage.test.tsx | 69 +++++++++ .../components/AccountPageEmpty.view.tsx | 10 +- .../components/AccountPageLoaded.view.tsx | 2 + .../components/RecentOperations.view.tsx | 54 ++++++-- src/shared/i18n/locales/ru.ts | 4 + src/shared/lib/decimal.test.ts | 41 ++++++ src/shared/lib/decimal.ts | 75 ++++++++++ src/shared/lib/display.test.ts | 5 + src/shared/lib/display.ts | 9 ++ src/shared/lib/group-items.test.ts | 45 ++++++ src/shared/lib/group-items.ts | 47 +++++++ src/shared/lib/index.ts | 10 ++ src/shared/lib/total-text.ts | 13 ++ .../ui/EntryGroup/EntryGroup.module.css | 41 ++++++ src/shared/ui/EntryGroup/EntryGroup.tsx | 96 +++++++++++++ src/shared/ui/index.ts | 6 + 40 files changed, 1564 insertions(+), 102 deletions(-) create mode 100644 src/entities/broker-operation/ui/OperationGroup.test.tsx create mode 100644 src/entities/broker-operation/ui/OperationGroup.tsx create mode 100644 src/entities/brokerage-event/model/event-key.test.ts create mode 100644 src/entities/brokerage-event/ui/BrokerageEventGroup.test.tsx create mode 100644 src/entities/brokerage-event/ui/BrokerageEventGroup.tsx create mode 100644 src/shared/lib/decimal.test.ts create mode 100644 src/shared/lib/decimal.ts create mode 100644 src/shared/lib/group-items.test.ts create mode 100644 src/shared/lib/group-items.ts create mode 100644 src/shared/lib/total-text.ts create mode 100644 src/shared/ui/EntryGroup/EntryGroup.module.css create mode 100644 src/shared/ui/EntryGroup/EntryGroup.tsx diff --git a/packages/ui/src/EntryCard/EntryCard.tsx b/packages/ui/src/EntryCard/EntryCard.tsx index 973c8ef..76e5f66 100644 --- a/packages/ui/src/EntryCard/EntryCard.tsx +++ b/packages/ui/src/EntryCard/EntryCard.tsx @@ -16,7 +16,7 @@ type EntryCardProps = { tone: "negative" | "neutral" | "positive"; - value: string; + value: ReactNode; valueLabel: string; wrap?: boolean; }; @@ -32,6 +32,7 @@ const EntryCard: FC = (props) => { const valueContainerClass = props.actions ? classes.valueFooter : undefined; const valueLabelClass = `${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`; + const valueComponent = typeof props.value === "string" ? undefined : "div"; return ( = (props) => { - + {props.value} diff --git a/src/entities/broker-operation/index.ts b/src/entities/broker-operation/index.ts index 9c81877..f249e34 100644 --- a/src/entities/broker-operation/index.ts +++ b/src/entities/broker-operation/index.ts @@ -5,5 +5,7 @@ export { type OperationCategory, useBrokerageOperations, } from "./api/useBrokerageOperations"; +export { brokerageOperationGroupKey } from "./model/display"; export { useBrokerageOperationHistory } from "./model/useBrokerageOperationHistory"; export { LedgerEntryRow } from "./ui/LedgerEntryRow"; +export { OperationGroup } from "./ui/OperationGroup"; diff --git a/src/entities/broker-operation/model/display.test.ts b/src/entities/broker-operation/model/display.test.ts index b37267f..bd53e00 100644 --- a/src/entities/broker-operation/model/display.test.ts +++ b/src/entities/broker-operation/model/display.test.ts @@ -1,9 +1,12 @@ -import { expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; -import { operationLabelKey } from "./display"; +import type { BrokerageOperation } from "../api/useBrokerageOperations"; + +import { brokerageOperationGroupKey, operationLabelKey } from "./display"; it.each([ ["OPERATION_TYPE_BUY", "buy"], + ["OPERATION_TYPE_COUPON", "other"], ["OPERATION_TYPE_BUY_CARD", "buy"], ["OPERATION_TYPE_BUY_MARGIN", "buy"], ["OPERATION_TYPE_SELL", "sell"], @@ -21,3 +24,71 @@ it.each([ ])("preserves the wire operation label for %s", (type, label) => { expect(operationLabelKey(type)).toBe(`ledger.operationType.${label}`); }); + +describe("brokerageOperationGroupKey", () => { + const base: BrokerageOperation = { + account: { id: "account-1", name: "Брокерский" }, + accruedInterest: null, + category: "income", + commission: null, + description: "Купон", + figi: "FIGI", + id: "operation", + instrumentType: "bond", + instrumentUid: "UID", + logoName: null, + occurredAt: "2026-08-14T10:00:00Z", + payment: null, + price: null, + quantity: null, + ticker: "BOND", + type: "OPERATION_TYPE_COUPON", + }; + + it("uses screen day, preferred identity and exact type, not account", () => { + const first = brokerageOperationGroupKey(base); + + const same = brokerageOperationGroupKey({ + ...base, + account: { id: "account-2", name: "ИИС" }, + figi: "OTHER-FIGI", + occurredAt: "2026-08-14T11:00:00Z", + }); + + expect(same).toBe(first); + expect(brokerageOperationGroupKey({ + ...base, + type: "OPERATION_TYPE_DIVIDEND", + })).not.toBe(first); + }); + + it("keeps buy and sell of one instrument in different groups", () => { + expect(brokerageOperationGroupKey({ ...base, type: "OPERATION_TYPE_BUY" })) + .not.toBe(brokerageOperationGroupKey({ + ...base, + type: "OPERATION_TYPE_SELL", + })); + }); + + it("separates local midnight and joins one local day across UTC dates", () => { + const before = new Date(2026, 7, 14, 23, 59).toISOString(); + const after = new Date(2026, 7, 15, 0, 1).toISOString(); + const morning = new Date(2026, 7, 14, 1).toISOString(); + const evening = new Date(2026, 7, 14, 23).toISOString(); + + expect(brokerageOperationGroupKey({ ...base, occurredAt: before })) + .not.toBe(brokerageOperationGroupKey({ ...base, occurredAt: after })); + expect(brokerageOperationGroupKey({ ...base, occurredAt: morning })) + .toBe(brokerageOperationGroupKey({ ...base, occurredAt: evening })); + }); + + it("falls back to FIGI and leaves missing identity ungrouped", () => { + expect(brokerageOperationGroupKey({ ...base, instrumentUid: null })) + .toBe(brokerageOperationGroupKey({ ...base, instrumentUid: "" })); + expect(brokerageOperationGroupKey({ + ...base, + figi: null, + instrumentUid: null, + })).toBeNull(); + }); +}); diff --git a/src/entities/broker-operation/model/display.ts b/src/entities/broker-operation/model/display.ts index 2c9aa3a..53e86e1 100644 --- a/src/entities/broker-operation/model/display.ts +++ b/src/entities/broker-operation/model/display.ts @@ -1,4 +1,4 @@ -import { formatMoney } from "@/shared/lib"; +import { formatMoney, formatSnapshotDay } from "@/shared/lib"; import type { BrokerageOperation } from "../api/useBrokerageOperations"; @@ -27,6 +27,19 @@ type PaymentPresentation = { | "positive"; }; +function brokerageOperationGroupKey( + operation: BrokerageOperation, +): null | string { + const identity = operation.instrumentUid || operation.figi; + if (!identity) return null; + + return JSON.stringify([ + formatSnapshotDay(operation.occurredAt), + identity, + operation.type, + ]); +} + function entryLabelKey(operation: BrokerageOperation): string { if (operation.category === "income") return isOperationType(operation.type, OperationType.Coupon) @@ -115,4 +128,11 @@ function paymentPresentation( }; } -export { isIncomePayment, operationLabelKey, paymentPresentation }; +export { + brokerageOperationGroupKey, + entryLabelKey, + isIncomePayment, + isZeroDecimal, + operationLabelKey, + paymentPresentation, +}; diff --git a/src/entities/broker-operation/ui/OperationGroup.test.tsx b/src/entities/broker-operation/ui/OperationGroup.test.tsx new file mode 100644 index 0000000..7c830fa --- /dev/null +++ b/src/entities/broker-operation/ui/OperationGroup.test.tsx @@ -0,0 +1,131 @@ +import { MantineProvider } from "@mantine/core"; +import { render, screen, within } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; +import { describe, expect, it } from "vitest"; + +import type { BrokerageOperation } from "../api/useBrokerageOperations"; + +import { OperationGroup } from "./OperationGroup"; + +function operation( + id: string, + account: { id: string; name: string }, + value: null | string, + currency = "RUB", +): BrokerageOperation { + return { + account, + accruedInterest: null, + category: "income", + commission: null, + description: "Купон", + figi: "FIGI", + id, + instrumentType: "bond", + instrumentUid: "UID", + logoName: null, + occurredAt: "2026-08-14T10:00:00Z", + payment: value === null ? null : { currency, value }, + price: null, + quantity: null, + ticker: "BOND", + type: "OPERATION_TYPE_COUPON", + }; +} + +function renderGroup( + operations: BrokerageOperation[], + isTotalVisible = true, +) { + return render( + + +
    + ({ + key: `${operation.id}-${index}`, + operation, + }))} + totalVisible={isTotalVisible} + /> +
+
+
, + ); +} + +describe("OperationGroup", () => { + it("shows the header, exact total and separate card rows per account", () => { + renderGroup([ + operation("1", { id: "broker", name: "Брокерский" }, "0.1"), + operation("2", { id: "iis", name: "ИИС" }, "0.2"), + ]); + + expect(screen.getByText("Купон · BOND")).toBeInTheDocument(); + expect(screen.getByText("+0,3 ₽")).toBeInTheDocument(); + + const accounts = within(screen.getByRole("list", { + name: /Купон · BOND, Исполнено 14\.08\.2026.*, \+0,3 ₽/, + })); + + expect(accounts.getByRole("listitem", { name: "Брокерский, +0,1 ₽" })) + .toHaveTextContent("Купон"); + expect(accounts.getByRole("listitem", { name: "ИИС, +0,2 ₽" })) + .toHaveTextContent("Купон"); + expect(accounts.queryByText(/Исполнено/)).not.toBeInTheDocument(); + expect(accounts.queryByRole("link")).not.toBeInTheDocument(); + }); + + it("announces the hidden total as unavailable", () => { + renderGroup([ + operation("1", { id: "broker", name: "Брокерский" }, "100"), + operation("2", { id: "iis", name: "ИИС" }, null), + ]); + + expect(screen.getByRole("list", { + name: /Купон · BOND, Исполнено .*Итог недоступен/, + })).toBeInTheDocument(); + }); + + it.each(["0", "0.000"])( + "hides the whole total for a zero source amount (%s)", + (value) => { + renderGroup([ + operation("1", { id: "broker", name: "Брокерский" }, value), + operation("2", { id: "iis", name: "ИИС" }, "100"), + ]); + + expect(screen.getAllByText("+100 ₽")).toHaveLength(1); + expect(screen.getByText("Итог недоступен")).toBeInTheDocument(); + }, + ); + + it("hides the total for incomplete data but keeps compensated zero", () => { + const { unmount } = renderGroup([ + operation("1", { id: "broker", name: "Брокерский" }, "100"), + operation("2", { id: "iis", name: "ИИС" }, "100"), + ], false); + + expect(screen.queryByText("+200 ₽")).not.toBeInTheDocument(); + unmount(); + + renderGroup([ + operation("1", { id: "broker", name: "Брокерский" }, "100"), + operation("2", { id: "iis", name: "ИИС" }, "-100"), + ]); + expect(screen.getByText("0 ₽")).toBeInTheDocument(); + }); + + it("renders repeated ids inside one group as separate rows", () => { + renderGroup([ + operation("same", { id: "broker", name: "Брокерский" }, "10"), + operation("same", { id: "iis", name: "ИИС" }, "20"), + ]); + + expect(screen.getByText("+30 ₽")).toBeInTheDocument(); + 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 new file mode 100644 index 0000000..a5d73fb --- /dev/null +++ b/src/entities/broker-operation/ui/OperationGroup.tsx @@ -0,0 +1,96 @@ +import type { FC } from "react"; + +import { EntryCard } from "@wealthledger/ui"; +import { useTranslation } from "react-i18next"; + +import { + formatSnapshotDay, + instrumentLogoUrl, + sumMoney, + totalText, +} from "@/shared/lib"; +import { AccountCardRow, EntryGroup, EntryGroupTotal } from "@/shared/ui"; + +import type { BrokerageOperation } from "../api/useBrokerageOperations"; + +import { + entryLabelKey, + isZeroDecimal, + operationLabelKey, + paymentPresentation, +} from "../model/display"; + +type OperationGroupProps = { + entries: { key: string; operation: BrokerageOperation }[]; + totalVisible: boolean; +}; + +const OperationGroup: FC = (props) => { + const { t } = useTranslation(); + const operations = props.entries.map(entry => entry.operation); + const first = operations.at(0); + + // groupItems only emits groups of at least two entries. + if (!first) return null; + + const typeLabel = t(first.category === "income" + ? entryLabelKey(first) + : operationLabelKey(first.type)); + + const instrument + = first.ticker ?? first.instrumentType ?? t("ledger.noInstrument"); + + const amounts = operations.flatMap(operation => + operation.payment ? [operation.payment] : [], + ); + + const isAmountsKnown = amounts.length === operations.length + && amounts.every(amount => !isZeroDecimal(amount.value)); + + const totals = isAmountsKnown && props.totalVisible + ? sumMoney(amounts) + : null; + + const date = formatSnapshotDay(first.occurredAt); + const title = `${typeLabel} · ${instrument}`; + const totalsText = totalText(totals, true); + + const accessibleName = [ + title, + t("payout.executedAt", { date }), + totalsText || t("entryGroup.totalUnavailable"), + ].join(", "); + + return ( + } + valueLabel={typeLabel} + wrap + /> + )} + label={accessibleName} + > + {props.entries.map(({ key, operation }) => { + const payment = paymentPresentation(operation); + + return ( + + ); + })} + + ); +}; + +export { OperationGroup }; diff --git a/src/entities/brokerage-event/index.ts b/src/entities/brokerage-event/index.ts index aab5711..b425ed3 100644 --- a/src/entities/brokerage-event/index.ts +++ b/src/entities/brokerage-event/index.ts @@ -2,8 +2,9 @@ export { type BrokerageEvent, useBrokerageEvents, } from "./api/useBrokerageEvents"; -export { brokerageEventKey } from "./model/event-key"; +export { brokerageEventGroupKey, brokerageEventKey } from "./model/event-key"; export { BrokerageEventCard } from "./ui/BrokerageEventCard"; +export { BrokerageEventGroup } from "./ui/BrokerageEventGroup"; export { BrokerageEventsPreview, type BrokerageEventsPreviewState, diff --git a/src/entities/brokerage-event/model/event-key.test.ts b/src/entities/brokerage-event/model/event-key.test.ts new file mode 100644 index 0000000..3f4d094 --- /dev/null +++ b/src/entities/brokerage-event/model/event-key.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import type { BrokerageEvent } from "../api/useBrokerageEvents"; + +import { brokerageEventGroupKey } from "./event-key"; + +const base: BrokerageEvent = { + account: { id: "account-1", name: "Брокерский" }, + date: "2026-08-20", + dateKind: "event", + displayName: "ОФЗ 26238", + figi: "FIGI", + instrumentUid: "UID", + isin: "RU000A0JX0J2", + logoName: null, + projectedAmount: { currency: "RUB", value: "100" }, + ticker: "OFZ", + type: "coupon", +}; + +describe("brokerageEventGroupKey", () => { + it("uses date, preferred identity, exact type and dateKind", () => { + const key = brokerageEventGroupKey(base); + + expect(brokerageEventGroupKey({ + ...base, + account: { id: "account-2", name: "ИИС" }, + figi: "OTHER-FIGI", + })).toBe(key); + expect(brokerageEventGroupKey({ ...base, date: "2026-08-21" })) + .not.toBe(key); + expect(brokerageEventGroupKey({ ...base, dateKind: "registry" })) + .not.toBe(key); + expect(brokerageEventGroupKey({ ...base, type: "dividend" })) + .not.toBe(key); + }); + + it("falls back through FIGI and ISIN and rejects missing identity", () => { + expect(brokerageEventGroupKey({ ...base, instrumentUid: null })) + .toBe(brokerageEventGroupKey({ ...base, instrumentUid: "" })); + expect(brokerageEventGroupKey({ + ...base, + figi: null, + instrumentUid: null, + })).toBe(brokerageEventGroupKey({ + ...base, + figi: "", + instrumentUid: null, + })); + expect(brokerageEventGroupKey({ + ...base, + figi: null, + instrumentUid: null, + isin: null, + })).toBeNull(); + }); +}); diff --git a/src/entities/brokerage-event/model/event-key.ts b/src/entities/brokerage-event/model/event-key.ts index 1d460bd..5a72442 100644 --- a/src/entities/brokerage-event/model/event-key.ts +++ b/src/entities/brokerage-event/model/event-key.ts @@ -1,5 +1,13 @@ import type { BrokerageEvent } from "../api/useBrokerageEvents"; +function brokerageEventGroupKey(event: BrokerageEvent): null | string { + const identity = event.instrumentUid || event.figi || event.isin; + + return identity + ? JSON.stringify([event.date, identity, event.type, event.dateKind]) + : null; +} + function brokerageEventKey(event: BrokerageEvent) { return JSON.stringify([ event.account?.id, event.type, event.date, event.instrumentUid, @@ -7,4 +15,4 @@ function brokerageEventKey(event: BrokerageEvent) { ]); } -export { brokerageEventKey }; +export { brokerageEventGroupKey, brokerageEventKey }; diff --git a/src/entities/brokerage-event/ui/BrokerageEventGroup.test.tsx b/src/entities/brokerage-event/ui/BrokerageEventGroup.test.tsx new file mode 100644 index 0000000..f08e78f --- /dev/null +++ b/src/entities/brokerage-event/ui/BrokerageEventGroup.test.tsx @@ -0,0 +1,122 @@ +import { MantineProvider } from "@mantine/core"; +import { render, screen, within } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; +import { describe, expect, it } from "vitest"; + +import type { BrokerageEvent } from "../api/useBrokerageEvents"; + +import { BrokerageEventGroup } from "./BrokerageEventGroup"; + +function event( + account: { id: string; name: string }, + value: null | string, + currency = "RUB", +): BrokerageEvent { + return { + account, + date: "2026-08-20", + dateKind: "registry", + displayName: "ОФЗ 26238", + figi: "FIGI", + instrumentUid: "UID", + isin: "RU000A0JX0J2", + logoName: null, + projectedAmount: value === null ? null : { currency, value }, + ticker: "OFZ", + type: "coupon", + }; +} + +function renderGroup( + events: [BrokerageEvent, BrokerageEvent], + isTotalVisible = true, +) { + return render( + + +
    + +
+
+
, + ); +} + +describe("BrokerageEventGroup", () => { + it("shows an unsigned forecast total and every account forecast", () => { + renderGroup([ + event({ id: "broker", name: "Брокерский" }, "100.5"), + event({ id: "iis", name: "ИИС" }, "49.5"), + ]); + + expect(screen.getByText("Купон · ОФЗ 26238")).toBeInTheDocument(); + expect(screen.getByText("150 ₽")).toBeInTheDocument(); + expect(screen.getAllByText("Прогноз").length).toBeGreaterThanOrEqual(3); + + const accounts = within(screen.getByRole("list", { + name: /Купон · ОФЗ 26238, 20\.08\.2026 · Дата реестра, 150 ₽/, + })); + + expect(accounts.getByText("Брокерский")).toBeInTheDocument(); + expect(accounts.getByText("ИИС")).toBeInTheDocument(); + expect(accounts.getByText("100,5 ₽")).toBeInTheDocument(); + expect(accounts.getByText("49,5 ₽")).toBeInTheDocument(); + expect(accounts.queryByRole("link")).not.toBeInTheDocument(); + }); + + it("announces an unknown total as unavailable", () => { + renderGroup([ + event({ id: "broker", name: "Брокерский" }, null), + event({ id: "iis", name: "ИИС" }, "49.5"), + ]); + + expect(screen.getByRole("list", { + name: /Купон · ОФЗ 26238.*Итог недоступен/, + })).toBeInTheDocument(); + expect(screen.getByText("Сумма неизвестна")).toBeInTheDocument(); + }); + + it("hides the total for incomplete data", () => { + renderGroup([ + event({ id: "broker", name: "Брокерский" }, "100.5"), + event({ id: "iis", name: "ИИС" }, "49.5"), + ], false); + expect(screen.queryByText("150 ₽")).not.toBeInTheDocument(); + }); + + it("allows zero source forecasts to produce a visible zero total", () => { + renderGroup([ + event({ id: "broker", name: "Брокерский" }, "0"), + event({ id: "iis", name: "ИИС" }, "0.00"), + ]); + + expect(screen.getAllByText("0 ₽")).toHaveLength(2); + expect(screen.getByText("0,00 ₽")).toBeInTheDocument(); + }); + + it("shows separate forecast totals per currency without conversion", () => { + renderGroup([ + event({ id: "broker", name: "Брокерский" }, "10", "USD"), + event({ id: "iis", name: "ИИС" }, "200"), + ]); + + expect(screen.getByRole("list", { name: /10 USD.*200 ₽/ })) + .toBeInTheDocument(); + }); + + it("shows an offer group without a total", () => { + renderGroup([ + { ...event({ id: "broker", name: "Брокерский" }, null), type: "offer" }, + { ...event({ id: "iis", name: "ИИС" }, null), type: "offer" }, + ]); + + const accounts = within(screen.getByRole("list", { + name: /Итог недоступен/, + })); + + expect(accounts.getAllByRole("listitem")).toHaveLength(2); + }); +}); diff --git a/src/entities/brokerage-event/ui/BrokerageEventGroup.tsx b/src/entities/brokerage-event/ui/BrokerageEventGroup.tsx new file mode 100644 index 0000000..8c2228c --- /dev/null +++ b/src/entities/brokerage-event/ui/BrokerageEventGroup.tsx @@ -0,0 +1,87 @@ +import type { FC } from "react"; + +import { EntryCard } from "@wealthledger/ui"; +import { useTranslation } from "react-i18next"; + +import { + formatDate, + formatMoney, + instrumentLogoUrl, + sumMoney, + totalText, +} from "@/shared/lib"; +import { AccountRow, EntryGroup, EntryGroupTotal } from "@/shared/ui"; + +import type { BrokerageEvent } from "../api/useBrokerageEvents"; + +import { brokerageEventKey } from "../model/event-key"; + +type BrokerageEventGroupProps = { + events: [BrokerageEvent, BrokerageEvent, ...BrokerageEvent[]]; + totalVisible: boolean; +}; + +const BrokerageEventGroup: FC = (props) => { + const { t } = useTranslation(); + const [first] = props.events; + const typeLabel = t(`events.type.${first.type}`); + + const amounts = props.events.flatMap(event => + event.projectedAmount ? [event.projectedAmount] : [], + ); + + const totals = props.totalVisible && amounts.length === props.events.length + ? sumMoney(amounts) + : null; + + const date = formatDate(first.date); + + const metadata = [ + date, + first.dateKind === "registry" ? t("events.dateKind.registry") : null, + ].filter(Boolean).join(" · "); + + const title = `${typeLabel} · ${first.displayName}`; + const totalsText = totalText(totals, false); + + const accessibleName = [ + title, + metadata, + totalsText || t("entryGroup.totalUnavailable"), + ].join(", "); + + return ( + } + valueLabel={t("payout.forecast")} + wrap + /> + )} + label={accessibleName} + > + {props.events.map((event) => { + const value = event.projectedAmount + ? formatMoney(event.projectedAmount) + : t("events.noAmount"); + + return ( + + ); + })} + + ); +}; + +export { BrokerageEventGroup }; diff --git a/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx b/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx index a571dfa..4e4818b 100644 --- a/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx +++ b/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx @@ -5,12 +5,16 @@ import { LoadingSkeleton } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; +import { groupItems } from "@/shared/lib"; + import type { BrokerageEvent } from "../../api/useBrokerageEvents"; -import { brokerageEventKey } from "../../model/event-key"; +import { brokerageEventGroupKey, brokerageEventKey } from "../../model/event-key"; import { BrokerageEventCard } from "../BrokerageEventCard"; +import { BrokerageEventGroup } from "../BrokerageEventGroup"; type BrokerageEventsPreviewProps = { + allAccounts?: boolean; events: BrokerageEventsPreviewState; moreLabel?: string; }; @@ -20,6 +24,7 @@ type BrokerageEventsPreviewState items: BrokerageEvent[]; state: "loaded" | "partial"; to: string; + totalVisible?: boolean; } | { state: "empty" | "error" @@ -43,13 +48,32 @@ const BrokerageEventsPreview: FC = (props) => { {events.state === "partial" && {t("investment.partial")}} - {(events.state === "loaded" || events.state === "partial") - && events.items.map(event => ( - - ))} + {(events.state === "loaded" || events.state === "partial") && ( + + {groupItems( + events.items, + props.allAccounts ? brokerageEventGroupKey : () => null, + ).map(entry => entry.type === "group" + ? ( + + ) + : ( +
  • + +
  • + ))} +
    + )}