feat: add stock pages and ticker navigation
Add stock search and detail pages with charts, dividends and shared event rows. Use verified ticker links for portfolio names and logos, numeric dates and refreshed account cards. Update the generated API contract.
This commit is contained in:
@@ -121,6 +121,83 @@ function emptyProps() {
|
||||
}
|
||||
|
||||
describe("AccountPage states", () => {
|
||||
it.each(["account-1", "all"])(
|
||||
"uses verified ticker hrefs for both name and logo in %s",
|
||||
async (accountId) => {
|
||||
const uid = "c190ff1f-1447-4227-b543-316332699ca5";
|
||||
const requests = vi.fn();
|
||||
const position = {
|
||||
...portfolio.positions[0],
|
||||
instrumentUid: uid,
|
||||
ticker: "SBERP",
|
||||
displayName: "Сбербанк привилегированные",
|
||||
};
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json({
|
||||
...portfolio,
|
||||
positions:
|
||||
accountId === "all"
|
||||
? ["account-1", "account-2"].map((id) => ({
|
||||
...position,
|
||||
account: { id, name: id },
|
||||
}))
|
||||
: [position],
|
||||
}),
|
||||
),
|
||||
http.get("*/api/v1/stocks/:uid", () => {
|
||||
requests();
|
||||
return HttpResponse.json({ instrumentUid: uid, slug: "SBERP" });
|
||||
}),
|
||||
);
|
||||
configureTestAuth();
|
||||
renderPage(<AccountPage accountId={accountId} />);
|
||||
await waitFor(() => {
|
||||
const links = screen.getAllByRole("link", {
|
||||
name: position.displayName,
|
||||
});
|
||||
expect(links).toHaveLength(2);
|
||||
for (const link of links)
|
||||
expect(link).toHaveAttribute("href", "/stocks/SBERP");
|
||||
});
|
||||
expect(requests).toHaveBeenCalledOnce();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["unavailable", "noncanonical"])(
|
||||
"keeps exact UID links and the portfolio visible when ticker is %s",
|
||||
async (scenario) => {
|
||||
const uid = "c190ff1f-1447-4227-b543-316332699ca5";
|
||||
const requests = vi.fn();
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
||||
HttpResponse.json({
|
||||
...portfolio,
|
||||
positions: [
|
||||
{
|
||||
...portfolio.positions[0],
|
||||
instrumentUid: uid,
|
||||
ticker: "SBERP",
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
http.get("*/api/v1/stocks/:uid", () => {
|
||||
requests();
|
||||
return scenario === "unavailable"
|
||||
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
||||
: HttpResponse.json({ instrumentUid: uid, slug: uid });
|
||||
}),
|
||||
);
|
||||
renderAccountRoute(createTestQueryClient());
|
||||
await waitFor(() => expect(requests).toHaveBeenCalledOnce());
|
||||
const links = await screen.findAllByRole("link", { name: "Вторая" });
|
||||
expect(links).toHaveLength(2);
|
||||
for (const link of links)
|
||||
expect(link).toHaveAttribute("href", `/stocks/${uid}`);
|
||||
},
|
||||
);
|
||||
|
||||
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", () =>
|
||||
@@ -715,11 +792,11 @@ describe("AccountPage states", () => {
|
||||
expect(titles.map((title) => text.indexOf(title))).toEqual(
|
||||
titles.map((title) => text.indexOf(title)).sort((a, b) => a - b),
|
||||
);
|
||||
expect(content.getByText("10 сент. 2026 г. · RU000A0JX0J2")).toBeVisible();
|
||||
expect(content.getByText("10.09.2026 · RU000A0JX0J2")).toBeVisible();
|
||||
expect(content.getAllByText(/RU000A0JX0J2$/)).toHaveLength(4);
|
||||
expect(content.queryByText(/Дата события/)).not.toBeInTheDocument();
|
||||
expect(
|
||||
content.getByText("11 сент. 2026 г. · Дата реестра · RU000A0JX0J2"),
|
||||
content.getByText("11.09.2026 · Дата реестра · RU000A0JX0J2"),
|
||||
).toBeVisible();
|
||||
for (const amount of ["850,50 ₽", "0 USD", "1 000 ₽", "Сумма неизвестна"])
|
||||
expect(content.getByText(amount)).toBeVisible();
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { type IncomeSummary, useIncomeSummary } from "@/entities/income";
|
||||
import type { Portfolio } from "@/entities/portfolio";
|
||||
import { formatShare, usePortfolio } from "@/entities/portfolio";
|
||||
import { useStocks } from "@/entities/stock";
|
||||
import { apiErrorCode } from "@/shared/api";
|
||||
import { formatMoney, instrumentLogoUrl } from "@/shared/lib";
|
||||
|
||||
@@ -24,6 +25,7 @@ type AccountPagePosition = Portfolio["positions"][number] & {
|
||||
returnText: string | null;
|
||||
returnTone?: AccountPageReturnTone;
|
||||
logoSrc: string | null;
|
||||
stockTo?: string;
|
||||
};
|
||||
|
||||
type AccountPagePositionGroup = {
|
||||
@@ -85,6 +87,13 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
|
||||
const { accountId: routeAccountId } = useParams();
|
||||
const accountId = scopeId ?? routeAccountId;
|
||||
const portfolio = usePortfolio(accountId);
|
||||
const stockDetails = useStocks(
|
||||
portfolio.data?.positions.flatMap((position) =>
|
||||
position.type === "STOCK" && position.instrumentUid
|
||||
? [position.instrumentUid]
|
||||
: [],
|
||||
) ?? [],
|
||||
);
|
||||
const operations = useBrokerageOperations(accountId, {
|
||||
limit: 5,
|
||||
});
|
||||
@@ -125,7 +134,10 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
|
||||
account,
|
||||
accountId,
|
||||
portfolio: portfolio.data,
|
||||
positionSections: toAccountPagePositionSections(portfolio.data),
|
||||
positionSections: toAccountPagePositionSections(
|
||||
portfolio.data,
|
||||
stockDetails,
|
||||
),
|
||||
recentOperations,
|
||||
recentEvents,
|
||||
incomeSummary,
|
||||
@@ -170,6 +182,7 @@ function toRecentEvents(
|
||||
|
||||
function toAccountPagePositionSections(
|
||||
portfolio: Portfolio,
|
||||
stockDetails: ReturnType<typeof useStocks> = new Map(),
|
||||
): AccountPagePositionSection[] {
|
||||
const denominators = new Map<string, number>();
|
||||
for (const position of portfolio.positions) {
|
||||
@@ -180,9 +193,9 @@ function toAccountPagePositionSections(
|
||||
(denominators.get(currency) ?? 0) + Number(position.currentValue.value),
|
||||
);
|
||||
}
|
||||
const stocks = toSection("stocks", portfolio, denominators);
|
||||
const bonds = toSection("bonds", portfolio, denominators);
|
||||
const other = toSection("other", portfolio, denominators);
|
||||
const stocks = toSection("stocks", portfolio, denominators, stockDetails);
|
||||
const bonds = toSection("bonds", portfolio, denominators, stockDetails);
|
||||
const other = toSection("other", portfolio, denominators, stockDetails);
|
||||
return [stocks, bonds, other].filter((section) => section.groups.length > 0);
|
||||
}
|
||||
|
||||
@@ -190,6 +203,7 @@ function toSection(
|
||||
kind: PositionSectionKind,
|
||||
portfolio: Portfolio,
|
||||
denominators: Map<string, number>,
|
||||
stockDetails: ReturnType<typeof useStocks>,
|
||||
): AccountPagePositionSection {
|
||||
return {
|
||||
kind,
|
||||
@@ -204,7 +218,14 @@ function toSection(
|
||||
position.type !== "CURRENCY"
|
||||
);
|
||||
})
|
||||
.map((position) => toPosition(position, denominators, kind)),
|
||||
.map((position) =>
|
||||
toPosition(
|
||||
position,
|
||||
denominators,
|
||||
kind,
|
||||
stockDetails.get(position.instrumentUid ?? "")?.slug,
|
||||
),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -214,11 +235,13 @@ function groupPositions(
|
||||
): AccountPagePositionGroup[] {
|
||||
const groups = new Map<string, AccountPagePositionGroup>();
|
||||
for (const [index, position] of positions.entries()) {
|
||||
const identity = position.figi?.trim()
|
||||
? ["figi", position.figi.trim()]
|
||||
: position.isin?.trim()
|
||||
? ["isin", position.isin.trim()]
|
||||
: null;
|
||||
const identity = position.instrumentUid?.trim()
|
||||
? ["uid", position.instrumentUid.trim()]
|
||||
: position.figi?.trim()
|
||||
? ["figi", position.figi.trim()]
|
||||
: position.isin?.trim()
|
||||
? ["isin", position.isin.trim()]
|
||||
: null;
|
||||
const rowKey = JSON.stringify(["row", position.account?.id, index]);
|
||||
const key =
|
||||
position.account && identity
|
||||
@@ -242,6 +265,7 @@ function toPosition(
|
||||
position: Portfolio["positions"][number],
|
||||
denominators: Map<string, number>,
|
||||
kind: PositionSectionKind,
|
||||
stockSlug: string | undefined,
|
||||
): AccountPagePosition {
|
||||
const expanded = kind !== "other";
|
||||
const returnValue = position.absoluteReturn
|
||||
@@ -255,6 +279,10 @@ function toPosition(
|
||||
: "negative";
|
||||
return {
|
||||
...position,
|
||||
stockTo:
|
||||
kind === "stocks" && position.instrumentUid
|
||||
? `/stocks/${encodeURIComponent(stockSlug ?? position.instrumentUid)}`
|
||||
: undefined,
|
||||
shareText: position.currentValue?.currency
|
||||
? formatShare(position.currentValue, {
|
||||
currency: position.currentValue.currency,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
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";
|
||||
|
||||
@@ -30,6 +30,7 @@ const PositionCard: FC<PositionCardProps> = (props) => {
|
||||
value: position.returnText,
|
||||
})
|
||||
: null;
|
||||
const stockTo = position.stockTo;
|
||||
const identifier =
|
||||
kind === "stocks" ? (position.ticker ?? position.isin) : position.isin;
|
||||
const identifierText = [
|
||||
@@ -68,11 +69,18 @@ const PositionCard: FC<PositionCardProps> = (props) => {
|
||||
key={position.logoSrc ?? "no-logo"}
|
||||
src={position.logoSrc}
|
||||
label={position.displayName}
|
||||
to={stockTo}
|
||||
/>
|
||||
<Stack gap={2} miw={0} className={classes.expandedIdentity}>
|
||||
<Text fw={600} lineClamp={1}>
|
||||
{position.displayName}
|
||||
</Text>
|
||||
{stockTo ? (
|
||||
<Anchor component={Link} to={stockTo} c="inherit" fw={600}>
|
||||
{position.displayName}
|
||||
</Anchor>
|
||||
) : (
|
||||
<Text fw={600} lineClamp={1}>
|
||||
{position.displayName}
|
||||
</Text>
|
||||
)}
|
||||
{accountLink}
|
||||
<Text c="dimmed" size="xs" className={classes.expandedIdentifiers}>
|
||||
{identifierText}
|
||||
|
||||
@@ -19,6 +19,7 @@ const PositionGroupCard: FC<PositionGroupCardProps> = (props) => {
|
||||
const { group, kind } = props;
|
||||
const { t } = useTranslation();
|
||||
const identity = group.positions[0];
|
||||
const stockTo = identity.stockTo;
|
||||
return (
|
||||
<Card className={classes.tile} withBorder>
|
||||
<Group wrap="nowrap" gap="sm">
|
||||
@@ -27,10 +28,17 @@ const PositionGroupCard: FC<PositionGroupCardProps> = (props) => {
|
||||
key={identity.logoSrc ?? "no-logo"}
|
||||
src={identity.logoSrc}
|
||||
label={identity.displayName}
|
||||
to={stockTo}
|
||||
/>
|
||||
) : null}
|
||||
<Stack gap={2} miw={0}>
|
||||
<Text fw={600}>{identity.displayName}</Text>
|
||||
{stockTo ? (
|
||||
<Anchor component={Link} to={stockTo} c="inherit" fw={600}>
|
||||
{identity.displayName}
|
||||
</Anchor>
|
||||
) : (
|
||||
<Text fw={600}>{identity.displayName}</Text>
|
||||
)}
|
||||
<Text c="dimmed" size="xs">
|
||||
{identity.ticker ?? identity.isin ?? identity.figi}
|
||||
</Text>
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { Avatar } from "@mantine/core";
|
||||
import { Anchor, Avatar } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
type PositionLogoProps = {
|
||||
src: string | null;
|
||||
label: string;
|
||||
to?: string;
|
||||
};
|
||||
|
||||
const PositionLogo: FC<PositionLogoProps> = (props) => {
|
||||
const { src, label } = props;
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (src && !failed)
|
||||
return (
|
||||
const logo =
|
||||
src && !failed ? (
|
||||
<Avatar
|
||||
src={src}
|
||||
alt={label}
|
||||
@@ -20,11 +22,25 @@ const PositionLogo: FC<PositionLogoProps> = (props) => {
|
||||
radius="md"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<Avatar size={40} radius="md">
|
||||
{(label.trim()[0] ?? "?").toUpperCase()}
|
||||
</Avatar>
|
||||
);
|
||||
return (
|
||||
<Avatar size={40} radius="md">
|
||||
{(label.trim()[0] ?? "?").toUpperCase()}
|
||||
</Avatar>
|
||||
return props.to ? (
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={props.to}
|
||||
aria-label={label}
|
||||
c="inherit"
|
||||
underline="never"
|
||||
display="block"
|
||||
style={{ borderRadius: "var(--mantine-radius-md)" }}
|
||||
>
|
||||
{logo}
|
||||
</Anchor>
|
||||
) : (
|
||||
logo
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user