From 8fb727057570286cfb7e5db308a1a6a3149bfb84 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 26 Oct 2026 20:01:26 +0300 Subject: [PATCH 1/2] feat: replace account page accordion with tabs and show open position result (#112) --- src/app/routes/AppRoutes/AllAccounts.test.tsx | 18 +- .../BrokerageEventsPreview.tsx | 3 +- src/pages/account/model/positions.test.ts | 121 ++++++++++- src/pages/account/model/positions.ts | 149 ++++++++++++++ .../ui/AccountPage/AccountPage.module.css | 18 +- .../ui/AccountPage/AccountPage.test.tsx | 188 +++++++++++++++--- .../account/ui/AccountPage/AccountPage.tsx | 1 + .../account/ui/AccountPage/AccountPage.vm.ts | 3 + .../components/AccountPageLoaded.view.tsx | 169 ++++++++++------ .../AccountPageLoading.view.tsx | 4 +- .../components/IncomeSummary.view.tsx | 11 +- .../PositionCard/PositionCard.stories.tsx | 3 + .../PositionCard/PositionCard.view.tsx | 18 +- .../components/PositionGroupCard.view.tsx | 23 ++- .../components/RecentOperations.view.tsx | 3 +- .../ui/AccountPage/position-groups.test.tsx | 2 + .../position-layout.browser.test.tsx | 1 + src/shared/i18n/locales/ru.ts | 19 +- 18 files changed, 620 insertions(+), 134 deletions(-) diff --git a/src/app/routes/AppRoutes/AllAccounts.test.tsx b/src/app/routes/AppRoutes/AllAccounts.test.tsx index a7dacf9..88676eb 100644 --- a/src/app/routes/AppRoutes/AllAccounts.test.tsx +++ b/src/app/routes/AppRoutes/AllAccounts.test.tsx @@ -303,14 +303,16 @@ describe("All accounts routes", () => { "href", `/accounts/${account.id}`, ); - for (const suffix of ["operations", "income", "events"]) - expect( - screen - .getAllByRole("link") - .find( - link => link.getAttribute("href") === `/accounts/all/${suffix}`, - ), - ).toBeInTheDocument(); + + for (const [suffix, tab] of [ + ["operations", "Операции"], + ["income", "Доход"], + ["events", "События"], + ]) { + fireEvent.click(screen.getByRole("tab", { name: tab })); + expect(screen.getByRole("link", { name: "Подробнее" })) + .toHaveAttribute("href", `/accounts/all/${suffix}`); + } }); it("switches accounts within operations with applied filters and a fresh cursor", async () => { diff --git a/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx b/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx index 5855d0a..a571dfa 100644 --- a/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx +++ b/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx @@ -12,6 +12,7 @@ import { BrokerageEventCard } from "../BrokerageEventCard"; type BrokerageEventsPreviewProps = { events: BrokerageEventsPreviewState; + moreLabel?: string; }; type BrokerageEventsPreviewState @@ -51,7 +52,7 @@ const BrokerageEventsPreview: FC = (props) => { ))} ); diff --git a/src/pages/account/model/positions.test.ts b/src/pages/account/model/positions.test.ts index b6f905d..6d63f7f 100644 --- a/src/pages/account/model/positions.test.ts +++ b/src/pages/account/model/positions.test.ts @@ -2,9 +2,19 @@ import { describe, expect, it } from "vitest"; import type { Portfolio } from "@/entities/portfolio"; -import { toAccountPagePositionSections } from "./positions"; +import { + formatPositionReturnPercent, + toAccountPagePositionSections, + toUnrealizedReturns, +} from "./positions"; import { portfolio, position, second } from "./positions.fixtures"; +function makePosition( + overrides: Partial, +): Portfolio["positions"][number] { + return { ...position, ...overrides }; +} + function sections(positions: Portfolio["positions"]) { return toAccountPagePositionSections({ ...portfolio, @@ -82,6 +92,7 @@ describe("account position sections", () => { { displayName: "Акция", logoSrc: "https://invest-brands.cdn-tinkoff.ru/akciyax160.png", + returnPercent: null, returnText: "+10 ₽", returnTone: "positive", shareText: "66,7 %", @@ -95,6 +106,7 @@ describe("account position sections", () => { { displayName: "Облигация", logoSrc: null, + returnPercent: "-11.111111", returnText: "-5 ₽", returnTone: "negative", shareText: "33,3 %", @@ -107,6 +119,7 @@ describe("account position sections", () => { { displayName: "Фонд", logoSrc: null, + returnPercent: null, returnText: null, returnTone: undefined, shareText: null, @@ -187,3 +200,109 @@ describe("account position sections", () => { expect(source).toEqual(before); }); }); + +describe("account position returns", () => { + it.each([ + { + absoluteReturn: { currency: "RUB", value: "25" }, + averagePurchasePrice: { currency: "RUB", value: "280" }, + expected: "7.142857", + name: "gain", + quantity: "1.25", + type: "STOCK", + }, + { + absoluteReturn: { currency: "RUB", value: "-25" }, + averagePurchasePrice: { currency: "RUB", value: "280" }, + expected: "-7.142857", + name: "loss", + quantity: "1.25", + type: "STOCK", + }, + { + absoluteReturn: { currency: "RUB", value: "-5" }, + averagePurchasePrice: { currency: "RUB", value: "45" }, + expected: "-11.111111", + name: "bond without accrued interest", + quantity: "1", + type: "BOND", + }, + ] as const)("calculates $name from the clean purchase cost", (testCase) => { + const result = sections([makePosition({ + absoluteReturn: testCase.absoluteReturn, + averagePurchasePrice: testCase.averagePurchasePrice, + quantity: testCase.quantity, + type: testCase.type, + })]); + + expect(result[0]?.groups[0]?.positions[0].returnPercent) + .toBe(testCase.expected); + }); + + it.each([ + { name: "zero quantity", overrides: { quantity: "0" } }, + { name: "negative quantity", overrides: { quantity: "-1" } }, + { name: "missing average price", overrides: { averagePurchasePrice: null } }, + { + name: "currency mismatch", + overrides: { averagePurchasePrice: { currency: "USD", value: "280" } }, + }, + { name: "zero cost", overrides: { averagePurchasePrice: { currency: "RUB", value: "0" } } }, + ])("returns null for $name", ({ overrides }) => { + const result = sections([makePosition({ + absoluteReturn: { currency: "RUB", value: "25" }, + averagePurchasePrice: { currency: "RUB", value: "280" }, + quantity: "1.25", + ...overrides, + })]); + + expect(result[0]?.groups[0]?.positions[0].returnPercent).toBeNull(); + }); + + it.each([ + { absoluteReturn: "1", expected: "0.000001" }, + { absoluteReturn: "-1", expected: "-0.000001" }, + ])("rounds an exact half away from zero for $absoluteReturn", (testCase) => { + const result = sections([makePosition({ + absoluteReturn: { currency: "RUB", value: testCase.absoluteReturn }, + averagePurchasePrice: { currency: "RUB", value: "200000000" }, + quantity: "1", + })]); + + expect(result[0]?.groups[0]?.positions[0].returnPercent) + .toBe(testCase.expected); + }); + + it.each([ + { expected: "+1.01", value: "1.005" }, + { expected: "-1.01", value: "-1.005" }, + ])("formats $value for display with a sign and two decimals", (testCase) => { + expect(formatPositionReturnPercent(testCase.value)).toBe(testCase.expected); + }); + + it("sums complete unrealized returns by currency", () => { + const result = toUnrealizedReturns([ + makePosition({ absoluteReturn: { currency: "RUB", value: "0.1" } }), + makePosition({ absoluteReturn: { currency: "RUB", value: "0.2" } }), + makePosition({ absoluteReturn: { currency: "USD", value: "-2.5" } }), + ]); + + expect(result).toEqual([ + { currency: "RUB", value: "0.3" }, + { currency: "USD", value: "-2.5" }, + ]); + }); + + it("omits only currencies with incomplete returns", () => { + const result = toUnrealizedReturns([ + makePosition({ absoluteReturn: { currency: "RUB", value: "10" } }), + makePosition({ + absoluteReturn: null, + currentValue: { currency: "RUB", value: "100" }, + }), + makePosition({ absoluteReturn: { currency: "USD", value: "2" } }), + ]); + + expect(result).toEqual([{ currency: "USD", value: "2" }]); + }); +}); diff --git a/src/pages/account/model/positions.ts b/src/pages/account/model/positions.ts index 63414f3..6c74c2b 100644 --- a/src/pages/account/model/positions.ts +++ b/src/pages/account/model/positions.ts @@ -4,6 +4,7 @@ import { formatMoney, instrumentLogoUrl } from "@/shared/lib"; type AccountPagePosition = Portfolio["positions"][number] & { instrumentTo?: string; logoSrc: null | string; + returnPercent: null | string; returnText: null | string; returnTone?: AccountPageReturnTone; shareText: null | string; @@ -21,10 +22,73 @@ type AccountPagePositionSection = { type AccountPageReturnTone = "negative" | "positive"; +type Decimal = { scale: number; value: bigint }; + +type Money = NonNullable; + type PositionSectionKind = "bonds" | "other" | "stocks"; +const RADIX = 10n; +const DECIMAL_PARTS = 2; +const PERCENT_SCALE = 6; +const DISPLAY_PERCENT_SCALE = 2; +const PERCENT_FACTOR = 100n; +const ROUNDING_FACTOR = 2n; + +function decimalFromString(value: string): Decimal { + const [whole = "0", fraction = ""] = value.split(".", DECIMAL_PARTS); + + return { scale: fraction.length, value: BigInt(`${whole}${fraction}`) }; +} + +function decimalToString(decimal: Decimal): string { + const sign = decimal.value < 0n ? "-" : ""; + + const digits = (decimal.value < 0n ? -decimal.value : decimal.value) + .toString() + .padStart(decimal.scale + 1, "0"); + + const whole = decimal.scale + ? digits.slice(0, -decimal.scale) + : digits; + + let fraction = decimal.scale ? digits.slice(-decimal.scale) : ""; + + while (fraction.endsWith("0")) fraction = fraction.slice(0, -1); + + const suffix = fraction ? `.${fraction}` : ""; + + return `${sign}${whole}${suffix}`; +} + +function formatPositionReturnPercent(value: string): string { + const decimal = decimalFromString(value); + let rounded = decimal.value; + + if (decimal.scale > DISPLAY_PERCENT_SCALE) { + const divisor = RADIX ** BigInt(decimal.scale - DISPLAY_PERCENT_SCALE); + const remainder = rounded % divisor; + const absoluteRemainder = remainder < 0n ? -remainder : remainder; + rounded /= divisor; + + if (absoluteRemainder * ROUNDING_FACTOR >= divisor) { + rounded += decimal.value < 0n ? -1n : 1n; + } + } + else { + rounded *= RADIX ** BigInt(DISPLAY_PERCENT_SCALE - decimal.scale); + } + + const sign = rounded > 0n ? "+" : ""; + + return `${sign}${decimalToString({ + scale: DISPLAY_PERCENT_SCALE, + value: rounded, + })}`; +} + function groupPositions( positions: AccountPagePosition[], ): AccountPagePositionGroup[] { @@ -67,6 +131,53 @@ function positionIdentity(position: AccountPagePosition) { return position.isin?.trim() ? ["isin", position.isin.trim()] : undefined; } +function returnPercent( + position: Portfolio["positions"][number], +): null | string { + const result = position.absoluteReturn; + const price = position.averagePurchasePrice; + if (!result || !price || result.currency !== price.currency) return null; + + const quantity = decimalFromString(position.quantity); + const purchasePrice = decimalFromString(price.value); + + const cost = { + scale: quantity.scale + purchasePrice.scale, + value: quantity.value * purchasePrice.value, + }; + + if (quantity.value <= 0n || cost.value <= 0n) return null; + + const absoluteReturn = decimalFromString(result.value); + + const numerator = absoluteReturn.value * PERCENT_FACTOR + * RADIX ** BigInt(cost.scale + PERCENT_SCALE); + + const denominator = cost.value * RADIX ** BigInt(absoluteReturn.scale); + const remainder = numerator % denominator; + + const isRoundsUp = (remainder < 0n ? -remainder : remainder) * ROUNDING_FACTOR + >= denominator; + + const rounding = isRoundsUp ? 1n : 0n; + const signedRounding = numerator < 0n ? -rounding : rounding; + const rounded = numerator / denominator + signedRounding; + + return decimalToString({ scale: PERCENT_SCALE, value: rounded }); +} + +function sumDecimals(values: Decimal[]): Decimal { + const scale = Math.max(...values.map(value => value.scale)); + + const sum = values.reduce( + (total, value) => total + + value.value * RADIX ** BigInt(scale - value.scale), + 0n, + ); + + return { scale, value: sum }; +} + function toAccountPagePositionSections( portfolio: Portfolio, ): AccountPagePositionSection[] { @@ -120,6 +231,7 @@ function toPosition( logoSrc: isExpanded ? instrumentLogoUrl(position.logoName) : null, + returnPercent: returnPercent(position), returnText: position.absoluteReturn ? `${returnPrefix}${formatMoney(position.absoluteReturn)}` : null, @@ -162,9 +274,46 @@ function toSection( }; } +function toUnrealizedReturns( + positions: Portfolio["positions"], +): Money[] { + const totals = new Map(); + + for (const position of positions) { + const currencies = position.absoluteReturn + ? [position.absoluteReturn.currency] + : [...new Set([ + position.averagePurchasePrice?.currency, + position.currentValue?.currency, + ].filter((currency): currency is string => !!currency))]; + + for (const currency of currencies) { + const total = totals.get(currency) ?? { complete: true, values: [] }; + + if (position.absoluteReturn) { + total.values.push(decimalFromString(position.absoluteReturn.value)); + } + else { + total.complete = false; + } + + totals.set(currency, total); + } + } + + return [...totals].flatMap(([currency, total]) => total.complete + ? [{ currency, value: decimalToString(sumDecimals(total.values)) }] + : []); +} + export { type AccountPagePosition, type AccountPagePositionGroup, type AccountPagePositionSection, + formatPositionReturnPercent, toAccountPagePositionSections, + toUnrealizedReturns, }; diff --git a/src/pages/account/ui/AccountPage/AccountPage.module.css b/src/pages/account/ui/AccountPage/AccountPage.module.css index 9c0e130..b43f25a 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.module.css +++ b/src/pages/account/ui/AccountPage/AccountPage.module.css @@ -98,21 +98,13 @@ font-family: var(--mantine-font-family-monospace); } -.accordionControl { - transition: - background-color 150ms ease, - color 150ms ease; +.tabsList { + flex-wrap: nowrap; + overflow-x: auto; } -.accordionLabel { - font-size: var(--mantine-h2-font-size); - font-weight: var(--mantine-h2-font-weight); - line-height: var(--mantine-h2-line-height); -} - -.accordionPanel { - padding-left: 0; - padding-right: 0; +.positionFilter { + overflow-x: auto; } .accountPositions { diff --git a/src/pages/account/ui/AccountPage/AccountPage.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.test.tsx index 2ad05c9..dacc807 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.test.tsx @@ -1,6 +1,7 @@ import { MantineProvider } from "@mantine/core"; import { type QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { + fireEvent, render, screen, waitFor, @@ -23,7 +24,10 @@ import type { AccountPageRecentOperations, } from "../../model/previews"; -import { toAccountPagePositionSections } from "../../model/positions"; +import { + toAccountPagePositionSections, + toUnrealizedReturns, +} from "../../model/positions"; import { AccountPage } from "./AccountPage"; import { AccountPageEmpty } from "./components/AccountPageEmpty.view"; import { AccountPageError } from "./components/AccountPageError.view"; @@ -142,6 +146,7 @@ function loadedProps(nextPortfolio: Portfolio = portfolio) { positionSections: toAccountPagePositionSections(nextPortfolio), recentEvents: emptyRecentEvents, recentOperations: emptyRecentOperations, + unrealizedReturns: toUnrealizedReturns(nextPortfolio.positions), }; } @@ -246,7 +251,7 @@ describe("AccountPage states", () => { ).toHaveTextContent("Данные о структуре портфеля пока недоступны."); expect( screen.getAllByRole("definition").map(node => node.textContent), - ).toEqual(["1 234,5 ₽", "0,5 $"]); + ).toEqual(["1 234,5 ₽"]); }); it("keeps initial placeholders decorative without amounts or focus targets", () => { const { container } = renderPage(); @@ -309,8 +314,10 @@ describe("AccountPage states", () => { />, ); + fireEvent.click(screen.getByRole("tab", { name: "Деньги" })); + const balances = within( - screen.getByRole("region", { name: "Денежные остатки" }), + screen.getByRole("tabpanel", { name: "Деньги" }), ); expect( @@ -318,7 +325,12 @@ describe("AccountPage states", () => { ).toEqual(["234,500000000000000001 ₽", "-0,5 $", "3,00 XYZ"]); expect( screen.getAllByRole("definition").map(node => node.textContent), - ).toEqual(["1 234,5 ₽", "234,500000000000000001 ₽", "-0,5 $", "3,00 XYZ"]); + ).toEqual([ + "1 234,5 ₽", + "234,500000000000000001 ₽", + "-0,5 $", + "3,00 XYZ", + ]); expect(screen.queryByText("Торгуемая валюта")).not.toBeInTheDocument(); expect( screen.queryByText(/полная оценка|общая стоимость/i), @@ -341,6 +353,7 @@ describe("AccountPage states", () => { ); renderAccountRoute(createTestQueryClient()); await screen.findByRole("heading", { level: 1, name: "Портфель" }); + fireEvent.click(screen.getByRole("tab", { name: "Деньги" })); expect(screen.getByRole("definition")).toHaveTextContent("0,5 $"); expect(screen.queryByText("Портфель пока пуст")).not.toBeInTheDocument(); }, @@ -381,9 +394,10 @@ describe("AccountPage states", () => { ).not.toBeInTheDocument(); expect(screen.queryByText("Торгуемая валюта")).not.toBeInTheDocument(); expect(screen.queryByText("Портфель пока пуст")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("tab", { name: "Деньги" })); expect( within( - screen.getByRole("region", { name: "Денежные остатки" }), + screen.getByRole("tabpanel", { name: "Деньги" }), ).queryByRole("definition"), ).not.toBeInTheDocument(); expect( @@ -395,11 +409,25 @@ describe("AccountPage states", () => { }, ); - it("renders the empty state", () => { - renderPage(); + it("renders the empty state with self-descriptive preview links", () => { + renderPage( + , + ); expect( screen.getByRole("heading", { name: "Портфель пока пуст" }), ).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Открыть журнал" })) + .toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Открыть будущие события" })) + .toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Открыть пассивный доход" })) + .toBeInTheDocument(); }); it("keeps a null portfolio envelope loading instead of an empty portfolio", async () => { @@ -455,18 +483,22 @@ describe("AccountPage states", () => { expect(screen.queryByText(/T-Bank временно недоступен/)) .not.toBeInTheDocument(); expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Акции" })).toBeInTheDocument(); + expect(screen.getByRole("radio", { name: "Акции" })).toBeInTheDocument(); expect( - screen.getByRole("button", { name: "Облигации" }), + screen.getByRole("radio", { name: "Облигации" }), ).toBeInTheDocument(); - const positions = screen - .getAllByText(/Вторая|Первая/) - .map(element => element.textContent); - - expect(positions).toEqual(["Вторая", "Первая"]); - expect(screen.getByText("10 ₽")).toBeInTheDocument(); + expect( + screen.getAllByRole("radio").map(radio => radio.value), + ).toEqual(["stocks", "bonds"]); + expect(screen.getByText("Вторая")).toBeInTheDocument(); expect(screen.getByText("-1 ₽")).toBeInTheDocument(); + expect(screen.getByRole("img", { name: "Вторая" })).toHaveAttribute( + "src", + "https://invest-brands.cdn-tinkoff.ru/vtorayax160.png", + ); + fireEvent.click(screen.getByRole("radio", { name: "Облигации" })); + expect(screen.getByText("10 ₽")).toBeInTheDocument(); expect(screen.queryByText(/Доход:/)).not.toBeInTheDocument(); expect( screen.getByText("Средняя цена: 11 ₽ · Доля: 100 %"), @@ -475,10 +507,6 @@ describe("AccountPage states", () => { screen.getByText("Количество: 1 · RU000A0JX0J2"), ).toBeInTheDocument(); expect(screen.queryByText("—")).not.toBeInTheDocument(); - expect(screen.getByRole("img", { name: "Вторая" })).toHaveAttribute( - "src", - "https://invest-brands.cdn-tinkoff.ru/vtorayax160.png", - ); }); it.each(["STOCK", "BOND"] as const)( @@ -515,8 +543,11 @@ describe("AccountPage states", () => { expect(screen.getByText("Б")).toBeInTheDocument(); expect(screen.getByText("Н")).toBeInTheDocument(); expect(screen.getAllByRole("img")).toHaveLength(1); - for (const value of ["+3 ₽", "+2 ₽", "0 ₽"]) + + for (const value of ["+3 ₽", "+2 ₽", "0 ₽"]) { expect(screen.getByText(value)).toBeInTheDocument(); + } + expect(screen.queryByText(/Доход:/)).not.toBeInTheDocument(); }, ); @@ -564,6 +595,7 @@ describe("AccountPage states", () => { expect(screen.queryByText("STOCK-ISIN")).not.toBeInTheDocument(); expect(screen.getByText("Количество: 2 · FALLBACK-ISIN")) .toBeInTheDocument(); + fireEvent.click(screen.getByRole("radio", { name: "Облигации" })); expect(screen.getByText("Количество: 3 · BOND-ISIN")) .toBeInTheDocument(); expect(screen.queryByText("OFZ")).not.toBeInTheDocument(); @@ -617,9 +649,11 @@ describe("AccountPage states", () => { />, ); + fireEvent.click(screen.getByRole("tab", { name: "Операции" })); expect( screen.getByText("Пока недоступно"), ).toBeInTheDocument(); + fireEvent.click(screen.getByRole("tab", { name: "Позиции" })); expect(screen.getByText("Вторая")).toBeInTheDocument(); }); @@ -654,6 +688,7 @@ describe("AccountPage states", () => { />, ); + fireEvent.click(screen.getByRole("tab", { name: "Операции" })); expect(screen.getByText("Сделка")).toBeInTheDocument(); expect(screen.getByText("−12 ₽")).toBeInTheDocument(); }); @@ -707,7 +742,8 @@ describe("AccountPage states", () => { />, ); - const section = screen.getByRole("region", { name: "Будущие события" }); + fireEvent.click(screen.getByRole("tab", { name: "События" })); + const section = screen.getByRole("tabpanel", { name: "События" }); const content = within(section); const text = section.textContent ?? ""; @@ -734,7 +770,7 @@ describe("AccountPage states", () => { expect(content.queryByText("Без движения денег")).not.toBeInTheDocument(); expect(content.queryByText(/^\+/)).not.toBeInTheDocument(); expect( - content.getByRole("link", { name: "Открыть будущие события" }), + content.getByRole("link", { name: "Подробнее" }), ).toHaveAttribute("href", "/accounts/account-1/events"); }); @@ -748,8 +784,10 @@ describe("AccountPage states", () => { />, ); + fireEvent.click(screen.getByRole("tab", { name: "События" })); + const content = within( - screen.getByRole("region", { name: "Будущие события" }), + screen.getByRole("tabpanel", { name: "События" }), ); if (state === "loading") { @@ -770,7 +808,7 @@ describe("AccountPage states", () => { } expect( - content.getByRole("link", { name: "Открыть будущие события" }), + content.getByRole("link", { name: "Подробнее" }), ).toHaveAttribute("href", "/accounts/account-1/events"); }, ); @@ -786,9 +824,11 @@ describe("AccountPage states", () => { />, ); + fireEvent.click(screen.getByRole("tab", { name: "События" })); expect( screen.getByText("Данные появятся здесь, когда чтение восстановится."), ).toBeInTheDocument(); + fireEvent.click(screen.getByRole("tab", { name: "Позиции" })); expect(screen.getByText("Вторая")).toBeInTheDocument(); }); @@ -857,7 +897,7 @@ describe("AccountPage states", () => { expect(screen.getByText("Вторая")).toBeInTheDocument(); expect( screen.getAllByRole("definition").map(node => node.textContent), - ).toEqual(["1 234,5 ₽", "0,5 $"]); + ).toEqual(["1 234,5 ₽"]); expect(screen.queryByRole("button", { name: "Повторить" })) .not.toBeInTheDocument(); void queryClient.invalidateQueries({ @@ -867,7 +907,7 @@ describe("AccountPage states", () => { expect(structure).toHaveTextContent("10 ₽"); expect( screen.getAllByRole("definition").map(node => node.textContent), - ).toEqual(["1 234,5 ₽", "0,5 $"]); + ).toEqual(["1 234,5 ₽"]); resolveRetry?.( HttpResponse.json( resource( @@ -886,7 +926,7 @@ describe("AccountPage states", () => { await waitFor(() => expect( screen.getAllByRole("definition").map(node => node.textContent), - ).toEqual(["-0,5 $", "2,50 €"]), + ).toEqual(["-0,5 $"]), ); expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument(); expect(structure).not.toHaveTextContent("10 ₽"); @@ -928,7 +968,7 @@ describe("AccountPage states", () => { expect(screen.getByText("Вторая")).toBeInTheDocument(); expect( screen.getAllByRole("definition").map(node => node.textContent), - ).toEqual(["1 234,5 ₽", "0,5 $"]); + ).toEqual(["1 234,5 ₽"]); expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument(); expect( screen.queryByRole("status", { name: "Загрузка" }), @@ -938,4 +978,96 @@ describe("AccountPage states", () => { resolveRefresh?.(HttpResponse.json(resource(portfolio))); }); + + it("opens positions by default and switches between local tabs", () => { + renderPage(); + + expect(screen.getByRole("tab", { name: "Позиции" })) + .toHaveAttribute("aria-selected", "true"); + expect(screen.getByText("Вторая")).toBeVisible(); + + fireEvent.click(screen.getByRole("tab", { name: "Деньги" })); + expect(screen.getByRole("tabpanel", { name: "Деньги" })) + .toBeVisible(); + expect(screen.queryByText("Вторая")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("tab", { name: "Операции" })); + expect(screen.getByText("Последних событий пока нет.")).toBeVisible(); + }); + + it("links operation, event and income previews to their detail pages", () => { + renderPage( + , + ); + + for (const [tab, href] of [ + ["Операции", "/accounts/account-1/operations"], + ["События", "/accounts/account-1/events"], + ["Доход", "/accounts/account-1/income"], + ]) { + fireEvent.click(screen.getByRole("tab", { name: tab })); + expect(screen.getByRole("link", { name: "Подробнее" })) + .toHaveAttribute("href", href); + } + }); + + it("filters positions by the kinds present in the portfolio", () => { + renderPage(); + + expect( + screen.getAllByRole("radio").map(radio => radio.value), + ).toEqual(["stocks", "bonds"]); + expect(screen.getByText("Вторая")).toBeVisible(); + expect(screen.queryByText("Первая")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("radio", { name: "Облигации" })); + expect(screen.getByText("Первая")).toBeVisible(); + expect(screen.queryByText("Вторая")).not.toBeInTheDocument(); + }); + + it("hides the position filter when only one kind exists", () => { + renderPage( + , + ); + + expect(screen.queryByRole("radiogroup", { name: "Тип позиции" })) + .not.toBeInTheDocument(); + }); + + it("hides unrealized returns for a partial portfolio", () => { + renderPage(); + + expect(screen.queryByRole("region", { name: "Нереализованный результат" })) + .not.toBeInTheDocument(); + }); + + it("omits an unrealized currency total when any return is incomplete", () => { + renderPage( + , + ); + + expect(screen.queryByRole("region", { name: "Нереализованный результат" })) + .not.toBeInTheDocument(); + }); }); diff --git a/src/pages/account/ui/AccountPage/AccountPage.tsx b/src/pages/account/ui/AccountPage/AccountPage.tsx index 81b5417..0e38a79 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.tsx @@ -31,6 +31,7 @@ const AccountPage: FC = () => { positionSections={viewModel.positionSections} recentEvents={viewModel.recentEvents} recentOperations={viewModel.recentOperations} + unrealizedReturns={viewModel.unrealizedReturns} /> ); }; diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.ts b/src/pages/account/ui/AccountPage/AccountPage.vm.ts index fe1a61b..8d5e906 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.ts +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.ts @@ -20,6 +20,7 @@ import { useInitialAvailability } from "@/shared/lib"; import { type AccountPagePositionSection, toAccountPagePositionSections, + toUnrealizedReturns, } from "../../model/positions"; import { type AccountPageIncomeSummary, @@ -48,6 +49,7 @@ type AccountPageLoadedViewModel = AccountPageContent & { positionSections: AccountPagePositionSection[]; source?: InvestmentResourceMeta; state: "loaded"; + unrealizedReturns: Portfolio["totalsByCurrency"]; }; type AccountPageViewModel @@ -133,6 +135,7 @@ function useAccountPageViewModel(): AccountPageViewModel { recentOperations, source: portfolio.meta, state: "loaded" as const, + 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 1dde06a..d0e1418 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx @@ -1,6 +1,12 @@ -import type { FC } from "react"; - -import { Accordion, Alert, Stack } from "@mantine/core"; +import { + Alert, + Group, + SegmentedControl, + Stack, + Tabs, + Text, +} from "@mantine/core"; +import { type FC, useState } from "react"; import { useTranslation } from "react-i18next"; import type { BrokerageAccount } from "@/entities/brokerage-account"; @@ -8,6 +14,7 @@ import type { Portfolio } from "@/entities/portfolio"; import type { InvestmentResourceMeta } from "@/shared/api"; import { BrokerageEventsPreview } from "@/entities/brokerage-event"; +import { formatMoney } from "@/shared/lib"; import { SourceSyncStatus } from "@/shared/ui"; import { PortfolioStructure } from "@/widgets/portfolio-structure"; @@ -35,18 +42,27 @@ type AccountPageLoadedProps = { recentEvents: AccountPageRecentEvents; recentOperations: AccountPageRecentOperations; source?: InvestmentResourceMeta; + unrealizedReturns: Portfolio["totalsByCurrency"]; }; const AccountPageLoaded: FC = (props) => { const { t } = useTranslation(); + const [filter, setFilter] = useState(); - const defaultValue = [ - ...props.positionSections.map(section => section.kind), - "cash", - "recentOperations", - "recentEvents", - "incomeSummary", - ]; + const activeSection = props.positionSections.find( + section => section.kind === filter, + ) ?? props.positionSections[0]; + + const positionKindLabels: Record = { + bonds: t("portfolio.positionKinds.bonds"), + other: t("portfolio.positionKinds.other"), + stocks: t("portfolio.positionKinds.stocks"), + }; + + const filterOptions = props.positionSections.map(section => ({ + label: positionKindLabels[section.kind], + value: section.kind, + })); return ( @@ -62,6 +78,26 @@ const AccountPageLoaded: FC = (props) => { ? {t("investment.partial")} : } + {!props.partial && props.unrealizedReturns.length > 0 && ( + + {t("portfolio.unrealizedResult")} + + + {props.unrealizedReturns.map(total => ( + {formatMoney(total)} + ))} + + + + {t("portfolio.unrealizedResultCaption")} + + + )} + {!props.partial && ( = (props) => { /> )} - - {props.positionSections.map(section => ( - - - {t(positionSectionTitleKey(section.kind))} - + + + {t("portfolio.tabs.positions")} + - - - - - ))} + {t("portfolio.tabs.cash")} - - {t("portfolio.cashBalances")} + + {t("portfolio.tabs.operations")} + - - - - + {t("portfolio.tabs.events")} - - {t("ledger.recentTitle")} + {t("portfolio.tabs.income")} + - - - - - - - {t("events.recentTitle")} - - - - - - - - {t("income.summaryTitle")} - - - {!!props.incomeSummary && ( - + + + {props.positionSections.length > 1 && ( +
+ +
)} -
-
-
+ + {activeSection && } +
+ + + + + + + + + + + + + + + + {!!props.incomeSummary && ( + + )} + + ); }; -function positionSectionTitleKey(kind: AccountPagePositionSection["kind"]) { - if (kind === "stocks") return "portfolio.stocks"; - - return kind === "bonds" ? "portfolio.bonds" : "portfolio.otherPositions"; -} - export { AccountPageLoaded }; diff --git a/src/pages/account/ui/AccountPage/components/AccountPageLoading/AccountPageLoading.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageLoading/AccountPageLoading.view.tsx index acf5c80..23ce70b 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageLoading/AccountPageLoading.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageLoading/AccountPageLoading.view.tsx @@ -34,7 +34,9 @@ const AccountPageLoading: FC = () => { - {["first", "second", "third"].map(placeholder => )} + + + ); }; diff --git a/src/pages/account/ui/AccountPage/components/IncomeSummary.view.tsx b/src/pages/account/ui/AccountPage/components/IncomeSummary.view.tsx index 56d91a3..d6a3130 100644 --- a/src/pages/account/ui/AccountPage/components/IncomeSummary.view.tsx +++ b/src/pages/account/ui/AccountPage/components/IncomeSummary.view.tsx @@ -9,8 +9,8 @@ import type { IncomeSummary as IncomeSummaryData } from "@/entities/income"; import { formatMoney } from "@/shared/lib"; -type IncomeSummaryProps - = | { +type IncomeSummaryProps = { moreLabel?: string } & ( + | { state: "empty"; to: string; } @@ -27,7 +27,8 @@ type IncomeSummaryProps | { state: "partial"; to: string; - }; + } +); const IncomeSummary: FC = (props) => { const { t } = useTranslation(); @@ -56,7 +57,7 @@ const IncomeSummary: FC = (props) => { {t("income.summaryEmpty")} ); @@ -75,7 +76,7 @@ const IncomeSummary: FC = (props) => { ))} ); diff --git a/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.stories.tsx b/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.stories.tsx index 526694e..3c792fa 100644 --- a/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.stories.tsx +++ b/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.stories.tsx @@ -15,6 +15,7 @@ const syntheticPosition logoName: null, logoSrc: null, quantity: "100", + returnPercent: "25", returnText: "+5 000 ₽", returnTone: "positive", shareText: "20%", @@ -73,6 +74,7 @@ export const BondLoss: Story "Учебные облигации для долгосрочных накоплений, выпуск 2030", isin: "DEMO-BOND-2030", quantity: "10", + returnPercent: "-5", returnText: "−500 ₽", returnTone: "negative", shareText: "7,6%", @@ -123,6 +125,7 @@ export const MissingValuation: Story averagePurchasePrice: null, currentValue: null, displayName: "Учебная позиция без текущей оценки", + returnPercent: null, returnText: null, returnTone: undefined, shareText: null, diff --git a/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.view.tsx b/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.view.tsx index a83205f..c6cfc2b 100644 --- a/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.view.tsx +++ b/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.view.tsx @@ -4,10 +4,13 @@ import { Anchor, Card, Group, Stack, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; -import { formatMoney } from "@/shared/lib"; - -import type { AccountPagePosition, AccountPagePositionSection } from "../../../../model/positions"; +import { formatDecimal, formatMoney } from "@/shared/lib"; +import { + type AccountPagePosition, + type AccountPagePositionSection, + formatPositionReturnPercent, +} from "../../../../model/positions"; import classes from "../../AccountPage.module.css"; import { PositionLogo } from "../PositionLogo.view"; @@ -26,8 +29,15 @@ const PositionCard: FC = (props) => { : undefined; let returnText = position.returnText; - if (returnText && !isExpanded) + + if (returnText && position.returnPercent) { + returnText += ` · ${formatDecimal(formatPositionReturnPercent(position.returnPercent))} %`; + } + + if (returnText && !isExpanded) { returnText = t("portfolio.return", { value: returnText }); + } + const instrumentTo = position.instrumentTo; const identifier diff --git a/src/pages/account/ui/AccountPage/components/PositionGroupCard.view.tsx b/src/pages/account/ui/AccountPage/components/PositionGroupCard.view.tsx index 25e0ddd..b3dcc3c 100644 --- a/src/pages/account/ui/AccountPage/components/PositionGroupCard.view.tsx +++ b/src/pages/account/ui/AccountPage/components/PositionGroupCard.view.tsx @@ -4,10 +4,13 @@ import { Anchor, Card, Group, Stack, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; -import { formatMoney } from "@/shared/lib"; - -import type { AccountPagePositionGroup, AccountPagePositionSection } from "../../../model/positions"; +import { formatDecimal, formatMoney } from "@/shared/lib"; +import { + type AccountPagePositionGroup, + type AccountPagePositionSection, + formatPositionReturnPercent, +} from "../../../model/positions"; import classes from "../AccountPage.module.css"; import { PositionLogo } from "./PositionLogo.view"; @@ -60,6 +63,16 @@ const PositionGroupCard: FC = (props) => { ? classes[position.returnTone] : undefined; + const returnPercent = position.returnPercent + ? ` · ${formatDecimal(formatPositionReturnPercent(position.returnPercent))} %` + : ""; + + const returnValue = `${position.returnText ?? ""}${returnPercent}`; + + const returnText = kind === "other" + ? t("portfolio.return", { value: returnValue }) + : returnValue; + return ( = (props) => { {!!position.returnText && ( - {kind === "other" - ? t("portfolio.return", { value: position.returnText }) - : position.returnText} + {returnText} )} diff --git a/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx b/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx index 84d5918..9963e59 100644 --- a/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx +++ b/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx @@ -10,6 +10,7 @@ import { LedgerEntryRow } from "@/entities/broker-operation"; import type { AccountPageRecentOperations } from "../../../model/previews"; type RecentOperationsProps = { + moreLabel?: string; operations: AccountPageRecentOperations; }; @@ -44,7 +45,7 @@ const RecentOperations: FC = (props) => { ))} ); diff --git a/src/pages/account/ui/AccountPage/position-groups.test.tsx b/src/pages/account/ui/AccountPage/position-groups.test.tsx index 278bb1e..12c869b 100644 --- a/src/pages/account/ui/AccountPage/position-groups.test.tsx +++ b/src/pages/account/ui/AccountPage/position-groups.test.tsx @@ -36,6 +36,8 @@ describe("Positions grouped by security", () => { ).toHaveAttribute("href", "/accounts/a"); expect(within(rows[0]).getByText("Количество: 1.25")).toBeInTheDocument(); expect(within(rows[0]).getByText("375 ₽")).toBeInTheDocument(); + expect(within(rows[0]).getByText("+25 ₽ · +7,14 %")) + .toBeInTheDocument(); expect( within(rows[0]).getByText("Средняя цена: 280 ₽"), ).toBeInTheDocument(); diff --git a/src/pages/account/ui/AccountPage/position-layout.browser.test.tsx b/src/pages/account/ui/AccountPage/position-layout.browser.test.tsx index 62ff297..03926a2 100644 --- a/src/pages/account/ui/AccountPage/position-layout.browser.test.tsx +++ b/src/pages/account/ui/AccountPage/position-layout.browser.test.tsx @@ -40,6 +40,7 @@ it("keeps expanded positions vertical while other positions adapt to the viewpor logoName: null, logoSrc: null, quantity: "10", + returnPercent: null, returnText: null, shareText: null, ticker: null, diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts index ca5f855..74db411 100644 --- a/src/shared/i18n/locales/ru.ts +++ b/src/shared/i18n/locales/ru.ts @@ -420,7 +420,7 @@ const ru = { next: "Далее", noInstrument: "Без инструмента", noMoney: "Без движения денег", - occurredAt: "{{date}}", + occurredAt: "Исполнено {{date}}", open: "Открыть журнал", operationType: { buy: "Покупка", @@ -482,13 +482,21 @@ const ru = { }, emptyText: "На счете нет позиций и доступных денежных остатков.", emptyTitle: "Портфель пока пуст", + more: "Подробнее", otherPositions: "Другие позиции", + positionFilter: "Тип позиции", + positionKinds: { + bonds: "Облигации", + other: "Прочее", + stocks: "Акции", + }, positions: "Позиции", quantity: "Количество: {{value}}", refreshError: "Не удалось обновить портфель. Показан последний полученный снимок.", refreshing: "Обновляем портфель", return: "Доход: {{value}}", + sections: "Разделы счёта", share: "Доля: {{value}}", staleWarning: "T-Bank временно недоступен. Показан последний успешный снимок.", @@ -514,7 +522,16 @@ const ru = { unvalued: "Нет оценки", unvaluedCategory: "{{category}}: без оценки — {{count}}", }, + tabs: { + cash: "Деньги", + events: "События", + income: "Доход", + operations: "Операции", + positions: "Позиции", + }, title: "Портфель", + unrealizedResult: "Нереализованный результат", + unrealizedResultCaption: "по цене, без НКД и купонов", updatedAt: "Данные получены {{date}}", }, routeError: { -- 2.54.0 From d3e0656ebbb80b0f71cb6fa7b972173ec803d8ec Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 26 Oct 2026 20:18:33 +0300 Subject: [PATCH 2/2] fix: keep position card stories off the account model data imports (#112) --- src/pages/account/model/positions.test.ts | 3 +++ src/pages/account/model/positions.ts | 10 ++++++++-- .../PositionCard/PositionCard.stories.tsx | 3 +++ .../components/PositionCard/PositionCard.view.tsx | 14 +++++++------- .../components/PositionGroupCard.view.tsx | 14 +++++++------- .../AccountPage/position-layout.browser.test.tsx | 1 + 6 files changed, 29 insertions(+), 16 deletions(-) diff --git a/src/pages/account/model/positions.test.ts b/src/pages/account/model/positions.test.ts index 6d63f7f..e70943c 100644 --- a/src/pages/account/model/positions.test.ts +++ b/src/pages/account/model/positions.test.ts @@ -93,6 +93,7 @@ describe("account position sections", () => { displayName: "Акция", logoSrc: "https://invest-brands.cdn-tinkoff.ru/akciyax160.png", returnPercent: null, + returnPercentText: null, returnText: "+10 ₽", returnTone: "positive", shareText: "66,7 %", @@ -107,6 +108,7 @@ describe("account position sections", () => { displayName: "Облигация", logoSrc: null, returnPercent: "-11.111111", + returnPercentText: "-11,11 %", returnText: "-5 ₽", returnTone: "negative", shareText: "33,3 %", @@ -120,6 +122,7 @@ describe("account position sections", () => { displayName: "Фонд", logoSrc: null, returnPercent: null, + returnPercentText: null, returnText: null, returnTone: undefined, shareText: null, diff --git a/src/pages/account/model/positions.ts b/src/pages/account/model/positions.ts index 6c74c2b..0a217db 100644 --- a/src/pages/account/model/positions.ts +++ b/src/pages/account/model/positions.ts @@ -1,10 +1,11 @@ import { formatShare, type Portfolio } from "@/entities/portfolio"; -import { formatMoney, instrumentLogoUrl } from "@/shared/lib"; +import { formatDecimal, formatMoney, instrumentLogoUrl } from "@/shared/lib"; type AccountPagePosition = Portfolio["positions"][number] & { instrumentTo?: string; logoSrc: null | string; returnPercent: null | string; + returnPercentText: null | string; returnText: null | string; returnTone?: AccountPageReturnTone; shareText: null | string; @@ -222,6 +223,8 @@ function toPosition( const returnPrefix = isExpanded && returnValue !== null && returnValue > 0 ? "+" : ""; + const percent = returnPercent(position); + return { ...position, instrumentTo: @@ -231,7 +234,10 @@ function toPosition( logoSrc: isExpanded ? instrumentLogoUrl(position.logoName) : null, - returnPercent: returnPercent(position), + returnPercent: percent, + returnPercentText: percent + ? `${formatDecimal(formatPositionReturnPercent(percent))} %` + : null, returnText: position.absoluteReturn ? `${returnPrefix}${formatMoney(position.absoluteReturn)}` : null, diff --git a/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.stories.tsx b/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.stories.tsx index 3c792fa..f6f3c97 100644 --- a/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.stories.tsx +++ b/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.stories.tsx @@ -16,6 +16,7 @@ const syntheticPosition logoSrc: null, quantity: "100", returnPercent: "25", + returnPercentText: "+25,00 %", returnText: "+5 000 ₽", returnTone: "positive", shareText: "20%", @@ -75,6 +76,7 @@ export const BondLoss: Story isin: "DEMO-BOND-2030", quantity: "10", returnPercent: "-5", + returnPercentText: "-5,00 %", returnText: "−500 ₽", returnTone: "negative", shareText: "7,6%", @@ -126,6 +128,7 @@ export const MissingValuation: Story currentValue: null, displayName: "Учебная позиция без текущей оценки", returnPercent: null, + returnPercentText: null, returnText: null, returnTone: undefined, shareText: null, diff --git a/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.view.tsx b/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.view.tsx index c6cfc2b..0ae8ee2 100644 --- a/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.view.tsx +++ b/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.view.tsx @@ -4,13 +4,13 @@ import { Anchor, Card, Group, Stack, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; -import { formatDecimal, formatMoney } from "@/shared/lib"; +import { formatMoney } from "@/shared/lib"; -import { - type AccountPagePosition, - type AccountPagePositionSection, - formatPositionReturnPercent, +import type { + AccountPagePosition, + AccountPagePositionSection, } from "../../../../model/positions"; + import classes from "../../AccountPage.module.css"; import { PositionLogo } from "../PositionLogo.view"; @@ -30,8 +30,8 @@ const PositionCard: FC = (props) => { let returnText = position.returnText; - if (returnText && position.returnPercent) { - returnText += ` · ${formatDecimal(formatPositionReturnPercent(position.returnPercent))} %`; + if (returnText && position.returnPercentText) { + returnText += ` · ${position.returnPercentText}`; } if (returnText && !isExpanded) { diff --git a/src/pages/account/ui/AccountPage/components/PositionGroupCard.view.tsx b/src/pages/account/ui/AccountPage/components/PositionGroupCard.view.tsx index b3dcc3c..2e5753f 100644 --- a/src/pages/account/ui/AccountPage/components/PositionGroupCard.view.tsx +++ b/src/pages/account/ui/AccountPage/components/PositionGroupCard.view.tsx @@ -4,13 +4,13 @@ import { Anchor, Card, Group, Stack, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; -import { formatDecimal, formatMoney } from "@/shared/lib"; +import { formatMoney } from "@/shared/lib"; -import { - type AccountPagePositionGroup, - type AccountPagePositionSection, - formatPositionReturnPercent, +import type { + AccountPagePositionGroup, + AccountPagePositionSection, } from "../../../model/positions"; + import classes from "../AccountPage.module.css"; import { PositionLogo } from "./PositionLogo.view"; @@ -63,8 +63,8 @@ const PositionGroupCard: FC = (props) => { ? classes[position.returnTone] : undefined; - const returnPercent = position.returnPercent - ? ` · ${formatDecimal(formatPositionReturnPercent(position.returnPercent))} %` + const returnPercent = position.returnPercentText + ? ` · ${position.returnPercentText}` : ""; const returnValue = `${position.returnText ?? ""}${returnPercent}`; diff --git a/src/pages/account/ui/AccountPage/position-layout.browser.test.tsx b/src/pages/account/ui/AccountPage/position-layout.browser.test.tsx index 03926a2..10bd665 100644 --- a/src/pages/account/ui/AccountPage/position-layout.browser.test.tsx +++ b/src/pages/account/ui/AccountPage/position-layout.browser.test.tsx @@ -41,6 +41,7 @@ it("keeps expanded positions vertical while other positions adapt to the viewpor logoSrc: null, quantity: "10", returnPercent: null, + returnPercentText: null, returnText: null, shareText: null, ticker: null, -- 2.54.0