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:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { StockPage } from "./ui/StockPage";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1 @@
|
||||
export { StocksPage } from "./ui/StocksPage";
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user