feat: render stock logos with signed returns on account page
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ export {
|
|||||||
formatMoney,
|
formatMoney,
|
||||||
formatShare,
|
formatShare,
|
||||||
formatSnapshotTime,
|
formatSnapshotTime,
|
||||||
|
instrumentLogoUrl,
|
||||||
portfolioErrorCode,
|
portfolioErrorCode,
|
||||||
} from "./model/display";
|
} from "./model/display";
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
Vendored
+1
@@ -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 */
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
Reference in New Issue
Block a user