feat: group all-account rows by instrument and day (WealthLedger/main#47) #126
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,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",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
-7
@@ -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",
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
+92
-3
@@ -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",
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+42
-9
@@ -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) {
|
||||||
|
|||||||
@@ -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[] {
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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: "Зачисление ценных бумаг",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"]],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user