feat: render stock logos with signed returns on account page

This commit is contained in:
2026-08-24 23:41:49 +03:00
parent 11be786943
commit a6d3051270
8 changed files with 122 additions and 8 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
"src/app/routes/ProtectedLayout.tsx": 3137, "src/app/routes/ProtectedLayout.tsx": 3137,
"src/pages/account-events/index.ts": 4468, "src/pages/account-events/index.ts": 4468,
"src/pages/account-operations/index.ts": 13861, "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/accounts/index.ts": 6174,
"src/pages/login/index.ts": 669, "src/pages/login/index.ts": 669,
"src/pages/not-found/index.ts": 850, "src/pages/not-found/index.ts": 850,
+1
View File
@@ -4,5 +4,6 @@ export {
formatMoney, formatMoney,
formatShare, formatShare,
formatSnapshotTime, formatSnapshotTime,
instrumentLogoUrl,
portfolioErrorCode, portfolioErrorCode,
} from "./model/display"; } from "./model/display";
+12 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { formatMoney, formatShare } from "./display"; import { formatMoney, formatShare, instrumentLogoUrl } from "./display";
describe("portfolio display helpers", () => { describe("portfolio display helpers", () => {
it("formats decimal strings without financial arithmetic", () => { it("formats decimal strings without financial arithmetic", () => {
@@ -33,4 +33,15 @@ describe("portfolio display helpers", () => {
), ),
).toBeNull(); ).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();
});
}); });
+11
View File
@@ -45,3 +45,14 @@ export function portfolioErrorCode(error: unknown): string | undefined {
? String(error.code) ? String(error.code)
: undefined; : 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;
}
+45
View File
@@ -56,6 +56,7 @@ const portfolio: Portfolio = {
averagePurchasePrice: null, averagePurchasePrice: null,
absoluteReturn: null, absoluteReturn: null,
isin: null, isin: null,
logoName: "vtoraya.png",
}, },
{ {
figi: "FIGI-1", figi: "FIGI-1",
@@ -66,6 +67,7 @@ const portfolio: Portfolio = {
averagePurchasePrice: { currency: "RUB", value: "11" }, averagePurchasePrice: { currency: "RUB", value: "11" },
absoluteReturn: { currency: "RUB", value: "-1" }, absoluteReturn: { currency: "RUB", value: "-1" },
isin: "RU000A0JX0J2", isin: "RU000A0JX0J2",
logoName: null,
}, },
], ],
cashBalances: [{ currency: "USD", value: "0.5" }], cashBalances: [{ currency: "USD", value: "0.5" }],
@@ -126,6 +128,49 @@ describe("AccountPage states", () => {
expect(screen.getByText("Доля: 100 %")).toBeInTheDocument(); expect(screen.getByText("Доля: 100 %")).toBeInTheDocument();
expect(screen.getByText("RU000A0JX0J2")).toBeInTheDocument(); expect(screen.getByText("RU000A0JX0J2")).toBeInTheDocument();
expect(screen.queryByText("—")).not.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(
<AccountPageLoaded
portfolio={{
...portfolio,
positions: [
{
figi: "FIGI-A",
displayName: "Абра",
type: "STOCK",
quantity: "1",
currentValue: { currency: "RUB", value: "5" },
averagePurchasePrice: null,
absoluteReturn: { currency: "RUB", value: "3" },
isin: null,
logoName: null,
},
{
figi: "FIGI-B",
displayName: "Кадабра",
type: "STOCK",
quantity: "1",
currentValue: { currency: "RUB", value: "7" },
averagePurchasePrice: null,
absoluteReturn: { currency: "RUB", value: "0" },
isin: null,
logoName: null,
},
],
}}
/>,
);
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", () => { it("uses the cached account title when available", () => {
+45 -5
View File
@@ -1,5 +1,6 @@
import { import {
Alert, Alert,
Avatar,
Button, Button,
Card, Card,
Group, Group,
@@ -10,6 +11,7 @@ import {
Text, Text,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { useBrokerageOperations } from "@/entities/broker-operation"; import { useBrokerageOperations } from "@/entities/broker-operation";
@@ -24,6 +26,7 @@ import {
formatMoney, formatMoney,
formatShare, formatShare,
formatSnapshotTime, formatSnapshotTime,
instrumentLogoUrl,
} from "@/entities/portfolio"; } from "@/entities/portfolio";
import { StateView } from "@/shared/ui"; import { StateView } from "@/shared/ui";
import classes from "./AccountPage.module.css"; import classes from "./AccountPage.module.css";
@@ -188,6 +191,7 @@ export function AccountPageLoaded({
title={t("portfolio.stocks")} title={t("portfolio.stocks")}
positions={stocks} positions={stocks}
denominators={denominators} denominators={denominators}
stock
/> />
) : null} ) : null}
{bonds.length ? ( {bonds.length ? (
@@ -349,19 +353,22 @@ function PositionSection({
title, title,
positions, positions,
denominators, denominators,
stock = false,
}: { }: {
title: string; title: string;
positions: Portfolio["positions"]; positions: Portfolio["positions"];
denominators: Map<string, number>; denominators: Map<string, number>;
stock?: boolean;
}) { }) {
return ( return (
<Section title={title}> <Section title={title}>
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}> <SimpleGrid cols={stock ? 1 : { base: 1, sm: 2, md: 3 }}>
{positions.map((position) => ( {positions.map((position) => (
<PositionCard <PositionCard
key={`${position.figi}-${position.displayName}-${position.quantity}`} key={`${position.figi}-${position.displayName}-${position.quantity}`}
position={position} position={position}
denominators={denominators} denominators={denominators}
stock={stock}
/> />
))} ))}
</SimpleGrid> </SimpleGrid>
@@ -371,9 +378,11 @@ function PositionSection({
function PositionCard({ function PositionCard({
position, position,
denominators, denominators,
stock = false,
}: { }: {
position: Portfolio["positions"][number]; position: Portfolio["positions"][number];
denominators: Map<string, number>; denominators: Map<string, number>;
stock?: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const share = position.currentValue?.currency const share = position.currentValue?.currency
@@ -389,9 +398,24 @@ function PositionCard({
? classes.negative ? classes.negative
: undefined : undefined
: 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 ( return (
<Card className={classes.tile} withBorder> <Card className={classes.tile} withBorder>
<Group justify="space-between" align="flex-start" wrap="nowrap"> <Group justify="space-between" align="flex-start" wrap="nowrap">
{stock ? (
<PositionLogo
key={logoSrc ?? "no-logo"}
src={logoSrc}
label={position.displayName}
/>
) : null}
<Stack gap={2} miw={0}> <Stack gap={2} miw={0}>
<Text fw={700} lineClamp={1}> <Text fw={700} lineClamp={1}>
{position.displayName} {position.displayName}
@@ -411,11 +435,9 @@ function PositionCard({
{formatMoney(position.currentValue)} {formatMoney(position.currentValue)}
</Text> </Text>
) : null} ) : null}
{position.absoluteReturn ? ( {returnText ? (
<Text size="sm" fw={600} className={returnClass}> <Text size="sm" fw={600} className={returnClass}>
{t("portfolio.return", { {returnText}
value: formatMoney(position.absoluteReturn),
})}
</Text> </Text>
) : null} ) : null}
</Stack> </Stack>
@@ -439,6 +461,24 @@ function PositionCard({
</Card> </Card>
); );
} }
function PositionLogo({ src, label }: { src: string | null; label: string }) {
const [failed, setFailed] = useState(false);
if (src && !failed)
return (
<Avatar
src={src}
alt={label}
size={40}
radius="md"
onError={() => setFailed(true)}
/>
);
return (
<Avatar size={40} radius="md">
{(label.trim()[0] ?? "?").toUpperCase()}
</Avatar>
);
}
function AccountHeading({ account }: { account?: BrokerageAccount }) { function AccountHeading({ account }: { account?: BrokerageAccount }) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
+1
View File
@@ -253,6 +253,7 @@ export interface components {
averagePurchasePrice: components["schemas"]["MoneyDto"] | null; averagePurchasePrice: components["schemas"]["MoneyDto"] | null;
absoluteReturn: components["schemas"]["MoneyDto"] | null; absoluteReturn: components["schemas"]["MoneyDto"] | null;
isin: string | null; isin: string | null;
logoName: string | null;
}; };
PortfolioDto: { PortfolioDto: {
/** Format: date-time */ /** Format: date-time */
+6 -1
View File
@@ -1345,6 +1345,10 @@
"isin": { "isin": {
"type": "string", "type": "string",
"nullable": true "nullable": true
},
"logoName": {
"type": "string",
"nullable": true
} }
}, },
"required": [ "required": [
@@ -1355,7 +1359,8 @@
"currentValue", "currentValue",
"averagePurchasePrice", "averagePurchasePrice",
"absoluteReturn", "absoluteReturn",
"isin" "isin",
"logoName"
] ]
}, },
"PortfolioDto": { "PortfolioDto": {