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:
@@ -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 };
|
||||
Reference in New Issue
Block a user