feat(portfolio): visualize asset allocation
This commit is contained in:
@@ -90,6 +90,7 @@ const portfolio: Portfolio = {
|
||||
logoName: null,
|
||||
},
|
||||
],
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
cashBalances: [{ currency: "USD", value: "0.5" }],
|
||||
};
|
||||
|
||||
@@ -120,6 +121,21 @@ function emptyProps() {
|
||||
}
|
||||
|
||||
describe("AccountPage states", () => {
|
||||
it("keeps the account and positions visible for an older HTTP response without structure", async () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json({ ...portfolio, structure: undefined }),
|
||||
),
|
||||
);
|
||||
renderAccountRoute(createTestQueryClient());
|
||||
expect(await screen.findByText("Вторая")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("region", { name: "Структура портфеля" }),
|
||||
).toHaveTextContent("Данные о структуре портфеля пока недоступны.");
|
||||
expect(
|
||||
screen.getAllByRole("definition").map((node) => node.textContent),
|
||||
).toEqual(["1 234,5 ₽", "0,5 $"]);
|
||||
});
|
||||
it("keeps initial placeholders decorative without amounts or focus targets", () => {
|
||||
const { container } = renderPage(<AccountPageLoading />);
|
||||
expect(screen.getByRole("status")).toHaveAttribute("aria-busy", "true");
|
||||
@@ -167,6 +183,7 @@ describe("AccountPage states", () => {
|
||||
currentValue: { currency: "RUB", value: "1000" },
|
||||
},
|
||||
],
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
cashBalances: [
|
||||
{ currency: "RUB", value: "234.500000000000000001" },
|
||||
{ currency: "USD", value: "-0.5" },
|
||||
@@ -226,6 +243,7 @@ describe("AccountPage states", () => {
|
||||
currentValue: { currency: "RUB", value: "100" },
|
||||
},
|
||||
],
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
cashBalances: [],
|
||||
};
|
||||
server.use(
|
||||
@@ -762,12 +780,26 @@ describe("AccountPage states", () => {
|
||||
it.each([503, 404])(
|
||||
"preserves totals and UTC snapshot through refresh error %i, retry and success",
|
||||
async (status) => {
|
||||
const initialPortfolio: Portfolio = {
|
||||
...portfolio,
|
||||
structure: {
|
||||
unvaluedCategories: [{ type: "STOCK", count: 1 }],
|
||||
currencies: [
|
||||
{
|
||||
currency: "RUB",
|
||||
totalValue: "10",
|
||||
hasNegativeValues: false,
|
||||
categories: [{ type: "BOND", value: "10", sharePercent: null }],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
let requestCount = 0;
|
||||
let resolveRetry: ((response: Response) => void) | undefined;
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) return HttpResponse.json(portfolio);
|
||||
if (requestCount === 1) return HttpResponse.json(initialPortfolio);
|
||||
if (requestCount === 3)
|
||||
return new Promise<Response>((resolve) => {
|
||||
resolveRetry = resolve;
|
||||
@@ -782,6 +814,10 @@ describe("AccountPage states", () => {
|
||||
renderAccountRoute(queryClient);
|
||||
|
||||
await screen.findByText("Вторая");
|
||||
const structure = screen.getByRole("region", {
|
||||
name: "Структура портфеля",
|
||||
});
|
||||
expect(structure).toHaveTextContent("10 ₽");
|
||||
const snapshotText = screen.getByText(/Данные получены/).textContent;
|
||||
expect(snapshotText).toContain("10:00");
|
||||
void queryClient.invalidateQueries({
|
||||
@@ -807,6 +843,7 @@ describe("AccountPage states", () => {
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
|
||||
await waitFor(() => expect(requestCount).toBe(3));
|
||||
expect(structure).toHaveTextContent("10 ₽");
|
||||
expect(
|
||||
screen.getAllByRole("definition").map((node) => node.textContent),
|
||||
).toEqual(["1 234,5 ₽", "0,5 $"]);
|
||||
@@ -819,6 +856,7 @@ describe("AccountPage states", () => {
|
||||
isStale: false,
|
||||
lastSuccessfulAt: "2026-08-14T11:30:00Z",
|
||||
totalsByCurrency: [{ currency: "USD", value: "-0.5" }],
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
cashBalances: [{ currency: "EUR", value: "2.50" }],
|
||||
}),
|
||||
);
|
||||
@@ -828,6 +866,8 @@ describe("AccountPage states", () => {
|
||||
).toEqual(["-0,5 $", "2,50 €"]),
|
||||
);
|
||||
expect(screen.getByText(/Данные получены/)).toHaveTextContent("11:30");
|
||||
expect(structure).not.toHaveTextContent("10 ₽");
|
||||
expect(structure).toHaveTextContent("Нет положительной стоимости");
|
||||
expect(
|
||||
screen.queryByText(/Не удалось обновить портфель/),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
@@ -55,6 +55,7 @@ const portfolio: Portfolio = {
|
||||
logoName: "fond.png",
|
||||
},
|
||||
],
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
cashBalances: [{ currency: "USD", value: "1" }],
|
||||
};
|
||||
|
||||
@@ -145,6 +146,7 @@ describe("useAccountPageViewModel", () => {
|
||||
const snapshot: Portfolio = {
|
||||
...portfolio,
|
||||
totalsByCurrency: [{ currency: "RUB", value: "1250" }],
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
cashBalances: [{ currency: "RUB", value: "1000" }],
|
||||
positions: [
|
||||
...portfolio.positions,
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
import { AccountHeading } from "./AccountHeading.view";
|
||||
import { IncomeSummary } from "./IncomeSummary.view";
|
||||
import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
|
||||
import { PortfolioStructure } from "./PortfolioStructure";
|
||||
import { RecentOperations } from "./RecentOperations.view";
|
||||
|
||||
type AccountPageEmptyProps = {
|
||||
@@ -43,6 +44,9 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
|
||||
: "portfolio.emptyText",
|
||||
)}
|
||||
/>
|
||||
<PortfolioStructure
|
||||
structure={{ currencies: [], unvaluedCategories: [] }}
|
||||
/>
|
||||
<RecentOperations operations={props.recentOperations} />
|
||||
<BrokerageEventsPreview events={props.recentEvents} />
|
||||
{props.incomeSummary ? <IncomeSummary {...props.incomeSummary} /> : null}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { AccountHeading } from "./AccountHeading.view";
|
||||
import { CurrencyCards } from "./CurrencyCards.view";
|
||||
import { IncomeSummary } from "./IncomeSummary.view";
|
||||
import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
|
||||
import { PortfolioStructure } from "./PortfolioStructure";
|
||||
import { PositionSection } from "./PositionSection.view";
|
||||
import { RecentOperations } from "./RecentOperations.view";
|
||||
|
||||
@@ -56,6 +57,10 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||
)}
|
||||
</Text>
|
||||
<CurrencyCards amounts={props.portfolio.totalsByCurrency} />
|
||||
<PortfolioStructure
|
||||
key={props.accountId}
|
||||
structure={props.portfolio.structure}
|
||||
/>
|
||||
<Accordion
|
||||
multiple
|
||||
defaultValue={[
|
||||
|
||||
@@ -26,6 +26,7 @@ const AccountPageLoading: FC = () => {
|
||||
</Paper>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<Skeleton height={260} radius="sm" />
|
||||
{[0, 1, 2].map((index) => (
|
||||
<Skeleton key={index} height={52} radius="sm" />
|
||||
))}
|
||||
|
||||
@@ -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,114 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
import { PortfolioStructure } from "./PortfolioStructure";
|
||||
|
||||
const rub: Portfolio["structure"]["currencies"][number] = {
|
||||
currency: "RUB",
|
||||
totalValue: "1250000",
|
||||
hasNegativeValues: false,
|
||||
categories: [
|
||||
{ type: "STOCK", value: "650000", sharePercent: "52" },
|
||||
{ type: "BOND", value: "375000", sharePercent: "30" },
|
||||
{ type: "OTHER", value: "125000", sharePercent: "10" },
|
||||
{ type: "CASH", value: "100000", sharePercent: "8" },
|
||||
],
|
||||
};
|
||||
const meta = {
|
||||
title: "Product/PortfolioStructure",
|
||||
component: PortfolioStructure,
|
||||
tags: ["autodocs"],
|
||||
args: { structure: { currencies: [rub], unvaluedCategories: [] } },
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Структура по типам активов, включая доступные деньги. Синтетические серверные суммы и доли, без запросов API и финансовых расчётов на клиенте. При неполной или отрицательной оценке остаётся текстовый список.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof PortfolioStructure>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Regular: Story = {};
|
||||
export const MultipleCurrencies: Story = {
|
||||
args: {
|
||||
structure: {
|
||||
currencies: [
|
||||
rub,
|
||||
{
|
||||
currency: "USD",
|
||||
totalValue: "500",
|
||||
hasNegativeValues: false,
|
||||
categories: [{ type: "CASH", value: "500", sharePercent: "100" }],
|
||||
},
|
||||
],
|
||||
unvaluedCategories: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
export const CashOnly: Story = {
|
||||
args: {
|
||||
structure: {
|
||||
currencies: [
|
||||
{
|
||||
currency: "RUB",
|
||||
totalValue: "100000",
|
||||
hasNegativeValues: false,
|
||||
categories: rub.categories.map((row) => ({
|
||||
...row,
|
||||
value: row.type === "CASH" ? "100000" : "0",
|
||||
sharePercent: row.type === "CASH" ? "100" : "0",
|
||||
})),
|
||||
},
|
||||
],
|
||||
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: [{ type: "STOCK", count: 2 }],
|
||||
},
|
||||
},
|
||||
};
|
||||
export const UnknownOnly: Story = {
|
||||
args: {
|
||||
structure: {
|
||||
currencies: [],
|
||||
unvaluedCategories: [{ type: "BOND", count: 1 }],
|
||||
},
|
||||
},
|
||||
};
|
||||
export const Negative: Story = {
|
||||
args: {
|
||||
structure: {
|
||||
currencies: [
|
||||
{
|
||||
...rub,
|
||||
hasNegativeValues: true,
|
||||
totalValue: "1150000",
|
||||
categories: rub.categories.map((row) => ({
|
||||
...row,
|
||||
value: row.type === "OTHER" ? "25000" : row.value,
|
||||
sharePercent: null,
|
||||
})),
|
||||
},
|
||||
],
|
||||
unvaluedCategories: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
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] = {
|
||||
currency: "RUB",
|
||||
totalValue: "100",
|
||||
hasNegativeValues: false,
|
||||
categories: [
|
||||
{ type: "STOCK", value: "60", sharePercent: "60" },
|
||||
{ type: "BOND", value: "20", sharePercent: "20" },
|
||||
{ type: "OTHER", value: "0", sharePercent: "0" },
|
||||
{ type: "CASH", value: "20", sharePercent: "20" },
|
||||
],
|
||||
};
|
||||
const ui = (structure: Portfolio["structure"] | null | 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: [
|
||||
{ type: "OTHER", count: 2 },
|
||||
{ type: "STOCK", count: 1 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
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,
|
||||
hasNegativeValues: true,
|
||||
categories: rub.categories.map((row) => ({
|
||||
...row,
|
||||
value: row.type === "STOCK" ? "-60" : row.value,
|
||||
sharePercent: null,
|
||||
})),
|
||||
},
|
||||
],
|
||||
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: [{ type: "BOND", count: 1 }] }),
|
||||
);
|
||||
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: [
|
||||
{
|
||||
type: "CASH",
|
||||
value: "9007199254740993.000000001",
|
||||
sharePercent: "0.000001",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
unvaluedCategories: [],
|
||||
}),
|
||||
);
|
||||
expect(screen.getByRole("list")).toHaveTextContent(
|
||||
"9 007 199 254 740 993,000000001 ₽",
|
||||
);
|
||||
expect(screen.getByRole("list")).toHaveTextContent("< 0,01 %");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { type FC, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
import { formatMoney } from "@/shared/lib";
|
||||
import { PortfolioStructureView } from "./PortfolioStructure.view";
|
||||
|
||||
// A cached snapshot or an older running server can predate this response field.
|
||||
type PortfolioStructureProps = { structure?: Portfolio["structure"] | null };
|
||||
|
||||
const categoryPresentation = {
|
||||
STOCK: { label: "portfolio.stocks", color: "blue.6" },
|
||||
BOND: { label: "portfolio.bonds", color: "grape.6" },
|
||||
OTHER: { label: "portfolio.structure.other", color: "orange.6" },
|
||||
CASH: { label: "portfolio.structure.cash", color: "green.6" },
|
||||
} as const;
|
||||
|
||||
const PortfolioStructure: FC<PortfolioStructureProps> = (props) => {
|
||||
const { structure } = props;
|
||||
const { t } = useTranslation();
|
||||
const [currency, setCurrency] = useState<string | null>(null);
|
||||
const selected =
|
||||
structure?.currencies.find((item) => item.currency === currency) ??
|
||||
structure?.currencies[0];
|
||||
const incomplete = (structure?.unvaluedCategories.length ?? 0) > 0;
|
||||
const rows = selected
|
||||
? selected.categories.map((category) => {
|
||||
const presentation = categoryPresentation[category.type];
|
||||
const percentage =
|
||||
category.sharePercent === null ? null : Number(category.sharePercent);
|
||||
const unknown = structure?.unvaluedCategories.some(
|
||||
(item) => item.type === category.type,
|
||||
);
|
||||
const amount = formatMoney({
|
||||
currency: selected.currency,
|
||||
value: category.value,
|
||||
});
|
||||
return {
|
||||
name: t(presentation.label),
|
||||
color: presentation.color,
|
||||
amount: unknown
|
||||
? category.value === "0"
|
||||
? t("portfolio.structure.unvalued")
|
||||
: t("portfolio.structure.knownAmount", { amount })
|
||||
: amount,
|
||||
share:
|
||||
percentage === null
|
||||
? t("portfolio.structure.shareUnavailable")
|
||||
: percentage > 0 && percentage < 0.01
|
||||
? t("portfolio.structure.smallShare")
|
||||
: `${new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 2 }).format(percentage)} %`,
|
||||
value: percentage ?? 0,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
const chartData = rows.filter((row) => row.value > 0);
|
||||
return (
|
||||
<PortfolioStructureView
|
||||
unavailable={!structure}
|
||||
currencies={structure?.currencies.map((item) => item.currency) ?? []}
|
||||
currency={selected?.currency ?? null}
|
||||
onCurrencyChange={setCurrency}
|
||||
rows={rows}
|
||||
chartData={chartData}
|
||||
incomplete={incomplete}
|
||||
negative={selected?.hasNegativeValues ?? false}
|
||||
unvalued={
|
||||
structure?.unvaluedCategories.map((category) =>
|
||||
t("portfolio.structure.unvaluedCategory", {
|
||||
category: t(categoryPresentation[category.type].label),
|
||||
count: category.count,
|
||||
}),
|
||||
) ?? []
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { PortfolioStructure };
|
||||
@@ -0,0 +1,148 @@
|
||||
import "@mantine/charts/styles.css";
|
||||
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 StructureRow = {
|
||||
name: string;
|
||||
color: string;
|
||||
amount: string;
|
||||
share: string;
|
||||
value: number;
|
||||
};
|
||||
type PortfolioStructureViewProps = {
|
||||
unavailable: boolean;
|
||||
currencies: string[];
|
||||
currency: string | null;
|
||||
onCurrencyChange: (currency: string | null) => void;
|
||||
rows: StructureRow[];
|
||||
chartData: StructureRow[];
|
||||
incomplete: boolean;
|
||||
negative: boolean;
|
||||
unvalued: string[];
|
||||
};
|
||||
|
||||
const PortfolioStructureView: FC<PortfolioStructureViewProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const titleId = useId();
|
||||
return (
|
||||
<Stack
|
||||
component="section"
|
||||
aria-labelledby={titleId}
|
||||
gap="md"
|
||||
className={classes.section}
|
||||
>
|
||||
<Group justify="space-between" align="center">
|
||||
<Title order={2} size="h4" id={titleId}>
|
||||
{t("portfolio.structure.title")}
|
||||
</Title>
|
||||
{props.currencies.length > 1 ? (
|
||||
<Select
|
||||
aria-label={t("portfolio.structure.currency")}
|
||||
data={props.currencies}
|
||||
value={props.currency}
|
||||
onChange={props.onCurrencyChange}
|
||||
allowDeselect={false}
|
||||
w={112}
|
||||
/>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{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 size="sm" c="dimmed" key={message}>
|
||||
{message}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
) : null}
|
||||
{props.negative ? (
|
||||
<Text size="sm">{t("portfolio.structure.negative")}</Text>
|
||||
) : null}
|
||||
{!props.unavailable &&
|
||||
!props.chartData.length &&
|
||||
!props.incomplete &&
|
||||
!props.negative ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("portfolio.structure.empty")}
|
||||
</Text>
|
||||
) : null}
|
||||
<div
|
||||
key={props.currency}
|
||||
className={`${classes.content} ${props.chartData.length ? classes.layout : ""}`}
|
||||
>
|
||||
{props.chartData.length ? (
|
||||
<DonutChart
|
||||
data={props.chartData}
|
||||
size={220}
|
||||
thickness={32}
|
||||
strokeWidth={2}
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
chartLabel={props.currency ?? undefined}
|
||||
tooltipDataSource="segment"
|
||||
pieProps={{ isAnimationActive: false }}
|
||||
pieChartProps={{ title: t("portfolio.structure.chartLabel") }}
|
||||
aria-label={t("portfolio.structure.chartLabel")}
|
||||
tooltipProps={{
|
||||
content: ({ active, payload }) => {
|
||||
const row = props.chartData.find(
|
||||
(item) => item.name === payload?.[0]?.name,
|
||||
);
|
||||
return active && row ? (
|
||||
<Paper shadow="sm" p="sm" radius="md">
|
||||
<Text size="sm" fw={600}>
|
||||
{row.name}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{row.amount} · {row.share}
|
||||
</Text>
|
||||
</Paper>
|
||||
) : null;
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{props.rows.length ? (
|
||||
<ul
|
||||
className={classes.list}
|
||||
aria-label={t("portfolio.structure.categories")}
|
||||
>
|
||||
{props.rows.map((row) => (
|
||||
<li key={row.name} className={classes.row}>
|
||||
<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 size="sm" fw={600}>
|
||||
{row.amount}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{row.share}
|
||||
</Text>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export { PortfolioStructureView };
|
||||
@@ -32,6 +32,7 @@ function sections(positions: Portfolio["positions"]) {
|
||||
lastSuccessfulAt: "2026-09-09T12:00:00Z",
|
||||
isStale: false,
|
||||
totalsByCurrency: [],
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
cashBalances: [],
|
||||
positions,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user