refactor: move PortfolioStructure to widgets layer

This commit is contained in:
2026-10-06 22:05:24 +03:00
parent 5b30bb802a
commit c25f6bd85b
11 changed files with 23 additions and 14 deletions
+1
View File
@@ -0,0 +1 @@
export { PortfolioStructure } from "./ui/PortfolioStructure";
@@ -0,0 +1,72 @@
.section {
padding-block: var(--mantine-spacing-sm);
}
.content {
animation: currency-change 220ms ease-out;
}
@keyframes currency-change {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.content {
animation: none;
}
}
.layout {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
align-items: center;
gap: var(--mantine-spacing-xl);
}
.list {
list-style: none;
padding: 0;
margin: 0;
min-width: 0;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--mantine-spacing-md);
padding-block: var(--mantine-spacing-sm);
}
.marker {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.values {
min-width: 0;
text-align: right;
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
@media (max-width: 48em) {
.layout {
grid-template-columns: minmax(0, 1fr);
gap: var(--mantine-spacing-sm);
}
.layout > :first-child {
justify-self: center;
}
}
@@ -0,0 +1,127 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { Portfolio } from "@/entities/portfolio";
import { PortfolioStructure } from "./PortfolioStructure";
const rub: Portfolio["structure"]["currencies"][number] = {
categories: [
{ sharePercent: "52", type: "STOCK", value: "650000" },
{ sharePercent: "30", type: "BOND", value: "375000" },
{ sharePercent: "10", type: "OTHER", value: "125000" },
{ sharePercent: "8", type: "CASH", value: "100000" },
],
currency: "RUB",
hasNegativeValues: false,
totalValue: "1250000",
};
const meta = {
args: { structure: { currencies: [rub], unvaluedCategories: [] } },
component: PortfolioStructure,
parameters: {
docs: {
description: {
component:
"Структура по типам активов, включая доступные деньги. Синтетические серверные суммы и доли, без запросов API и финансовых расчётов на клиенте. При неполной или отрицательной оценке остаётся текстовый список.",
},
},
},
tags: ["autodocs"],
title: "Widgets/PortfolioStructure",
} satisfies Meta<typeof PortfolioStructure>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Regular: Story = {};
export const MultipleCurrencies: Story = {
args: {
structure: {
currencies: [
rub,
{
categories: [{ sharePercent: "100", type: "CASH", value: "500" }],
currency: "USD",
hasNegativeValues: false,
totalValue: "500",
},
],
unvaluedCategories: [],
},
},
};
export const CashOnly: Story = {
args: {
structure: {
currencies: [
{
categories: rub.categories.map(row => ({
...row,
sharePercent: row.type === "CASH" ? "100" : "0",
value: row.type === "CASH" ? "100000" : "0",
})),
currency: "RUB",
hasNegativeValues: false,
totalValue: "100000",
},
],
unvaluedCategories: [],
},
},
};
export const Empty: Story = {
args: { structure: { currencies: [], unvaluedCategories: [] } },
};
export const Incomplete: Story = {
args: {
structure: {
currencies: [
{
...rub,
categories: rub.categories.map(
row =>
({
...row,
sharePercent: null,
}),
),
},
],
unvaluedCategories: [{ count: 2, type: "STOCK" }],
},
},
};
export const UnknownOnly: Story = {
args: {
structure: {
currencies: [],
unvaluedCategories: [{ count: 1, type: "BOND" }],
},
},
};
export const Negative: Story = {
args: {
structure: {
currencies: [
{
...rub,
categories: rub.categories.map(
row =>
({
...row,
sharePercent: null,
value: row.type === "OTHER" ? "25000" : row.value,
}),
),
hasNegativeValues: true,
totalValue: "1150000",
},
],
unvaluedCategories: [],
},
},
};
@@ -0,0 +1,163 @@
import { MantineProvider } from "@mantine/core";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { Portfolio } from "@/entities/portfolio";
import { PortfolioStructure } from "./PortfolioStructure";
const rub: Portfolio["structure"]["currencies"][number] = {
categories: [
{ sharePercent: "60", type: "STOCK", value: "60" },
{ sharePercent: "20", type: "BOND", value: "20" },
{ sharePercent: "0", type: "OTHER", value: "0" },
{ sharePercent: "20", type: "CASH", value: "20" },
],
currency: "RUB",
hasNegativeValues: false,
totalValue: "100",
};
const ui = (structure: null | Portfolio["structure"] | undefined) => (
<MantineProvider>
<PortfolioStructure structure={structure} />
</MantineProvider>
);
describe("portfolio structure", () => {
it.each([
undefined,
null,
])(
"keeps an older snapshot without structure available (%s) and recovers on refresh",
(structure) => {
const view = render(ui(structure));
expect(
screen.getByRole("region", { name: "Структура портфеля" }),
).toHaveTextContent("Данные о структуре портфеля пока недоступны.");
expect(
screen.queryByText(/Нет положительной стоимости/),
).not.toBeInTheDocument();
expect(screen.queryByRole("list")).not.toBeInTheDocument();
view.rerender(ui({ currencies: [rub], unvaluedCategories: [] }));
expect(screen.getByRole("list")).toHaveTextContent("Акции60 ₽60 %");
expect(
screen.queryByText("Данные о структуре портфеля пока недоступны."),
).not.toBeInTheDocument();
},
);
it("shows exact server amounts and cash-inclusive shares without hovering", () => {
render(ui({ currencies: [rub], unvaluedCategories: [] }));
const list = screen.getByRole("list", { name: "Категории активов" });
expect(list).toHaveTextContent("Акции60 ₽60 %");
expect(list).toHaveTextContent("Свободные деньги20 ₽20 %");
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
});
it("switches currencies and falls back when a refresh removes the selection", () => {
const usd = { ...rub, currency: "USD" };
const view = render(ui({ currencies: [rub, usd], unvaluedCategories: [] }));
fireEvent.click(
screen.getByRole("combobox", { name: "Валюта структуры портфеля" }),
);
fireEvent.click(screen.getByRole("option", { name: "USD" }));
expect(screen.getByRole("list")).toHaveTextContent("60 USD");
expect(screen.getByRole("list")).not.toHaveTextContent("₽");
view.rerender(ui({ currencies: [rub], unvaluedCategories: [] }));
expect(screen.getByRole("list")).toHaveTextContent("60 ₽");
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
});
it("retains known amounts and names unvalued categories without inventing a zero", () => {
render(
ui({
currencies: [
{
...rub,
categories: rub.categories.map(
row =>
({
...row,
sharePercent: null,
}),
),
},
],
unvaluedCategories: [
{ count: 2, type: "OTHER" },
{ count: 1, type: "STOCK" },
],
}),
);
expect(screen.getByText(/Оценка неполная/)).toBeInTheDocument();
expect(screen.getByRole("list")).toHaveTextContent("Известно: 60 ₽");
expect(screen.getByRole("list")).toHaveTextContent(
"Другие активыНет оценки",
);
expect(
screen.queryByLabelText(/Структура по типам/),
).not.toBeInTheDocument();
});
it("keeps negative amounts visible without a donut or invented positive shares", () => {
render(
ui({
currencies: [
{
...rub,
categories: rub.categories.map(
row =>
({
...row,
sharePercent: null,
value: row.type === "STOCK" ? "-60" : row.value,
}),
),
hasNegativeValues: true,
},
],
unvaluedCategories: [],
}),
);
expect(screen.getByText(/Есть отрицательные/)).toBeInTheDocument();
expect(screen.getByRole("list")).toHaveTextContent("-60 ₽");
expect(
within(screen.getByRole("list")).getAllByText("Доля недоступна"),
).toHaveLength(4);
expect(
screen.queryByLabelText(/Структура по типам/),
).not.toBeInTheDocument();
});
it("handles unknown-only and empty compositions", () => {
const view = render(
ui({ currencies: [], unvaluedCategories: [{ count: 1, type: "BOND" }] }),
);
expect(screen.getByText("Облигации: без оценки — 1")).toBeInTheDocument();
expect(screen.queryByRole("list")).not.toBeInTheDocument();
view.rerender(ui({ currencies: [], unvaluedCategories: [] }));
expect(screen.getByText(/Нет положительной стоимости/)).toBeInTheDocument();
});
it("keeps full decimal amounts and describes tiny shares", () => {
render(
ui({
currencies: [
{
...rub,
categories: [
{
sharePercent: "0.000001",
type: "CASH",
value: "9007199254740993.000000001",
},
],
},
],
unvaluedCategories: [],
}),
);
expect(screen.getByRole("list")).toHaveTextContent(
"9 007 199 254 740 993,000000001 ₽",
);
expect(screen.getByRole("list")).toHaveTextContent("< 0,01 %");
});
});
@@ -0,0 +1,15 @@
import type { FC } from "react";
import { PortfolioStructureView } from "./PortfolioStructure.view";
import {
type PortfolioStructureProps,
usePortfolioStructureVm,
} from "./PortfolioStructure.vm";
const PortfolioStructure: FC<PortfolioStructureProps> = (props) => {
const vm = usePortfolioStructureVm(props);
return <PortfolioStructureView {...vm} />;
};
export { PortfolioStructure };
@@ -0,0 +1,166 @@
import { DonutChart } from "@mantine/charts";
import { Box, Group, Paper, Select, Stack, Text, Title } from "@mantine/core";
import { type FC, useId } from "react";
import { useTranslation } from "react-i18next";
import classes from "./PortfolioStructure.module.css";
type PortfolioStructureViewProps = {
chartData: StructureRow[];
currencies: string[];
currency: null | string;
incomplete: boolean;
negative: boolean;
onCurrencyChange: (currency: null | string) => void;
rows: StructureRow[];
unavailable: boolean;
unvalued: string[];
};
type PortfolioTooltipProps = {
active?: boolean;
payload?: { name?: number | string }[];
rows: StructureRow[];
};
type StructureRow = {
amount: string;
color: string;
name: string;
share: string;
value: number;
};
const PortfolioTooltip: FC<PortfolioTooltipProps> = (props) => {
const row = props.rows.find(item => item.name === props.payload?.[0]?.name);
if (!row || !props.active) return;
return (
<Paper p="sm" radius="md" shadow="sm">
<Text fw={600} size="sm">{row.name}</Text>
<Text size="sm">{row.amount} · {row.share}</Text>
</Paper>
);
};
const PortfolioStructureView: FC<PortfolioStructureViewProps> = (props) => {
const { t } = useTranslation();
const titleId = useId();
const appearanceClass = `${classes.content} ${props.chartData.length > 0 ? classes.layout : ""}`;
const tooltipProps = {
content: <PortfolioTooltip rows={props.chartData} />,
};
return (
<Stack
aria-labelledby={titleId}
className={classes.section}
component="section"
gap="md"
>
<Group align="center" justify="space-between">
<Title id={titleId} order={2} size="h4">
{t("portfolio.structure.title")}
</Title>
{props.currencies.length > 1 && (
<Select
allowDeselect={false}
aria-label={t("portfolio.structure.currency")}
data={props.currencies}
onChange={props.onCurrencyChange}
value={props.currency}
w={112}
/>
)}
</Group>
<Text c="dimmed" size="sm">
{t(
props.unavailable
? "portfolio.structure.unavailable"
: "portfolio.structure.basis",
)}
</Text>
{!!props.incomplete && (
<Stack gap={4}>
<Text size="sm">{t("portfolio.structure.incomplete")}</Text>
{props.unvalued.map(message => (
<Text c="dimmed" key={message} size="sm">
{message}
</Text>
))}
</Stack>
)}
{!!props.negative && <Text size="sm">{t("portfolio.structure.negative")}</Text>}
{!!(
!props.unavailable
&& props.chartData.length === 0
&& !props.incomplete
&& !props.negative
) && (
<Text c="dimmed" size="sm">
{t("portfolio.structure.empty")}
</Text>
)}
<div className={appearanceClass} key={props.currency}>
{props.chartData.length > 0 && (
<DonutChart
aria-label={t("portfolio.structure.chartLabel")}
chartLabel={props.currency ?? undefined}
data={props.chartData}
endAngle={-270}
pieChartProps={{ title: t("portfolio.structure.chartLabel") }}
pieProps={{ isAnimationActive: false }}
size={220}
startAngle={90}
strokeWidth={2}
thickness={32}
tooltipDataSource="segment"
tooltipProps={tooltipProps}
/>
)}
{props.rows.length > 0 && (
<ul
aria-label={t("portfolio.structure.categories")}
className={classes.list}
>
{props.rows.map(row => (
<li className={classes.row} key={row.name}>
<Group gap="xs" wrap="nowrap">
<Box
aria-hidden="true"
bg={row.color}
className={classes.marker}
/>
<Text size="sm">{row.name}</Text>
</Group>
<div className={classes.values}>
<Text fw={600} size="sm">
{row.amount}
</Text>
<Text c="dimmed" size="sm">
{row.share}
</Text>
</div>
</li>
))}
</ul>
)}
</div>
</Stack>
);
};
export { PortfolioStructureView };
@@ -0,0 +1,108 @@
import type { TFunction } from "i18next";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { Portfolio } from "@/entities/portfolio";
import { formatMoney } from "@/shared/lib";
const MIN_VISIBLE_SHARE_PERCENT = 0.01;
// A cached snapshot or an older running server can predate this response field.
type PortfolioStructureProps = { structure?: null | Portfolio["structure"] };
const categoryPresentation = {
BOND: { color: "grape.6", label: "portfolio.bonds" },
CASH: { color: "green.6", label: "portfolio.structure.cash" },
OTHER: { color: "orange.6", label: "portfolio.structure.other" },
STOCK: { color: "blue.6", label: "portfolio.stocks" },
} as const;
const usePortfolioStructureVm = (props: PortfolioStructureProps) => {
const { structure } = props;
const { t } = useTranslation();
const [currency, setCurrency] = useState<null | string>(null);
const selected
= structure?.currencies.find(item => item.currency === currency)
?? structure?.currencies[0];
const isIncomplete = (structure?.unvaluedCategories.length ?? 0) > 0;
const rows = portfolioRows(selected, structure, t);
const chartData = rows.filter(row => row.value > 0);
return {
chartData,
currencies: structure?.currencies.map(item => item.currency) ?? [],
currency: selected?.currency ?? null,
incomplete: isIncomplete,
negative: selected?.hasNegativeValues ?? false,
onCurrencyChange: setCurrency,
rows,
unavailable: !structure,
unvalued:
structure?.unvaluedCategories.map(category =>
t("portfolio.structure.unvaluedCategory", {
category: t(categoryPresentation[category.type].label),
count: category.count,
}),
) ?? [],
};
};
function portfolioRows(
selected:
NonNullable<Portfolio["structure"]>["currencies"][number] | undefined,
structure: PortfolioStructureProps["structure"],
t: TFunction,
) {
if (!selected) return [];
return selected.categories.map((category) => {
const presentation = categoryPresentation[category.type];
const percentage
= category.sharePercent === null
? undefined
: Number(category.sharePercent);
const unknown = structure?.unvaluedCategories.some(
item => item.type === category.type,
);
let amount = formatMoney({
currency: selected.currency,
value: category.value,
});
if (unknown) {
amount
= category.value === "0"
? t("portfolio.structure.unvalued")
: t("portfolio.structure.knownAmount", { amount });
}
let share = t("portfolio.structure.shareUnavailable");
if (percentage !== undefined) {
if (percentage > 0 && percentage < MIN_VISIBLE_SHARE_PERCENT)
share = t("portfolio.structure.smallShare");
else
share = `${new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 2,
}).format(percentage)} %`;
}
return {
amount,
color: presentation.color,
name: t(presentation.label),
share,
value: percentage ?? 0,
};
});
}
export { type PortfolioStructureProps, usePortfolioStructureVm };