feat(account): add concentration tab with top-5 slices and chart (WealthLedger/main#48) (#127)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Failing after 9m31s

This commit was merged in pull request #127.
This commit is contained in:
2026-11-01 19:28:52 +03:00
parent 7242240b21
commit 70a2c335fd
15 changed files with 1503 additions and 22 deletions
@@ -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";
@@ -25,6 +27,7 @@ import type {
AccountPageRecentOperations,
} from "../../model/previews";
import { toConcentration } from "../../model/concentration";
import {
toAccountPagePositionSections,
toUnrealizedReturns,
@@ -35,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 (
<button
onClick={() => navigate("/accounts/account-2")}
type="button"
>
Другой счёт
</button>
);
}
function configureTestAuth() {
configureAuthClient({
accessToken: () => "test-token",
@@ -50,6 +66,8 @@ function renderAccountRoute(queryClient: QueryClient, accountId = "account-1") {
<QueryClientProvider client={queryClient}>
<MantineProvider>
<MemoryRouter initialEntries={[`/accounts/${accountId}`]}>
<AccountSwitcher />
<Routes>
<Route element={<AccountPage />} path="/accounts/:accountId" />
</Routes>
@@ -84,8 +102,10 @@ const portfolio
displayName: "Вторая",
figi: "FIGI-2",
isin: null,
issuer: null,
logoName: "vtoraya.png",
quantity: "2",
sector: null,
ticker: null,
type: "STOCK",
},
@@ -96,8 +116,10 @@ const portfolio
displayName: "Первая",
figi: "FIGI-1",
isin: "RU000A0JX0J2",
issuer: null,
logoName: null,
quantity: "1",
sector: null,
ticker: null,
type: "BOND",
},
@@ -116,8 +138,10 @@ function position(
displayName: "Инструмент",
figi: null,
isin: null,
issuer: null,
logoName: null,
quantity: "1",
sector: null,
ticker: null,
type: "STOCK",
...overrides,
@@ -134,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,
@@ -141,17 +178,357 @@ 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) {
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);
expect(screen.getByRole("img", { name: "Распределение позиций" }))
.toHaveAccessibleDescription(/Акция: 100 ₽, 100 %/);
});
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", concentrationLabels(),
);
const props = loadedProps(data);
renderPage(
<LoadedHarness
{...props}
accountId="a"
concentration={model}
currency="RUB"
tab="concentration"
/>,
);
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.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 () => {
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
@@ -306,7 +683,7 @@ describe("AccountPage states", () => {
it("shows supplied cash separately without adding currency instruments or recalculating totals", () => {
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps({
...portfolio,
cashBalances: [
@@ -490,7 +867,7 @@ describe("AccountPage states", () => {
});
it("renders a stale portfolio in upstream order with nullable values hidden", () => {
renderPage(<AccountPageLoaded {...loadedProps()} />);
renderPage(<LoadedHarness {...loadedProps()} />);
expect(
screen.getByRole("heading", { level: 1, name: "Портфель" }),
@@ -528,7 +905,7 @@ describe("AccountPage states", () => {
"renders %s logos, fallbacks and signed returns including zero",
(type) => {
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps({
...portfolio,
positions: [
@@ -571,7 +948,7 @@ describe("AccountPage states", () => {
"groups %s supporting metrics into quiet single lines",
(type) => {
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps({
...portfolio,
positions: [position({
@@ -593,7 +970,7 @@ describe("AccountPage states", () => {
it("uses the ticker only for stock cards and otherwise keeps the ISIN", () => {
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps({
...portfolio,
positions: [
@@ -618,7 +995,7 @@ describe("AccountPage states", () => {
it("keeps other-position cards compact with labeled return and no logo", () => {
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps({
...portfolio,
positions: [position({
@@ -636,7 +1013,7 @@ describe("AccountPage states", () => {
it("uses the cached account title when available", () => {
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps()}
account={{
@@ -655,7 +1032,7 @@ describe("AccountPage states", () => {
it("keeps the portfolio visible when recent operations fail", () => {
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps()}
recentOperations={{
state: "error",
@@ -674,7 +1051,7 @@ describe("AccountPage states", () => {
it("uses the ledger row for a recent outgoing trade operation", () => {
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps()}
recentOperations={{
@@ -724,7 +1101,7 @@ describe("AccountPage states", () => {
};
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps()}
recentEvents={{
@@ -793,7 +1170,7 @@ describe("AccountPage states", () => {
"keeps the upcoming %s state and calendar action",
(state) => {
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps()}
recentEvents={{ state, to: "/accounts/account-1/events" }}
/>,
@@ -830,7 +1207,7 @@ describe("AccountPage states", () => {
it("keeps the portfolio visible when upcoming events fail", () => {
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps()}
recentEvents={{
state: "error",
@@ -995,7 +1372,7 @@ describe("AccountPage states", () => {
});
it("opens positions by default and switches between local tabs", () => {
renderPage(<AccountPageLoaded {...loadedProps()} />);
renderPage(<LoadedHarness {...loadedProps()} />);
expect(screen.getByRole("tab", { name: "Позиции" }))
.toHaveAttribute("aria-selected", "true");
@@ -1012,7 +1389,7 @@ describe("AccountPage states", () => {
it("links operation, event and income previews to their detail pages", () => {
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps()}
incomeSummary={{
state: "empty",
@@ -1033,7 +1410,7 @@ describe("AccountPage states", () => {
});
it("filters positions by the kinds present in the portfolio", () => {
renderPage(<AccountPageLoaded {...loadedProps()} />);
renderPage(<LoadedHarness {...loadedProps()} />);
expect(
screen.getAllByRole<HTMLInputElement>("radio").map(radio => radio.value),
@@ -1047,7 +1424,7 @@ describe("AccountPage states", () => {
it("hides the position filter when only one kind exists", () => {
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps({
...portfolio,
positions: [portfolio.positions[0]],
@@ -1060,7 +1437,7 @@ describe("AccountPage states", () => {
});
it("hides unrealized returns for a partial portfolio", () => {
renderPage(<AccountPageLoaded {...loadedProps()} partial />);
renderPage(<LoadedHarness {...loadedProps()} partial />);
expect(screen.queryByRole("region", { name: "Нереализованный результат" }))
.not.toBeInTheDocument();
@@ -1068,7 +1445,7 @@ describe("AccountPage states", () => {
it("omits an unrealized currency total when any return is incomplete", () => {
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps({
...portfolio,
positions: [
@@ -25,13 +25,20 @@ const AccountPage: FC = () => {
<AccountPageLoaded
account={viewModel.account}
accountId={viewModel.accountId}
concentration={viewModel.concentration}
currency={viewModel.currency}
incomeSummary={viewModel.incomeSummary}
onCurrencyChange={viewModel.onCurrencyChange}
onSliceChange={viewModel.onSliceChange}
onTabChange={viewModel.onTabChange}
partial={viewModel.partial}
portfolio={viewModel.portfolio}
positionSections={viewModel.positionSections}
recentEvents={viewModel.recentEvents}
recentOperations={viewModel.recentOperations}
slice={viewModel.slice}
source={viewModel.source}
tab={viewModel.tab}
unrealizedReturns={viewModel.unrealizedReturns}
/>
);
@@ -1,3 +1,5 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router";
import type { Portfolio } from "@/entities/portfolio";
@@ -17,6 +19,13 @@ import { useScreenSync } from "@/features/tbank-connection";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
import { useInitialAvailability } from "@/shared/lib";
import {
type Concentration,
concentrationCurrencies,
type ConcentrationLabels,
type Slice,
toConcentration,
} from "../../model/concentration";
import {
type AccountPagePositionSection,
toAccountPagePositionSections,
@@ -45,10 +54,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 +82,24 @@ function isPortfolioAccessFailure(code?: string) {
function useAccountPageViewModel(): AccountPageViewModel {
const { accountId } = useParams();
const { t } = useTranslation();
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<typeof selection>) =>
setSelection({ ...current, ...change });
const portfolio = usePortfolio(accountId);
const operations = useBrokerageOperations(accountId, {
@@ -122,18 +156,42 @@ function useAccountPageViewModel(): AccountPageViewModel {
};
const isPartial = portfolio.meta?.completeness === "incomplete";
const currencies = concentrationCurrencies(portfolio.data);
const currency = current.currency && currencies.includes(current.currency)
? current.currency
: 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"
&& current.tab === "concentration"
? toConcentration(portfolio.data, current.slice, currency, labels)
: 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),
};
}
@@ -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,29 @@ 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<AccountPageLoadedProps> = (props) => {
const { t } = useTranslation();
const [filter, setFilter] = useState<string>();
const tab = props.tab;
const onTabChange = props.onTabChange;
const activeSection = props.positionSections.find(
section => section.kind === filter,
@@ -75,7 +87,9 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
/>
{props.partial
? <Alert role="status">{t("investment.partial")}</Alert>
? tab !== "concentration" && (
<Alert role="status">{t("investment.partial")}</Alert>
)
: <CurrencyCards amounts={props.portfolio.totalsByCurrency} />}
{!props.partial && props.unrealizedReturns.length > 0 && (
@@ -107,14 +121,21 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<Tabs
classNames={{ list: classes.tabsList }}
defaultValue="positions"
keepMounted={false}
onChange={onTabChange}
value={tab}
>
<Tabs.List aria-label={t("portfolio.sections")} mb="md">
<Tabs.Tab value="positions">
{t("portfolio.tabs.positions")}
</Tabs.Tab>
{props.accountId !== "all" && (
<Tabs.Tab value="concentration">
{t("portfolio.concentration.title")}
</Tabs.Tab>
)}
<Tabs.Tab value="cash">{t("portfolio.tabs.cash")}</Tabs.Tab>
<Tabs.Tab value="operations">
@@ -143,6 +164,22 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
</Stack>
</Tabs.Panel>
{props.accountId !== "all" && (
<Tabs.Panel value="concentration">
{props.concentration && (
<Concentration
currency={props.currency}
key={`${props.slice}-${props.currency}`}
model={props.concentration}
onCurrencyChange={props.onCurrencyChange}
onSliceChange={props.onSliceChange}
partial={!!props.partial}
slice={props.slice}
/>
)}
</Tabs.Panel>
)}
<Tabs.Panel value="cash">
<CurrencyCards amounts={props.portfolio.cashBalances} />
</Tabs.Panel>
@@ -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; }
}
@@ -0,0 +1,330 @@
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 { PositionLogo } from "../PositionLogo.view";
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;
logo?: ConcentrationItem["logo"];
name: string;
};
const colors = [
"#2563eb", "#18a999", "#f59e0b", "#8b5cf6", "#ef6c5b",
"#64748b",
];
const PERCENT = 100;
const OTHER_START = 6;
function amountFor(item: ConcentrationItem, currency: string): string {
return formatMoney({ currency, value: item.amount });
}
const ConcentrationRow: FC<RowProps> = props => (
<li className={classes.row}>
<span
aria-hidden="true"
className={classes.rank}
style={{ backgroundColor: props.color }}
>
{props.index}
</span>
{props.logo && <PositionLogo label={props.name} src={props.logo.src} />}
<span className={classes.identity}>
<span>{props.name}</span>
{props.logo?.subtitle && (
<Text c="dimmed" size="xs">{props.logo.subtitle}</Text>
)}
<span aria-hidden="true" className={classes.bar}>
<span style={{
backgroundColor: props.color,
width: `${props.item.chartValue * PERCENT}%`,
}}
/>
</span>
</span>
<span className={classes.value}>
<strong>{amountFor(props.item, props.currency)}</strong>
<Text c="dimmed" size="sm">{props.item.share}</Text>
</span>
</li>
);
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,
...((item?.unknown || (index === rows.length - 1
&& item?.key === "__other"))
&& { strokeDasharray: "3 2" }),
};
}
function rankLabel({ index }: { index: number; percent?: number }) {
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 { 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: String(index),
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 => `${item.label}: ${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 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
? formatMoney({ currency, value: props.model.total })
: "";
return (
<Stack gap="lg">
<Group align="end" justify="space-between">
<SegmentedControl
aria-label={t("portfolio.concentration.slice")}
className={classes.slices}
data={slices}
onChange={selectSlice}
value={props.slice}
/>
{props.model.currencies.length > 1 && (
<Select
allowDeselect={false}
aria-label={t("portfolio.concentration.currency")}
data={props.model.currencies}
onChange={props.onCurrencyChange}
value={props.currency}
w={130}
/>
)}
</Group>
{props.partial && (
<Alert role="status">{t("portfolio.concentration.incomplete")}</Alert>
)}
{props.model.empty
? (
<Stack gap="xs">
<Text fw={600}>{t(sliceLabels[props.slice])}
{props.currency && ` · ${props.currency}`}
</Text>
<Text>{t("portfolio.concentration.empty")}</Text>
</Stack>
)
: (
<Paper className={classes.layout} p="lg" radius="md" withBorder>
<Box className={classes.chart}>
<DonutChart
aria-describedby={chartId}
aria-label={t("portfolio.concentration.chart")}
cellProps={cellProps}
data={chartData}
pieProps={pieProps}
role="img"
size={260}
strokeWidth={24}
tooltipDataSource="segment"
tooltipProps={tooltipProps}
/>
<div aria-hidden="true" className={classes.center}>
{props.model.top5Share && (
<Text fw={700} size="sm">{t("portfolio.concentration.top5", {
share: props.model.top5Share,
})}
</Text>
)}
<Text fw={700}>{totalText}</Text>
</div>
<Text
className={classes.srOnly}
id={chartId}
>
{chartText}
</Text>
</Box>
<Stack gap="xs">
<Title order={3} size="md">{t("portfolio.concentration.title")}</Title>
<ol className={classes.list}>
{props.model.items.map((item, index) => (
<ConcentrationRow
color={colors[index % colors.length] ?? "blue"}
currency={currency}
index={index + 1}
item={item}
key={item.key}
logo={item.logo}
name={item.label}
/>
))}
</ol>
{props.model.other && (
<>
<button
aria-controls={otherId}
aria-expanded={expanded}
className={classes.other}
onClick={toggleOther}
type="button"
>
<span>{t("portfolio.concentration.other")} · {t(
"portfolio.concentration.otherCount",
{ count: props.model.other.count },
)}
</span>
<span>{amountFor(props.model.other, currency)} · {
props.model.other.share
}
</span>
</button>
<ol
className={classes.list}
hidden={!expanded}
id={otherId}
start={OTHER_START}
>
{props.model.other.entries.map((item, index) => (
<ConcentrationRow
color={colors[(index + OTHER_START) % colors.length]
?? "blue"}
currency={currency}
index={index + OTHER_START}
item={item}
key={item.key}
logo={item.logo}
name={item.label}
/>
))}
</ol>
</>
)}
</Stack>
</Paper>
)}
{props.model.unaccounted > 0 && (
<Text c="dimmed" size="sm">{t(
"portfolio.concentration.unaccounted",
{ count: props.model.unaccounted },
)}
</Text>
)}
</Stack>
);
};
export { Concentration };
@@ -12,6 +12,7 @@ const syntheticPosition
displayName: "Учебные технологии",
figi: null,
isin: null,
issuer: null,
logoName: null,
logoSrc: null,
quantity: "100",
@@ -19,6 +20,7 @@ const syntheticPosition
returnPercentText: "+25,00 %",
returnText: "+5 000 ₽",
returnTone: "positive",
sector: null,
shareText: "20%",
ticker: "DEMO",
type: "STOCK",
@@ -37,12 +37,14 @@ it("keeps expanded positions vertical while other positions adapt to the viewpor
displayName: `Инструмент ${index}`,
figi: null,
isin: null,
issuer: null,
logoName: null,
logoSrc: null,
quantity: "10",
returnPercent: null,
returnPercentText: null,
returnText: null,
sector: null,
shareText: null,
ticker: null,
type: kind === "stocks" ? "STOCK" : otherType,