diff --git a/src/pages/account/model/concentration.test.ts b/src/pages/account/model/concentration.test.ts index ec0e77f..80fd3f0 100644 --- a/src/pages/account/model/concentration.test.ts +++ b/src/pages/account/model/concentration.test.ts @@ -23,20 +23,27 @@ function stock( }; } +const labels = { + missingIssuer: "Эмитент не указан", + missingSector: "Сектор не указан", + other: "Прочее", + sector: (code: string) => code, +}; + describe("account concentration", () => { it("uses exact positive values and excludes cash, negatives and nulls", () => { const model = toConcentration(snapshot([ stock("A", "1"), stock("B", "15"), stock("C", "0"), stock("D", "-50"), stock("E", null), stock("F", null, "USD"), stock("G", "-3", "USD"), - ]), "stocks", "RUB"); + ]), "stocks", "RUB", labels); expect(model.currencies).toEqual(["RUB", "USD"]); expect(model.total).toBe("16"); expect(model.items.map(item => item.share)).toEqual(["93,8 %", "6,3 %"]); expect(model.unaccounted).toBe(3); const negative = snapshot([stock("A", "-3", "USD")]); - expect(toConcentration(negative, "stocks", "USD")) + expect(toConcentration(negative, "stocks", "USD", labels)) .toMatchObject({ empty: true, unaccounted: 1 }); }); @@ -45,7 +52,7 @@ describe("account concentration", () => { stock("A", "400"), stock("B", "250"), stock("C", "150"), stock("D", "100"), stock("E", "50"), stock("F", "30"), stock("G", "20"), - ]), "stocks", "RUB"); + ]), "stocks", "RUB", labels); expect(model.items.map(item => item.amount)).toEqual([ "400", "250", "150", "100", "50", @@ -64,7 +71,7 @@ describe("account concentration", () => { stock("Сбер", "100"), stock("X5", "100"), stock("VK", "100"), stock("D", "100"), stock("E", "100"), stock("F", "100"), - ]), "stocks", "RUB"); + ]), "stocks", "RUB", labels); expect(model.items).toHaveLength(6); expect(model.other).toBeNull(); @@ -76,7 +83,7 @@ describe("account concentration", () => { 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 %"); }); @@ -96,28 +103,73 @@ describe("account concentration", () => { 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"]); - expect(toConcentration(snapshot(positions), "issuers", "RUB") + expect(toConcentration(snapshot(positions), "issuers", "RUB", labels) .items.map(item => [item.name, item.amount])).toEqual([ ["Сбербанк", "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", () => { const data = snapshot([ - stock("A", "200", "RUB", { - issuer: { id: "brand", name: "Сбербанк" }, + stock("A", "100", "RUB", { + issuer: { id: "brand", name: "Ястреб" }, }), - stock("B", "200", "RUB", { - issuer: { id: "brand", name: "Сбер" }, + stock("B", "300", "RUB", { + issuer: { id: "brand", name: "Альфа" }, }), - stock("C", "300", "RUB", { - issuer: { id: "other", name: "Альфа" }, + stock("C", "400", "RUB", { + issuer: { id: "other", name: "Бета" }, }), ]); - expect(toConcentration(data, "issuers", "RUB").items[0]?.name) - .toBe("Сбер"); + expect(toConcentration(data, "issuers", "RUB", labels).items + .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"); }); }); diff --git a/src/pages/account/model/concentration.ts b/src/pages/account/model/concentration.ts index d8b46c7..e9eb18a 100644 --- a/src/pages/account/model/concentration.ts +++ b/src/pages/account/model/concentration.ts @@ -4,6 +4,7 @@ import { type Decimal, decimalFromString, decimalToString, + instrumentLogoUrl, sumDecimals, } from "@/shared/lib"; @@ -21,11 +22,25 @@ type ConcentrationItem = { amount: string; chartValue: number; key: string; + label: string; + logo: ConcentrationLogo | null; name: string; share: string; 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 & { count: number; entries: ConcentrationItem[]; @@ -35,6 +50,8 @@ type Group = { best?: { name: string; value: Decimal }; first: number; key: string; + label: string; + logo: ConcentrationLogo | null; name: string; unknown: boolean; values: Decimal[]; @@ -65,6 +82,7 @@ function addPosition( position: Position, index: number, slice: Slice, + labels: ConcentrationLabels, selected: null | string, ): number { if (!isIncluded(slice, position.type)) return 0; @@ -73,7 +91,7 @@ function addPosition( const value = decimalFromString(position.currentValue.value); if (value.value < 0n) return 1; 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) ?? { ...identity, first: index, values: [], @@ -89,6 +107,7 @@ function addPosition( && compareNames(identity.name, best.name) < 0)) { group.best = { name: identity.name, value }; group.name = identity.name; + group.label = identity.label; } } @@ -116,23 +135,58 @@ function compareDecimal(a: Decimal, b: Decimal): number { 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") { 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") { + const isUnknown = !position.issuer; + return { key: position.issuer?.id ?? "", + label: position.issuer ? position.issuer.name : labels.missingIssuer, + logo: null, name: position.issuer?.name ?? "", - unknown: !position.issuer, + unknown: isUnknown, }; } return { 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, unknown: false, }; @@ -161,14 +215,9 @@ function toConcentration( portfolio: Portfolio, slice: Slice, currency: null | string, + labels: ConcentrationLabels, ): Concentration { - const currencies = [...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)); + const currencies = concentrationCurrencies(portfolio); const selected = currency && currencies.includes(currency) ? currency @@ -178,13 +227,15 @@ function toConcentration( let unaccounted = 0; 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 => ({ ...group, sum: sumDecimals(group.values), })).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 ? sumDecimals(ranked.map(item => item.sum)) @@ -195,6 +246,8 @@ function toConcentration( amount: decimalToString(group.sum), chartValue: chartValue(group.sum, total), key: group.key, + label: group.label, + logo: group.logo, name: group.name, share: share(group.sum, total), unknown: group.unknown, @@ -214,6 +267,8 @@ function toConcentration( count: rest.length, entries: rest, key: "__other", + label: labels.other, + logo: null, name: "", share: share(otherSum, total), unknown: false, } : null; @@ -233,5 +288,13 @@ function toConcentration( }; } -export { toConcentration }; -export type { Concentration, ConcentrationItem, Slice }; +export { + concentrationCurrencies, + toConcentration, +}; +export type { + Concentration, + ConcentrationItem, + ConcentrationLabels, + Slice, +}; diff --git a/src/pages/account/ui/AccountPage/AccountPage.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.test.tsx index 6eda271..296a08a 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.test.tsx @@ -8,7 +8,9 @@ import { within, } from "@testing-library/react"; 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 type { BrokerageOperation } from "@/entities/broker-operation"; @@ -36,6 +38,19 @@ import { AccountPageError } from "./components/AccountPageError.view"; import { AccountPageLoaded } from "./components/AccountPageLoaded.view"; import { AccountPageLoading } from "./components/AccountPageLoading/AccountPageLoading.view"; +function AccountSwitcher() { + const navigate = useNavigate(); + + return ( + navigate("/accounts/account-2")} + type="button" + > + Другой счёт + + ); +} + function configureTestAuth() { configureAuthClient({ accessToken: () => "test-token", @@ -51,6 +66,8 @@ function renderAccountRoute(queryClient: QueryClient, accountId = "account-1") { + + } path="/accounts/:accountId" /> @@ -141,6 +158,19 @@ const emptyRecentEvents: AccountPageRecentEvents = { 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() { return { recentEvents: emptyRecentEvents, @@ -148,6 +178,21 @@ function emptyProps() { }; } +const LoadedHarness = ( + props: Omit[0]>, "onTabChange">, +) => { + const [tab, setTab] = useState(props.tab ?? "positions"); + + return ( + setTab(value ?? "positions")} + tab={tab} + /> + ); +}; + function loadedProps(nextPortfolio: Portfolio = portfolio) { return { concentration: null, @@ -182,6 +227,8 @@ describe("AccountPage states", () => { expect(tab).toHaveAttribute("aria-selected", "false"); fireEvent.click(tab); expect(screen.getAllByText("100 ₽")).toHaveLength(2); + expect(screen.getByRole("img", { name: "Распределение позиций" })) + .toHaveAccessibleDescription(/Акция: 100 ₽, 100 %/); }); it("expands and resets other when switching slice", () => { @@ -193,16 +240,18 @@ describe("AccountPage states", () => { ); const data = { ...portfolio, positions }; - const model = toConcentration(data, "stocks", "RUB"); + + const model = toConcentration( + data, "stocks", "RUB", concentrationLabels(), + ); + const props = loadedProps(data); - const onTabChange = vi.fn(); renderPage( - , ); @@ -214,6 +263,272 @@ describe("AccountPage states", () => { fireEvent.click(screen.getByRole("radio", { name: "Секторы" })); 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( + , + ); + + 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( + , + ); + + expect(screen.getByText("SBER")).toBeInTheDocument(); + + view.unmount(); + + renderPage( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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 () => { server.use( 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", () => { renderPage( - { }); it("renders a stale portfolio in upstream order with nullable values hidden", () => { - renderPage(); + renderPage(); expect( screen.getByRole("heading", { level: 1, name: "Портфель" }), @@ -590,7 +905,7 @@ describe("AccountPage states", () => { "renders %s logos, fallbacks and signed returns including zero", (type) => { renderPage( - { "groups %s supporting metrics into quiet single lines", (type) => { renderPage( - { it("uses the ticker only for stock cards and otherwise keeps the ISIN", () => { renderPage( - { it("keeps other-position cards compact with labeled return and no logo", () => { renderPage( - { it("uses the cached account title when available", () => { renderPage( - { it("keeps the portfolio visible when recent operations fail", () => { renderPage( - { it("uses the ledger row for a recent outgoing trade operation", () => { renderPage( - { }; renderPage( - { "keeps the upcoming %s state and calendar action", (state) => { renderPage( - , @@ -892,7 +1207,7 @@ describe("AccountPage states", () => { it("keeps the portfolio visible when upcoming events fail", () => { renderPage( - { }); it("opens positions by default and switches between local tabs", () => { - renderPage(); + renderPage(); expect(screen.getByRole("tab", { name: "Позиции" })) .toHaveAttribute("aria-selected", "true"); @@ -1074,7 +1389,7 @@ describe("AccountPage states", () => { it("links operation, event and income previews to their detail pages", () => { renderPage( - { }); it("filters positions by the kinds present in the portfolio", () => { - renderPage(); + renderPage(); expect( screen.getAllByRole("radio").map(radio => radio.value), @@ -1109,7 +1424,7 @@ describe("AccountPage states", () => { it("hides the position filter when only one kind exists", () => { renderPage( - { }); it("hides unrealized returns for a partial portfolio", () => { - renderPage(); + renderPage(); expect(screen.queryByRole("region", { name: "Нереализованный результат" })) .not.toBeInTheDocument(); @@ -1130,7 +1445,7 @@ describe("AccountPage states", () => { it("omits an unrealized currency total when any return is incomplete", () => { renderPage( - t(`stock.sectors.${code}`, { defaultValue: code }), + }; + const concentration = accountId !== "all" && current.tab === "concentration" - ? toConcentration(portfolio.data, current.slice, currency) + ? toConcentration(portfolio.data, current.slice, currency, labels) : null; return { diff --git a/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx index 9200ead..8348501 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx @@ -42,7 +42,7 @@ type AccountPageLoadedProps = { incomeSummary?: AccountPageIncomeSummary; onCurrencyChange: (currency: null | string) => void; onSliceChange: (slice: Slice) => void; - onTabChange?: (tab: null | string) => void; + onTabChange: (tab: null | string) => void; partial?: boolean; portfolio: Portfolio; positionSections: AccountPagePositionSection[]; @@ -50,18 +50,16 @@ type AccountPageLoadedProps = { recentOperations: AccountPageRecentOperations; slice: Slice; source?: InvestmentResourceMeta; - tab?: string; + tab: string; unrealizedReturns: Portfolio["totalsByCurrency"]; }; const AccountPageLoaded: FC = (props) => { const { t } = useTranslation(); const [filter, setFilter] = useState(); - const [fallbackTab, setFallbackTab] = useState("positions"); - const tab = props.tab ?? fallbackTab; + const tab = props.tab; - const onTabChange = props.onTabChange - ?? ((value: null | string) => setFallbackTab(value ?? "positions")); + const onTabChange = props.onTabChange; const activeSection = props.positionSections.find( section => section.kind === filter, diff --git a/src/pages/account/ui/AccountPage/components/Concentration/Concentration.view.tsx b/src/pages/account/ui/AccountPage/components/Concentration/Concentration.view.tsx index b954c91..9f9783a 100644 --- a/src/pages/account/ui/AccountPage/components/Concentration/Concentration.view.tsx +++ b/src/pages/account/ui/AccountPage/components/Concentration/Concentration.view.tsx @@ -14,6 +14,7 @@ import type { Slice, } from "../../../../model/concentration"; +import { PositionLogo } from "../PositionLogo.view"; import classes from "./Concentration.module.css"; type Props = { @@ -37,6 +38,7 @@ type RowProps = { currency: string; index: number; item: ConcentrationItem; + logo?: ConcentrationItem["logo"]; name: string; }; @@ -46,29 +48,12 @@ const colors = [ ]; const PERCENT = 100; -const RANK_THRESHOLD = 0.04; const OTHER_START = 6; function amountFor(item: ConcentrationItem, currency: string): string { return formatMoney({ currency, value: item.amount }); } -function nameFor( - item: ConcentrationItem, - slice: Slice, - t: ReturnType["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 = props => ( = props => ( {props.index} + {props.logo && } + {props.name} + {props.logo?.subtitle && ( + {props.logo.subtitle} + )} + = props => ( ); +type TooltipProps = { + active?: boolean; + payload?: { name?: number | string }[]; + rows: { amount: string; label: string; share: string }[]; +}; + function chartCellProps(series: { name: string }, rows: ConcentrationItem[]) { + const index = Number(series.name); + const item = rows.at(index); + return { stroke: "white", strokeWidth: 2, - ...((series.name === "__other" - || rows.some(item => item.key === series.name && item.unknown)) + ...((item?.unknown || (index === rows.length - 1 + && item?.key === "__other")) && { strokeDasharray: "3 2" }), }; } -function rankLabel({ index, percent }: { index: number; percent?: number }) { - return (percent ?? 0) >= RANK_THRESHOLD ? String(index + 1) : ""; +function rankLabel({ index }: { index: number; percent?: number }) { + return String(index + 1); } +const ChartTooltip: FC = (props) => { + const row = props.rows[Number(props.payload?.[0]?.name)]; + if (!row || !props.active) return; + + return ( + + {row.label} + + {row.amount} · {row.share} + + ); +}; + const Concentration: FC = (props) => { const { t } = useTranslation(); const [expanded, setExpanded] = useState(false); @@ -126,7 +139,7 @@ const Concentration: FC = (props) => { const chartData = rows.map((item, index) => ({ color: colors[index % colors.length] ?? colors[0] ?? "blue", - name: item.key, + name: String(index), value: item.chartValue, })); @@ -137,8 +150,7 @@ const Concentration: FC = (props) => { props.model.total && formatMoney({ currency, value: props.model.total, }), - ...rows.map(item => - `${nameFor(item, props.slice, t)} · ${amountFor(item, currency)} · ${item.share}`), + ...rows.map(item => `${item.label}: ${amountFor(item, currency)}, ${item.share}`), ].filter(Boolean).join("; "); const slices = [ @@ -157,6 +169,14 @@ const Concentration: FC = (props) => { const cellProps = (series: { name: string }) => chartCellProps(series, rows); const pieProps = { isAnimationActive: false, label: rankLabel }; + const tooltipRows = rows.map(item => ({ + amount: amountFor(item, currency), + label: item.label, + share: item.share, + })); + + const tooltipProps = { content: }; + const totalText = props.model.total ? formatMoney({ currency, value: props.model.total }) : ""; @@ -174,6 +194,7 @@ const Concentration: FC = (props) => { {props.model.currencies.length > 1 && ( = (props) => { role="img" size={260} strokeWidth={24} + tooltipDataSource="segment" + tooltipProps={tooltipProps} /> @@ -222,7 +245,10 @@ const Concentration: FC = (props) => { {totalText} - + {chartText} @@ -238,7 +264,8 @@ const Concentration: FC = (props) => { index={index + 1} item={item} key={item.key} - name={nameFor(item, props.slice, t)} + logo={item.logo} + name={item.label} /> ))} @@ -278,7 +305,8 @@ const Concentration: FC = (props) => { index={index + OTHER_START} item={item} key={item.key} - name={nameFor(item, props.slice, t)} + logo={item.logo} + name={item.label} /> ))} diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts index a4c30f9..1e32cb3 100644 --- a/src/shared/i18n/locales/ru.ts +++ b/src/shared/i18n/locales/ru.ts @@ -519,6 +519,10 @@ const ru = { missingSector: "Сектор не указан", other: "Прочее", otherCount: "{{count}} элементов", + otherCount_few: "{{count}} элемента", + otherCount_many: "{{count}} элементов", + otherCount_one: "{{count}} элемент", + otherCount_other: "{{count}} элемента", sectors: "Секторы", slice: "Разрез концентрации", title: "Концентрация",