From ba7f8547bb8283001e8d30dd114b18a01ccc9797 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sun, 1 Nov 2026 12:24:38 +0300 Subject: [PATCH] feat(account): add concentration tab with top-5 slices and chart (WealthLedger/main#48) --- src/app/routes/AppRoutes/AllAccounts.test.tsx | 2 + src/pages/account/model/concentration.test.ts | 123 +++++++ src/pages/account/model/concentration.ts | 237 ++++++++++++++ src/pages/account/model/positions.fixtures.ts | 6 + .../ui/AccountPage/AccountPage.test.tsx | 62 ++++ .../account/ui/AccountPage/AccountPage.tsx | 7 + .../account/ui/AccountPage/AccountPage.vm.ts | 47 +++ .../components/AccountPageLoaded.view.tsx | 43 ++- .../Concentration/Concentration.module.css | 111 +++++++ .../Concentration/Concentration.view.tsx | 302 ++++++++++++++++++ .../PositionCard/PositionCard.stories.tsx | 2 + .../position-layout.browser.test.tsx | 2 + src/shared/api/generated/schema.d.ts | 8 + src/shared/api/spec/openapi.json | 47 ++- src/shared/i18n/locales/ru.ts | 17 + 15 files changed, 1013 insertions(+), 3 deletions(-) create mode 100644 src/pages/account/model/concentration.test.ts create mode 100644 src/pages/account/model/concentration.ts create mode 100644 src/pages/account/ui/AccountPage/components/Concentration/Concentration.module.css create mode 100644 src/pages/account/ui/AccountPage/components/Concentration/Concentration.view.tsx diff --git a/src/app/routes/AppRoutes/AllAccounts.test.tsx b/src/app/routes/AppRoutes/AllAccounts.test.tsx index a189416..0363249 100644 --- a/src/app/routes/AppRoutes/AllAccounts.test.tsx +++ b/src/app/routes/AppRoutes/AllAccounts.test.tsx @@ -46,8 +46,10 @@ const position: Portfolio["positions"][number] displayName: "Сбербанк", figi: "SBER", isin: null, + issuer: null, logoName: null, quantity: "2", + sector: null, ticker: "SBER", type: "STOCK", }; diff --git a/src/pages/account/model/concentration.test.ts b/src/pages/account/model/concentration.test.ts new file mode 100644 index 0000000..ec0e77f --- /dev/null +++ b/src/pages/account/model/concentration.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; + +import type { Portfolio } from "@/entities/portfolio"; + +import { toConcentration } from "./concentration"; +import { portfolio, position } from "./positions.fixtures"; + +function snapshot(positions: Portfolio["positions"]): Portfolio { + return { ...portfolio, positions }; +} + +function stock( + name: string, + value: null | string, + currency = "RUB", + overrides: Partial = {}, +): Portfolio["positions"][number] { + return { + ...position, + currentValue: value === null ? null : { currency, value }, + displayName: name, + ...overrides, + }; +} + +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"); + + 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")) + .toMatchObject({ empty: true, unaccounted: 1 }); + }); + + it("ranks seven positions with exact other and top-five totals", () => { + const model = toConcentration(snapshot([ + stock("A", "400"), stock("B", "250"), stock("C", "150"), + stock("D", "100"), stock("E", "50"), stock("F", "30"), + stock("G", "20"), + ]), "stocks", "RUB"); + + expect(model.items.map(item => item.amount)).toEqual([ + "400", "250", "150", "100", "50", + ]); + expect(model.other).toMatchObject({ + amount: "50", count: 2, share: "5 %", + }); + expect(model.top5Share).toBe("95 %"); + expect(model.other?.entries.map(item => item.share)).toEqual([ + "3 %", "2 %", + ]); + }); + + it("keeps sixth row, tiny share and stable alphabetical ties", () => { + const model = toConcentration(snapshot([ + stock("Сбер", "100"), stock("X5", "100"), + stock("VK", "100"), stock("D", "100"), + stock("E", "100"), stock("F", "100"), + ]), "stocks", "RUB"); + + expect(model.items).toHaveLength(6); + expect(model.other).toBeNull(); + expect(model.items.slice(0, 2).map(item => item.name)).toEqual([ + "D", "E", + ]); + + const tiny = snapshot([ + stock("Large", "100000000"), stock("Small", "1"), + ]); + + expect(toConcentration(tiny, "stocks", "RUB").items[1]?.share) + .toBe("< 0,1 %"); + }); + + it("groups sectors and issuers by codes, not labels", () => { + const positions = [ + stock("A", "300", "RUB", { + issuer: { id: "one", name: "Сбербанк" }, sector: "energy", + }), + stock("B", "200", "RUB", { + issuer: { id: "one", name: "Сбер" }, sector: "energy", + type: "BOND", + }), + stock("C", "100", "RUB", { + issuer: { id: "two", name: "Сбер" }, sector: "financial", + type: "FUND", + }), + stock("D", "50", "RUB", { issuer: null, sector: null }), + ]; + + expect(toConcentration(snapshot(positions), "sectors", "RUB") + .items.map(item => item.amount)).toEqual(["500", "100", "50"]); + expect(toConcentration(snapshot(positions), "issuers", "RUB") + .items.map(item => [item.name, item.amount])).toEqual([ + ["Сбербанк", "500"], ["", "50"], + ]); + }); + + it("chooses issuer name from the largest position, then alphabetically", () => { + const data = snapshot([ + stock("A", "200", "RUB", { + issuer: { id: "brand", name: "Сбербанк" }, + }), + stock("B", "200", "RUB", { + issuer: { id: "brand", name: "Сбер" }, + }), + stock("C", "300", "RUB", { + issuer: { id: "other", name: "Альфа" }, + }), + ]); + + expect(toConcentration(data, "issuers", "RUB").items[0]?.name) + .toBe("Сбер"); + }); +}); diff --git a/src/pages/account/model/concentration.ts b/src/pages/account/model/concentration.ts new file mode 100644 index 0000000..d8b46c7 --- /dev/null +++ b/src/pages/account/model/concentration.ts @@ -0,0 +1,237 @@ +import type { Portfolio } from "@/entities/portfolio"; + +import { + type Decimal, + decimalFromString, + decimalToString, + sumDecimals, +} from "@/shared/lib"; + +type Concentration = { + currencies: string[]; + empty: boolean; + items: ConcentrationItem[]; + other: ConcentrationOther | null; + top5Share: null | string; + total: null | string; + unaccounted: number; +}; + +type ConcentrationItem = { + amount: string; + chartValue: number; + key: string; + name: string; + share: string; + unknown: boolean; +}; + +type ConcentrationOther = ConcentrationItem & { + count: number; + entries: ConcentrationItem[]; +}; + +type Group = { + best?: { name: string; value: Decimal }; + first: number; + key: string; + name: string; + unknown: boolean; + values: Decimal[]; +}; + +type Position = Portfolio["positions"][number]; + +type Slice = "bonds" + | "issuers" + | "sectors" + | "stocks"; + +const eligibleTypes = new Set(["BOND", "FUND", "STOCK"]); +const RADIX = 10n; +const CHART_PRECISION = 1_000_000n; +const SHARE_PRECISION = 1000n; +const HALF = 2n; +const TOP = 5; +const FIRST_OTHER = 7; + +const compareNames = (a: string, b: string) => + a.toLowerCase().localeCompare(b.toLowerCase(), "en", { + numeric: false, + }); + +function addPosition( + groups: Map, + position: Position, + index: number, + slice: Slice, + selected: null | string, +): number { + if (!isIncluded(slice, position.type)) return 0; + if (!position.currentValue) return 1; + if (position.currentValue.currency !== selected) return 0; + 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 group = groups.get(identity.key) ?? { + ...identity, first: index, values: [], + }; + + group.values.push(value); + + if (slice === "issuers" && !identity.unknown) { + const best = group.best; + + if (!best || compareDecimal(value, best.value) > 0 + || (compareDecimal(value, best.value) === 0 + && compareNames(identity.name, best.name) < 0)) { + group.best = { name: identity.name, value }; + group.name = identity.name; + } + } + + groups.set(identity.key, group); + + return 0; +} + +function chartValue(part: Decimal, total: Decimal): number { + const scale = Math.max(part.scale, total.scale); + const numerator = part.value * RADIX ** BigInt(scale - part.scale); + const denominator = total.value * RADIX ** BigInt(scale - total.scale); + + return Number(numerator * CHART_PRECISION / denominator) + / Number(CHART_PRECISION); +} + +function compareDecimal(a: Decimal, b: Decimal): number { + const scale = Math.max(a.scale, b.scale); + const left = a.value * RADIX ** BigInt(scale - a.scale); + const right = b.value * RADIX ** BigInt(scale - b.scale); + + if (left > right) return 1; + + return left < right ? -1 : 0; +} + +function groupIdentity(position: Position, slice: Slice, index: number) { + if (slice === "sectors") { + const sector = position.sector; + + return { key: sector ?? "", name: sector ?? "", unknown: !sector }; + } + + if (slice === "issuers") { + return { + key: position.issuer?.id ?? "", + name: position.issuer?.name ?? "", + unknown: !position.issuer, + }; + } + + return { + key: String(index), + name: position.displayName, + unknown: false, + }; +} + +function isIncluded(slice: Slice, type: string): boolean { + if (slice === "stocks") return type === "STOCK"; + if (slice === "bonds") return type === "BOND"; + + return slice === "issuers" ? type === "STOCK" || type === "BOND" : eligibleTypes.has(type); +} + +function share(part: Decimal, total: Decimal): string { + const scale = Math.max(part.scale, total.scale); + const numerator = part.value * RADIX ** BigInt(scale - part.scale); + const denominator = total.value * RADIX ** BigInt(scale - total.scale); + if (numerator * SHARE_PRECISION < denominator) return "< 0,1 %"; + + const tenths = (numerator * SHARE_PRECISION * HALF + denominator) + / (HALF * denominator); + + return tenths % RADIX === 0n ? `${tenths / RADIX} %` : `${tenths / RADIX},${tenths % RADIX} %`; +} + +function toConcentration( + portfolio: Portfolio, + slice: Slice, + currency: null | string, +): 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 selected = currency && currencies.includes(currency) + ? currency + : currencies[0] ?? null; + + const groups = new Map(); + let unaccounted = 0; + + for (const [index, position] of portfolio.positions.entries()) { + unaccounted += addPosition(groups, position, index, slice, 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); + + const total = ranked.length > 0 + ? sumDecimals(ranked.map(item => item.sum)) + : null; + + const entries = total + ? ranked.map(group => ({ + amount: decimalToString(group.sum), + chartValue: chartValue(group.sum, total), + key: group.key, + name: group.name, + share: share(group.sum, total), + unknown: group.unknown, + })) + : []; + + const rest = entries.length >= FIRST_OTHER ? entries.slice(TOP) : []; + + const otherSum = rest.length > 0 + ? sumDecimals(ranked.slice(TOP).map(item => item.sum)) + : null; + + const other = otherSum && total + ? { + amount: decimalToString(otherSum), + chartValue: chartValue(otherSum, total), + count: rest.length, + entries: rest, + key: "__other", + name: "", share: share(otherSum, total), unknown: false, + } + : null; + + const top5 = ranked.length > TOP + ? sumDecimals(ranked.slice(0, TOP).map(item => item.sum)) + : null; + + return { + currencies, + empty: !total, + items: other ? entries.slice(0, TOP) : entries, + other, + top5Share: top5 && total ? share(top5, total) : null, + total: total && decimalToString(total), + unaccounted, + }; +} + +export { toConcentration }; +export type { Concentration, ConcentrationItem, Slice }; diff --git a/src/pages/account/model/positions.fixtures.ts b/src/pages/account/model/positions.fixtures.ts index 54508f6..ff4718e 100644 --- a/src/pages/account/model/positions.fixtures.ts +++ b/src/pages/account/model/positions.fixtures.ts @@ -13,8 +13,10 @@ const portfolio displayName: "Акция", figi: "FIGI-STOCK", isin: null, + issuer: null, logoName: "akciya.png", quantity: "2", + sector: null, ticker: null, type: "STOCK", }, @@ -25,8 +27,10 @@ const portfolio displayName: "Облигация", figi: "FIGI-BOND", isin: "RU000A0JX0J2", + issuer: null, logoName: null, quantity: "1", + sector: null, ticker: null, type: "BOND", }, @@ -37,8 +41,10 @@ const portfolio displayName: "Фонд", figi: "FIGI-FUND", isin: null, + issuer: null, logoName: "fond.png", quantity: "3", + sector: null, ticker: null, type: "FUND", }, diff --git a/src/pages/account/ui/AccountPage/AccountPage.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.test.tsx index 5c4e610..6eda271 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.test.tsx @@ -25,6 +25,7 @@ import type { AccountPageRecentOperations, } from "../../model/previews"; +import { toConcentration } from "../../model/concentration"; import { toAccountPagePositionSections, toUnrealizedReturns, @@ -84,8 +85,10 @@ const portfolio displayName: "Вторая", figi: "FIGI-2", isin: null, + issuer: null, logoName: "vtoraya.png", quantity: "2", + sector: null, ticker: null, type: "STOCK", }, @@ -96,8 +99,10 @@ const portfolio displayName: "Первая", figi: "FIGI-1", isin: "RU000A0JX0J2", + issuer: null, logoName: null, quantity: "1", + sector: null, ticker: null, type: "BOND", }, @@ -116,8 +121,10 @@ function position( displayName: "Инструмент", figi: null, isin: null, + issuer: null, logoName: null, quantity: "1", + sector: null, ticker: null, type: "STOCK", ...overrides, @@ -143,15 +150,70 @@ function emptyProps() { function loadedProps(nextPortfolio: Portfolio = portfolio) { return { + concentration: null, + currency: null, + onCurrencyChange: vi.fn(), + onSliceChange: vi.fn(), portfolio: nextPortfolio, positionSections: toAccountPagePositionSections(nextPortfolio), recentEvents: emptyRecentEvents, recentOperations: emptyRecentOperations, + slice: "stocks" as const, unrealizedReturns: toUnrealizedReturns(nextPortfolio.positions), }; } describe("AccountPage states", () => { + it("shows concentration only for a loaded individual account", async () => { + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => + HttpResponse.json(resource({ + ...portfolio, + positions: [position({ + currentValue: { currency: "RUB", value: "100" }, + displayName: "Акция", + })], + })), + ), + ); + const client = createTestQueryClient(); + renderAccountRoute(client); + const tab = await screen.findByRole("tab", { name: "Концентрация" }); + expect(tab).toHaveAttribute("aria-selected", "false"); + fireEvent.click(tab); + expect(screen.getAllByText("100 ₽")).toHaveLength(2); + }); + + it("expands and resets other when switching slice", () => { + const positions = [400, 250, 150, 100, 50, 30, 20].map( + (value, index) => position({ + currentValue: { currency: "RUB", value: String(value) }, + displayName: `Акция ${index + 1}`, + }), + ); + + const data = { ...portfolio, positions }; + const model = toConcentration(data, "stocks", "RUB"); + const props = loadedProps(data); + const onTabChange = vi.fn(); + renderPage( + , + ); + const other = screen.getByRole("button", { name: /Прочее/ }); + expect(other).toHaveAttribute("aria-expanded", "false"); + fireEvent.click(other); + expect(other).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByText("Акция 7")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("radio", { name: "Секторы" })); + expect(props.onSliceChange).toHaveBeenCalledWith("sectors"); + }); it("shows the loaded portfolio source sync status", async () => { server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => diff --git a/src/pages/account/ui/AccountPage/AccountPage.tsx b/src/pages/account/ui/AccountPage/AccountPage.tsx index a30dc6a..e742f7f 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.tsx @@ -25,13 +25,20 @@ const AccountPage: FC = () => { ); diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.ts b/src/pages/account/ui/AccountPage/AccountPage.vm.ts index a68c0db..ee78ccf 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.ts +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.ts @@ -1,3 +1,4 @@ +import { useState } from "react"; import { useParams } from "react-router"; import type { Portfolio } from "@/entities/portfolio"; @@ -17,6 +18,11 @@ import { useScreenSync } from "@/features/tbank-connection"; import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; import { useInitialAvailability } from "@/shared/lib"; +import { + type Concentration, + type Slice, + toConcentration, +} from "../../model/concentration"; import { type AccountPagePositionSection, toAccountPagePositionSections, @@ -45,10 +51,17 @@ type AccountPageEmptyViewModel = AccountPageContent & { }; type AccountPageLoadedViewModel = AccountPageContent & { + concentration: Concentration | null; + currency: null | string; + onCurrencyChange: (currency: null | string) => void; + onSliceChange: (slice: Slice) => void; + onTabChange: (tab: null | string) => void; portfolio: Portfolio; positionSections: AccountPagePositionSection[]; + slice: Slice; source?: InvestmentResourceMeta; state: "loaded"; + tab: string; unrealizedReturns: Portfolio["totalsByCurrency"]; }; @@ -66,6 +79,23 @@ function isPortfolioAccessFailure(code?: string) { function useAccountPageViewModel(): AccountPageViewModel { const { accountId } = useParams(); + + const [selection, setSelection] = useState<{ + accountId?: string; + currency: null | string; + slice: Slice; + tab: string; + }>({ accountId, currency: null, slice: "stocks", tab: "positions" }); + + const current = selection.accountId === accountId + ? selection + : { + accountId, currency: null, slice: "stocks" as const, tab: "positions", + }; + + const updateSelection = (change: Partial) => + setSelection({ ...current, ...change }); + const portfolio = usePortfolio(accountId); const operations = useBrokerageOperations(accountId, { @@ -122,18 +152,35 @@ function useAccountPageViewModel(): AccountPageViewModel { }; const isPartial = portfolio.meta?.completeness === "incomplete"; + const currencies = toConcentration(portfolio.data, "stocks", null).currencies; + + const currency = current.currency && currencies.includes(current.currency) + ? current.currency + : currencies[0] ?? null; + + const concentration = accountId !== "all" + && current.tab === "concentration" + ? toConcentration(portfolio.data, current.slice, currency) + : null; return { account, accountId, + concentration, + currency, incomeSummary, + onCurrencyChange: value => updateSelection({ currency: value }), + onSliceChange: slice => updateSelection({ slice }), + onTabChange: tab => updateSelection({ tab: tab ?? "positions" }), partial: isPartial, portfolio: portfolio.data, positionSections: toAccountPagePositionSections(portfolio.data), recentEvents, recentOperations, + slice: current.slice, source: portfolio.meta, state: "loaded" as const, + tab: current.tab, unrealizedReturns: toUnrealizedReturns(portfolio.data.positions), }; } diff --git a/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx index b8c9a99..9200ead 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx @@ -18,6 +18,7 @@ import { formatMoney } from "@/shared/lib"; import { SourceSyncStatus } from "@/shared/ui"; import { PortfolioStructure } from "@/widgets/portfolio-structure"; +import type { Concentration as ConcentrationModel, Slice } from "../../../model/concentration"; import type { AccountPagePositionSection } from "../../../model/positions"; import type { AccountPageIncomeSummary, @@ -27,6 +28,7 @@ import type { import classes from "../AccountPage.module.css"; import { AccountHeading } from "./AccountHeading.view"; +import { Concentration } from "./Concentration/Concentration.view"; import { CurrencyCards } from "./CurrencyCards/CurrencyCards.view"; import { IncomeSummary } from "./IncomeSummary.view"; import { PositionSection } from "./PositionSection.view"; @@ -35,19 +37,31 @@ import { RecentOperations } from "./RecentOperations.view"; type AccountPageLoadedProps = { account?: BrokerageAccount; accountId?: string; + concentration: ConcentrationModel | null; + currency: null | string; incomeSummary?: AccountPageIncomeSummary; + onCurrencyChange: (currency: null | string) => void; + onSliceChange: (slice: Slice) => void; + onTabChange?: (tab: null | string) => void; partial?: boolean; portfolio: Portfolio; positionSections: AccountPagePositionSection[]; recentEvents: AccountPageRecentEvents; recentOperations: AccountPageRecentOperations; + slice: Slice; source?: InvestmentResourceMeta; + 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 onTabChange = props.onTabChange + ?? ((value: null | string) => setFallbackTab(value ?? "positions")); const activeSection = props.positionSections.find( section => section.kind === filter, @@ -75,7 +89,9 @@ const AccountPageLoaded: FC = (props) => { /> {props.partial - ? {t("investment.partial")} + ? tab !== "concentration" && ( + {t("investment.partial")} + ) : } {!props.partial && props.unrealizedReturns.length > 0 && ( @@ -107,14 +123,21 @@ const AccountPageLoaded: FC = (props) => { {t("portfolio.tabs.positions")} + {props.accountId !== "all" && ( + + {t("portfolio.concentration.title")} + + )} + {t("portfolio.tabs.cash")} @@ -143,6 +166,22 @@ const AccountPageLoaded: FC = (props) => { + {props.accountId !== "all" && ( + + {props.concentration && ( + + )} + + )} + diff --git a/src/pages/account/ui/AccountPage/components/Concentration/Concentration.module.css b/src/pages/account/ui/AccountPage/components/Concentration/Concentration.module.css new file mode 100644 index 0000000..5a204c1 --- /dev/null +++ b/src/pages/account/ui/AccountPage/components/Concentration/Concentration.module.css @@ -0,0 +1,111 @@ +.layout { + display: grid; + grid-template-columns: minmax(260px, 1fr) minmax(0, 1.4fr); + gap: var(--mantine-spacing-xl); + align-items: center; +} + +.chart { + position: relative; + justify-self: center; +} + +.pattern { + position: absolute; + width: 0; + height: 0; +} + +.center { + position: absolute; + inset: 32%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + font-size: clamp(12px, 1.3vw, 17px); + overflow-wrap: anywhere; + pointer-events: none; +} + +.srOnly { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; +} + +.list { + list-style: none; + margin: 0; + padding: 0; +} + +.row { + display: flex; + align-items: center; + gap: var(--mantine-spacing-sm); + min-height: 58px; + border-bottom: 1px solid var(--mantine-color-gray-2); +} + +.rank { + display: grid; + place-items: center; + width: 28px; + height: 28px; + flex: none; + border-radius: 50%; + color: white; +} + +.identity { + flex: 1; + min-width: 0; + overflow-wrap: anywhere; +} + +.bar, +.bar span { + display: block; + height: 4px; + border-radius: 4px; +} + +.bar { + background: var(--mantine-color-gray-2); +} + +.value { + text-align: right; + white-space: nowrap; +} + +.other { + display: flex; + justify-content: space-between; + gap: var(--mantine-spacing-sm); + width: 100%; + min-height: 44px; + align-items: center; + border: 0; + background: var(--mantine-color-gray-0); + color: inherit; + cursor: pointer; + text-align: left; +} + +.other:focus-visible { + outline: 2px solid var(--mantine-color-blue-6); + outline-offset: 2px; +} + +@media (max-width: 700px) { + .layout { grid-template-columns: 1fr; } + .slices { max-width: 100%; overflow-x: auto; } + .other { flex-wrap: wrap; } +} diff --git a/src/pages/account/ui/AccountPage/components/Concentration/Concentration.view.tsx b/src/pages/account/ui/AccountPage/components/Concentration/Concentration.view.tsx new file mode 100644 index 0000000..b954c91 --- /dev/null +++ b/src/pages/account/ui/AccountPage/components/Concentration/Concentration.view.tsx @@ -0,0 +1,302 @@ +import { DonutChart } from "@mantine/charts"; +import { + Alert, Box, Group, Paper, SegmentedControl, Select, Stack, + Text, Title, +} from "@mantine/core"; +import { type FC, useId, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { formatMoney } from "@/shared/lib"; + +import type { + ConcentrationItem, + Concentration as ConcentrationModel, + Slice, +} from "../../../../model/concentration"; + +import classes from "./Concentration.module.css"; + +type Props = { + currency: null | string; + model: ConcentrationModel; + onCurrencyChange: (currency: null | string) => void; + onSliceChange: (slice: Slice) => void; + partial: boolean; + slice: Slice; +}; + +const sliceLabels = { + bonds: "portfolio.bonds", + issuers: "portfolio.concentration.issuers", + sectors: "portfolio.concentration.sectors", + stocks: "portfolio.stocks", +} as const; + +type RowProps = { + color: string; + currency: string; + index: number; + item: ConcentrationItem; + name: string; +}; + +const colors = [ + "#2563eb", "#18a999", "#f59e0b", "#8b5cf6", "#ef6c5b", + "#64748b", +]; + +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.name} + + + + + {amountFor(props.item, props.currency)} + + {props.item.share} + +
  • +); + +function chartCellProps(series: { name: string }, rows: ConcentrationItem[]) { + return { + stroke: "white", + strokeWidth: 2, + ...((series.name === "__other" + || rows.some(item => item.key === series.name && item.unknown)) + && { strokeDasharray: "3 2" }), + }; +} + +function rankLabel({ index, percent }: { index: number; percent?: number }) { + return (percent ?? 0) >= RANK_THRESHOLD ? String(index + 1) : ""; +} + +const Concentration: FC = (props) => { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + const otherId = useId(); + const chartId = useId(); + const currency = props.currency ?? ""; + + const rows = props.model.other + ? [...props.model.items, props.model.other] + : props.model.items; + + const chartData = rows.map((item, index) => ({ + color: colors[index % colors.length] ?? colors[0] ?? "blue", + name: item.key, + value: item.chartValue, + })); + + const chartText = [ + props.model.top5Share && t("portfolio.concentration.top5", { + share: props.model.top5Share, + }), + props.model.total && formatMoney({ + currency, value: props.model.total, + }), + ...rows.map(item => + `${nameFor(item, props.slice, t)} · ${amountFor(item, currency)} · ${item.share}`), + ].filter(Boolean).join("; "); + + const slices = [ + { label: t("portfolio.stocks"), value: "stocks" }, + { label: t("portfolio.bonds"), value: "bonds" }, + { label: t("portfolio.concentration.sectors"), value: "sectors" }, + { label: t("portfolio.concentration.issuers"), value: "issuers" }, + ]; + + // eslint-disable-next-line frontend/readability -- Mantine callback closes over page action. + const selectSlice = (value: string) => props.onSliceChange(value as Slice); + // eslint-disable-next-line frontend/readability -- Disclosure state belongs to this view. + const toggleOther = () => setExpanded(value => !value); + + // eslint-disable-next-line frontend/readability -- Chart callback closes over visible rows. + const cellProps = (series: { name: string }) => chartCellProps(series, rows); + const pieProps = { isAnimationActive: false, label: rankLabel }; + + const totalText = props.model.total + ? formatMoney({ currency, value: props.model.total }) + : ""; + + return ( + + + + + {props.model.currencies.length > 1 && ( +