feat: group all-account rows by instrument and day (WealthLedger/main#47) (#126)
This commit was merged in pull request #126.
This commit is contained in:
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+28
-7
@@ -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",
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+5
-1
@@ -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>
|
||||
);
|
||||
|
||||
+92
-3
@@ -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",
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+42
-9
@@ -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) {
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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")}
|
||||
|
||||
Reference in New Issue
Block a user