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:
2026-09-10 21:23:41 +03:00
parent 0306526d33
commit 561222e468
56 changed files with 5051 additions and 190 deletions
@@ -108,9 +108,9 @@ describe("AccountEventsPage", () => {
expect(screen.getByText("Сумма неизвестна")).toBeInTheDocument();
expect(screen.getByText("Купон · ОФЗ 26238")).toBeVisible();
expect(screen.getByText("Оферта · ОФЗ 26238")).toBeVisible();
expect(screen.getByText("20 авг. 2026 г. · RU000A0JX0J2")).toBeVisible();
expect(screen.getByText("20.08.2026 · RU000A0JX0J2")).toBeVisible();
expect(screen.getAllByText(/RU000A0JX0J2$/)).toHaveLength(1);
expect(screen.getByText("25 авг. 2026 г.")).toBeVisible();
expect(screen.getByText("25.08.2026")).toBeVisible();
expect(screen.queryByText(/Дата события/)).not.toBeInTheDocument();
expect(screen.getAllByText("Ожидаемая сумма")).toHaveLength(2);
const logo = container.querySelector("img");
@@ -392,10 +392,10 @@ describe("AccountIncomePage", () => {
expect(eventsRequest).not.toHaveBeenCalled();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument();
const selected = screen.getByRole("region", {
name: /^1–27 авг\. 2026\sг\.$/,
name: "01.08.2026 – 27.08.2026",
});
expect(
screen.getByRole("region", { name: /^1–27 авг\. 2025\sг\.$/ }),
screen.getByRole("region", { name: "01.08.2025 – 27.08.2025" }),
).toBeInTheDocument();
expect(screen.queryByText(/Предыдущий период/)).not.toBeInTheDocument();
expect(screen.queryByText("Выбранный период")).not.toBeInTheDocument();
@@ -498,10 +498,10 @@ describe("income comparison display", () => {
},
});
expect(display?.comparison?.selectedPeriod.replaceAll(/\s/g, " ")).toBe(
"1 дек. 2025 г. – 9 янв. 2026 г.",
"01.12.2025 – 09.01.2026",
);
expect(display?.comparison?.comparedPeriod.replaceAll(/\s/g, " ")).toBe(
"1 дек. 2024 г. – 9 янв. 2025 г.",
"01.12.2024 – 09.01.2025",
);
expect(display?.comparison?.values.map((value) => value.currency)).toEqual([
"RUB",
@@ -5,6 +5,7 @@ import { useParams, useSearchParams } from "react-router";
import { z } from "zod";
import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
import { validationMessage } from "@/shared/i18n";
import { formatDate } from "@/shared/lib";
type IncomePeriod = { from: string; to: string };
type IncomeForm = IncomePeriod;
@@ -252,13 +253,7 @@ function toIncomeDisplayModel(
}
function formatComparisonPeriod(period: IncomePeriod): string {
return new Intl.DateTimeFormat("ru-RU", {
dateStyle: "medium",
timeZone: "UTC",
}).formatRange(
new Date(`${period.from}T00:00:00Z`),
new Date(`${period.to}T00:00:00Z`),
);
return `${formatDate(period.from)} – ${formatDate(period.to)}`;
}
function formatIncomeChange(
+79 -2
View File
@@ -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();
+38 -10
View File
@@ -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
);
};
@@ -1,4 +1,4 @@
import { Button, Stack, Title } from "@mantine/core";
import { Button, Stack, Text, Title } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
@@ -19,8 +19,13 @@ const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
const { accounts, ...refresh } = props;
const { t } = useTranslation();
return (
<Stack>
<Title order={1}>{t("accounts.title")}</Title>
<Stack gap="lg">
<Stack gap={4}>
<Title order={1}>{t("accounts.title")}</Title>
<Text c="dimmed" size="sm">
{t("accounts.subtitle")}
</Text>
</Stack>
<AccountsRefreshState {...refresh} />
<Button
component={Link}
@@ -1,4 +1,4 @@
import { Skeleton } from "@mantine/core";
import { Card, Group, SimpleGrid, Skeleton, Stack } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { LoadingSkeleton } from "@/shared/ui";
@@ -8,9 +8,28 @@ const AccountsPageLoading: FC = () => {
return (
<LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="30%" />
{[0, 1, 2].map((index) => (
<Skeleton key={index} height={116} radius="md" />
))}
<Skeleton height={20} width="45%" />
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="lg" aria-hidden="true">
{[0, 1].map((index) => (
<Card key={index} padding="xl" radius="lg" withBorder>
<Stack gap="xl">
<Stack gap="sm">
<Skeleton height={24} width="35%" />
<Skeleton height={28} width="65%" />
</Stack>
<Stack gap="xs">
<Skeleton height={16} width="30%" />
<Skeleton height={40} width="60%" />
</Stack>
<Group grow>
<Skeleton height={56} />
<Skeleton height={56} />
</Group>
<Skeleton height={20} />
</Stack>
</Card>
))}
</SimpleGrid>
</LoadingSkeleton>
);
};
+1
View File
@@ -0,0 +1 @@
export { StockPage } from "./ui/StockPage";
+109
View File
@@ -0,0 +1,109 @@
.page {
max-width: 1120px;
margin: 0 auto;
min-width: 0;
}
.section {
padding: var(--mantine-spacing-lg);
border-radius: var(--mantine-radius-lg);
min-width: 0;
}
.identity {
display: flex;
align-items: center;
gap: var(--mantine-spacing-md);
min-width: 0;
}
.price {
font-size: clamp(2rem, 4vw, 3.2rem);
font-weight: 700;
letter-spacing: -0.04em;
font-variant-numeric: tabular-nums;
line-height: 1.2;
overflow-wrap: anywhere;
}
.positive {
color: var(--mantine-color-green-8);
}
.negative {
color: var(--mantine-color-red-8);
}
.metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--mantine-spacing-lg);
}
.metricValue {
font-variant-numeric: tabular-nums;
font-size: var(--mantine-font-size-xl);
font-weight: 600;
overflow-wrap: anywhere;
}
.holding {
background: var(--mantine-color-blue-0);
padding: var(--mantine-spacing-lg);
border-radius: var(--mantine-radius-md);
}
.accountRow {
display: grid;
grid-template-columns: 1.2fr repeat(4, minmax(0, 1fr));
gap: var(--mantine-spacing-sm);
padding: var(--mantine-spacing-md) 0;
border-top: 1px solid var(--wl-color-border);
align-items: start;
}
.dividend {
padding: var(--mantine-spacing-md);
background: var(--mantine-color-green-0);
border-radius: var(--mantine-radius-md);
}
.companyMetrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(135px, 1fr));
gap: var(--mantine-spacing-lg);
}
.table {
overflow-x: auto;
max-width: 100%;
}
.controls {
max-width: 100%;
overflow-x: auto;
}
@media (max-width: 48em) {
.section {
padding: var(--mantine-spacing-md);
}
.metrics,
.companyMetrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.accountRow {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.accountRow > :first-child {
grid-column: 1 / -1;
}
}
.embeddedSection {
margin-top: var(--mantine-spacing-lg);
padding-top: var(--mantine-spacing-lg);
border-top: 1px solid var(--wl-color-border);
}
.companyMetrics > div {
padding: var(--mantine-spacing-sm);
background: var(--wl-color-subtle);
border-radius: var(--mantine-radius-md);
}
.priceTable {
margin-top: var(--mantine-spacing-md);
}
.priceTable table {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.priceTable th:nth-child(2),
.priceTable td:nth-child(2) {
text-align: right;
}
+330
View File
@@ -0,0 +1,330 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { MemoryRouter, Route, Routes, useLocation } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import { StockPage } from "./StockPage";
const LocationProbe = () => {
const location = useLocation();
return (
<output data-testid="location">
{location.pathname}
{location.search}
</output>
);
};
const uid = "e6123145-9665-43e0-8413-cd61b8aa9b13";
const money = (value: string) => ({ value, currency: "RUB" });
const stock = {
instrumentUid: uid,
name: "Тестовая акция",
ticker: "TEST",
shareType: "SHARE_TYPE_COMMON",
currency: "RUB",
sector: "financial",
lot: 10,
logoName: null,
historyFrom: null,
};
const position = {
quantity: "10",
currentValue: money("3000"),
averagePurchasePrice: money("200"),
absoluteReturn: money("1000"),
returnPercent: "50",
accounts: [
{
instrumentUid: uid,
quantity: "10",
currentValue: money("3000"),
averagePurchasePrice: money("200"),
absoluteReturn: money("1000"),
returnPercent: "50",
account: { id: "main", name: "Основной" },
},
],
isStale: false,
observedAt: "2026-09-10T10:00:00Z",
};
const quote = {
price: money("300"),
priceAt: "2026-09-10T10:00:00Z",
tradingStatus: "SECURITY_TRADING_STATUS_NORMAL_TRADING",
change: money("10"),
changePercent: "3.448276",
previousCloseDate: "2026-09-09",
previousClose: money("290"),
unavailable: [],
observedAt: "2026-09-10T10:00:00Z",
};
function setup(
options: {
noPosition?: boolean;
failCompany?: boolean;
failStock?: boolean;
path?: string;
slug?: string;
ambiguousTicker?: boolean;
} = {},
) {
const requests: URL[] = [];
const resolved = { ...stock, slug: options.slug ?? uid };
let failQuote = false;
server.use(
http.get("*/api/v1/stocks/:uid", () =>
options.failStock
? HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 })
: HttpResponse.json(resolved),
),
http.get("*/api/v1/stocks/by-ticker/:ticker", ({ request }) => {
requests.push(new URL(request.url));
return options.ambiguousTicker
? HttpResponse.json({ code: "STOCK_TICKER_AMBIGUOUS" }, { status: 409 })
: HttpResponse.json(resolved);
}),
http.get("*/api/v1/stocks/:uid/:section", ({ params, request }) => {
requests.push(new URL(request.url));
if (params.section === "quote")
return failQuote
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(quote);
if (params.section === "position")
return HttpResponse.json(
options.noPosition
? { ...position, quantity: "0", accounts: [] }
: position,
);
if (params.section === "candles")
return HttpResponse.json({
candles: [],
currency: "RUB",
historyFrom: null,
observedAt: quote.observedAt,
});
if (params.section === "company")
return options.failCompany
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json({
description: "Описание компании",
metrics: [
{
key: "peRatioTtm",
value: 5.6,
unit: "ratio",
currency: null,
period: "TTM",
},
],
unavailable: [],
observedAt: quote.observedAt,
});
return HttpResponse.json({
dividends: [
{
perShare: money("32.88"),
recordDate: "2026-10-13",
lastBuyDate: "2026-10-12",
paymentDate: "2026-10-27",
timing: "upcoming",
estimatedAmount: options.noPosition ? null : money("328.8"),
},
{
perShare: money("20"),
recordDate: "2025-10-13",
paymentDate: null,
lastBuyDate: null,
timing: "past",
estimatedAmount: null,
},
],
from: "2021-09-10",
to: "2027-09-10",
observedAt: quote.observedAt,
positionAt: null,
estimateUnavailable: false,
positionIsStale: false,
});
}),
http.get("*/api/v1/brokerage-accounts/all/operations", ({ request }) => {
const url = new URL(request.url);
requests.push(url);
const next = url.searchParams.has("cursor");
return HttpResponse.json({
accountId: "all",
observedAt: quote.observedAt,
hasNext: !next,
nextCursor: next ? null : "next",
items: [
{
id: next ? "sold" : "dividend",
occurredAt: next ? "2022-01-02T10:00:00Z" : "2025-01-02T10:00:00Z",
type: next ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_DIVIDEND",
category: next ? "trade" : "income",
description: "",
account: { id: "main", name: "Основной" },
payment: money("200"),
quantity: next ? "2" : null,
price: next ? money("100") : null,
},
],
});
}),
);
configureAuthClient({
accessToken: () => "synthetic",
refresh: async () => false,
clear: vi.fn(),
});
const client = createTestQueryClient();
render(
<QueryClientProvider client={client}>
<MantineProvider>
<MemoryRouter initialEntries={[options.path ?? `/stocks/${uid}`]}>
<LocationProbe />
<Routes>
<Route path="/stocks/:stockId" element={<StockPage />} />
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
return {
requests,
failQuote: () => {
failQuote = true;
},
};
}
describe("stock page", () => {
it("opens a direct ticker URL and uses its resolved UID for market and personal requests", async () => {
const { requests } = setup({ path: "/stocks/SBERP", slug: "SBERP" });
await screen.findByText("Зачисление дивидендов");
expect(screen.getByTestId("location")).toHaveTextContent("/stocks/SBERP");
expect(
requests.some((url) => url.pathname === `/api/v1/stocks/${uid}/quote`),
).toBe(true);
expect(
requests.some((url) => url.searchParams.get("instrumentUid") === uid),
).toBe(true);
expect(requests.some((url) => url.pathname.includes("/SBERP/quote"))).toBe(
false,
);
});
it("replaces a legacy UID URL with the verified ticker while preserving filters", async () => {
setup({ path: `/stocks/${uid}?period=year&events=trades`, slug: "SBERP" });
await waitFor(() =>
expect(screen.getByTestId("location")).toHaveTextContent(
"/stocks/SBERP?period=year&events=trades",
),
);
expect(await screen.findByText("Зачисление дивидендов")).toBeVisible();
});
it("offers search for ambiguous tickers without requesting personal data", async () => {
const { requests } = setup({ path: "/stocks/SAME", ambiguousTicker: true });
expect(
await screen.findByText(/Этот тикер относится к нескольким выпускам/),
).toBeVisible();
expect(
requests.some(
(url) =>
url.pathname.endsWith("/operations") ||
url.pathname.endsWith("/position"),
),
).toBe(false);
});
it("opens by UID, displays server calculations and distinguishes company and personal dividends", async () => {
setup();
expect(
await screen.findByRole("heading", { name: "Тестовая акция" }),
).toBeVisible();
expect(
await screen.findByRole("heading", { name: "Моя позиция" }),
).toBeVisible();
expect(screen.getAllByText("3 000 ₽")[0]).toBeVisible();
expect(
screen.getByText("К закрытию предыдущего торгового дня, 09.09.2026"),
).toBeVisible();
expect(await screen.findByText("Зачисление дивидендов")).toBeVisible();
expect(screen.getByText("Событие компании")).toBeVisible();
expect(screen.getByText("328,8 ₽")).toBeVisible();
expect(screen.getByText("P/E · TTM")).toBeVisible();
expect(screen.getByText("Описание компании")).toBeVisible();
expect(
screen.queryByRole("button", { name: "О компании" }),
).not.toBeInTheDocument();
expect(screen.getByText("13.10.2026")).toBeVisible();
fireEvent.click(
screen.getByRole("button", { name: "История дивидендов компании" }),
);
await waitFor(() => expect(screen.getByText("13.10.2025")).toBeVisible());
});
it("keeps sold-position history and market content when company data fails", async () => {
setup({ noPosition: true, failCompany: true });
expect(await screen.findByText("Зачисление дивидендов")).toBeVisible();
expect(
screen.queryByRole("heading", { name: "Моя позиция" }),
).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "График цены" })).toBeVisible();
expect(await screen.findByRole("alert")).toHaveTextContent(
"Не удалось загрузить этот раздел",
);
});
it("sends instrument-filtered continuation, resets it on filter changes and changes chart period", async () => {
const { requests } = setup();
await screen.findByText("Зачисление дивидендов");
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
expect(await screen.findByText("Продажа")).toBeVisible();
expect(screen.getByText("2 шт.")).toBeVisible();
expect(screen.getByText("Цена: 100 ₽")).toBeVisible();
expect(screen.getByText(/02.01.2022, 10:00 UTC/)).toBeVisible();
expect(
requests.some(
(url) =>
url.searchParams.get("cursor") === "next" &&
url.searchParams.get("instrumentUid") === uid,
),
).toBe(true);
fireEvent.click(screen.getByRole("radio", { name: "Сделки" }));
await waitFor(() =>
expect(
requests.some(
(url) =>
url.searchParams.get("categories") === "trade" &&
!url.searchParams.has("cursor"),
),
).toBe(true),
);
fireEvent.click(screen.getByRole("radio", { name: "Год" }));
await waitFor(() =>
expect(
requests.some((url) => url.searchParams.get("period") === "year"),
).toBe(true),
);
});
it("keeps the last quote on a refresh failure", async () => {
const controls = setup();
await screen.findByText("300 ₽");
controls.failQuote();
fireEvent.click(screen.getByRole("button", { name: "Обновить цену" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
"последние полученные",
);
expect(screen.getByText("300 ₽")).toBeVisible();
});
it("offers recovery for an unavailable direct link", async () => {
setup({ failStock: true });
expect(
await screen.findByRole("heading", { name: "Акция недоступна" }),
).toBeVisible();
expect(screen.getByRole("link", { name: "Все акции" })).toHaveAttribute(
"href",
"/stocks",
);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { Anchor, Button, Skeleton, Stack, Text, Title } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link, Navigate } from "react-router";
import { LoadingSkeleton } from "@/shared/ui";
import { StockPageView } from "./StockPage.view";
import { useStockPageVm } from "./StockPage.vm";
const StockPage: FC = () => {
const vm = useStockPageVm();
const { t } = useTranslation();
if (vm.canonicalTo) return <Navigate to={vm.canonicalTo} replace />;
if (vm.stockState.loading)
return (
<LoadingSkeleton label={t("stock.loading")}>
<Stack>
<Skeleton h={110} />
<Skeleton h={350} />
<Skeleton h={170} />
</Stack>
</LoadingSkeleton>
);
if (!vm.stock)
return (
<Stack role="alert">
<Title order={1}>{t("stock.unavailable")}</Title>
<Text>
{t(
vm.code === "TBANK_NOT_CONNECTED" ||
vm.code === "INVALID_TBANK_TOKEN"
? "stock.connect"
: vm.code === "STOCK_TICKER_AMBIGUOUS"
? "stock.ambiguousTicker"
: "stock.unavailableText",
)}
</Text>
<Anchor component={Link} to="/stocks">
{t("stock.backToSearch")}
</Anchor>
<Anchor component={Link} to="/settings">
{t("accounts.openSettings")}
</Anchor>
<Button variant="default" onClick={vm.stockState.retry}>
{t("accounts.retry")}
</Button>
</Stack>
);
return <StockPageView vm={vm} />;
};
export { StockPage };
+224
View File
@@ -0,0 +1,224 @@
import {
Alert,
Avatar,
Badge,
Button,
Group,
Paper,
Skeleton,
Stack,
Text,
Title,
} from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import {
formatDate,
formatDecimal,
formatMoney,
formatSnapshotTime,
} from "@/shared/lib";
import { LoadingSkeleton } from "@/shared/ui";
import { StockChart } from "./components/StockChart.view";
import { StockDividends, StockEvents } from "./components/StockEvents.view";
import { StockPosition } from "./components/StockPosition.view";
import { StockSection } from "./components/StockSection.view";
import classes from "./StockPage.module.css";
import type { StockPageVm } from "./StockPage.vm";
type StockPageViewProps = { vm: StockPageVm };
const StockPageView: FC<StockPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
const stock = vm.stock;
if (!stock) return null;
return (
<Stack className={classes.page} gap="lg">
<Paper component="header" className={classes.section} withBorder>
<Group justify="space-between" align="start">
<div className={classes.identity}>
<Avatar
key={vm.logoSrc}
src={vm.logoSrc}
alt=""
size={56}
radius="md"
>
{stock.name[0]}
</Avatar>
<div>
<Title order={1} size="h2">
{stock.name}
</Title>
<Text size="sm" c="dimmed">
{stock.ticker} ·{" "}
{t(`stock.shareTypes.${stock.shareType}`, {
defaultValue: t("stock.share"),
})}
</Text>
</div>
</div>
<Button
variant="subtle"
onClick={() => {
vm.stockState.retry();
vm.quoteState.retry();
}}
>
{t("stock.refreshPrice")}
</Button>
</Group>
{vm.company?.description ? (
<Text size="sm" mt="md" style={{ whiteSpace: "pre-line" }}>
{vm.company.description}
</Text>
) : null}
{vm.stockState.error || vm.quoteState.error ? (
<Alert role="alert" color="orange" mt="md">
{t(vm.quote ? "accounts.refreshError" : "stock.sectionError")}
</Alert>
) : null}
{vm.quoteState.loading ? (
<LoadingSkeleton label={t("stock.loadingPrice")}>
<Skeleton mt="lg" h={55} w="50%" />
</LoadingSkeleton>
) : (
<div aria-live="polite">
<Text className={classes.price} mt="lg">
{formatMoney(vm.quote?.price ?? null)}
</Text>
{vm.quote?.change ? (
<Text
fw={600}
className={
vm.quote.change.value.startsWith("-")
? classes.negative
: classes.positive
}
>
{vm.quote.change.value.startsWith("-") ? "" : "+"}
{formatMoney(vm.quote.change)}
{vm.quote.changePercent !== null
? ` (${formatDecimal(vm.quote.changePercent)} %)`
: ""}
</Text>
) : null}
{vm.quote?.previousCloseDate ? (
<Text size="xs" c="dimmed">
{t("stock.changeBase", {
date: formatDate(vm.quote.previousCloseDate),
})}
</Text>
) : (
<Text size="xs" c="dimmed">
{t("stock.noComparison")}
</Text>
)}
<Group gap="sm" mt="sm">
<Badge variant="light" color="gray">
{t(`stock.trading.${vm.quote?.tradingStatus}`, {
defaultValue: t("stock.trading.unknown"),
})}
</Badge>
{vm.quote?.priceAt ? (
<Text size="xs" c="dimmed">
{t("stock.priceAt", {
date: formatSnapshotTime(vm.quote.priceAt),
})}
</Text>
) : null}
</Group>
{vm.quote?.unavailable.length ? (
<Text c="dimmed" size="xs" mt="xs">
{t("stock.partialQuote")}
</Text>
) : null}
{vm.quoteState.refreshing ? (
<Text role="status" size="xs" c="dimmed">
{t("accounts.refreshing")}
</Text>
) : null}
</div>
)}
<StockSection
title={t("stock.company")}
state={vm.companyState}
embedded
>
<Group gap="xl" mb="md">
{stock.sector ? (
<Text size="sm">
{t("stock.sector")}:{" "}
{t(`stock.sectors.${stock.sector}`, {
defaultValue: stock.sector,
})}
</Text>
) : null}
{stock.lot !== null ? (
<Text size="sm">{t("stock.lot", { value: stock.lot })}</Text>
) : null}
</Group>
{vm.company ? (
<Stack gap="lg">
{vm.company.unavailable.length ? (
<Alert color="orange">
<Group justify="space-between">
<Text size="sm">{t("stock.partialCompany")}</Text>
<Button
size="xs"
variant="default"
onClick={vm.companyState.retry}
>
{t("accounts.retry")}
</Button>
</Group>
</Alert>
) : null}
{vm.company.metrics.length ? (
<div className={classes.companyMetrics}>
{vm.company.metrics.map((metric) => (
<div key={metric.key}>
<Text size="xs" c="dimmed">
{t(`stock.metrics.${metric.key}`)}
{metric.period ? ` · ${metric.period}` : ""}
</Text>
<Text fw={600} size="lg">
{new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 2,
notation:
metric.unit === "money" ? "compact" : "standard",
}).format(metric.value)}{" "}
{metric.unit === "money"
? new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: metric.currency ?? "RUB",
currencyDisplay: "narrowSymbol",
})
.formatToParts(0)
.find((part) => part.type === "currency")?.value
: metric.unit === "percent"
? "%"
: "×"}
</Text>
</div>
))}
</div>
) : (
<Text c="dimmed">{t("stock.noFundamentals")}</Text>
)}
<Text size="xs" c="dimmed">
{t("stock.periodNote")}
</Text>
</Stack>
) : null}
</StockSection>
</Paper>
<StockChart vm={vm} />
<StockPosition vm={vm} />
<StockDividends vm={vm} />
<StockEvents vm={vm} />
</Stack>
);
};
export { StockPageView };
+175
View File
@@ -0,0 +1,175 @@
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useParams, useSearchParams } from "react-router";
import {
brokerageOperationsQueryOptions,
type OperationCategory,
useBrokerageOperations,
} from "@/entities/broker-operation";
import {
type StockPeriod,
useStock,
useStockCandles,
useStockCompany,
useStockDividends,
useStockPosition,
useStockQuote,
} from "@/entities/stock";
import { apiErrorCode } from "@/shared/api";
import { formatDate, instrumentLogoUrl } from "@/shared/lib";
type SectionState = {
hasData: boolean;
loading: boolean;
error: boolean;
refreshing: boolean;
retry: () => void;
};
function section(query: {
data: unknown;
isError: boolean;
isFetching: boolean;
refetch: () => unknown;
}): SectionState {
return {
hasData: Boolean(query.data),
loading: !query.data && !query.isError,
error: query.isError,
refreshing: Boolean(query.data) && query.isFetching,
retry: () => {
void query.refetch();
},
};
}
const periods: StockPeriod[] = ["month", "halfYear", "year", "all"];
const filters = ["all", "trades", "dividends", "other"] as const;
type StockEventFilter = (typeof filters)[number];
const categories: Record<StockEventFilter, OperationCategory[]> = {
all: [],
trades: ["trade"],
dividends: ["income"],
other: ["tax", "fee", "transfer", "other"],
};
function useStockPageVm() {
const { stockId = "" } = useParams();
const stock = useStock(stockId);
const uid = stock.data?.instrumentUid ?? "";
const [params, setParams] = useSearchParams();
const period =
periods.find((value) => value === params.get("period")) ?? "month";
const filter =
filters.find((value) => value === params.get("events")) ?? "all";
const scope = `${uid}:${filter}`;
const [pagination, setPagination] = useState<{
scope: string;
cursors: string[];
}>({ scope, cursors: [] });
const cursors = pagination.scope === scope ? pagination.cursors : [];
const client = useQueryClient();
const quote = useStockQuote(uid);
const candles = useStockCandles(uid, period);
const position = useStockPosition(uid);
const company = useStockCompany(uid);
const dividends = useStockDividends(uid);
const firstPage = {
instrumentUid: uid,
categories: categories[filter],
limit: 20,
};
const operations = useBrokerageOperations(uid ? "all" : undefined, {
...firstPage,
cursor: cursors.at(-1),
});
const setParam = (key: string, value: string) => {
const next = new URLSearchParams(params);
next.set(key, value);
setParams(next, { replace: true });
};
const retryOperations = () => {
if (apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") {
setPagination({ scope, cursors: [] });
void client.invalidateQueries({
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
exact: true,
});
} else void operations.refetch();
};
const chartData =
candles.data?.candles.map((candle) => ({
...candle,
date: formatDate(candle.time),
price: Number(candle.close),
})) ?? [];
const average = position.data?.averagePurchasePrice;
const averageLine =
average && average.currency === candles.data?.currency
? Number(average.value)
: null;
const companyData = company.data;
return {
canonicalTo:
stock.data?.slug && stock.data.slug !== stockId
? `/stocks/${encodeURIComponent(stock.data.slug)}${params.size ? `?${params.toString()}` : ""}`
: null,
stock: stock.data,
stockState: section(stock),
code: apiErrorCode(stock.error),
logoSrc: instrumentLogoUrl(stock.data?.logoName),
quote: quote.data,
quoteState: section(quote),
candles: candles.data,
chartData,
priceRows: chartData.toReversed(),
candlesState: section(candles),
period,
setPeriod: (value: string) => setParam("period", value),
averageLine:
averageLine !== null && Number.isFinite(averageLine) ? averageLine : null,
hasIncompleteCandle:
candles.data?.candles.some((candle) => !candle.isComplete) ?? false,
position: position.data,
positionState: section(position),
company: companyData,
companyState: section(company),
dividends: dividends.data,
dividendsState: section(dividends),
upcoming:
dividends.data?.dividends
.filter((event) => event.timing === "upcoming")
.sort((a, b) =>
(a.recordDate ?? a.paymentDate ?? "").localeCompare(
b.recordDate ?? b.paymentDate ?? "",
),
) ?? [],
pastDividends:
dividends.data?.dividends
.filter((event) => event.timing !== "upcoming")
.sort((a, b) =>
(b.recordDate ?? b.paymentDate ?? "").localeCompare(
a.recordDate ?? a.paymentDate ?? "",
),
) ?? [],
operations: operations.data,
operationsState: { ...section(operations), retry: retryOperations },
eventRows:
operations.data?.items.map((item) => ({
...item,
ticker: item.ticker ?? stock.data?.ticker ?? null,
logoName: item.logoName ?? stock.data?.logoName ?? null,
})) ?? [],
filter,
setFilter: (value: string) => setParam("events", value),
hasPrevious: cursors.length > 0,
onPrevious: () => setPagination({ scope, cursors: cursors.slice(0, -1) }),
onNext: () => {
if (operations.data?.nextCursor)
setPagination({
scope,
cursors: [...cursors, operations.data.nextCursor],
});
},
};
}
type StockPageVm = ReturnType<typeof useStockPageVm>;
export { type SectionState, type StockPageVm, useStockPageVm };
@@ -0,0 +1,173 @@
import { AreaChart } from "@mantine/charts";
import {
Accordion,
Badge,
ScrollArea,
SegmentedControl,
Table,
Text,
} from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { formatMoney } from "@/shared/lib";
import classes from "../StockPage.module.css";
import type { StockPageVm } from "../StockPage.vm";
import { StockSection } from "./StockSection.view";
type StockChartProps = { vm: StockPageVm };
const StockChart: FC<StockChartProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
return (
<StockSection
title={t("stock.chart")}
state={vm.candlesState}
large
controls={
<div className={classes.controls}>
<SegmentedControl
aria-label={t("stock.period")}
value={vm.period}
onChange={vm.setPeriod}
data={["month", "halfYear", "year", "all"].map((value) => ({
value,
label: t(`stock.periods.${value}`),
}))}
/>
</div>
}
>
{vm.candles ? (
vm.chartData.length ? (
<>
<AreaChart
h={310}
data={vm.chartData}
dataKey="date"
series={[
{ name: "price", color: "blue.6", label: t("stock.price") },
]}
curveType="linear"
withDots={vm.chartData.length === 1}
withGradient
gridAxis="y"
tickLine="none"
tooltipAnimationDuration={0}
areaProps={{ isAnimationActive: false }}
yAxisProps={{ domain: ["auto", "auto"], width: 64 }}
referenceLines={
vm.averageLine === null
? []
: [
{
y: vm.averageLine,
ifOverflow: "extendDomain",
color: "orange.7",
label: t("stock.averageLine"),
},
]
}
valueFormatter={(value) =>
new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 4,
}).format(value)
}
aria-label={t("stock.chartLabel", {
currency: vm.candles.currency ?? "",
})}
/>
<Text size="xs" c="dimmed" mt="sm">
{t("stock.chartNote")}
</Text>
{vm.hasIncompleteCandle ? (
<Text size="xs" c="dimmed">
{t("stock.incomplete")}
</Text>
) : null}
{vm.averageLine !== null ? (
<Text size="sm" mt="xs">
{t("stock.averageLine")}:{" "}
{formatMoney(vm.position?.averagePurchasePrice ?? null)}
</Text>
) : null}
<Accordion
className={classes.priceTable}
variant="contained"
radius="md"
>
<Accordion.Item value="prices">
<Accordion.Control>
<Text fw={600}>{t("stock.priceTable")}</Text>
<Text size="xs" c="dimmed">
{t("stock.priceTableHint", { count: vm.chartData.length })}
</Text>
</Accordion.Control>
<Accordion.Panel>
<ScrollArea.Autosize
mah={360}
type="auto"
viewportProps={{
tabIndex: 0,
role: "region",
"aria-label": t("stock.priceTable"),
}}
>
<Table
striped
highlightOnHover
stickyHeader
verticalSpacing="sm"
horizontalSpacing="md"
>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("stock.date")}</Table.Th>
<Table.Th>{t("stock.price")}</Table.Th>
<Table.Th>{t("stock.candle")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{vm.priceRows.map((candle) => (
<Table.Tr key={candle.time}>
<Table.Td>{candle.date}</Table.Td>
<Table.Td>
{formatMoney(
vm.candles?.currency
? {
value: candle.close,
currency: vm.candles.currency,
}
: null,
)}
</Table.Td>
<Table.Td>
<Badge
variant="light"
color={candle.isComplete ? "gray" : "blue"}
size="sm"
>
{t(
candle.isComplete
? "stock.complete"
: "stock.forming",
)}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea.Autosize>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</>
) : (
<Text c="dimmed">{t("stock.noCandles")}</Text>
)
) : null}
</StockSection>
);
};
export { StockChart };
@@ -0,0 +1,233 @@
import {
Accordion,
Alert,
Badge,
Button,
Group,
Paper,
SegmentedControl,
Stack,
Text,
} from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { LedgerEntryRow } from "@/entities/broker-operation";
import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib";
import classes from "../StockPage.module.css";
import type { StockPageVm } from "../StockPage.vm";
import { StockSection } from "./StockSection.view";
type StockEventsProps = { vm: StockPageVm };
const StockDividends: FC<StockEventsProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
return (
<StockSection title={t("stock.upcoming")} state={vm.dividendsState}>
{vm.dividends ? (
<Stack gap="md">
{vm.dividends.estimateUnavailable || vm.dividends.positionIsStale ? (
<Alert color="orange">{t("stock.estimateUnavailable")}</Alert>
) : null}
{vm.upcoming.length ? (
vm.upcoming.map((event) => (
<div
className={classes.dividend}
key={JSON.stringify([
event.recordDate,
event.paymentDate,
event.perShare,
])}
>
<Group justify="space-between" align="start">
<div>
<Badge color="green" variant="light">
{t("stock.companyEvent")}
</Badge>
<Text size="xl" fw={650} mt="xs">
{formatMoney(event.perShare)}{" "}
<Text component="span" size="sm" c="dimmed">
{t("stock.perShare")}
</Text>
</Text>
</div>
{event.estimatedAmount ? (
<div>
<Text size="xs" c="dimmed">
{t("stock.yourEstimate")}
</Text>
<Text fw={600}>{formatMoney(event.estimatedAmount)}</Text>
</div>
) : null}
</Group>
<Group gap="xl" mt="md">
{event.lastBuyDate ? (
<div>
<Text size="xs" c="dimmed">
{t("stock.lastBuy")}
</Text>
<Text>{formatDate(event.lastBuyDate)}</Text>
</div>
) : null}
{event.recordDate ? (
<div>
<Text size="xs" c="dimmed">
{t("stock.record")}
</Text>
<Text>{formatDate(event.recordDate)}</Text>
</div>
) : null}
{event.paymentDate ? (
<div>
<Text size="xs" c="dimmed">
{t("stock.payment")}
</Text>
<Text>{formatDate(event.paymentDate)}</Text>
</div>
) : null}
</Group>
</div>
))
) : (
<Text c="dimmed">
{t("stock.noUpcoming", { to: formatDate(vm.dividends.to) })}
</Text>
)}
<Text size="xs" c="dimmed">
{t("stock.dividendNote")}
</Text>
{vm.dividends.positionAt ? (
<Text size="xs" c="dimmed">
{t("stock.snapshotAt", {
date: formatSnapshotTime(vm.dividends.positionAt),
})}
</Text>
) : null}
<Accordion variant="contained" radius="md">
<Accordion.Item value="history">
<Accordion.Control>
<Text fw={600}>{t("stock.dividendHistory")}</Text>
</Accordion.Control>
<Accordion.Panel>
<Text size="xs" c="dimmed" my="sm">
{t("stock.dividendWindow", {
from: formatDate(vm.dividends.from),
to: formatDate(vm.dividends.to),
})}
</Text>
{vm.pastDividends.length ? (
<Stack gap="sm">
{vm.pastDividends.map((event) => (
<Paper
key={JSON.stringify([
event.recordDate,
event.paymentDate,
event.perShare,
])}
withBorder
p="md"
radius="md"
>
<Group justify="space-between" align="start">
<div>
<Text size="xs" c="dimmed">
{t("stock.record")}
</Text>
<Text fw={600}>
{event.recordDate
? formatDate(event.recordDate)
: t("stock.dateUnknown")}
</Text>
{event.paymentDate ? (
<Text size="xs" c="dimmed" mt="xs">
{t("stock.payment")}:{" "}
{formatDate(event.paymentDate)}
</Text>
) : null}
</div>
<div>
<Text fw={650} size="lg">
{formatMoney(event.perShare)}
</Text>
<Text size="xs" c="dimmed">
{t("stock.perShare")}
</Text>
</div>
</Group>
</Paper>
))}
</Stack>
) : (
<Text c="dimmed">{t("stock.noDividends")}</Text>
)}
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
) : null}
</StockSection>
);
};
const StockEvents: FC<StockEventsProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
return (
<StockSection
title={t("stock.history")}
state={vm.operationsState}
controls={
<div className={classes.controls}>
<SegmentedControl
aria-label={t("stock.eventFilter")}
value={vm.filter}
onChange={vm.setFilter}
data={["all", "trades", "dividends", "other"].map((value) => ({
value,
label: t(`stock.filters.${value}`),
}))}
/>
</div>
}
>
{vm.operations ? (
<>
<Text size="xs" c="dimmed">
{t("stock.historyNote")}
</Text>
{vm.eventRows.length ? (
<Stack gap="sm" mt="md">
{vm.eventRows.map((event) => (
<LedgerEntryRow
key={`${event.account?.id}:${event.id}`}
operation={event}
withDetails
/>
))}
</Stack>
) : (
<Text c="dimmed" py="lg">
{t("stock.noOperations")}
</Text>
)}
<Group justify="space-between" mt="md">
<Button
variant="default"
disabled={!vm.hasPrevious || vm.operationsState.refreshing}
onClick={vm.onPrevious}
>
{t("stock.previous")}
</Button>
<Button
variant="default"
disabled={!vm.operations.hasNext || vm.operationsState.refreshing}
onClick={vm.onNext}
>
{t("stock.next")}
</Button>
</Group>
</>
) : null}
</StockSection>
);
};
export { StockDividends, StockEvents };
@@ -0,0 +1,135 @@
import { Alert, Anchor, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { formatDecimal, formatMoney, formatSnapshotTime } from "@/shared/lib";
import classes from "../StockPage.module.css";
import type { StockPageVm } from "../StockPage.vm";
import { StockSection } from "./StockSection.view";
type StockPositionProps = { vm: StockPageVm };
const StockPosition: FC<StockPositionProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
if (vm.position && !vm.position.accounts.length) return null;
return (
<StockSection title={t("stock.position")} state={vm.positionState}>
{vm.position ? (
<Stack gap="md">
{vm.position.isStale ? (
<Alert color="orange">{t("portfolio.staleWarning")}</Alert>
) : null}
<div className={`${classes.metrics} ${classes.holding}`}>
<div>
<Text size="sm" c="dimmed">
{t("stock.quantity")}
</Text>
<Text className={classes.metricValue}>
{formatDecimal(vm.position.quantity)}
</Text>
</div>
<div>
<Text size="sm" c="dimmed">
{t("stock.value")}
</Text>
<Text className={classes.metricValue}>
{formatMoney(vm.position.currentValue)}
</Text>
</div>
<div>
<Text size="sm" c="dimmed">
{t("stock.average")}
</Text>
<Text className={classes.metricValue}>
{formatMoney(vm.position.averagePurchasePrice)}
</Text>
</div>
<div>
<Text size="sm" c="dimmed">
{t("stock.openReturn")}
</Text>
<Text
className={classes.metricValue}
c={
vm.position.absoluteReturn?.value.startsWith("-")
? "red.8"
: "green.8"
}
>
{formatMoney(vm.position.absoluteReturn)}
</Text>
{vm.position.returnPercent !== null ? (
<Text size="sm">
{formatDecimal(vm.position.returnPercent)} %
</Text>
) : null}
</div>
</div>
<Text size="xs" c="dimmed">
{t("stock.openReturnNote")}
</Text>
<div>
{vm.position.accounts.map((account) => (
<div className={classes.accountRow} key={account.account?.id}>
{account.account ? (
<Anchor
component={Link}
to={`/accounts/${encodeURIComponent(account.account.id)}`}
>
{account.account.name}
</Anchor>
) : (
<Text>{t("stock.portfolio")}</Text>
)}
<div>
<Text size="xs" c="dimmed">
{t("stock.quantity")}
</Text>
<Text>{formatDecimal(account.quantity)}</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("stock.average")}
</Text>
<Text>{formatMoney(account.averagePurchasePrice)}</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("stock.value")}
</Text>
<Text>{formatMoney(account.currentValue)}</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("stock.openReturn")}
</Text>
<Text
c={
account.absoluteReturn?.value.startsWith("-")
? "red.8"
: undefined
}
>
{formatMoney(account.absoluteReturn)}
</Text>
{account.returnPercent !== null ? (
<Text size="xs">
{formatDecimal(account.returnPercent)} %
</Text>
) : null}
</div>
</div>
))}
</div>
<Text size="xs" c="dimmed">
{t("stock.snapshotAt", {
date: formatSnapshotTime(vm.position.observedAt),
})}
</Text>
</Stack>
) : null}
</StockSection>
);
};
export { StockPosition };
@@ -0,0 +1,73 @@
import {
Alert,
Button,
Group,
Paper,
Skeleton,
Stack,
Text,
Title,
} from "@mantine/core";
import type { FC, PropsWithChildren, ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { LoadingSkeleton } from "@/shared/ui";
import classes from "../StockPage.module.css";
import type { SectionState } from "../StockPage.vm";
type StockSectionProps = PropsWithChildren<{
title: string;
state: SectionState;
controls?: ReactNode;
large?: boolean;
embedded?: boolean;
}>;
const StockSection: FC<StockSectionProps> = (props) => {
const { t } = useTranslation();
return (
<Paper
component="section"
className={props.embedded ? classes.embeddedSection : classes.section}
withBorder={!props.embedded}
>
<Group justify="space-between" mb="md">
<Title order={2} size="h3">
{props.title}
</Title>
{props.controls}
</Group>
{props.state.refreshing ? (
<Text role="status" size="xs" c="dimmed">
{t("accounts.refreshing")}
</Text>
) : null}
{props.state.error ? (
<Alert color="orange" mb="sm" role="alert">
<Group justify="space-between">
<Text size="sm">
{props.state.hasData
? t("accounts.refreshError")
: t("stock.sectionError")}
</Text>
<Button size="xs" variant="default" onClick={props.state.retry}>
{t("accounts.retry")}
</Button>
</Group>
</Alert>
) : null}
{props.state.loading ? (
<LoadingSkeleton
label={t("stock.loadingSection", { section: props.title })}
>
<Stack>
<Skeleton h={props.large ? 280 : 65} />
<Skeleton h={14} w="60%" />
</Stack>
</LoadingSkeleton>
) : (
props.children
)}
</Paper>
);
};
export { StockSection };
+1
View File
@@ -0,0 +1 @@
export { StocksPage } from "./ui/StocksPage";
+36
View File
@@ -0,0 +1,36 @@
.card {
display: grid;
grid-template-columns: auto minmax(0, 1fr) minmax(150px, 210px);
gap: var(--mantine-spacing-md);
align-items: start;
padding: var(--mantine-spacing-lg);
border-radius: var(--mantine-radius-lg);
color: inherit;
text-decoration: none;
}
.card:hover {
border-color: var(--mantine-color-blue-4);
background: var(--mantine-color-blue-0);
}
.card:focus-visible {
outline: 2px solid var(--mantine-color-blue-6);
outline-offset: 3px;
}
.identity {
min-width: 0;
overflow-wrap: anywhere;
}
.quote {
text-align: right;
font-variant-numeric: tabular-nums;
}
@media (max-width: 40em) {
.card {
grid-template-columns: auto minmax(0, 1fr);
padding: var(--mantine-spacing-md);
}
.quote {
grid-column: 2;
text-align: left;
}
}
+63
View File
@@ -0,0 +1,63 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { MemoryRouter } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import { StocksPage } from "./StocksPage";
describe("stock search", () => {
it("waits for a meaningful query and uses the server-verified ticker link", async () => {
const query = vi.fn();
server.use(
http.get("*/api/v1/stocks", ({ request }) => {
query(new URL(request.url).searchParams.get("query"));
return HttpResponse.json([
{
name: "Сбербанк",
ticker: "SBER",
instrumentUid: "unique-stock",
slug: "SBER",
logoName: "sber.png",
classCode: "TQBR",
isin: "RU0009029540",
realExchange: "REAL_EXCHANGE_MOEX",
shareType: "SHARE_TYPE_COMMON",
apiTradeAvailable: true,
price: { currency: "RUB", value: "306.12" },
priceAt: "2026-09-10T10:00:00Z",
},
]);
}),
);
configureAuthClient({
accessToken: () => "synthetic",
refresh: async () => false,
clear: vi.fn(),
});
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider>
<MemoryRouter>
<StocksPage />
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
expect(query).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText("Поиск акций"), {
target: { value: "SBER" },
});
expect(
await screen.findByRole("link", { name: /Сбербанк/ }),
).toHaveAttribute("href", "/stocks/SBER");
expect(screen.getByText("306,12 ₽")).toBeVisible();
expect(screen.getByText("Режим: TQBR")).toBeVisible();
expect(screen.getByText("ISIN: RU0009029540")).toBeVisible();
expect(screen.getByText("Мосбиржа")).toBeVisible();
await waitFor(() => expect(query).toHaveBeenCalledWith("SBER"));
});
});
+7
View File
@@ -0,0 +1,7 @@
import type { FC } from "react";
import { StocksPageView } from "./StocksPage.view";
import { useStocksPageVm } from "./StocksPage.vm";
const StocksPage: FC = () => <StocksPageView vm={useStocksPageVm()} />;
export { StocksPage };
+152
View File
@@ -0,0 +1,152 @@
import {
Alert,
Anchor,
Avatar,
Badge,
Button,
Group,
Paper,
Skeleton,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { formatMoney, formatSnapshotTime } from "@/shared/lib";
import { LoadingSkeleton } from "@/shared/ui";
import classes from "./StocksPage.module.css";
import type { StocksPageVm } from "./StocksPage.vm";
type StocksPageViewProps = { vm: StocksPageVm };
const StocksPageView: FC<StocksPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
return (
<Stack maw={860} mx="auto" gap="lg">
<div>
<Title order={1}>{t("stock.searchTitle")}</Title>
<Text c="dimmed" mt="xs">
{t("stock.searchSubtitle")}
</Text>
</div>
<TextInput
label={t("stock.searchLabel")}
placeholder={t("stock.searchPlaceholder")}
size="md"
maxLength={80}
value={vm.query}
onChange={(event) => vm.setQuery(event.currentTarget.value)}
/>
{vm.query.trim().length < 2 ? (
<Text c="dimmed">{t("stock.searchHint")}</Text>
) : null}
{vm.error ? (
<Alert role="alert" color="orange">
<Stack gap="xs">
<Text>
{t(
vm.code === "TBANK_NOT_CONNECTED" ||
vm.code === "INVALID_TBANK_TOKEN"
? "stock.connect"
: "stock.sectionError",
)}
</Text>
<Anchor component={Link} to="/settings">
{t("accounts.openSettings")}
</Anchor>
<Button variant="default" onClick={vm.retry}>
{t("accounts.retry")}
</Button>
</Stack>
</Alert>
) : null}
{vm.loading ? (
<LoadingSkeleton label={t("stock.searching")}>
<Stack>
<Skeleton h={78} />
<Skeleton h={78} />
<Skeleton h={78} />
</Stack>
</LoadingSkeleton>
) : vm.ready ? (
vm.items.length ? (
<Stack gap="sm">
<Text size="xs" c="dimmed">
{t("stock.searchVariants")}
</Text>
{vm.items.map((stock) => (
<Paper
component={Link}
to={stock.to}
className={classes.card}
withBorder
key={stock.instrumentUid}
>
<Avatar src={stock.logoSrc} alt="" radius="md" size={48}>
{stock.name[0]}
</Avatar>
<div className={classes.identity}>
<Text fw={600} size="lg">
{stock.name}
</Text>
<Text size="sm" c="dimmed">
{stock.ticker} ·{" "}
{t(`stock.shareTypes.${stock.shareType}`, {
defaultValue: t("stock.share"),
})}
</Text>
<Group gap="xs" mt="sm">
<Badge variant="light" color="gray">
{t(`stock.exchanges.${stock.realExchange}`, {
defaultValue: t("stock.exchangeUnknown"),
})}
</Badge>
{stock.classCode ? (
<Text size="xs" c="dimmed">
{t("stock.classCode", { code: stock.classCode })}
</Text>
) : null}
</Group>
{stock.isin ? (
<Text size="xs" c="dimmed" mt={4}>
{t("stock.isin", { value: stock.isin })}
</Text>
) : null}
{stock.apiTradeAvailable === false ? (
<Text size="xs" c="dimmed" mt={4}>
{t("stock.apiUnavailable")}
</Text>
) : null}
</div>
<div className={classes.quote}>
<Text fw={650} size="xl">
{stock.price ? formatMoney(stock.price) : "—"}
</Text>
<Text size="xs" c="dimmed">
{stock.price && stock.priceAt
? t("stock.priceAt", {
date: formatSnapshotTime(stock.priceAt),
})
: t("stock.priceUnavailable")}
</Text>
{stock.apiTradeAvailable === true ? (
<Text size="xs" c="green.8" mt="sm">
{t("stock.apiAvailable")}
</Text>
) : null}
</div>
</Paper>
))}
</Stack>
) : (
<Text c="dimmed">{t("stock.noSearchResults")}</Text>
)
) : null}
</Stack>
);
};
export { StocksPageView };
+33
View File
@@ -0,0 +1,33 @@
import { useDebouncedValue } from "@mantine/hooks";
import { useSearchParams } from "react-router";
import { useStockSearch } from "@/entities/stock";
import { apiErrorCode } from "@/shared/api";
import { instrumentLogoUrl } from "@/shared/lib";
function useStocksPageVm() {
const [params, setParams] = useSearchParams();
const query = params.get("q") ?? "";
const [debounced] = useDebouncedValue(query.trim(), 300);
const result = useStockSearch(debounced);
const waiting = query.trim() !== debounced;
return {
query,
setQuery: (q: string) => setParams(q ? { q } : {}, { replace: true }),
loading: query.trim().length >= 2 && (waiting || result.isLoading),
error: result.isError,
code: apiErrorCode(result.error),
retry: () => {
void result.refetch();
},
ready: debounced.length >= 2 && !waiting && Boolean(result.data),
items:
result.data?.map((stock) => ({
...stock,
to: `/stocks/${encodeURIComponent(stock.slug ?? stock.instrumentUid)}`,
logoSrc: instrumentLogoUrl(stock.logoName),
})) ?? [],
};
}
type StocksPageVm = ReturnType<typeof useStocksPageVm>;
export { type StocksPageVm, useStocksPageVm };