fix(account): align concentration labels and chart accessibility (WealthLedger/main#48)
This commit is contained in:
@@ -23,20 +23,27 @@ function stock(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const labels = {
|
||||||
|
missingIssuer: "Эмитент не указан",
|
||||||
|
missingSector: "Сектор не указан",
|
||||||
|
other: "Прочее",
|
||||||
|
sector: (code: string) => code,
|
||||||
|
};
|
||||||
|
|
||||||
describe("account concentration", () => {
|
describe("account concentration", () => {
|
||||||
it("uses exact positive values and excludes cash, negatives and nulls", () => {
|
it("uses exact positive values and excludes cash, negatives and nulls", () => {
|
||||||
const model = toConcentration(snapshot([
|
const model = toConcentration(snapshot([
|
||||||
stock("A", "1"), stock("B", "15"),
|
stock("A", "1"), stock("B", "15"),
|
||||||
stock("C", "0"), stock("D", "-50"), stock("E", null),
|
stock("C", "0"), stock("D", "-50"), stock("E", null),
|
||||||
stock("F", null, "USD"), stock("G", "-3", "USD"),
|
stock("F", null, "USD"), stock("G", "-3", "USD"),
|
||||||
]), "stocks", "RUB");
|
]), "stocks", "RUB", labels);
|
||||||
|
|
||||||
expect(model.currencies).toEqual(["RUB", "USD"]);
|
expect(model.currencies).toEqual(["RUB", "USD"]);
|
||||||
expect(model.total).toBe("16");
|
expect(model.total).toBe("16");
|
||||||
expect(model.items.map(item => item.share)).toEqual(["93,8 %", "6,3 %"]);
|
expect(model.items.map(item => item.share)).toEqual(["93,8 %", "6,3 %"]);
|
||||||
expect(model.unaccounted).toBe(3);
|
expect(model.unaccounted).toBe(3);
|
||||||
const negative = snapshot([stock("A", "-3", "USD")]);
|
const negative = snapshot([stock("A", "-3", "USD")]);
|
||||||
expect(toConcentration(negative, "stocks", "USD"))
|
expect(toConcentration(negative, "stocks", "USD", labels))
|
||||||
.toMatchObject({ empty: true, unaccounted: 1 });
|
.toMatchObject({ empty: true, unaccounted: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,7 +52,7 @@ describe("account concentration", () => {
|
|||||||
stock("A", "400"), stock("B", "250"), stock("C", "150"),
|
stock("A", "400"), stock("B", "250"), stock("C", "150"),
|
||||||
stock("D", "100"), stock("E", "50"), stock("F", "30"),
|
stock("D", "100"), stock("E", "50"), stock("F", "30"),
|
||||||
stock("G", "20"),
|
stock("G", "20"),
|
||||||
]), "stocks", "RUB");
|
]), "stocks", "RUB", labels);
|
||||||
|
|
||||||
expect(model.items.map(item => item.amount)).toEqual([
|
expect(model.items.map(item => item.amount)).toEqual([
|
||||||
"400", "250", "150", "100", "50",
|
"400", "250", "150", "100", "50",
|
||||||
@@ -64,7 +71,7 @@ describe("account concentration", () => {
|
|||||||
stock("Сбер", "100"), stock("X5", "100"),
|
stock("Сбер", "100"), stock("X5", "100"),
|
||||||
stock("VK", "100"), stock("D", "100"),
|
stock("VK", "100"), stock("D", "100"),
|
||||||
stock("E", "100"), stock("F", "100"),
|
stock("E", "100"), stock("F", "100"),
|
||||||
]), "stocks", "RUB");
|
]), "stocks", "RUB", labels);
|
||||||
|
|
||||||
expect(model.items).toHaveLength(6);
|
expect(model.items).toHaveLength(6);
|
||||||
expect(model.other).toBeNull();
|
expect(model.other).toBeNull();
|
||||||
@@ -76,7 +83,7 @@ describe("account concentration", () => {
|
|||||||
stock("Large", "100000000"), stock("Small", "1"),
|
stock("Large", "100000000"), stock("Small", "1"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(toConcentration(tiny, "stocks", "RUB").items[1]?.share)
|
expect(toConcentration(tiny, "stocks", "RUB", labels).items[1]?.share)
|
||||||
.toBe("< 0,1 %");
|
.toBe("< 0,1 %");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -96,28 +103,73 @@ describe("account concentration", () => {
|
|||||||
stock("D", "50", "RUB", { issuer: null, sector: null }),
|
stock("D", "50", "RUB", { issuer: null, sector: null }),
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(toConcentration(snapshot(positions), "sectors", "RUB")
|
expect(toConcentration(snapshot(positions), "sectors", "RUB", labels)
|
||||||
.items.map(item => item.amount)).toEqual(["500", "100", "50"]);
|
.items.map(item => item.amount)).toEqual(["500", "100", "50"]);
|
||||||
expect(toConcentration(snapshot(positions), "issuers", "RUB")
|
expect(toConcentration(snapshot(positions), "issuers", "RUB", labels)
|
||||||
.items.map(item => [item.name, item.amount])).toEqual([
|
.items.map(item => [item.name, item.amount])).toEqual([
|
||||||
["Сбербанк", "500"], ["", "50"],
|
["Сбербанк", "500"], ["", "50"],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("orders equal sums by displayed name", () => {
|
||||||
|
const issuers = snapshot([
|
||||||
|
stock("A", "300", "RUB", {
|
||||||
|
issuer: { id: "g", name: "Газпром" }, sector: "energy",
|
||||||
|
}),
|
||||||
|
stock("B", "300", "RUB", {
|
||||||
|
issuer: { id: "s", name: "Сбер" }, sector: "energy",
|
||||||
|
}),
|
||||||
|
stock("C", "200", "RUB", { issuer: null, sector: "energy" }),
|
||||||
|
stock("D", "100", "RUB", { issuer: null, sector: "energy" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(toConcentration(issuers, "issuers", "RUB", labels)
|
||||||
|
.items.map(item => [item.label, item.amount])).toEqual([
|
||||||
|
["Газпром", "300"], ["Сбер", "300"], ["Эмитент не указан", "300"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sectors = snapshot([
|
||||||
|
stock("A", "100", "RUB", { sector: "it" }),
|
||||||
|
stock("B", "100", "RUB", { sector: "energy" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sectorLabels = {
|
||||||
|
...labels,
|
||||||
|
sector: (code: string) => code === "it"
|
||||||
|
? "Информационные технологии"
|
||||||
|
: "Энергетика",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(toConcentration(sectors, "sectors", "RUB", sectorLabels)
|
||||||
|
.items.map(item => item.label))
|
||||||
|
.toEqual(["Информационные технологии", "Энергетика"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("chooses issuer name from the largest position, then alphabetically", () => {
|
it("chooses issuer name from the largest position, then alphabetically", () => {
|
||||||
const data = snapshot([
|
const data = snapshot([
|
||||||
stock("A", "200", "RUB", {
|
stock("A", "100", "RUB", {
|
||||||
issuer: { id: "brand", name: "Сбербанк" },
|
issuer: { id: "brand", name: "Ястреб" },
|
||||||
}),
|
}),
|
||||||
stock("B", "200", "RUB", {
|
stock("B", "300", "RUB", {
|
||||||
issuer: { id: "brand", name: "Сбер" },
|
issuer: { id: "brand", name: "Альфа" },
|
||||||
}),
|
}),
|
||||||
stock("C", "300", "RUB", {
|
stock("C", "400", "RUB", {
|
||||||
issuer: { id: "other", name: "Альфа" },
|
issuer: { id: "other", name: "Бета" },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(toConcentration(data, "issuers", "RUB").items[0]?.name)
|
expect(toConcentration(data, "issuers", "RUB", labels).items
|
||||||
.toBe("Сбер");
|
.map(item => [item.name, item.label, item.amount])).toEqual([
|
||||||
|
["Альфа", "Альфа", "400"], ["Бета", "Бета", "400"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses ISIN before ticker for bonds", () => {
|
||||||
|
const data = snapshot([stock("Облигация", "100", "RUB", {
|
||||||
|
isin: "RU000A0JX0J2", ticker: "BOND", type: "BOND",
|
||||||
|
})]);
|
||||||
|
|
||||||
|
expect(toConcentration(data, "bonds", "RUB", labels)
|
||||||
|
.items[0]?.logo?.subtitle).toBe("RU000A0JX0J2");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type Decimal,
|
type Decimal,
|
||||||
decimalFromString,
|
decimalFromString,
|
||||||
decimalToString,
|
decimalToString,
|
||||||
|
instrumentLogoUrl,
|
||||||
sumDecimals,
|
sumDecimals,
|
||||||
} from "@/shared/lib";
|
} from "@/shared/lib";
|
||||||
|
|
||||||
@@ -21,11 +22,25 @@ type ConcentrationItem = {
|
|||||||
amount: string;
|
amount: string;
|
||||||
chartValue: number;
|
chartValue: number;
|
||||||
key: string;
|
key: string;
|
||||||
|
label: string;
|
||||||
|
logo: ConcentrationLogo | null;
|
||||||
name: string;
|
name: string;
|
||||||
share: string;
|
share: string;
|
||||||
unknown: boolean;
|
unknown: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ConcentrationLabels = {
|
||||||
|
missingIssuer: string;
|
||||||
|
missingSector: string;
|
||||||
|
other: string;
|
||||||
|
sector: (code: string) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ConcentrationLogo = {
|
||||||
|
src: null | string;
|
||||||
|
subtitle: null | string;
|
||||||
|
};
|
||||||
|
|
||||||
type ConcentrationOther = ConcentrationItem & {
|
type ConcentrationOther = ConcentrationItem & {
|
||||||
count: number;
|
count: number;
|
||||||
entries: ConcentrationItem[];
|
entries: ConcentrationItem[];
|
||||||
@@ -35,6 +50,8 @@ type Group = {
|
|||||||
best?: { name: string; value: Decimal };
|
best?: { name: string; value: Decimal };
|
||||||
first: number;
|
first: number;
|
||||||
key: string;
|
key: string;
|
||||||
|
label: string;
|
||||||
|
logo: ConcentrationLogo | null;
|
||||||
name: string;
|
name: string;
|
||||||
unknown: boolean;
|
unknown: boolean;
|
||||||
values: Decimal[];
|
values: Decimal[];
|
||||||
@@ -65,6 +82,7 @@ function addPosition(
|
|||||||
position: Position,
|
position: Position,
|
||||||
index: number,
|
index: number,
|
||||||
slice: Slice,
|
slice: Slice,
|
||||||
|
labels: ConcentrationLabels,
|
||||||
selected: null | string,
|
selected: null | string,
|
||||||
): number {
|
): number {
|
||||||
if (!isIncluded(slice, position.type)) return 0;
|
if (!isIncluded(slice, position.type)) return 0;
|
||||||
@@ -73,7 +91,7 @@ function addPosition(
|
|||||||
const value = decimalFromString(position.currentValue.value);
|
const value = decimalFromString(position.currentValue.value);
|
||||||
if (value.value < 0n) return 1;
|
if (value.value < 0n) return 1;
|
||||||
if (value.value === 0n) return 0;
|
if (value.value === 0n) return 0;
|
||||||
const identity = groupIdentity(position, slice, index);
|
const identity = groupIdentity(position, slice, index, labels);
|
||||||
|
|
||||||
const group = groups.get(identity.key) ?? {
|
const group = groups.get(identity.key) ?? {
|
||||||
...identity, first: index, values: [],
|
...identity, first: index, values: [],
|
||||||
@@ -89,6 +107,7 @@ function addPosition(
|
|||||||
&& compareNames(identity.name, best.name) < 0)) {
|
&& compareNames(identity.name, best.name) < 0)) {
|
||||||
group.best = { name: identity.name, value };
|
group.best = { name: identity.name, value };
|
||||||
group.name = identity.name;
|
group.name = identity.name;
|
||||||
|
group.label = identity.label;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,23 +135,58 @@ function compareDecimal(a: Decimal, b: Decimal): number {
|
|||||||
return left < right ? -1 : 0;
|
return left < right ? -1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupIdentity(position: Position, slice: Slice, index: number) {
|
function concentrationCurrencies(portfolio: Portfolio): string[] {
|
||||||
|
return [...new Set(portfolio.positions
|
||||||
|
.filter(position => eligibleTypes.has(position.type))
|
||||||
|
.filter(position => position.currentValue
|
||||||
|
&& decimalFromString(position.currentValue.value).value !== 0n)
|
||||||
|
.map(position => position.currentValue?.currency)
|
||||||
|
.filter((value): value is string => !!value))]
|
||||||
|
.toSorted((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupIdentity(
|
||||||
|
position: Position,
|
||||||
|
slice: Slice,
|
||||||
|
index: number,
|
||||||
|
labels: ConcentrationLabels,
|
||||||
|
) {
|
||||||
if (slice === "sectors") {
|
if (slice === "sectors") {
|
||||||
const sector = position.sector;
|
const sector = position.sector;
|
||||||
|
const isUnknown = !sector;
|
||||||
|
|
||||||
return { key: sector ?? "", name: sector ?? "", unknown: !sector };
|
return {
|
||||||
|
key: sector ?? "",
|
||||||
|
label: isUnknown
|
||||||
|
? labels.missingSector
|
||||||
|
: labels.sector(sector),
|
||||||
|
logo: null,
|
||||||
|
name: sector ?? "",
|
||||||
|
unknown: isUnknown,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (slice === "issuers") {
|
if (slice === "issuers") {
|
||||||
|
const isUnknown = !position.issuer;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: position.issuer?.id ?? "",
|
key: position.issuer?.id ?? "",
|
||||||
|
label: position.issuer ? position.issuer.name : labels.missingIssuer,
|
||||||
|
logo: null,
|
||||||
name: position.issuer?.name ?? "",
|
name: position.issuer?.name ?? "",
|
||||||
unknown: !position.issuer,
|
unknown: isUnknown,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: String(index),
|
key: String(index),
|
||||||
|
label: position.displayName,
|
||||||
|
logo: {
|
||||||
|
src: instrumentLogoUrl(position.logoName),
|
||||||
|
subtitle: slice === "bonds"
|
||||||
|
? position.isin ?? position.ticker ?? position.figi
|
||||||
|
: position.ticker ?? position.isin ?? position.figi,
|
||||||
|
},
|
||||||
name: position.displayName,
|
name: position.displayName,
|
||||||
unknown: false,
|
unknown: false,
|
||||||
};
|
};
|
||||||
@@ -161,14 +215,9 @@ function toConcentration(
|
|||||||
portfolio: Portfolio,
|
portfolio: Portfolio,
|
||||||
slice: Slice,
|
slice: Slice,
|
||||||
currency: null | string,
|
currency: null | string,
|
||||||
|
labels: ConcentrationLabels,
|
||||||
): Concentration {
|
): Concentration {
|
||||||
const currencies = [...new Set(portfolio.positions
|
const currencies = concentrationCurrencies(portfolio);
|
||||||
.filter(position => eligibleTypes.has(position.type))
|
|
||||||
.filter(position => position.currentValue
|
|
||||||
&& decimalFromString(position.currentValue.value).value !== 0n)
|
|
||||||
.map(position => position.currentValue?.currency)
|
|
||||||
.filter((value): value is string => !!value))]
|
|
||||||
.toSorted((a, b) => a.localeCompare(b));
|
|
||||||
|
|
||||||
const selected = currency && currencies.includes(currency)
|
const selected = currency && currencies.includes(currency)
|
||||||
? currency
|
? currency
|
||||||
@@ -178,13 +227,15 @@ function toConcentration(
|
|||||||
let unaccounted = 0;
|
let unaccounted = 0;
|
||||||
|
|
||||||
for (const [index, position] of portfolio.positions.entries()) {
|
for (const [index, position] of portfolio.positions.entries()) {
|
||||||
unaccounted += addPosition(groups, position, index, slice, selected);
|
unaccounted += addPosition(
|
||||||
|
groups, position, index, slice, labels, selected,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ranked = Array.from(groups.values(), group => ({
|
const ranked = Array.from(groups.values(), group => ({
|
||||||
...group, sum: sumDecimals(group.values),
|
...group, sum: sumDecimals(group.values),
|
||||||
})).toSorted((a, b) => compareDecimal(b.sum, a.sum)
|
})).toSorted((a, b) => compareDecimal(b.sum, a.sum)
|
||||||
|| compareNames(a.name, b.name) || a.first - b.first);
|
|| compareNames(a.label, b.label) || a.first - b.first);
|
||||||
|
|
||||||
const total = ranked.length > 0
|
const total = ranked.length > 0
|
||||||
? sumDecimals(ranked.map(item => item.sum))
|
? sumDecimals(ranked.map(item => item.sum))
|
||||||
@@ -195,6 +246,8 @@ function toConcentration(
|
|||||||
amount: decimalToString(group.sum),
|
amount: decimalToString(group.sum),
|
||||||
chartValue: chartValue(group.sum, total),
|
chartValue: chartValue(group.sum, total),
|
||||||
key: group.key,
|
key: group.key,
|
||||||
|
label: group.label,
|
||||||
|
logo: group.logo,
|
||||||
name: group.name,
|
name: group.name,
|
||||||
share: share(group.sum, total),
|
share: share(group.sum, total),
|
||||||
unknown: group.unknown,
|
unknown: group.unknown,
|
||||||
@@ -214,6 +267,8 @@ function toConcentration(
|
|||||||
count: rest.length,
|
count: rest.length,
|
||||||
entries: rest,
|
entries: rest,
|
||||||
key: "__other",
|
key: "__other",
|
||||||
|
label: labels.other,
|
||||||
|
logo: null,
|
||||||
name: "", share: share(otherSum, total), unknown: false,
|
name: "", share: share(otherSum, total), unknown: false,
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
@@ -233,5 +288,13 @@ function toConcentration(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export { toConcentration };
|
export {
|
||||||
export type { Concentration, ConcentrationItem, Slice };
|
concentrationCurrencies,
|
||||||
|
toConcentration,
|
||||||
|
};
|
||||||
|
export type {
|
||||||
|
Concentration,
|
||||||
|
ConcentrationItem,
|
||||||
|
ConcentrationLabels,
|
||||||
|
Slice,
|
||||||
|
};
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
within,
|
within,
|
||||||
} from "@testing-library/react";
|
} from "@testing-library/react";
|
||||||
import { http, HttpResponse } from "msw";
|
import { http, HttpResponse } from "msw";
|
||||||
import { MemoryRouter, Route, Routes } from "react-router";
|
import { useState } from "react";
|
||||||
|
import { getI18n } from "react-i18next";
|
||||||
|
import { MemoryRouter, Route, Routes, useNavigate } 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 { BrokerageOperation } from "@/entities/broker-operation";
|
||||||
@@ -36,6 +38,19 @@ import { AccountPageError } from "./components/AccountPageError.view";
|
|||||||
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
|
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
|
||||||
import { AccountPageLoading } from "./components/AccountPageLoading/AccountPageLoading.view";
|
import { AccountPageLoading } from "./components/AccountPageLoading/AccountPageLoading.view";
|
||||||
|
|
||||||
|
function AccountSwitcher() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/accounts/account-2")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Другой счёт
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function configureTestAuth() {
|
function configureTestAuth() {
|
||||||
configureAuthClient({
|
configureAuthClient({
|
||||||
accessToken: () => "test-token",
|
accessToken: () => "test-token",
|
||||||
@@ -51,6 +66,8 @@ function renderAccountRoute(queryClient: QueryClient, accountId = "account-1") {
|
|||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<MemoryRouter initialEntries={[`/accounts/${accountId}`]}>
|
<MemoryRouter initialEntries={[`/accounts/${accountId}`]}>
|
||||||
|
<AccountSwitcher />
|
||||||
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<AccountPage />} path="/accounts/:accountId" />
|
<Route element={<AccountPage />} path="/accounts/:accountId" />
|
||||||
</Routes>
|
</Routes>
|
||||||
@@ -141,6 +158,19 @@ const emptyRecentEvents: AccountPageRecentEvents = {
|
|||||||
to: "/accounts/account-1/events",
|
to: "/accounts/account-1/events",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function concentrationLabels() {
|
||||||
|
const i18n = getI18n();
|
||||||
|
|
||||||
|
return {
|
||||||
|
missingIssuer: i18n.t("portfolio.concentration.missingIssuer"),
|
||||||
|
missingSector: i18n.t("portfolio.concentration.missingSector"),
|
||||||
|
other: i18n.t("portfolio.concentration.other"),
|
||||||
|
sector: (code: string) => i18n.t(`stock.sectors.${code}`, {
|
||||||
|
defaultValue: code,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function emptyProps() {
|
function emptyProps() {
|
||||||
return {
|
return {
|
||||||
recentEvents: emptyRecentEvents,
|
recentEvents: emptyRecentEvents,
|
||||||
@@ -148,6 +178,21 @@ function emptyProps() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LoadedHarness = (
|
||||||
|
props: Omit<Partial<Parameters<typeof AccountPageLoaded>[0]>, "onTabChange">,
|
||||||
|
) => {
|
||||||
|
const [tab, setTab] = useState(props.tab ?? "positions");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccountPageLoaded
|
||||||
|
{...loadedProps()}
|
||||||
|
{...props}
|
||||||
|
onTabChange={value => setTab(value ?? "positions")}
|
||||||
|
tab={tab}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
function loadedProps(nextPortfolio: Portfolio = portfolio) {
|
function loadedProps(nextPortfolio: Portfolio = portfolio) {
|
||||||
return {
|
return {
|
||||||
concentration: null,
|
concentration: null,
|
||||||
@@ -182,6 +227,8 @@ describe("AccountPage states", () => {
|
|||||||
expect(tab).toHaveAttribute("aria-selected", "false");
|
expect(tab).toHaveAttribute("aria-selected", "false");
|
||||||
fireEvent.click(tab);
|
fireEvent.click(tab);
|
||||||
expect(screen.getAllByText("100 ₽")).toHaveLength(2);
|
expect(screen.getAllByText("100 ₽")).toHaveLength(2);
|
||||||
|
expect(screen.getByRole("img", { name: "Распределение позиций" }))
|
||||||
|
.toHaveAccessibleDescription(/Акция: 100 ₽, 100 %/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("expands and resets other when switching slice", () => {
|
it("expands and resets other when switching slice", () => {
|
||||||
@@ -193,16 +240,18 @@ describe("AccountPage states", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const data = { ...portfolio, positions };
|
const data = { ...portfolio, positions };
|
||||||
const model = toConcentration(data, "stocks", "RUB");
|
|
||||||
|
const model = toConcentration(
|
||||||
|
data, "stocks", "RUB", concentrationLabels(),
|
||||||
|
);
|
||||||
|
|
||||||
const props = loadedProps(data);
|
const props = loadedProps(data);
|
||||||
const onTabChange = vi.fn();
|
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<LoadedHarness
|
||||||
{...props}
|
{...props}
|
||||||
accountId="a"
|
accountId="a"
|
||||||
concentration={model}
|
concentration={model}
|
||||||
currency="RUB"
|
currency="RUB"
|
||||||
onTabChange={onTabChange}
|
|
||||||
tab="concentration"
|
tab="concentration"
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -214,6 +263,272 @@ describe("AccountPage states", () => {
|
|||||||
fireEvent.click(screen.getByRole("radio", { name: "Секторы" }));
|
fireEvent.click(screen.getByRole("radio", { name: "Секторы" }));
|
||||||
expect(props.onSliceChange).toHaveBeenCalledWith("sectors");
|
expect(props.onSliceChange).toHaveBeenCalledWith("sectors");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[2, "2 элемента"],
|
||||||
|
[5, "5 элементов"],
|
||||||
|
[21, "21 элемент"],
|
||||||
|
])("pluralizes the other count %d as %s", (count, expected) => {
|
||||||
|
const values = [400, 250, 150, 100, 50];
|
||||||
|
|
||||||
|
const rest = Array.from(
|
||||||
|
{ length: count },
|
||||||
|
(_, index) => 20 + index,
|
||||||
|
);
|
||||||
|
|
||||||
|
const positions = [...values, ...rest].map(
|
||||||
|
(value, index) => position({
|
||||||
|
currentValue: { currency: "RUB", value: String(value) },
|
||||||
|
displayName: `Акция ${index + 1}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderPage(
|
||||||
|
<LoadedHarness
|
||||||
|
{...loadedProps({ ...portfolio, positions })}
|
||||||
|
accountId="a"
|
||||||
|
concentration={toConcentration(
|
||||||
|
{ ...portfolio, positions },
|
||||||
|
"stocks",
|
||||||
|
"RUB",
|
||||||
|
concentrationLabels(),
|
||||||
|
)}
|
||||||
|
currency="RUB"
|
||||||
|
tab="concentration"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const other = screen.getByRole("button", { name: /Прочее/ });
|
||||||
|
expect(other).toHaveTextContent(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows ticker subtitles and stocks rows with logos, issuers without", () => {
|
||||||
|
const positions = [
|
||||||
|
position({
|
||||||
|
currentValue: { currency: "RUB", value: "300" },
|
||||||
|
displayName: "Сбербанк",
|
||||||
|
ticker: "SBER",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const data = { ...portfolio, positions };
|
||||||
|
|
||||||
|
const view = renderPage(
|
||||||
|
<LoadedHarness
|
||||||
|
{...loadedProps(data)}
|
||||||
|
accountId="a"
|
||||||
|
concentration={toConcentration(
|
||||||
|
data, "stocks", "RUB", concentrationLabels(),
|
||||||
|
)}
|
||||||
|
currency="RUB"
|
||||||
|
tab="concentration"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("SBER")).toBeInTheDocument();
|
||||||
|
|
||||||
|
view.unmount();
|
||||||
|
|
||||||
|
renderPage(
|
||||||
|
<LoadedHarness
|
||||||
|
{...loadedProps(data)}
|
||||||
|
accountId="a"
|
||||||
|
concentration={toConcentration(
|
||||||
|
data, "issuers", "RUB", concentrationLabels(),
|
||||||
|
)}
|
||||||
|
currency="RUB"
|
||||||
|
slice="issuers"
|
||||||
|
tab="concentration"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText("SBER")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the currency select when only one currency is available", () => {
|
||||||
|
const data = { ...portfolio, positions: [position({
|
||||||
|
currentValue: { currency: "RUB", value: "100" },
|
||||||
|
displayName: "Акция",
|
||||||
|
})] };
|
||||||
|
|
||||||
|
renderPage(
|
||||||
|
<LoadedHarness
|
||||||
|
{...loadedProps(data)}
|
||||||
|
accountId="a"
|
||||||
|
concentration={toConcentration(
|
||||||
|
data, "stocks", "RUB", concentrationLabels(),
|
||||||
|
)}
|
||||||
|
currency="RUB"
|
||||||
|
tab="concentration"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("combobox", { name: "Валюта концентрации" }))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows sector labels including government and unknown codes", () => {
|
||||||
|
const positions = [
|
||||||
|
position({
|
||||||
|
currentValue: { currency: "RUB", value: "300" },
|
||||||
|
displayName: "А",
|
||||||
|
sector: "government",
|
||||||
|
}),
|
||||||
|
position({
|
||||||
|
currentValue: { currency: "RUB", value: "100" },
|
||||||
|
displayName: "Б",
|
||||||
|
sector: "unknown_code",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const data = { ...portfolio, positions };
|
||||||
|
|
||||||
|
renderPage(
|
||||||
|
<LoadedHarness
|
||||||
|
{...loadedProps(data)}
|
||||||
|
accountId="a"
|
||||||
|
concentration={toConcentration(
|
||||||
|
data, "sectors", "RUB", concentrationLabels(),
|
||||||
|
)}
|
||||||
|
currency="RUB"
|
||||||
|
slice="sectors"
|
||||||
|
tab="concentration"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Государственный сектор")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("unknown_code")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("describes ranked segments with amounts and shares", () => {
|
||||||
|
const data = { ...portfolio, positions: [
|
||||||
|
position({
|
||||||
|
currentValue: { currency: "RUB", value: "300" },
|
||||||
|
displayName: "Первая акция",
|
||||||
|
}),
|
||||||
|
position({
|
||||||
|
currentValue: { currency: "RUB", value: "100" },
|
||||||
|
displayName: "Вторая акция",
|
||||||
|
}),
|
||||||
|
...[50, 40, 30, 20, 10].map((value, index) => position({
|
||||||
|
currentValue: { currency: "RUB", value: String(value) },
|
||||||
|
displayName: `Остаток ${index + 1}`,
|
||||||
|
})),
|
||||||
|
] };
|
||||||
|
|
||||||
|
renderPage(
|
||||||
|
<LoadedHarness
|
||||||
|
{...loadedProps(data)}
|
||||||
|
accountId="a"
|
||||||
|
concentration={toConcentration(
|
||||||
|
data, "stocks", "RUB", concentrationLabels(),
|
||||||
|
)}
|
||||||
|
currency="RUB"
|
||||||
|
tab="concentration"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const chart = screen.getByRole("img", { name: "Распределение позиций" });
|
||||||
|
|
||||||
|
expect(chart).toHaveAccessibleDescription(
|
||||||
|
/Первая акция: 300 ₽, 54,5 %; Вторая акция: 100 ₽, 18,2 %; Остаток 1: 50 ₽, 9,1 %; Остаток 2: 40 ₽, 7,3 %; Остаток 3: 30 ₽, 5,5 %; Прочее: 30 ₽, 5,5 %/,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("button", { name: /Прочее/ }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not duplicate the partial warning on the concentration tab", () => {
|
||||||
|
const data = { ...portfolio, positions: [position({
|
||||||
|
currentValue: { currency: "RUB", value: "100" },
|
||||||
|
displayName: "Акция",
|
||||||
|
})] };
|
||||||
|
|
||||||
|
renderPage(
|
||||||
|
<LoadedHarness
|
||||||
|
{...loadedProps(data)}
|
||||||
|
accountId="a"
|
||||||
|
concentration={toConcentration(
|
||||||
|
data, "stocks", "RUB", concentrationLabels(),
|
||||||
|
)}
|
||||||
|
currency="RUB"
|
||||||
|
partial
|
||||||
|
tab="concentration"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getAllByRole("status"))
|
||||||
|
.toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps slice and currency while switching page tabs and resets on account change", async () => {
|
||||||
|
const multiCurrency = { ...portfolio, positions:
|
||||||
|
["RUB", "USD"].flatMap(currency =>
|
||||||
|
[400, 250, 150, 100, 50, 30, 20].map((value, index) =>
|
||||||
|
position({
|
||||||
|
currentValue: { currency, value: String(value) },
|
||||||
|
displayName: `${currency} акция ${index + 1}`,
|
||||||
|
sector: "energy", type: "STOCK",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
) };
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||||
|
HttpResponse.json(resource(multiCurrency)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const client = createTestQueryClient();
|
||||||
|
renderAccountRoute(client);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("tab", { name: "Концентрация" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Прочее/ }));
|
||||||
|
expect(screen.getByRole("button", { name: /Прочее/ }))
|
||||||
|
.toHaveAttribute("aria-expanded", "true");
|
||||||
|
fireEvent.click(screen.getByRole("combobox", { name: "Валюта концентрации" }));
|
||||||
|
fireEvent.click(screen.getByRole("option", { name: "USD" }));
|
||||||
|
expect(screen.getByRole("button", { name: /Прочее/ }))
|
||||||
|
.toHaveAttribute("aria-expanded", "false");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Прочее/ }));
|
||||||
|
fireEvent.click(screen.getByRole("radio", { name: "Секторы" }));
|
||||||
|
fireEvent.click(screen.getByRole("radio", { name: "Акции" }));
|
||||||
|
expect(screen.getByRole("button", { name: /Прочее/ }))
|
||||||
|
.toHaveAttribute("aria-expanded", "false");
|
||||||
|
fireEvent.click(screen.getByRole("radio", { name: "Секторы" }));
|
||||||
|
expect(screen.getByRole("combobox", { name: "Валюта концентрации" }))
|
||||||
|
.toHaveValue("USD");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: "Позиции" }));
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: "Концентрация" }));
|
||||||
|
|
||||||
|
expect(screen.getByRole("radio", { name: "Секторы" }))
|
||||||
|
.toBeChecked();
|
||||||
|
expect(screen.getByRole("combobox", { name: "Валюта концентрации" }))
|
||||||
|
.toHaveValue("USD");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Другой счёт" }));
|
||||||
|
fireEvent.click(await screen.findByRole("tab", { name: "Концентрация" }));
|
||||||
|
expect(screen.getByRole("radio", { name: "Акции" })).toBeChecked();
|
||||||
|
expect(screen.getByRole("combobox", { name: "Валюта концентрации" }))
|
||||||
|
.toHaveValue("RUB");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the first currency when the selected one disappears", () => {
|
||||||
|
const data = { ...portfolio, positions: [
|
||||||
|
position({
|
||||||
|
currentValue: { currency: "USD", value: "200" },
|
||||||
|
}),
|
||||||
|
position({
|
||||||
|
currentValue: { currency: "RUB", value: "100" },
|
||||||
|
}),
|
||||||
|
] };
|
||||||
|
|
||||||
|
const model = toConcentration(
|
||||||
|
data, "stocks", "EUR", concentrationLabels(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.currencies).toEqual(["RUB", "USD"]);
|
||||||
|
expect(model.total).toBe("100");
|
||||||
|
expect(model.items.map(item => item.amount)).toEqual(["100"]);
|
||||||
|
});
|
||||||
it("shows the loaded portfolio source sync status", async () => {
|
it("shows the loaded portfolio source sync status", async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||||
@@ -368,7 +683,7 @@ describe("AccountPage states", () => {
|
|||||||
|
|
||||||
it("shows supplied cash separately without adding currency instruments or recalculating totals", () => {
|
it("shows supplied cash separately without adding currency instruments or recalculating totals", () => {
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<LoadedHarness
|
||||||
{...loadedProps({
|
{...loadedProps({
|
||||||
...portfolio,
|
...portfolio,
|
||||||
cashBalances: [
|
cashBalances: [
|
||||||
@@ -552,7 +867,7 @@ describe("AccountPage states", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders a stale portfolio in upstream order with nullable values hidden", () => {
|
it("renders a stale portfolio in upstream order with nullable values hidden", () => {
|
||||||
renderPage(<AccountPageLoaded {...loadedProps()} />);
|
renderPage(<LoadedHarness {...loadedProps()} />);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("heading", { level: 1, name: "Портфель" }),
|
screen.getByRole("heading", { level: 1, name: "Портфель" }),
|
||||||
@@ -590,7 +905,7 @@ describe("AccountPage states", () => {
|
|||||||
"renders %s logos, fallbacks and signed returns including zero",
|
"renders %s logos, fallbacks and signed returns including zero",
|
||||||
(type) => {
|
(type) => {
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<LoadedHarness
|
||||||
{...loadedProps({
|
{...loadedProps({
|
||||||
...portfolio,
|
...portfolio,
|
||||||
positions: [
|
positions: [
|
||||||
@@ -633,7 +948,7 @@ describe("AccountPage states", () => {
|
|||||||
"groups %s supporting metrics into quiet single lines",
|
"groups %s supporting metrics into quiet single lines",
|
||||||
(type) => {
|
(type) => {
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<LoadedHarness
|
||||||
{...loadedProps({
|
{...loadedProps({
|
||||||
...portfolio,
|
...portfolio,
|
||||||
positions: [position({
|
positions: [position({
|
||||||
@@ -655,7 +970,7 @@ describe("AccountPage states", () => {
|
|||||||
|
|
||||||
it("uses the ticker only for stock cards and otherwise keeps the ISIN", () => {
|
it("uses the ticker only for stock cards and otherwise keeps the ISIN", () => {
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<LoadedHarness
|
||||||
{...loadedProps({
|
{...loadedProps({
|
||||||
...portfolio,
|
...portfolio,
|
||||||
positions: [
|
positions: [
|
||||||
@@ -680,7 +995,7 @@ describe("AccountPage states", () => {
|
|||||||
|
|
||||||
it("keeps other-position cards compact with labeled return and no logo", () => {
|
it("keeps other-position cards compact with labeled return and no logo", () => {
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<LoadedHarness
|
||||||
{...loadedProps({
|
{...loadedProps({
|
||||||
...portfolio,
|
...portfolio,
|
||||||
positions: [position({
|
positions: [position({
|
||||||
@@ -698,7 +1013,7 @@ describe("AccountPage states", () => {
|
|||||||
|
|
||||||
it("uses the cached account title when available", () => {
|
it("uses the cached account title when available", () => {
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<LoadedHarness
|
||||||
{...loadedProps()}
|
{...loadedProps()}
|
||||||
|
|
||||||
account={{
|
account={{
|
||||||
@@ -717,7 +1032,7 @@ describe("AccountPage states", () => {
|
|||||||
|
|
||||||
it("keeps the portfolio visible when recent operations fail", () => {
|
it("keeps the portfolio visible when recent operations fail", () => {
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<LoadedHarness
|
||||||
{...loadedProps()}
|
{...loadedProps()}
|
||||||
recentOperations={{
|
recentOperations={{
|
||||||
state: "error",
|
state: "error",
|
||||||
@@ -736,7 +1051,7 @@ describe("AccountPage states", () => {
|
|||||||
|
|
||||||
it("uses the ledger row for a recent outgoing trade operation", () => {
|
it("uses the ledger row for a recent outgoing trade operation", () => {
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<LoadedHarness
|
||||||
{...loadedProps()}
|
{...loadedProps()}
|
||||||
|
|
||||||
recentOperations={{
|
recentOperations={{
|
||||||
@@ -786,7 +1101,7 @@ describe("AccountPage states", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<LoadedHarness
|
||||||
{...loadedProps()}
|
{...loadedProps()}
|
||||||
|
|
||||||
recentEvents={{
|
recentEvents={{
|
||||||
@@ -855,7 +1170,7 @@ describe("AccountPage states", () => {
|
|||||||
"keeps the upcoming %s state and calendar action",
|
"keeps the upcoming %s state and calendar action",
|
||||||
(state) => {
|
(state) => {
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<LoadedHarness
|
||||||
{...loadedProps()}
|
{...loadedProps()}
|
||||||
recentEvents={{ state, to: "/accounts/account-1/events" }}
|
recentEvents={{ state, to: "/accounts/account-1/events" }}
|
||||||
/>,
|
/>,
|
||||||
@@ -892,7 +1207,7 @@ describe("AccountPage states", () => {
|
|||||||
|
|
||||||
it("keeps the portfolio visible when upcoming events fail", () => {
|
it("keeps the portfolio visible when upcoming events fail", () => {
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<LoadedHarness
|
||||||
{...loadedProps()}
|
{...loadedProps()}
|
||||||
recentEvents={{
|
recentEvents={{
|
||||||
state: "error",
|
state: "error",
|
||||||
@@ -1057,7 +1372,7 @@ describe("AccountPage states", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("opens positions by default and switches between local tabs", () => {
|
it("opens positions by default and switches between local tabs", () => {
|
||||||
renderPage(<AccountPageLoaded {...loadedProps()} />);
|
renderPage(<LoadedHarness {...loadedProps()} />);
|
||||||
|
|
||||||
expect(screen.getByRole("tab", { name: "Позиции" }))
|
expect(screen.getByRole("tab", { name: "Позиции" }))
|
||||||
.toHaveAttribute("aria-selected", "true");
|
.toHaveAttribute("aria-selected", "true");
|
||||||
@@ -1074,7 +1389,7 @@ describe("AccountPage states", () => {
|
|||||||
|
|
||||||
it("links operation, event and income previews to their detail pages", () => {
|
it("links operation, event and income previews to their detail pages", () => {
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<LoadedHarness
|
||||||
{...loadedProps()}
|
{...loadedProps()}
|
||||||
incomeSummary={{
|
incomeSummary={{
|
||||||
state: "empty",
|
state: "empty",
|
||||||
@@ -1095,7 +1410,7 @@ describe("AccountPage states", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("filters positions by the kinds present in the portfolio", () => {
|
it("filters positions by the kinds present in the portfolio", () => {
|
||||||
renderPage(<AccountPageLoaded {...loadedProps()} />);
|
renderPage(<LoadedHarness {...loadedProps()} />);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getAllByRole<HTMLInputElement>("radio").map(radio => radio.value),
|
screen.getAllByRole<HTMLInputElement>("radio").map(radio => radio.value),
|
||||||
@@ -1109,7 +1424,7 @@ describe("AccountPage states", () => {
|
|||||||
|
|
||||||
it("hides the position filter when only one kind exists", () => {
|
it("hides the position filter when only one kind exists", () => {
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<LoadedHarness
|
||||||
{...loadedProps({
|
{...loadedProps({
|
||||||
...portfolio,
|
...portfolio,
|
||||||
positions: [portfolio.positions[0]],
|
positions: [portfolio.positions[0]],
|
||||||
@@ -1122,7 +1437,7 @@ describe("AccountPage states", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("hides unrealized returns for a partial portfolio", () => {
|
it("hides unrealized returns for a partial portfolio", () => {
|
||||||
renderPage(<AccountPageLoaded {...loadedProps()} partial />);
|
renderPage(<LoadedHarness {...loadedProps()} partial />);
|
||||||
|
|
||||||
expect(screen.queryByRole("region", { name: "Нереализованный результат" }))
|
expect(screen.queryByRole("region", { name: "Нереализованный результат" }))
|
||||||
.not.toBeInTheDocument();
|
.not.toBeInTheDocument();
|
||||||
@@ -1130,7 +1445,7 @@ describe("AccountPage states", () => {
|
|||||||
|
|
||||||
it("omits an unrealized currency total when any return is incomplete", () => {
|
it("omits an unrealized currency total when any return is incomplete", () => {
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<LoadedHarness
|
||||||
{...loadedProps({
|
{...loadedProps({
|
||||||
...portfolio,
|
...portfolio,
|
||||||
positions: [
|
positions: [
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useParams } from "react-router";
|
import { useParams } from "react-router";
|
||||||
|
|
||||||
import type { Portfolio } from "@/entities/portfolio";
|
import type { Portfolio } from "@/entities/portfolio";
|
||||||
@@ -20,6 +21,8 @@ import { useInitialAvailability } from "@/shared/lib";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
type Concentration,
|
type Concentration,
|
||||||
|
concentrationCurrencies,
|
||||||
|
type ConcentrationLabels,
|
||||||
type Slice,
|
type Slice,
|
||||||
toConcentration,
|
toConcentration,
|
||||||
} from "../../model/concentration";
|
} from "../../model/concentration";
|
||||||
@@ -79,6 +82,7 @@ function isPortfolioAccessFailure(code?: string) {
|
|||||||
|
|
||||||
function useAccountPageViewModel(): AccountPageViewModel {
|
function useAccountPageViewModel(): AccountPageViewModel {
|
||||||
const { accountId } = useParams();
|
const { accountId } = useParams();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [selection, setSelection] = useState<{
|
const [selection, setSelection] = useState<{
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
@@ -152,15 +156,22 @@ function useAccountPageViewModel(): AccountPageViewModel {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isPartial = portfolio.meta?.completeness === "incomplete";
|
const isPartial = portfolio.meta?.completeness === "incomplete";
|
||||||
const currencies = toConcentration(portfolio.data, "stocks", null).currencies;
|
const currencies = concentrationCurrencies(portfolio.data);
|
||||||
|
|
||||||
const currency = current.currency && currencies.includes(current.currency)
|
const currency = current.currency && currencies.includes(current.currency)
|
||||||
? current.currency
|
? current.currency
|
||||||
: currencies[0] ?? null;
|
: currencies[0] ?? null;
|
||||||
|
|
||||||
|
const labels: ConcentrationLabels = {
|
||||||
|
missingIssuer: t("portfolio.concentration.missingIssuer"),
|
||||||
|
missingSector: t("portfolio.concentration.missingSector"),
|
||||||
|
other: t("portfolio.concentration.other"),
|
||||||
|
sector: code => t(`stock.sectors.${code}`, { defaultValue: code }),
|
||||||
|
};
|
||||||
|
|
||||||
const concentration = accountId !== "all"
|
const concentration = accountId !== "all"
|
||||||
&& current.tab === "concentration"
|
&& current.tab === "concentration"
|
||||||
? toConcentration(portfolio.data, current.slice, currency)
|
? toConcentration(portfolio.data, current.slice, currency, labels)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ type AccountPageLoadedProps = {
|
|||||||
incomeSummary?: AccountPageIncomeSummary;
|
incomeSummary?: AccountPageIncomeSummary;
|
||||||
onCurrencyChange: (currency: null | string) => void;
|
onCurrencyChange: (currency: null | string) => void;
|
||||||
onSliceChange: (slice: Slice) => void;
|
onSliceChange: (slice: Slice) => void;
|
||||||
onTabChange?: (tab: null | string) => void;
|
onTabChange: (tab: null | string) => void;
|
||||||
partial?: boolean;
|
partial?: boolean;
|
||||||
portfolio: Portfolio;
|
portfolio: Portfolio;
|
||||||
positionSections: AccountPagePositionSection[];
|
positionSections: AccountPagePositionSection[];
|
||||||
@@ -50,18 +50,16 @@ type AccountPageLoadedProps = {
|
|||||||
recentOperations: AccountPageRecentOperations;
|
recentOperations: AccountPageRecentOperations;
|
||||||
slice: Slice;
|
slice: Slice;
|
||||||
source?: InvestmentResourceMeta;
|
source?: InvestmentResourceMeta;
|
||||||
tab?: string;
|
tab: string;
|
||||||
unrealizedReturns: Portfolio["totalsByCurrency"];
|
unrealizedReturns: Portfolio["totalsByCurrency"];
|
||||||
};
|
};
|
||||||
|
|
||||||
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [filter, setFilter] = useState<string>();
|
const [filter, setFilter] = useState<string>();
|
||||||
const [fallbackTab, setFallbackTab] = useState("positions");
|
const tab = props.tab;
|
||||||
const tab = props.tab ?? fallbackTab;
|
|
||||||
|
|
||||||
const onTabChange = props.onTabChange
|
const onTabChange = props.onTabChange;
|
||||||
?? ((value: null | string) => setFallbackTab(value ?? "positions"));
|
|
||||||
|
|
||||||
const activeSection = props.positionSections.find(
|
const activeSection = props.positionSections.find(
|
||||||
section => section.kind === filter,
|
section => section.kind === filter,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
Slice,
|
Slice,
|
||||||
} from "../../../../model/concentration";
|
} from "../../../../model/concentration";
|
||||||
|
|
||||||
|
import { PositionLogo } from "../PositionLogo.view";
|
||||||
import classes from "./Concentration.module.css";
|
import classes from "./Concentration.module.css";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -37,6 +38,7 @@ type RowProps = {
|
|||||||
currency: string;
|
currency: string;
|
||||||
index: number;
|
index: number;
|
||||||
item: ConcentrationItem;
|
item: ConcentrationItem;
|
||||||
|
logo?: ConcentrationItem["logo"];
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -46,29 +48,12 @@ const colors = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const PERCENT = 100;
|
const PERCENT = 100;
|
||||||
const RANK_THRESHOLD = 0.04;
|
|
||||||
const OTHER_START = 6;
|
const OTHER_START = 6;
|
||||||
|
|
||||||
function amountFor(item: ConcentrationItem, currency: string): string {
|
function amountFor(item: ConcentrationItem, currency: string): string {
|
||||||
return formatMoney({ currency, value: item.amount });
|
return formatMoney({ currency, value: item.amount });
|
||||||
}
|
}
|
||||||
|
|
||||||
function nameFor(
|
|
||||||
item: ConcentrationItem,
|
|
||||||
slice: Slice,
|
|
||||||
t: ReturnType<typeof useTranslation>["t"],
|
|
||||||
): string {
|
|
||||||
if (item.key === "__other") return t("portfolio.concentration.other");
|
|
||||||
if (item.unknown) return t(slice === "sectors"
|
|
||||||
? "portfolio.concentration.missingSector"
|
|
||||||
: "portfolio.concentration.missingIssuer");
|
|
||||||
if (slice === "sectors") return t(`stock.sectors.${item.name}`, {
|
|
||||||
defaultValue: item.name,
|
|
||||||
});
|
|
||||||
|
|
||||||
return item.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ConcentrationRow: FC<RowProps> = props => (
|
const ConcentrationRow: FC<RowProps> = props => (
|
||||||
<li className={classes.row}>
|
<li className={classes.row}>
|
||||||
<span
|
<span
|
||||||
@@ -79,9 +64,15 @@ const ConcentrationRow: FC<RowProps> = props => (
|
|||||||
{props.index}
|
{props.index}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{props.logo && <PositionLogo label={props.name} src={props.logo.src} />}
|
||||||
|
|
||||||
<span className={classes.identity}>
|
<span className={classes.identity}>
|
||||||
<span>{props.name}</span>
|
<span>{props.name}</span>
|
||||||
|
|
||||||
|
{props.logo?.subtitle && (
|
||||||
|
<Text c="dimmed" size="xs">{props.logo.subtitle}</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
<span aria-hidden="true" className={classes.bar}>
|
<span aria-hidden="true" className={classes.bar}>
|
||||||
<span style={{
|
<span style={{
|
||||||
backgroundColor: props.color,
|
backgroundColor: props.color,
|
||||||
@@ -99,20 +90,42 @@ const ConcentrationRow: FC<RowProps> = props => (
|
|||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
type TooltipProps = {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: { name?: number | string }[];
|
||||||
|
rows: { amount: string; label: string; share: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
function chartCellProps(series: { name: string }, rows: ConcentrationItem[]) {
|
function chartCellProps(series: { name: string }, rows: ConcentrationItem[]) {
|
||||||
|
const index = Number(series.name);
|
||||||
|
const item = rows.at(index);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
stroke: "white",
|
stroke: "white",
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
...((series.name === "__other"
|
...((item?.unknown || (index === rows.length - 1
|
||||||
|| rows.some(item => item.key === series.name && item.unknown))
|
&& item?.key === "__other"))
|
||||||
&& { strokeDasharray: "3 2" }),
|
&& { strokeDasharray: "3 2" }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function rankLabel({ index, percent }: { index: number; percent?: number }) {
|
function rankLabel({ index }: { index: number; percent?: number }) {
|
||||||
return (percent ?? 0) >= RANK_THRESHOLD ? String(index + 1) : "";
|
return String(index + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ChartTooltip: FC<TooltipProps> = (props) => {
|
||||||
|
const row = props.rows[Number(props.payload?.[0]?.name)];
|
||||||
|
if (!row || !props.active) return;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper p="sm" radius="md" shadow="sm">
|
||||||
|
<Text fw={600} size="sm">{row.label}</Text>
|
||||||
|
|
||||||
|
<Text size="sm">{row.amount} · {row.share}</Text>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const Concentration: FC<Props> = (props) => {
|
const Concentration: FC<Props> = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
@@ -126,7 +139,7 @@ const Concentration: FC<Props> = (props) => {
|
|||||||
|
|
||||||
const chartData = rows.map((item, index) => ({
|
const chartData = rows.map((item, index) => ({
|
||||||
color: colors[index % colors.length] ?? colors[0] ?? "blue",
|
color: colors[index % colors.length] ?? colors[0] ?? "blue",
|
||||||
name: item.key,
|
name: String(index),
|
||||||
value: item.chartValue,
|
value: item.chartValue,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -137,8 +150,7 @@ const Concentration: FC<Props> = (props) => {
|
|||||||
props.model.total && formatMoney({
|
props.model.total && formatMoney({
|
||||||
currency, value: props.model.total,
|
currency, value: props.model.total,
|
||||||
}),
|
}),
|
||||||
...rows.map(item =>
|
...rows.map(item => `${item.label}: ${amountFor(item, currency)}, ${item.share}`),
|
||||||
`${nameFor(item, props.slice, t)} · ${amountFor(item, currency)} · ${item.share}`),
|
|
||||||
].filter(Boolean).join("; ");
|
].filter(Boolean).join("; ");
|
||||||
|
|
||||||
const slices = [
|
const slices = [
|
||||||
@@ -157,6 +169,14 @@ const Concentration: FC<Props> = (props) => {
|
|||||||
const cellProps = (series: { name: string }) => chartCellProps(series, rows);
|
const cellProps = (series: { name: string }) => chartCellProps(series, rows);
|
||||||
const pieProps = { isAnimationActive: false, label: rankLabel };
|
const pieProps = { isAnimationActive: false, label: rankLabel };
|
||||||
|
|
||||||
|
const tooltipRows = rows.map(item => ({
|
||||||
|
amount: amountFor(item, currency),
|
||||||
|
label: item.label,
|
||||||
|
share: item.share,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const tooltipProps = { content: <ChartTooltip rows={tooltipRows} /> };
|
||||||
|
|
||||||
const totalText = props.model.total
|
const totalText = props.model.total
|
||||||
? formatMoney({ currency, value: props.model.total })
|
? formatMoney({ currency, value: props.model.total })
|
||||||
: "";
|
: "";
|
||||||
@@ -174,6 +194,7 @@ const Concentration: FC<Props> = (props) => {
|
|||||||
|
|
||||||
{props.model.currencies.length > 1 && (
|
{props.model.currencies.length > 1 && (
|
||||||
<Select
|
<Select
|
||||||
|
allowDeselect={false}
|
||||||
aria-label={t("portfolio.concentration.currency")}
|
aria-label={t("portfolio.concentration.currency")}
|
||||||
data={props.model.currencies}
|
data={props.model.currencies}
|
||||||
onChange={props.onCurrencyChange}
|
onChange={props.onCurrencyChange}
|
||||||
@@ -209,6 +230,8 @@ const Concentration: FC<Props> = (props) => {
|
|||||||
role="img"
|
role="img"
|
||||||
size={260}
|
size={260}
|
||||||
strokeWidth={24}
|
strokeWidth={24}
|
||||||
|
tooltipDataSource="segment"
|
||||||
|
tooltipProps={tooltipProps}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div aria-hidden="true" className={classes.center}>
|
<div aria-hidden="true" className={classes.center}>
|
||||||
@@ -222,7 +245,10 @@ const Concentration: FC<Props> = (props) => {
|
|||||||
<Text fw={700}>{totalText}</Text>
|
<Text fw={700}>{totalText}</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Text className={classes.srOnly} id={chartId}>
|
<Text
|
||||||
|
className={classes.srOnly}
|
||||||
|
id={chartId}
|
||||||
|
>
|
||||||
{chartText}
|
{chartText}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -238,7 +264,8 @@ const Concentration: FC<Props> = (props) => {
|
|||||||
index={index + 1}
|
index={index + 1}
|
||||||
item={item}
|
item={item}
|
||||||
key={item.key}
|
key={item.key}
|
||||||
name={nameFor(item, props.slice, t)}
|
logo={item.logo}
|
||||||
|
name={item.label}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
@@ -278,7 +305,8 @@ const Concentration: FC<Props> = (props) => {
|
|||||||
index={index + OTHER_START}
|
index={index + OTHER_START}
|
||||||
item={item}
|
item={item}
|
||||||
key={item.key}
|
key={item.key}
|
||||||
name={nameFor(item, props.slice, t)}
|
logo={item.logo}
|
||||||
|
name={item.label}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
|||||||
@@ -519,6 +519,10 @@ const ru = {
|
|||||||
missingSector: "Сектор не указан",
|
missingSector: "Сектор не указан",
|
||||||
other: "Прочее",
|
other: "Прочее",
|
||||||
otherCount: "{{count}} элементов",
|
otherCount: "{{count}} элементов",
|
||||||
|
otherCount_few: "{{count}} элемента",
|
||||||
|
otherCount_many: "{{count}} элементов",
|
||||||
|
otherCount_one: "{{count}} элемент",
|
||||||
|
otherCount_other: "{{count}} элемента",
|
||||||
sectors: "Секторы",
|
sectors: "Секторы",
|
||||||
slice: "Разрез концентрации",
|
slice: "Разрез концентрации",
|
||||||
title: "Концентрация",
|
title: "Концентрация",
|
||||||
|
|||||||
Reference in New Issue
Block a user