feat: group all-account rows by instrument and day (WealthLedger/main#47) #126

Merged
codex-bot merged 1 commits from feature/47-group-rows-by-instrument into develop 2026-10-30 07:28:19 +03:00
40 changed files with 1564 additions and 102 deletions
+7 -2
View File
@@ -16,7 +16,7 @@ type EntryCardProps = {
tone: "negative" tone: "negative"
| "neutral" | "neutral"
| "positive"; | "positive";
value: string; value: ReactNode;
valueLabel: string; valueLabel: string;
wrap?: boolean; wrap?: boolean;
}; };
@@ -32,6 +32,7 @@ const EntryCard: FC<EntryCardProps> = (props) => {
const valueContainerClass = props.actions ? classes.valueFooter : undefined; const valueContainerClass = props.actions ? classes.valueFooter : undefined;
const valueLabelClass = `${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`; const valueLabelClass = `${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`;
const valueComponent = typeof props.value === "string" ? undefined : "div";
return ( return (
<Paper <Paper
@@ -63,7 +64,11 @@ const EntryCard: FC<EntryCardProps> = (props) => {
</Stack> </Stack>
<Stack className={classes.value} gap={2}> <Stack className={classes.value} gap={2}>
<Text className={classes[props.tone]} fw={700}> <Text
className={classes[props.tone]}
component={valueComponent}
fw={700}
>
{props.value} {props.value}
</Text> </Text>
+2
View File
@@ -5,5 +5,7 @@ export {
type OperationCategory, type OperationCategory,
useBrokerageOperations, useBrokerageOperations,
} from "./api/useBrokerageOperations"; } from "./api/useBrokerageOperations";
export { brokerageOperationGroupKey } from "./model/display";
export { useBrokerageOperationHistory } from "./model/useBrokerageOperationHistory"; export { useBrokerageOperationHistory } from "./model/useBrokerageOperationHistory";
export { LedgerEntryRow } from "./ui/LedgerEntryRow"; export { LedgerEntryRow } from "./ui/LedgerEntryRow";
export { OperationGroup } from "./ui/OperationGroup";
@@ -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([ it.each([
["OPERATION_TYPE_BUY", "buy"], ["OPERATION_TYPE_BUY", "buy"],
["OPERATION_TYPE_COUPON", "other"],
["OPERATION_TYPE_BUY_CARD", "buy"], ["OPERATION_TYPE_BUY_CARD", "buy"],
["OPERATION_TYPE_BUY_MARGIN", "buy"], ["OPERATION_TYPE_BUY_MARGIN", "buy"],
["OPERATION_TYPE_SELL", "sell"], ["OPERATION_TYPE_SELL", "sell"],
@@ -21,3 +24,71 @@ it.each([
])("preserves the wire operation label for %s", (type, label) => { ])("preserves the wire operation label for %s", (type, label) => {
expect(operationLabelKey(type)).toBe(`ledger.operationType.${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();
});
});
+22 -2
View File
@@ -1,4 +1,4 @@
import { formatMoney } from "@/shared/lib"; import { formatMoney, formatSnapshotDay } from "@/shared/lib";
import type { BrokerageOperation } from "../api/useBrokerageOperations"; import type { BrokerageOperation } from "../api/useBrokerageOperations";
@@ -27,6 +27,19 @@ type PaymentPresentation = {
| "positive"; | "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 { function entryLabelKey(operation: BrokerageOperation): string {
if (operation.category === "income") if (operation.category === "income")
return isOperationType(operation.type, OperationType.Coupon) return isOperationType(operation.type, OperationType.Coupon)
@@ -115,4 +128,11 @@ function paymentPresentation(
}; };
} }
export { isIncomePayment, operationLabelKey, paymentPresentation }; export {
brokerageOperationGroupKey,
entryLabelKey,
isIncomePayment,
isZeroDecimal,
operationLabelKey,
paymentPresentation,
};
@@ -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(
<MantineProvider>
<MemoryRouter>
<ul>
<OperationGroup
entries={operations.map((operation, index) => ({
key: `${operation.id}-${index}`,
operation,
}))}
totalVisible={isTotalVisible}
/>
</ul>
</MemoryRouter>
</MantineProvider>,
);
}
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);
});
});
@@ -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<OperationGroupProps> = (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 (
<EntryGroup
header={(
<EntryCard
imageSrc={instrumentLogoUrl(first.logoName)}
initial={(first.ticker?.trim()[0] ?? "?").toUpperCase()}
metadata={t("payout.executedAt", { date })}
title={title}
tone="neutral"
value={<EntryGroupTotal isSigned totals={totals} />}
valueLabel={typeLabel}
wrap
/>
)}
label={accessibleName}
>
{props.entries.map(({ key, operation }) => {
const payment = paymentPresentation(operation);
return (
<AccountCardRow
accountName={operation.account?.name ?? "—"}
key={key}
value={payment.amount}
valueLabel={t(payment.label)}
/>
);
})}
</EntryGroup>
);
};
export { OperationGroup };
+2 -1
View File
@@ -2,8 +2,9 @@ export {
type BrokerageEvent, type BrokerageEvent,
useBrokerageEvents, useBrokerageEvents,
} from "./api/useBrokerageEvents"; } from "./api/useBrokerageEvents";
export { brokerageEventKey } from "./model/event-key"; export { brokerageEventGroupKey, brokerageEventKey } from "./model/event-key";
export { BrokerageEventCard } from "./ui/BrokerageEventCard"; export { BrokerageEventCard } from "./ui/BrokerageEventCard";
export { BrokerageEventGroup } from "./ui/BrokerageEventGroup";
export { export {
BrokerageEventsPreview, BrokerageEventsPreview,
type BrokerageEventsPreviewState, type BrokerageEventsPreviewState,
@@ -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();
});
});
@@ -1,5 +1,13 @@
import type { BrokerageEvent } from "../api/useBrokerageEvents"; 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) { function brokerageEventKey(event: BrokerageEvent) {
return JSON.stringify([ return JSON.stringify([
event.account?.id, event.type, event.date, event.instrumentUid, event.account?.id, event.type, event.date, event.instrumentUid,
@@ -7,4 +15,4 @@ function brokerageEventKey(event: BrokerageEvent) {
]); ]);
} }
export { brokerageEventKey }; export { brokerageEventGroupKey, brokerageEventKey };
@@ -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(
<MantineProvider>
<MemoryRouter>
<ul>
<BrokerageEventGroup
events={events}
totalVisible={isTotalVisible}
/>
</ul>
</MemoryRouter>
</MantineProvider>,
);
}
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);
});
});
@@ -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<BrokerageEventGroupProps> = (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 (
<EntryGroup
header={(
<EntryCard
imageSrc={instrumentLogoUrl(first.logoName)}
initial={(first.ticker.trim()[0] ?? "?").toUpperCase()}
metadata={metadata}
title={title}
tone="neutral"
value={<EntryGroupTotal totals={totals} />}
valueLabel={t("payout.forecast")}
wrap
/>
)}
label={accessibleName}
>
{props.events.map((event) => {
const value = event.projectedAmount
? formatMoney(event.projectedAmount)
: t("events.noAmount");
return (
<AccountRow
accountName={event.account?.name ?? "—"}
key={brokerageEventKey(event)}
value={value}
valueLabel={t("payout.forecast")}
/>
);
})}
</EntryGroup>
);
};
export { BrokerageEventGroup };
@@ -5,12 +5,16 @@ import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { groupItems } from "@/shared/lib";
import type { BrokerageEvent } from "../../api/useBrokerageEvents"; import type { BrokerageEvent } from "../../api/useBrokerageEvents";
import { brokerageEventKey } from "../../model/event-key"; import { brokerageEventGroupKey, brokerageEventKey } from "../../model/event-key";
import { BrokerageEventCard } from "../BrokerageEventCard"; import { BrokerageEventCard } from "../BrokerageEventCard";
import { BrokerageEventGroup } from "../BrokerageEventGroup";
type BrokerageEventsPreviewProps = { type BrokerageEventsPreviewProps = {
allAccounts?: boolean;
events: BrokerageEventsPreviewState; events: BrokerageEventsPreviewState;
moreLabel?: string; moreLabel?: string;
}; };
@@ -20,6 +24,7 @@ type BrokerageEventsPreviewState
items: BrokerageEvent[]; items: BrokerageEvent[];
state: "loaded" | "partial"; state: "loaded" | "partial";
to: string; to: string;
totalVisible?: boolean;
} }
| { state: "empty" | { state: "empty"
| "error" | "error"
@@ -43,13 +48,32 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
{events.state === "partial" && <Text role="status">{t("investment.partial")}</Text>} {events.state === "partial" && <Text role="status">{t("investment.partial")}</Text>}
{(events.state === "loaded" || events.state === "partial") {(events.state === "loaded" || events.state === "partial") && (
&& events.items.map(event => ( <Stack
<BrokerageEventCard component="ul"
event={event} gap="sm"
key={brokerageEventKey(event)} m={0}
/> p={0}
))} style={{ listStyle: "none" }}
>
{groupItems(
events.items,
props.allAccounts ? brokerageEventGroupKey : () => null,
).map(entry => entry.type === "group"
? (
<BrokerageEventGroup
events={entry.items}
key={entry.key}
totalVisible={events.totalVisible === true}
/>
)
: (
<li key={brokerageEventKey(entry.item)}>
<BrokerageEventCard event={entry.item} />
</li>
))}
</Stack>
)}
<Button component={Link} to={events.to} variant="subtle"> <Button component={Link} to={events.to} variant="subtle">
{props.moreLabel ?? t("events.open")} {props.moreLabel ?? t("events.open")}
@@ -237,4 +237,79 @@ describe("AccountEventsPage", () => {
screen.queryByText("За выбранный период событий не найдено"), screen.queryByText("За выбранный период событий не найдено"),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
it("groups same-day instrument events only in all-account mode", () => {
const events = [
{
account: { id: "broker", name: "Брокерский" },
date: "2026-08-20",
dateKind: "event",
displayName: "ОФЗ 26238",
figi: "FIGI",
instrumentUid: "UID",
isin: "RU000A0JX0J2",
logoName: null,
projectedAmount: { currency: "RUB", value: "200" },
ticker: "OFZ",
type: "coupon",
},
{
account: { id: "iis", 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",
},
] as const;
query.mockReturnValue({
data: {
accountId: "all",
calculatedAt: "2026-08-14T00:00:00Z",
events: [...events],
},
isError: false,
isLoading: false,
meta: {
availability: "available",
completeness: "complete",
lastAttempt: {
finishedAt: null,
problems: [],
startedAt: null,
state: "none",
},
},
refetch: vi.fn(),
} as never);
render(
<MantineProvider>
<MemoryRouter initialEntries={["/accounts/all/events"]}>
<Routes>
<Route
element={<AccountEventsPage />}
path="/accounts/:accountId/events"
/>
</Routes>
</MemoryRouter>
</MantineProvider>,
);
expect(screen.getByText("Купон · ОФЗ 26238")).toBeInTheDocument();
expect(screen.getByText("300 ₽")).toBeInTheDocument();
const accounts = within(screen.getByRole("list", {
name: /Купон · ОФЗ 26238, 20\.08\.2026, 300 ₽/,
}));
expect(accounts.getByText("Брокерский")).toBeInTheDocument();
expect(accounts.getByText("ИИС")).toBeInTheDocument();
});
}); });
@@ -18,6 +18,7 @@ import {
type AccountEventsPageViewModel type AccountEventsPageViewModel
= | { = | {
allAccounts: boolean;
canApply: boolean; canApply: boolean;
draftPeriod: [null | string, null | string]; draftPeriod: [null | string, null | string];
events: BrokerageEvent[]; events: BrokerageEvent[];
@@ -29,6 +30,7 @@ type AccountEventsPageViewModel
partial?: boolean; partial?: boolean;
source?: InvestmentResourceMeta; source?: InvestmentResourceMeta;
state: "loaded"; state: "loaded";
totalVisible: boolean;
} }
| { onReset: () => void; state: "error" } | { onReset: () => void; state: "error" }
| { state: "loading" }; | { state: "loading" };
@@ -100,6 +102,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
: { state: "loading" }; : { state: "loading" };
return { return {
allAccounts: accountId === "all",
canApply: canApply:
(draftFrom !== appliedFrom || draftTo !== appliedTo) (draftFrom !== appliedFrom || draftTo !== appliedTo)
&& isValidPeriod(draftFrom, draftTo), && isValidPeriod(draftFrom, draftTo),
@@ -121,6 +124,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
partial: events.meta?.completeness === "incomplete", partial: events.meta?.completeness === "incomplete",
source: events.meta, source: events.meta,
state: "loaded", state: "loaded",
totalVisible: events.meta?.completeness === "complete",
}; };
} }
@@ -13,8 +13,11 @@ import { useTranslation } from "react-i18next";
import { import {
BrokerageEventCard, BrokerageEventCard,
BrokerageEventGroup,
brokerageEventGroupKey,
brokerageEventKey, brokerageEventKey,
} from "@/entities/brokerage-event"; } from "@/entities/brokerage-event";
import { groupItems } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui"; import { SourceSyncStatus } from "@/shared/ui";
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm"; import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
@@ -30,14 +33,32 @@ const AccountEventsPageLoadedEvents: FC<AccountEventsPageLoadedProps> = (
props, props,
) => { ) => {
if (props.events.length > 0) { if (props.events.length > 0) {
const entries = groupItems(
props.events,
props.allAccounts ? brokerageEventGroupKey : () => null,
);
return ( return (
<Stack gap="sm"> <Stack
{props.events.map(event => ( component="ul"
<BrokerageEventCard gap="sm"
event={event} m={0}
key={brokerageEventKey(event)} p={0}
/> style={{ listStyle: "none" }}
))} >
{entries.map(entry => entry.type === "group"
? (
<BrokerageEventGroup
events={entry.items}
key={entry.key}
totalVisible={props.totalVisible}
/>
)
: (
<li key={brokerageEventKey(entry.item)}>
<BrokerageEventCard event={entry.item} />
</li>
))}
</Stack> </Stack>
); );
} }
@@ -100,7 +100,10 @@ function money(currency: string, value: string) {
return { currency, value }; return { currency, value };
} }
function renderPage(search = "from=2026-08-01&to=2026-08-27") { function renderPage(
search = "from=2026-08-01&to=2026-08-27",
accountId = "account-1",
) {
configureAuthClient({ configureAuthClient({
accessToken: () => "token", accessToken: () => "token",
clear: vi.fn(), clear: vi.fn(),
@@ -113,7 +116,7 @@ function renderPage(search = "from=2026-08-01&to=2026-08-27") {
<DatesProvider settings={{ locale: "ru" }}> <DatesProvider settings={{ locale: "ru" }}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<MemoryRouter <MemoryRouter
initialEntries={[`/accounts/account-1/income?${search}`]} initialEntries={[`/accounts/${accountId}/income?${search}`]}
> >
<LocationProbe /> <LocationProbe />
@@ -680,4 +683,110 @@ describe("income period and snapshot boundary", () => {
expect(screen.queryByText(/Итого:|Выплат:/)).not.toBeInTheDocument(); expect(screen.queryByText(/Итого:|Выплат:/)).not.toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument(); expect(screen.queryByRole("button")).not.toBeInTheDocument();
}); });
it("groups all-account payouts by instrument with an exact total", async () => {
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/income-analytics",
() => HttpResponse.json(resource({
...analytics,
accountId: "all",
entries: [
{
...entry(),
account: { id: "broker", name: "Брокерский" },
payment: money("RUB", "10"),
},
{
...entry(),
account: { id: "iis", name: "ИИС" },
id: "coupon-2",
payment: money("RUB", "20"),
},
],
})),
),
);
renderPage(undefined, "all");
expect(await screen.findByText("Купон · BOND")).toBeInTheDocument();
expect(screen.getByText("+30 ₽")).toBeInTheDocument();
const accounts = within(screen.getByRole("list", {
name: /Купон · BOND, Исполнено/,
}));
expect(accounts.getByText("Брокерский")).toBeInTheDocument();
expect(accounts.getByText("ИИС")).toBeInTheDocument();
expect(accounts.queryByRole("link")).not.toBeInTheDocument();
});
it("keeps repeated payout ids inside one group as separate cards", async () => {
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/income-analytics",
() => HttpResponse.json(resource({
...analytics,
accountId: "all",
entries: [
{
...entry(),
account: { id: "broker", name: "Брокерский" },
payment: money("RUB", "10"),
},
{
...entry(),
account: { id: "iis", name: "ИИС" },
payment: money("RUB", "20"),
},
],
})),
),
);
renderPage(undefined, "all");
expect(await screen.findByText("+30 ₽")).toBeInTheDocument();
const accounts = within(screen.getByRole("list", {
name: /Купон · BOND/,
}));
expect(accounts.getAllByRole("listitem")).toHaveLength(2);
});
it("shows the group total after completeness changes to complete", async () => {
let completeness: "complete" | "incomplete" = "incomplete";
const entries = [
{
...entry(),
account: { id: "broker", name: "Брокерский" },
payment: money("RUB", "10"),
},
{
...entry(),
account: { id: "iis", name: "ИИС" },
id: "coupon-2",
payment: money("RUB", "20"),
},
];
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/income-analytics",
() => HttpResponse.json(
resource({ ...analytics, accountId: "all", entries }, { completeness }),
),
),
);
const view = renderPage(undefined, "all");
await screen.findByText("Купон · BOND");
expect(screen.queryByText("+30 ₽")).not.toBeInTheDocument();
view.unmount();
completeness = "complete";
renderPage(undefined, "all");
expect(await screen.findByText("+30 ₽")).toBeInTheDocument();
});
}); });
@@ -31,6 +31,7 @@ type AccountIncomePageViewModel
onSubmit: (values: IncomeForm) => void; onSubmit: (values: IncomeForm) => void;
partial?: boolean; partial?: boolean;
state: "loaded"; state: "loaded";
totalVisible: boolean;
} }
| { | {
period: IncomePeriod; period: IncomePeriod;
@@ -116,6 +117,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
onSubmit, onSubmit,
partial: analytics.meta?.completeness === "incomplete", partial: analytics.meta?.completeness === "incomplete",
state: "loaded", state: "loaded",
totalVisible: analytics.meta?.completeness === "complete",
}; };
}; };
@@ -53,7 +53,11 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
<Alert color="blue">{t("income.taxBoundary")}</Alert> <Alert color="blue">{t("income.taxBoundary")}</Alert>
<IncomePayments entries={display.entries} /> <IncomePayments
allAccounts={props.analytics.accountId === "all"}
entries={display.entries}
totalVisible={props.totalVisible}
/>
</Stack> </Stack>
); );
}; };
@@ -3,13 +3,22 @@ import type { FC } from "react";
import { Stack, Title } from "@mantine/core"; import { Stack, Title } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { LedgerEntryRow } from "@/entities/broker-operation"; import {
brokerageOperationGroupKey,
LedgerEntryRow,
OperationGroup,
} from "@/entities/broker-operation";
import { groupItems } from "@/shared/lib";
import type { IncomeDisplayModel } from "../../../model/display"; import type { IncomeDisplayModel } from "../../../model/display";
import classes from "../AccountIncomePage.module.css"; import classes from "../AccountIncomePage.module.css";
type IncomePaymentsProps = { entries: IncomeDisplayModel["entries"] }; type IncomePaymentsProps = {
allAccounts?: boolean;
entries: IncomeDisplayModel["entries"];
totalVisible?: boolean;
};
const IncomePayments: FC<IncomePaymentsProps> = (props) => { const IncomePayments: FC<IncomePaymentsProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -19,11 +28,24 @@ const IncomePayments: FC<IncomePaymentsProps> = (props) => {
<Title order={2}>{t("income.payments")}</Title> <Title order={2}>{t("income.payments")}</Title>
<Stack className={classes.entries} component="ul" gap="sm"> <Stack className={classes.entries} component="ul" gap="sm">
{props.entries.map(({ key, operation }) => ( {groupItems(
<li key={key}> props.entries,
<LedgerEntryRow operation={operation} wrap /> entry => props.allAccounts
</li> ? brokerageOperationGroupKey(entry.operation)
))} : null,
).map(entry => entry.type === "group"
? (
<OperationGroup
entries={entry.items}
key={entry.key}
totalVisible={props.totalVisible === true}
/>
)
: (
<li key={entry.item.key}>
<LedgerEntryRow operation={entry.item.operation} wrap />
</li>
))}
</Stack> </Stack>
</Stack> </Stack>
); );
@@ -2,7 +2,7 @@ import type { FC } from "react";
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { http, HttpResponse } from "msw"; import { http, HttpResponse } from "msw";
import { import {
MemoryRouter, MemoryRouter,
@@ -44,6 +44,25 @@ const operation: BrokerageOperation
type: "OPERATION_TYPE_BUY", type: "OPERATION_TYPE_BUY",
}; };
function coupon(
id: string,
accountId: string,
accountName: string,
value: string,
): BrokerageOperation {
return {
...operation,
account: { id: accountId, name: accountName },
category: "income",
description: "Купон",
id,
instrumentUid: "UID",
payment: { currency: "RUB", value },
ticker: "BOND",
type: "OPERATION_TYPE_COUPON",
};
}
const Navigation: FC = () => { const Navigation: FC = () => {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -71,12 +90,12 @@ function configureTestAuth() {
}); });
} }
function renderPage(search = "") { function renderPage(search = "", accountId = "account-1") {
return render( return render(
<QueryClientProvider client={createTestQueryClient()}> <QueryClientProvider client={createTestQueryClient()}>
<MantineProvider> <MantineProvider>
<MemoryRouter <MemoryRouter
initialEntries={[`/accounts/account-1/operations${search}`]} initialEntries={[`/accounts/${accountId}/operations${search}`]}
> >
<Routes> <Routes>
<Route <Route
@@ -650,3 +669,73 @@ it("recovers an invalid cursor at the first page without clearing applied filter
for (const key of ["from", "to", "categories"]) for (const key of ["from", "to", "categories"])
expect(searches[2].get(key)).toBe(searches[0].get(key)); expect(searches[2].get(key)).toBe(searches[0].get(key));
}); });
describe("all-account operation groups", () => {
it("groups only the current page and rebuilds the group at its boundary", async () => {
configureTestAuth();
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/operations",
({ request }) => {
const cursor = new URL(request.url).searchParams.get("cursor");
const items = cursor
? [
coupon("X2", "iis", "ИИС", "150"),
coupon("X3", "broker", "Брокерский", "50"),
operation,
]
: [coupon("X1", "broker", "Брокерский", "100")];
return HttpResponse.json(resource({
accountId: "all",
hasNext: !cursor,
items,
nextCursor: cursor ? null : "page-2",
}));
},
),
);
renderPage("", "all");
await screen.findAllByText("Купон");
expect(screen.queryByRole("list", { name: "Разбивка по счетам" }))
.not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
const accounts = await screen.findByRole("list", {
name: /Купон · BOND, Исполнено/,
});
expect(screen.getByText("Купон · BOND")).toBeInTheDocument();
expect(screen.getByText("+200 ₽")).toBeInTheDocument();
expect(within(accounts).getAllByRole("listitem")).toHaveLength(2);
fireEvent.click(screen.getByRole("button", { name: "Назад" }));
await screen.findAllByText("Купон");
expect(screen.queryByRole("list", { name: "Разбивка по счетам" }))
.not.toBeInTheDocument();
});
it("leaves repeated operations ungrouped for one account", async () => {
configureTestAuth();
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json(resource({
accountId: "account-1",
hasNext: false,
items: [
coupon("X1", "account-1", "Брокерский", "100"),
coupon("X2", "account-1", "Брокерский", "150"),
],
nextCursor: null,
})),
),
);
renderPage();
await screen.findAllByText("Купон");
expect(screen.queryByRole("list", { name: /Разбивка|Исполнено/ }))
.not.toBeInTheDocument();
});
});
@@ -25,6 +25,7 @@ const operationCategorySet = new Set<string>(operationCategories);
type AccountOperationsPageViewModel type AccountOperationsPageViewModel
= | { = | {
allAccounts: boolean;
canApply: boolean; canApply: boolean;
draftCategories: OperationCategory[]; draftCategories: OperationCategory[];
draftPeriod: [null | string, null | string]; draftPeriod: [null | string, null | string];
@@ -43,6 +44,7 @@ type AccountOperationsPageViewModel
partial?: boolean; partial?: boolean;
source?: InvestmentResourceMeta; source?: InvestmentResourceMeta;
state: "loaded"; state: "loaded";
totalVisible: boolean;
} }
| { onReset: () => void; state: "error" } | { onReset: () => void; state: "error" }
| { state: "loading" }; | { state: "loading" };
@@ -147,6 +149,7 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
|| !isSameCategories(draftCategories, appliedCategories); || !isSameCategories(draftCategories, appliedCategories);
return { return {
allAccounts: accountId === "all",
canApply: isDirty && isValidPeriod(draftFrom, draftTo), canApply: isDirty && isValidPeriod(draftFrom, draftTo),
draftCategories, draftCategories,
draftPeriod, draftPeriod,
@@ -181,6 +184,7 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
partial: operations.meta?.completeness === "incomplete", partial: operations.meta?.completeness === "incomplete",
source: operations.meta, source: operations.meta,
state: "loaded", state: "loaded",
totalVisible: operations.meta?.completeness === "complete",
}; };
}; };
@@ -12,7 +12,13 @@ import { DatePickerInput } from "@mantine/dates";
import { StateView } from "@wealthledger/ui"; import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { LedgerEntryRow, operationCategories } from "@/entities/broker-operation"; import {
brokerageOperationGroupKey,
LedgerEntryRow,
operationCategories,
OperationGroup,
} from "@/entities/broker-operation";
import { groupItems } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui"; import { SourceSyncStatus } from "@/shared/ui";
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm"; import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
@@ -24,22 +30,49 @@ type AccountOperationsPageLoadedProps = Extract<
const AccountOperationsPageLoadedOperations: FC< const AccountOperationsPageLoadedOperations: FC<
AccountOperationsPageLoadedProps AccountOperationsPageLoadedProps
> = (props) => { // eslint-disable-line sonarjs/function-return-type -- React renders elements and lists. > = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { items } = props; const { items } = props;
if (items.length > 0) { if (items.length > 0) {
return items.map(operation => ( const entries = groupItems(
<LedgerEntryRow items.map(operation => ({
key={JSON.stringify([ key: JSON.stringify([
operation.account?.id, operation.account?.id,
operation.id, operation.id,
operation.logoName, operation.logoName,
])} ]),
operation={operation} operation,
/> })),
)); entry => props.allAccounts
? brokerageOperationGroupKey(entry.operation)
: null,
);
return (
<Stack
component="ul"
gap="lg"
m={0}
p={0}
style={{ listStyle: "none" }}
>
{entries.map(entry => entry.type === "group"
? (
<OperationGroup
entries={entry.items}
key={entry.key}
totalVisible={props.totalVisible}
/>
)
: (
<li key={entry.item.key}>
<LedgerEntryRow operation={entry.item.operation} />
</li>
))}
</Stack>
);
} }
if (props.partial) { if (props.partial) {
+9 -42
View File
@@ -1,5 +1,13 @@
import { formatShare, type Portfolio } from "@/entities/portfolio"; import { formatShare, type Portfolio } from "@/entities/portfolio";
import { formatDecimal, formatMoney, instrumentLogoUrl } from "@/shared/lib"; import {
type Decimal,
decimalFromString,
decimalToString,
formatDecimal,
formatMoney,
instrumentLogoUrl,
sumDecimals,
} from "@/shared/lib";
type AccountPagePosition = Portfolio["positions"][number] & { type AccountPagePosition = Portfolio["positions"][number] & {
instrumentTo?: string; instrumentTo?: string;
@@ -23,8 +31,6 @@ type AccountPagePositionSection = {
type AccountPageReturnTone = "negative" | "positive"; type AccountPageReturnTone = "negative" | "positive";
type Decimal = { scale: number; value: bigint };
type Money = NonNullable<Portfolio["positions"][number]["absoluteReturn"]>; type Money = NonNullable<Portfolio["positions"][number]["absoluteReturn"]>;
type PositionSectionKind = "bonds" type PositionSectionKind = "bonds"
@@ -32,38 +38,11 @@ type PositionSectionKind = "bonds"
| "stocks"; | "stocks";
const RADIX = 10n; const RADIX = 10n;
const DECIMAL_PARTS = 2;
const PERCENT_SCALE = 6; const PERCENT_SCALE = 6;
const DISPLAY_PERCENT_SCALE = 2; const DISPLAY_PERCENT_SCALE = 2;
const PERCENT_FACTOR = 100n; const PERCENT_FACTOR = 100n;
const ROUNDING_FACTOR = 2n; const ROUNDING_FACTOR = 2n;
function decimalFromString(value: string): Decimal {
const [whole = "0", fraction = ""] = value.split(".", DECIMAL_PARTS);
return { scale: fraction.length, value: BigInt(`${whole}${fraction}`) };
}
function decimalToString(decimal: Decimal): string {
const sign = decimal.value < 0n ? "-" : "";
const digits = (decimal.value < 0n ? -decimal.value : decimal.value)
.toString()
.padStart(decimal.scale + 1, "0");
const whole = decimal.scale
? digits.slice(0, -decimal.scale)
: digits;
let fraction = decimal.scale ? digits.slice(-decimal.scale) : "";
while (fraction.endsWith("0")) fraction = fraction.slice(0, -1);
const suffix = fraction ? `.${fraction}` : "";
return `${sign}${whole}${suffix}`;
}
function formatPositionReturnPercent(value: string): string { function formatPositionReturnPercent(value: string): string {
const decimal = decimalFromString(value); const decimal = decimalFromString(value);
let rounded = decimal.value; let rounded = decimal.value;
@@ -167,18 +146,6 @@ function returnPercent(
return decimalToString({ scale: PERCENT_SCALE, value: rounded }); return decimalToString({ scale: PERCENT_SCALE, value: rounded });
} }
function sumDecimals(values: Decimal[]): Decimal {
const scale = Math.max(...values.map(value => value.scale));
const sum = values.reduce(
(total, value) => total
+ value.value * RADIX ** BigInt(scale - value.scale),
0n,
);
return { scale, value: sum };
}
function toAccountPagePositionSections( function toAccountPagePositionSections(
portfolio: Portfolio, portfolio: Portfolio,
): AccountPagePositionSection[] { ): AccountPagePositionSection[] {
+10 -1
View File
@@ -33,6 +33,7 @@ type AccountPageRecentOperations
stale?: boolean; stale?: boolean;
state: "loaded" | "partial"; state: "loaded" | "partial";
to: string; to: string;
totalVisible?: boolean;
} }
| { | {
state: "empty" state: "empty"
@@ -84,6 +85,7 @@ function toRecentEvents(
items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [], items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [],
state: "partial", state: "partial",
to, to,
totalVisible: false,
}; };
if (!events.data && (events.isError || events.meta?.lastAttempt.state === "failed")) if (!events.data && (events.isError || events.meta?.lastAttempt.state === "failed"))
return { state: "error", to }; return { state: "error", to };
@@ -94,6 +96,7 @@ function toRecentEvents(
items, items,
state: "loaded", state: "loaded",
to, to,
totalVisible: events.meta?.completeness === "complete",
}; };
} }
@@ -109,7 +112,12 @@ function toRecentOperations(
) )
return { state: "loading", to }; return { state: "loading", to };
if (operations.meta?.completeness === "incomplete") if (operations.meta?.completeness === "incomplete")
return { items: operations.data?.items ?? [], state: "partial", to }; return {
items: operations.data?.items ?? [],
state: "partial",
to,
totalVisible: false,
};
if ( if (
!operations.data !operations.data
&& (operations.isError || operations.meta?.lastAttempt.state === "failed") && (operations.isError || operations.meta?.lastAttempt.state === "failed")
@@ -125,6 +133,7 @@ function toRecentOperations(
stale: operations.meta?.freshness === "stale", stale: operations.meta?.freshness === "stale",
state: "loaded", state: "loaded",
to, to,
totalVisible: operations.meta?.completeness === "complete",
}; };
} }
@@ -11,6 +11,7 @@ import { http, HttpResponse } from "msw";
import { MemoryRouter, Route, Routes } from "react-router"; import { MemoryRouter, Route, Routes } from "react-router";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { BrokerageOperation } from "@/entities/broker-operation";
import type { BrokerageEvent } from "@/entities/brokerage-event"; import type { BrokerageEvent } from "@/entities/brokerage-event";
import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio"; import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio";
@@ -1084,4 +1085,72 @@ describe("AccountPage states", () => {
expect(screen.queryByRole("region", { name: "Нереализованный результат" })) expect(screen.queryByRole("region", { name: "Нереализованный результат" }))
.not.toBeInTheDocument(); .not.toBeInTheDocument();
}); });
it("groups overview items only after the existing five-item limits", async () => {
const operations = Array.from({ length: 6 }, (_, index) => ({
account: { id: `account-${index}`, name: `Счёт ${index + 1}` },
accruedInterest: null,
category: "income",
commission: null,
description: "Купон",
figi: "FIGI",
id: `operation-${index}`,
instrumentType: "bond",
instrumentUid: "UID",
logoName: null,
occurredAt: "2026-08-14T10:00:00Z",
payment: { currency: "RUB", value: "100" },
price: null,
quantity: null,
ticker: "BOND",
type: "OPERATION_TYPE_COUPON",
})) satisfies BrokerageOperation[];
const events = Array.from({ length: 6 }, (_, index) => ({
account: { id: `account-${index}`, name: `Счёт ${index + 1}` },
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",
})) satisfies BrokerageEvent[];
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json(resource({
accountId: "all",
hasNext: true,
items: operations.slice(0, 5),
nextCursor: "next",
}))),
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
HttpResponse.json(resource({
accountId: "all",
calculatedAt: "2026-08-14T00:00:00Z",
events,
}))),
);
renderAccountRoute(createTestQueryClient(), "all");
await screen.findByRole("heading", { level: 1, name: "Все счета" });
const operationGroup = screen.getByRole("list", {
name: /Купон · BOND, Исполнено/,
});
expect(screen.getByText("+500 ₽")).toBeInTheDocument();
expect(within(operationGroup).getAllByRole("listitem")).toHaveLength(5);
expect(within(operationGroup).queryByRole("link")).not.toBeInTheDocument();
const eventGroup = screen.getByRole("list", {
name: /Купон · ОФЗ 26238, 20\.08\.2026, 500 ₽/,
});
expect(screen.getAllByText("500 ₽").length).toBeGreaterThan(0);
expect(within(eventGroup).getAllByRole("listitem")).toHaveLength(5);
});
}); });
@@ -46,9 +46,15 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
structure={{ currencies: [], unvaluedCategories: [] }} structure={{ currencies: [], unvaluedCategories: [] }}
/> />
<RecentOperations operations={props.recentOperations} /> <RecentOperations
allAccounts={props.accountId === "all"}
operations={props.recentOperations}
/>
<BrokerageEventsPreview events={props.recentEvents} /> <BrokerageEventsPreview
allAccounts={props.accountId === "all"}
events={props.recentEvents}
/>
{!!props.incomeSummary && <IncomeSummary {...props.incomeSummary} />} {!!props.incomeSummary && <IncomeSummary {...props.incomeSummary} />}
</Stack> </Stack>
@@ -149,6 +149,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<Tabs.Panel value="operations"> <Tabs.Panel value="operations">
<RecentOperations <RecentOperations
allAccounts={props.accountId === "all"}
moreLabel={t("portfolio.more")} moreLabel={t("portfolio.more")}
operations={props.recentOperations} operations={props.recentOperations}
/> />
@@ -156,6 +157,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<Tabs.Panel value="events"> <Tabs.Panel value="events">
<BrokerageEventsPreview <BrokerageEventsPreview
allAccounts={props.accountId === "all"}
events={props.recentEvents} events={props.recentEvents}
moreLabel={t("portfolio.more")} moreLabel={t("portfolio.more")}
/> />
@@ -5,11 +5,17 @@ import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { LedgerEntryRow } from "@/entities/broker-operation"; import {
brokerageOperationGroupKey,
LedgerEntryRow,
OperationGroup,
} from "@/entities/broker-operation";
import { groupItems } from "@/shared/lib";
import type { AccountPageRecentOperations } from "../../../model/previews"; import type { AccountPageRecentOperations } from "../../../model/previews";
type RecentOperationsProps = { type RecentOperationsProps = {
allAccounts?: boolean;
moreLabel?: string; moreLabel?: string;
operations: AccountPageRecentOperations; operations: AccountPageRecentOperations;
}; };
@@ -32,17 +38,41 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
{operations.state === "partial" && <Text role="status">{t("investment.partial")}</Text>} {operations.state === "partial" && <Text role="status">{t("investment.partial")}</Text>}
{(operations.state === "loaded" || operations.state === "partial") {(operations.state === "loaded" || operations.state === "partial") && (
&& operations.items.map(operation => ( <Stack
<LedgerEntryRow component="ul"
key={JSON.stringify([ gap="sm"
operation.account?.id, m={0}
operation.id, p={0}
operation.logoName, style={{ listStyle: "none" }}
])} >
operation={operation} {groupItems(
/> operations.items.map(operation => ({
))} key: JSON.stringify([
operation.account?.id,
operation.id,
operation.logoName,
]),
operation,
})),
entry => props.allAccounts
? brokerageOperationGroupKey(entry.operation)
: null,
).map(entry => entry.type === "group"
? (
<OperationGroup
entries={entry.items}
key={entry.key}
totalVisible={operations.totalVisible === true}
/>
)
: (
<li key={entry.item.key}>
<LedgerEntryRow operation={entry.item.operation} />
</li>
))}
</Stack>
)}
<Button component={Link} to={operations.to} variant="subtle"> <Button component={Link} to={operations.to} variant="subtle">
{props.moreLabel ?? t("ledger.open")} {props.moreLabel ?? t("ledger.open")}
+4
View File
@@ -350,6 +350,9 @@ const ru = {
operations: "Операции", operations: "Операции",
settings: "Настройки", settings: "Настройки",
}, },
entryGroup: {
totalUnavailable: "Итог недоступен",
},
events: { events: {
dateKind: { event: "Дата события", registry: "Дата реестра" }, dateKind: { event: "Дата события", registry: "Дата реестра" },
emptyText: emptyText:
@@ -456,6 +459,7 @@ const ru = {
open: "Открыть журнал", open: "Открыть журнал",
operationType: { operationType: {
buy: "Покупка", buy: "Покупка",
coupon: "Купон",
dividend: "Зачисление дивидендов", dividend: "Зачисление дивидендов",
fee: "Комиссия", fee: "Комиссия",
input: "Зачисление ценных бумаг", input: "Зачисление ценных бумаг",
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { sumMoney } from "./decimal";
describe("sumMoney", () => {
it("adds exact signed decimals independently per currency", () => {
expect(sumMoney([
{ currency: "RUB", value: "0.1" },
{ currency: "USD", value: "0.01" },
{ currency: "RUB", value: "0.2" },
{ currency: "RUB", value: "-1500000.45" },
{ currency: "USD", value: "-0.02" },
{ currency: "RUB", value: "200" },
])).toEqual([
{ currency: "RUB", value: "-1499800.15" },
{ currency: "USD", value: "-0.01" },
]);
});
it("preserves significant fractional digits and a compensated zero", () => {
expect(sumMoney([
{ currency: "RUB", value: "100.5" },
{ currency: "RUB", value: "49.50" },
{ currency: "RUB", value: "0.005" },
])).toEqual([{ currency: "RUB", value: "150.005" }]);
expect(sumMoney([
{ currency: "RUB", value: "100" },
{ currency: "RUB", value: "-100" },
])).toEqual([{ currency: "RUB", value: "0" }]);
});
it.each(["", ".", "1.2.3", "not-a-number"])(
"rejects malformed amount %s instead of treating it as zero",
(value) => {
expect(sumMoney([
{ currency: "RUB", value: "100" },
{ currency: "RUB", value },
])).toBeNull();
},
);
});
+75
View File
@@ -0,0 +1,75 @@
type Decimal = { scale: number; value: bigint };
type Money = { currency: string; value: string };
const DECIMAL_PARTS = 2;
const RADIX = 10n;
// Decimal grammar is bounded to digits and one optional dot.
// eslint-disable-next-line security/detect-unsafe-regex
const decimalPattern = /^-?\d+(?:\.\d+)?$/;
function decimalFromString(value: string): Decimal {
if (!decimalPattern.test(value)) throw new SyntaxError("Invalid decimal");
const [whole = "0", fraction = ""] = value.split(".", DECIMAL_PARTS);
return { scale: fraction.length, value: BigInt(`${whole}${fraction}`) };
}
function decimalToString(decimal: Decimal): string {
const sign = decimal.value < 0n ? "-" : "";
const digits = (decimal.value < 0n ? -decimal.value : decimal.value)
.toString()
.padStart(decimal.scale + 1, "0");
const whole = decimal.scale
? digits.slice(0, -decimal.scale)
: digits;
let fraction = decimal.scale ? digits.slice(-decimal.scale) : "";
while (fraction.endsWith("0")) fraction = fraction.slice(0, -1);
const suffix = fraction ? `.${fraction}` : "";
return `${sign}${whole}${suffix}`;
}
function sumDecimals(decimals: readonly Decimal[]): Decimal {
const scale = Math.max(...decimals.map(decimal => decimal.scale));
const value = decimals.reduce(
(sum, decimal) =>
sum + decimal.value * RADIX ** BigInt(scale - decimal.scale),
0n,
);
return { scale, value };
}
function sumMoney(amounts: readonly Money[]): Money[] | null {
const decimals = new Map<string, Decimal[]>();
for (const amount of amounts) {
try {
const values = decimals.get(amount.currency) ?? [];
values.push(decimalFromString(amount.value));
decimals.set(amount.currency, values);
}
catch {
return null;
}
}
return Array.from(decimals, ([currency, values]) => ({
currency,
value: decimalToString(sumDecimals(values)),
}));
}
export {
type Decimal,
decimalFromString,
decimalToString,
sumDecimals,
sumMoney,
};
+5
View File
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import { import {
formatDate, formatDate,
formatMoney, formatMoney,
formatSnapshotDay,
formatSnapshotTime, formatSnapshotTime,
instrumentLogoUrl, instrumentLogoUrl,
} from "./display"; } from "./display";
@@ -22,6 +23,10 @@ describe("shared display helpers", () => {
}).format(new Date(instant)); }).format(new Date(instant));
expect(formatSnapshotTime(instant)).toBe(expected); expect(formatSnapshotTime(instant)).toBe(expected);
expect(formatSnapshotDay(instant)).toBe(
new Intl.DateTimeFormat("ru-RU", { dateStyle: "short" })
.format(new Date(instant)),
);
}); });
it("formats decimal strings without financial arithmetic", () => { it("formats decimal strings without financial arithmetic", () => {
expect(formatMoney({ currency: "RUB", value: "-1234567.89" })).toBe( expect(formatMoney({ currency: "RUB", value: "-1234567.89" })).toBe(
+9
View File
@@ -135,6 +135,14 @@ const snapshotFormatter = new Intl.DateTimeFormat("ru-RU", {
timeStyle: "short", timeStyle: "short",
}); });
const snapshotDayFormatter = new Intl.DateTimeFormat("ru-RU", {
dateStyle: "short",
});
function formatSnapshotDay(value: string): string {
return snapshotDayFormatter.format(new Date(value));
}
function formatSnapshotTime(value: string): string { function formatSnapshotTime(value: string): string {
return snapshotFormatter.format(new Date(value)); return snapshotFormatter.format(new Date(value));
} }
@@ -160,6 +168,7 @@ export {
formatDate, formatDate,
formatDecimal, formatDecimal,
formatMoney, formatMoney,
formatSnapshotDay,
formatSnapshotTime, formatSnapshotTime,
instrumentLogoUrl, instrumentLogoUrl,
portfolioCategoryPresentation, portfolioCategoryPresentation,
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { groupItems } from "./group-items";
describe("groupItems", () => {
it("keeps the first key position and source order through interleaving", () => {
const a1 = { id: "A1", key: "A" };
const b = { id: "B", key: null };
const a2 = { id: "A2", key: "A" };
const c = { id: "C", key: "C" };
const a3 = { id: "A3", key: "A" };
const input = [a1, b, a2, c, a3];
const result = groupItems(input, item => item.key);
expect(result).toEqual([
{ items: [a1, a2, a3], key: "A", type: "group" },
{ item: b, type: "single" },
{ item: c, type: "single" },
]);
expect(input.map(item => item.id)).toEqual(["A1", "B", "A2", "C", "A3"]);
});
it("keeps two interleaved groups independent and singleton rows single", () => {
const result = groupItems(
[
{ id: "X1", key: "X" },
{ id: "Y1", key: "Y" },
{ id: "X2", key: "X" },
{ id: "Y2", key: "Y" },
{ id: "X3", key: "X" },
{ id: "Z1", key: "Z" },
],
item => item.key,
);
expect(result.map(entry => entry.type === "group"
? [entry.key, entry.items.map(item => item.id)]
: [null, [entry.item.id]])).toEqual([
["X", ["X1", "X2", "X3"]],
["Y", ["Y1", "Y2"]],
[null, ["Z1"]],
]);
});
});
+47
View File
@@ -0,0 +1,47 @@
type GroupedItem<T>
= | { item: T; type: "single" }
| { items: [T, T, ...T[]]; key: string; type: "group" };
function groupItems<T>(
items: readonly T[],
keyOf: (item: T) => null | string,
): GroupedItem<T>[] {
const result: GroupedItem<T>[] = [];
const indices = new Map<string, number>();
for (const item of items) {
const key = keyOf(item);
if (key === null) {
result.push({ item, type: "single" });
continue;
}
const index = indices.get(key);
if (index === undefined) {
indices.set(key, result.length);
result.push({ item, type: "single" });
continue;
}
// eslint-disable-next-line security/detect-object-injection -- Map-owned index.
const existing = result[index];
if (existing?.type === "single") {
// eslint-disable-next-line security/detect-object-injection -- Map-owned index.
result[index] = {
items: [existing.item, item],
key,
type: "group",
};
}
else if (existing) {
existing.items.push(item);
}
}
return result;
}
export { groupItems };
+10
View File
@@ -1,13 +1,23 @@
export { compareDateDesc, defaultPeriod, isValidPeriod, moscowDate } from "./calendar"; export { compareDateDesc, defaultPeriod, isValidPeriod, moscowDate } from "./calendar";
export { calendarDate } from "./calendar.schema"; export { calendarDate } from "./calendar.schema";
export {
type Decimal,
decimalFromString,
decimalToString,
sumDecimals,
sumMoney,
} from "./decimal";
export { export {
formatChartValue, formatChartValue,
formatDate, formatDate,
formatDecimal, formatDecimal,
formatMoney, formatMoney,
formatSnapshotDay,
formatSnapshotTime, formatSnapshotTime,
instrumentLogoUrl, instrumentLogoUrl,
portfolioCategoryPresentation, portfolioCategoryPresentation,
} from "./display"; } from "./display";
export { groupItems } from "./group-items";
export { useInitialAvailability } from "./initial-availability"; export { useInitialAvailability } from "./initial-availability";
export { totalText } from "./total-text";
export { useUrlPeriod } from "./useUrlPeriod"; export { useUrlPeriod } from "./useUrlPeriod";
+13
View File
@@ -0,0 +1,13 @@
import { formatMoney } from "./display";
type Money = { currency: string; value: string };
function totalText(totals: Money[] | null, isSigned: boolean): string {
return totals
? totals.map(total =>
formatMoney(total, { withSign: isSigned || undefined }),
).join(", ")
: "";
}
export { totalText };
@@ -0,0 +1,41 @@
.group {
overflow: hidden;
border-color: var(--wl-color-border);
background: var(--wl-color-subtle);
}
.accounts {
padding: 0 var(--mantine-spacing-md) var(--mantine-spacing-sm);
list-style: none;
}
.accountRow {
display: flex;
gap: var(--mantine-spacing-sm);
align-items: center;
justify-content: space-between;
padding: var(--mantine-spacing-sm) 0;
border-top: 1px solid var(--wl-color-border);
}
.accountValue {
flex-shrink: 0;
text-align: right;
white-space: nowrap;
}
.accountCardRow {
display: flex;
gap: var(--mantine-spacing-sm);
align-items: center;
justify-content: space-between;
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border-color: var(--wl-color-border);
background: var(--wl-color-surface);
}
@media (max-width: 36em) {
.accountRow {
align-items: flex-start;
}
}
+96
View File
@@ -0,0 +1,96 @@
import type { FC, PropsWithChildren, ReactNode } from "react";
import { Paper, Stack, Text, VisuallyHidden } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { formatMoney } from "@/shared/lib";
import classes from "./EntryGroup.module.css";
type AccountRowProps = {
accountName: string;
value: string;
valueLabel: string;
};
type EntryGroupProps = PropsWithChildren<{
header: ReactNode;
label: string;
}>;
type EntryGroupTotalProps = {
isSigned?: boolean;
totals: null | { currency: string; value: string }[];
};
const AccountRow: FC<AccountRowProps> = (props) => {
const accessibleName = `${props.accountName}, ${props.value}`;
return (
<li aria-label={accessibleName} className={classes.accountRow}>
<Text>{props.accountName}</Text>
<Stack className={classes.accountValue} gap={2}>
<Text fw={700}>{props.value}</Text>
<Text c="dimmed" size="xs">{props.valueLabel}</Text>
</Stack>
</li>
);
};
const AccountCardRow: FC<AccountRowProps> = props => (
<li aria-label={`${props.accountName}, ${props.value}`}>
<Paper className={classes.accountCardRow} withBorder>
<Text fw={700}>{props.accountName}</Text>
<Stack className={classes.accountValue} gap={2}>
<Text fw={700}>{props.value}</Text>
<Text c="dimmed" size="xs">{props.valueLabel}</Text>
</Stack>
</Paper>
</li>
);
const EntryGroup: FC<EntryGroupProps> = props => (
<Paper className={classes.group} component="li" withBorder>
{props.header}
<Stack
aria-label={props.label}
className={classes.accounts}
component="ul"
gap={0}
>
{props.children}
</Stack>
</Paper>
);
const EntryGroupTotal: FC<EntryGroupTotalProps> = (props) => {
const { t } = useTranslation();
if (!props.totals) {
return <VisuallyHidden>{t("entryGroup.totalUnavailable")}</VisuallyHidden>;
}
const isSigned = props.isSigned === true;
return (
<Stack gap={2}>
{props.totals.map(total => (
<span key={`${total.currency}-${total.value}`}>
{formatMoney(total, { withSign: isSigned || undefined })}
</span>
))}
</Stack>
);
};
export {
AccountCardRow,
AccountRow,
EntryGroup,
EntryGroupTotal,
};
+6
View File
@@ -1,2 +1,8 @@
export {
AccountCardRow,
AccountRow,
EntryGroup,
EntryGroupTotal,
} from "./EntryGroup/EntryGroup";
export { PayoutCard } from "./PayoutCard/PayoutCard"; export { PayoutCard } from "./PayoutCard/PayoutCard";
export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus"; export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus";