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
@@ -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",