feat: group all-account rows by instrument and day (WealthLedger/main#47)
Frontend verify / commits (pull_request) Successful in 30s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m5s

This commit is contained in:
2026-10-30 07:12:36 +03:00
parent 6792f5b70a
commit e69f9aed19
40 changed files with 1564 additions and 102 deletions
+7 -2
View File
@@ -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<EntryCardProps> = (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 (
<Paper
@@ -63,7 +64,11 @@ const EntryCard: FC<EntryCardProps> = (props) => {
</Stack>
<Stack className={classes.value} gap={2}>
<Text className={classes[props.tone]} fw={700}>
<Text
className={classes[props.tone]}
component={valueComponent}
fw={700}
>
{props.value}
</Text>
+2
View File
@@ -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";
@@ -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();
});
});
+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";
@@ -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,
};
@@ -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,
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,
@@ -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";
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 };
@@ -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 { 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<BrokerageEventsPreviewProps> = (props) => {
{events.state === "partial" && <Text role="status">{t("investment.partial")}</Text>}
{(events.state === "loaded" || events.state === "partial")
&& events.items.map(event => (
<BrokerageEventCard
event={event}
key={brokerageEventKey(event)}
/>
))}
{(events.state === "loaded" || events.state === "partial") && (
<Stack
component="ul"
gap="sm"
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">
{props.moreLabel ?? t("events.open")}
@@ -237,4 +237,79 @@ describe("AccountEventsPage", () => {
screen.queryByText("За выбранный период событий не найдено"),
).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
= | {
allAccounts: boolean;
canApply: boolean;
draftPeriod: [null | string, null | string];
events: BrokerageEvent[];
@@ -29,6 +30,7 @@ type AccountEventsPageViewModel
partial?: boolean;
source?: InvestmentResourceMeta;
state: "loaded";
totalVisible: boolean;
}
| { onReset: () => void; state: "error" }
| { state: "loading" };
@@ -100,6 +102,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
: { state: "loading" };
return {
allAccounts: accountId === "all",
canApply:
(draftFrom !== appliedFrom || draftTo !== appliedTo)
&& isValidPeriod(draftFrom, draftTo),
@@ -121,6 +124,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
partial: events.meta?.completeness === "incomplete",
source: events.meta,
state: "loaded",
totalVisible: events.meta?.completeness === "complete",
};
}
@@ -13,8 +13,11 @@ import { useTranslation } from "react-i18next";
import {
BrokerageEventCard,
BrokerageEventGroup,
brokerageEventGroupKey,
brokerageEventKey,
} from "@/entities/brokerage-event";
import { groupItems } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui";
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
@@ -30,14 +33,32 @@ const AccountEventsPageLoadedEvents: FC<AccountEventsPageLoadedProps> = (
props,
) => {
if (props.events.length > 0) {
const entries = groupItems(
props.events,
props.allAccounts ? brokerageEventGroupKey : () => null,
);
return (
<Stack gap="sm">
{props.events.map(event => (
<BrokerageEventCard
event={event}
key={brokerageEventKey(event)}
/>
))}
<Stack
component="ul"
gap="sm"
m={0}
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>
);
}
@@ -100,7 +100,10 @@ function money(currency: string, value: string) {
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({
accessToken: () => "token",
clear: vi.fn(),
@@ -113,7 +116,7 @@ function renderPage(search = "from=2026-08-01&to=2026-08-27") {
<DatesProvider settings={{ locale: "ru" }}>
<QueryClientProvider client={queryClient}>
<MemoryRouter
initialEntries={[`/accounts/account-1/income?${search}`]}
initialEntries={[`/accounts/${accountId}/income?${search}`]}
>
<LocationProbe />
@@ -680,4 +683,110 @@ describe("income period and snapshot boundary", () => {
expect(screen.queryByText(/Итого:|Выплат:/)).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;
partial?: boolean;
state: "loaded";
totalVisible: boolean;
}
| {
period: IncomePeriod;
@@ -116,6 +117,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
onSubmit,
partial: analytics.meta?.completeness === "incomplete",
state: "loaded",
totalVisible: analytics.meta?.completeness === "complete",
};
};
@@ -53,7 +53,11 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
<Alert color="blue">{t("income.taxBoundary")}</Alert>
<IncomePayments entries={display.entries} />
<IncomePayments
allAccounts={props.analytics.accountId === "all"}
entries={display.entries}
totalVisible={props.totalVisible}
/>
</Stack>
);
};
@@ -3,13 +3,22 @@ import type { FC } from "react";
import { Stack, Title } from "@mantine/core";
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 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 { t } = useTranslation();
@@ -19,11 +28,24 @@ const IncomePayments: FC<IncomePaymentsProps> = (props) => {
<Title order={2}>{t("income.payments")}</Title>
<Stack className={classes.entries} component="ul" gap="sm">
{props.entries.map(({ key, operation }) => (
<li key={key}>
<LedgerEntryRow operation={operation} wrap />
</li>
))}
{groupItems(
props.entries,
entry => props.allAccounts
? 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>
);
@@ -2,7 +2,7 @@ import type { FC } from "react";
import { MantineProvider } from "@mantine/core";
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 {
MemoryRouter,
@@ -44,6 +44,25 @@ const operation: BrokerageOperation
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 location = useLocation();
const navigate = useNavigate();
@@ -71,12 +90,12 @@ function configureTestAuth() {
});
}
function renderPage(search = "") {
function renderPage(search = "", accountId = "account-1") {
return render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider>
<MemoryRouter
initialEntries={[`/accounts/account-1/operations${search}`]}
initialEntries={[`/accounts/${accountId}/operations${search}`]}
>
<Routes>
<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"])
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
= | {
allAccounts: boolean;
canApply: boolean;
draftCategories: OperationCategory[];
draftPeriod: [null | string, null | string];
@@ -43,6 +44,7 @@ type AccountOperationsPageViewModel
partial?: boolean;
source?: InvestmentResourceMeta;
state: "loaded";
totalVisible: boolean;
}
| { onReset: () => void; state: "error" }
| { state: "loading" };
@@ -147,6 +149,7 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
|| !isSameCategories(draftCategories, appliedCategories);
return {
allAccounts: accountId === "all",
canApply: isDirty && isValidPeriod(draftFrom, draftTo),
draftCategories,
draftPeriod,
@@ -181,6 +184,7 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
partial: operations.meta?.completeness === "incomplete",
source: operations.meta,
state: "loaded",
totalVisible: operations.meta?.completeness === "complete",
};
};
@@ -12,7 +12,13 @@ import { DatePickerInput } from "@mantine/dates";
import { StateView } from "@wealthledger/ui";
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 type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
@@ -24,22 +30,49 @@ type AccountOperationsPageLoadedProps = Extract<
const AccountOperationsPageLoadedOperations: FC<
AccountOperationsPageLoadedProps
> = (props) => { // eslint-disable-line sonarjs/function-return-type -- React renders elements and lists.
> = (props) => {
const { t } = useTranslation();
const { items } = props;
if (items.length > 0) {
return items.map(operation => (
<LedgerEntryRow
key={JSON.stringify([
const entries = groupItems(
items.map(operation => ({
key: JSON.stringify([
operation.account?.id,
operation.id,
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) {
+9 -42
View File
@@ -1,5 +1,13 @@
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] & {
instrumentTo?: string;
@@ -23,8 +31,6 @@ type AccountPagePositionSection = {
type AccountPageReturnTone = "negative" | "positive";
type Decimal = { scale: number; value: bigint };
type Money = NonNullable<Portfolio["positions"][number]["absoluteReturn"]>;
type PositionSectionKind = "bonds"
@@ -32,38 +38,11 @@ type PositionSectionKind = "bonds"
| "stocks";
const RADIX = 10n;
const DECIMAL_PARTS = 2;
const PERCENT_SCALE = 6;
const DISPLAY_PERCENT_SCALE = 2;
const PERCENT_FACTOR = 100n;
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 {
const decimal = decimalFromString(value);
let rounded = decimal.value;
@@ -167,18 +146,6 @@ function returnPercent(
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(
portfolio: Portfolio,
): AccountPagePositionSection[] {
+10 -1
View File
@@ -33,6 +33,7 @@ type AccountPageRecentOperations
stale?: boolean;
state: "loaded" | "partial";
to: string;
totalVisible?: boolean;
}
| {
state: "empty"
@@ -84,6 +85,7 @@ function toRecentEvents(
items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [],
state: "partial",
to,
totalVisible: false,
};
if (!events.data && (events.isError || events.meta?.lastAttempt.state === "failed"))
return { state: "error", to };
@@ -94,6 +96,7 @@ function toRecentEvents(
items,
state: "loaded",
to,
totalVisible: events.meta?.completeness === "complete",
};
}
@@ -109,7 +112,12 @@ function toRecentOperations(
)
return { state: "loading", to };
if (operations.meta?.completeness === "incomplete")
return { items: operations.data?.items ?? [], state: "partial", to };
return {
items: operations.data?.items ?? [],
state: "partial",
to,
totalVisible: false,
};
if (
!operations.data
&& (operations.isError || operations.meta?.lastAttempt.state === "failed")
@@ -125,6 +133,7 @@ function toRecentOperations(
stale: operations.meta?.freshness === "stale",
state: "loaded",
to,
totalVisible: operations.meta?.completeness === "complete",
};
}
@@ -11,6 +11,7 @@ import { http, HttpResponse } from "msw";
import { MemoryRouter, Route, Routes } from "react-router";
import { describe, expect, it, vi } from "vitest";
import type { BrokerageOperation } from "@/entities/broker-operation";
import type { BrokerageEvent } from "@/entities/brokerage-event";
import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio";
@@ -1084,4 +1085,72 @@ describe("AccountPage states", () => {
expect(screen.queryByRole("region", { name: "Нереализованный результат" }))
.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: [] }}
/>
<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} />}
</Stack>
@@ -149,6 +149,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<Tabs.Panel value="operations">
<RecentOperations
allAccounts={props.accountId === "all"}
moreLabel={t("portfolio.more")}
operations={props.recentOperations}
/>
@@ -156,6 +157,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<Tabs.Panel value="events">
<BrokerageEventsPreview
allAccounts={props.accountId === "all"}
events={props.recentEvents}
moreLabel={t("portfolio.more")}
/>
@@ -5,11 +5,17 @@ import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
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";
type RecentOperationsProps = {
allAccounts?: boolean;
moreLabel?: string;
operations: AccountPageRecentOperations;
};
@@ -32,17 +38,41 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
{operations.state === "partial" && <Text role="status">{t("investment.partial")}</Text>}
{(operations.state === "loaded" || operations.state === "partial")
&& operations.items.map(operation => (
<LedgerEntryRow
key={JSON.stringify([
operation.account?.id,
operation.id,
operation.logoName,
])}
operation={operation}
/>
))}
{(operations.state === "loaded" || operations.state === "partial") && (
<Stack
component="ul"
gap="sm"
m={0}
p={0}
style={{ listStyle: "none" }}
>
{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">
{props.moreLabel ?? t("ledger.open")}
+4
View File
@@ -350,6 +350,9 @@ const ru = {
operations: "Операции",
settings: "Настройки",
},
entryGroup: {
totalUnavailable: "Итог недоступен",
},
events: {
dateKind: { event: "Дата события", registry: "Дата реестра" },
emptyText:
@@ -456,6 +459,7 @@ const ru = {
open: "Открыть журнал",
operationType: {
buy: "Покупка",
coupon: "Купон",
dividend: "Зачисление дивидендов",
fee: "Комиссия",
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 {
formatDate,
formatMoney,
formatSnapshotDay,
formatSnapshotTime,
instrumentLogoUrl,
} from "./display";
@@ -22,6 +23,10 @@ describe("shared display helpers", () => {
}).format(new Date(instant));
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", () => {
expect(formatMoney({ currency: "RUB", value: "-1234567.89" })).toBe(
+9
View File
@@ -135,6 +135,14 @@ const snapshotFormatter = new Intl.DateTimeFormat("ru-RU", {
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 {
return snapshotFormatter.format(new Date(value));
}
@@ -160,6 +168,7 @@ export {
formatDate,
formatDecimal,
formatMoney,
formatSnapshotDay,
formatSnapshotTime,
instrumentLogoUrl,
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 { calendarDate } from "./calendar.schema";
export {
type Decimal,
decimalFromString,
decimalToString,
sumDecimals,
sumMoney,
} from "./decimal";
export {
formatChartValue,
formatDate,
formatDecimal,
formatMoney,
formatSnapshotDay,
formatSnapshotTime,
instrumentLogoUrl,
portfolioCategoryPresentation,
} from "./display";
export { groupItems } from "./group-items";
export { useInitialAvailability } from "./initial-availability";
export { totalText } from "./total-text";
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 { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus";