diff --git a/scripts/lazy-route-bundle-baseline.json b/scripts/lazy-route-bundle-baseline.json index de340ba..967e7eb 100644 --- a/scripts/lazy-route-bundle-baseline.json +++ b/scripts/lazy-route-bundle-baseline.json @@ -2,7 +2,7 @@ "src/app/routes/ProtectedLayout.tsx": 3137, "src/pages/account-events/index.ts": 4468, "src/pages/account-operations/index.ts": 13861, - "src/pages/account/index.ts": 17018, + "src/pages/account/index.ts": 21868, "src/pages/accounts/index.ts": 6174, "src/pages/login/index.ts": 669, "src/pages/not-found/index.ts": 850, diff --git a/src/entities/portfolio/index.ts b/src/entities/portfolio/index.ts index cf9c198..f425e0b 100644 --- a/src/entities/portfolio/index.ts +++ b/src/entities/portfolio/index.ts @@ -4,5 +4,6 @@ export { formatMoney, formatShare, formatSnapshotTime, + instrumentLogoUrl, portfolioErrorCode, } from "./model/display"; diff --git a/src/entities/portfolio/model/display.test.ts b/src/entities/portfolio/model/display.test.ts index 198b8e7..f1dd98b 100644 --- a/src/entities/portfolio/model/display.test.ts +++ b/src/entities/portfolio/model/display.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { formatMoney, formatShare } from "./display"; +import { formatMoney, formatShare, instrumentLogoUrl } from "./display"; describe("portfolio display helpers", () => { it("formats decimal strings without financial arithmetic", () => { @@ -33,4 +33,15 @@ describe("portfolio display helpers", () => { ), ).toBeNull(); }); + + it("builds the brand logo url from an upstream file name", () => { + expect(instrumentLogoUrl("sber3.png")).toBe( + "https://invest-brands.cdn-tinkoff.ru/sber3x160.png", + ); + expect(instrumentLogoUrl(" RU000A0JPNM1.png ")).toBe( + "https://invest-brands.cdn-tinkoff.ru/RU000A0JPNM1x160.png", + ); + expect(instrumentLogoUrl(null)).toBeNull(); + expect(instrumentLogoUrl(" ")).toBeNull(); + }); }); diff --git a/src/entities/portfolio/model/display.ts b/src/entities/portfolio/model/display.ts index c73af7b..71738c6 100644 --- a/src/entities/portfolio/model/display.ts +++ b/src/entities/portfolio/model/display.ts @@ -45,3 +45,14 @@ export function portfolioErrorCode(error: unknown): string | undefined { ? String(error.code) : undefined; } + +const logoSize = 160; + +export function instrumentLogoUrl( + logoName: string | null | undefined, +): string | null { + const stem = logoName?.trim().replace(/\.png$/i, ""); + return stem + ? `https://invest-brands.cdn-tinkoff.ru/${stem}x${logoSize}.png` + : null; +} diff --git a/src/pages/account/ui/AccountPage.test.tsx b/src/pages/account/ui/AccountPage.test.tsx index 751c125..67c0122 100644 --- a/src/pages/account/ui/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage.test.tsx @@ -56,6 +56,7 @@ const portfolio: Portfolio = { averagePurchasePrice: null, absoluteReturn: null, isin: null, + logoName: "vtoraya.png", }, { figi: "FIGI-1", @@ -66,6 +67,7 @@ const portfolio: Portfolio = { averagePurchasePrice: { currency: "RUB", value: "11" }, absoluteReturn: { currency: "RUB", value: "-1" }, isin: "RU000A0JX0J2", + logoName: null, }, ], cashBalances: [{ currency: "USD", value: "0.5" }], @@ -126,6 +128,49 @@ describe("AccountPage states", () => { expect(screen.getByText("Доля: 100 %")).toBeInTheDocument(); expect(screen.getByText("RU000A0JX0J2")).toBeInTheDocument(); expect(screen.queryByText("—")).not.toBeInTheDocument(); + expect(screen.getByRole("img", { name: "Вторая" })).toHaveAttribute( + "src", + "https://invest-brands.cdn-tinkoff.ru/vtorayax160.png", + ); + }); + + it("renders stock cards with logo fallback and signed unlabeled return", () => { + renderPage( + , + ); + expect(screen.getByText("А")).toBeInTheDocument(); + expect(screen.getByText("+3 ₽")).toBeInTheDocument(); + expect(screen.getByText("0 ₽")).toBeInTheDocument(); + expect(screen.queryByText(/Доход:/)).not.toBeInTheDocument(); + expect(screen.queryByRole("img", { name: "Абра" })).not.toBeInTheDocument(); }); it("uses the cached account title when available", () => { diff --git a/src/pages/account/ui/AccountPage.view.tsx b/src/pages/account/ui/AccountPage.view.tsx index 758280c..daa894f 100644 --- a/src/pages/account/ui/AccountPage.view.tsx +++ b/src/pages/account/ui/AccountPage.view.tsx @@ -1,5 +1,6 @@ import { Alert, + Avatar, Button, Card, Group, @@ -10,6 +11,7 @@ import { Text, Title, } from "@mantine/core"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import { useBrokerageOperations } from "@/entities/broker-operation"; @@ -24,6 +26,7 @@ import { formatMoney, formatShare, formatSnapshotTime, + instrumentLogoUrl, } from "@/entities/portfolio"; import { StateView } from "@/shared/ui"; import classes from "./AccountPage.module.css"; @@ -188,6 +191,7 @@ export function AccountPageLoaded({ title={t("portfolio.stocks")} positions={stocks} denominators={denominators} + stock /> ) : null} {bonds.length ? ( @@ -349,19 +353,22 @@ function PositionSection({ title, positions, denominators, + stock = false, }: { title: string; positions: Portfolio["positions"]; denominators: Map; + stock?: boolean; }) { return (
- + {positions.map((position) => ( ))} @@ -371,9 +378,11 @@ function PositionSection({ function PositionCard({ position, denominators, + stock = false, }: { position: Portfolio["positions"][number]; denominators: Map; + stock?: boolean; }) { const { t } = useTranslation(); const share = position.currentValue?.currency @@ -389,9 +398,24 @@ function PositionCard({ ? classes.negative : undefined : undefined; + const returnText = position.absoluteReturn + ? stock + ? `${Number(position.absoluteReturn.value) > 0 ? "+" : ""}${formatMoney(position.absoluteReturn)}` + : t("portfolio.return", { + value: formatMoney(position.absoluteReturn), + }) + : null; + const logoSrc = stock ? instrumentLogoUrl(position.logoName) : null; return ( + {stock ? ( + + ) : null} {position.displayName} @@ -411,11 +435,9 @@ function PositionCard({ {formatMoney(position.currentValue)} ) : null} - {position.absoluteReturn ? ( + {returnText ? ( - {t("portfolio.return", { - value: formatMoney(position.absoluteReturn), - })} + {returnText} ) : null} @@ -439,6 +461,24 @@ function PositionCard({ ); } +function PositionLogo({ src, label }: { src: string | null; label: string }) { + const [failed, setFailed] = useState(false); + if (src && !failed) + return ( + setFailed(true)} + /> + ); + return ( + + {(label.trim()[0] ?? "?").toUpperCase()} + + ); +} function AccountHeading({ account }: { account?: BrokerageAccount }) { const { t } = useTranslation(); return ( diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 2b2dd9d..0d01bf8 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -253,6 +253,7 @@ export interface components { averagePurchasePrice: components["schemas"]["MoneyDto"] | null; absoluteReturn: components["schemas"]["MoneyDto"] | null; isin: string | null; + logoName: string | null; }; PortfolioDto: { /** Format: date-time */ diff --git a/src/shared/api/spec/openapi.json b/src/shared/api/spec/openapi.json index 254681a..d8e3e6d 100644 --- a/src/shared/api/spec/openapi.json +++ b/src/shared/api/spec/openapi.json @@ -1345,6 +1345,10 @@ "isin": { "type": "string", "nullable": true + }, + "logoName": { + "type": "string", + "nullable": true } }, "required": [ @@ -1355,7 +1359,8 @@ "currentValue", "averagePurchasePrice", "absoluteReturn", - "isin" + "isin", + "logoName" ] }, "PortfolioDto": {