feat: add stock pages and ticker navigation

Add stock search and detail pages with charts, dividends and shared event rows.

Use verified ticker links for portfolio names and logos, numeric dates and refreshed account cards.

Update the generated API contract.
This commit is contained in:
2026-09-10 21:23:41 +03:00
parent 0306526d33
commit 561222e468
56 changed files with 5051 additions and 190 deletions
+24
View File
@@ -4,6 +4,14 @@ import { Navigate, Route, Routes } from "react-router";
import { LazyPage } from "./RouteFallback";
import { Protected, Public } from "./RouteGuards";
const StockPage = lazy(async () => {
const { StockPage } = await import("@/pages/stock");
return { default: StockPage };
});
const StocksPage = lazy(async () => {
const { StocksPage } = await import("@/pages/stocks");
return { default: StocksPage };
});
const AccountPage = lazy(async () => {
const { AccountPage } = await import("@/pages/account");
return { default: AccountPage };
@@ -77,6 +85,22 @@ const AppRoutes: FC = () => (
</Protected>
}
>
<Route
path="/stocks"
element={
<LazyPage variant="content">
<StocksPage />
</LazyPage>
}
/>
<Route
path="/stocks/:stockId"
element={
<LazyPage variant="content">
<StockPage />
</LazyPage>
}
/>
<Route
path="/accounts"
element={
+4
View File
@@ -5,6 +5,7 @@ import { useMatch } from "react-router";
import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
import { AccountScopeSwitch } from "./AccountScopeSwitch";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
import { StockBreadcrumbs } from "./StockBreadcrumbs";
const AppBreadcrumbs: FC = () => {
const { t } = useTranslation();
@@ -18,6 +19,9 @@ const AppBreadcrumbs: FC = () => {
const accounts = useMatch({ path: "/accounts", end: true });
const settings = useMatch({ path: "/settings", end: true });
const stock = useMatch({ path: "/stocks/:stockId", end: true });
if (stock)
return <StockBreadcrumbs identifier={stock.params.stockId ?? ""} />;
if (operations)
return (
<Stack gap="sm" mb="lg">
+20
View File
@@ -0,0 +1,20 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useStock } from "@/entities/stock";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
type StockBreadcrumbsProps = { identifier: string };
const StockBreadcrumbs: FC<StockBreadcrumbsProps> = (props) => {
const { data } = useStock(props.identifier);
const { t } = useTranslation();
return (
<BreadcrumbTrail
items={[
{ label: t("stock.searchTitle"), to: "/stocks" },
{ label: data?.name ?? t("stock.share") },
]}
/>
);
};
export { StockBreadcrumbs };
@@ -25,6 +25,7 @@ const operationCategories = [
function brokerageOperationsQueryParams(query: BrokerageOperationsQuery) {
return {
...(query.instrumentUid ? { instrumentUid: query.instrumentUid } : {}),
...(query.from ? { from: query.from } : {}),
...(query.to ? { to: query.to } : {}),
...(query.categories?.length
+37 -1
View File
@@ -50,4 +50,40 @@ function isZeroDecimal(value: string): boolean {
return /^-?0(?:\.0+)?$/.test(value);
}
export { paymentPresentation };
function operationLabelKey(type: string): string {
if (
[
"OPERATION_TYPE_BUY",
"OPERATION_TYPE_BUY_CARD",
"OPERATION_TYPE_BUY_MARGIN",
].includes(type)
)
return "ledger.operationType.buy";
if (
[
"OPERATION_TYPE_SELL",
"OPERATION_TYPE_SELL_CARD",
"OPERATION_TYPE_SELL_MARGIN",
].includes(type)
)
return "ledger.operationType.sell";
if (
[
"OPERATION_TYPE_DIVIDEND",
"OPERATION_TYPE_DIV_EXT",
"OPERATION_TYPE_DIVIDEND_TRANSFER",
].includes(type)
)
return "ledger.operationType.dividend";
if (type.includes("TAX")) return "ledger.operationType.tax";
if (type.includes("FEE")) return "ledger.operationType.fee";
if (type === "OPERATION_TYPE_INPUT_SECURITIES")
return "ledger.operationType.input";
if (type === "OPERATION_TYPE_OUTPUT_SECURITIES")
return "ledger.operationType.output";
if (type.startsWith("OPERATION_TYPE_TRANS_"))
return "ledger.operationType.transfer";
return "ledger.operationType.other";
}
export { operationLabelKey, paymentPresentation };
@@ -1,12 +1,20 @@
import { Anchor, Group, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { formatSnapshotTime, instrumentLogoUrl } from "@/shared/lib";
import { Link } from "react-router";
import {
formatDecimal,
formatMoney,
formatSnapshotTime,
instrumentLogoUrl,
} from "@/shared/lib";
import { EntryCard } from "@/shared/ui";
import type { BrokerageOperation } from "../api/useBrokerageOperations";
import { paymentPresentation } from "../model/display";
import { operationLabelKey, paymentPresentation } from "../model/display";
type LedgerEntryRowProps = {
operation: BrokerageOperation;
withDetails?: boolean;
};
const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
@@ -16,17 +24,46 @@ const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
operation.ticker ?? operation.instrumentType ?? t("ledger.noInstrument");
const payment = paymentPresentation(operation);
const metadata = [
formatSnapshotTime(operation.occurredAt),
t("ledger.occurredAt", { date: formatSnapshotTime(operation.occurredAt) }),
instrument,
operation.account?.name,
props.withDetails ? null : operation.account?.name,
]
.filter(Boolean)
.join(" · ");
return (
<EntryCard
title={operation.description || operation.type}
title={operation.description || t(operationLabelKey(operation.type))}
metadata={metadata}
details={
props.withDetails ? (
<>
<Group gap="sm">
{operation.quantity != null ? (
<Text size="xs" c="dimmed">
{t("ledger.quantity", {
value: formatDecimal(operation.quantity),
})}
</Text>
) : null}
{operation.price ? (
<Text size="xs" c="dimmed">
{t("ledger.price", { value: formatMoney(operation.price) })}
</Text>
) : null}
</Group>
{operation.account ? (
<Anchor
component={Link}
size="sm"
to={`/accounts/${encodeURIComponent(operation.account.id)}`}
>
{operation.account.name}
</Anchor>
) : null}
</>
) : undefined
}
initial={(instrument.trim()[0] ?? "?").toUpperCase()}
imageSrc={instrumentLogoUrl(operation.logoName)}
value={payment.amount}
@@ -6,6 +6,7 @@ type Money = NonNullable<
type BrokerageAccountCard = {
account: BrokerageAccount;
positionCounts: { stocks: number; bonds: number } | null;
summary:
| { state: "unavailable" }
| { state: "empty" }
@@ -21,11 +22,21 @@ function brokerageAccountCards(
): BrokerageAccountCard[] {
return accounts.map((account) => {
const summary = account.portfolioSummary;
if (!summary) return { account, summary: { state: "unavailable" } };
if (!summary)
return {
account,
positionCounts: null,
summary: { state: "unavailable" },
};
const positionCounts = {
stocks: summary.stockPositionCount,
bonds: summary.bondPositionCount,
};
if (!summary.totalsByCurrency.length)
return { account, summary: { state: "empty" } };
return { account, positionCounts, summary: { state: "empty" } };
return {
account,
positionCounts,
summary: {
state: "available",
totals: summary.totalsByCurrency.slice(0, 2),
@@ -3,30 +3,59 @@
}
.card {
background: linear-gradient(
135deg,
var(--mantine-color-blue-0) 0%,
var(--mantine-color-body) 72%
);
border-color: var(--mantine-color-blue-2);
box-shadow: 0 10px 28px rgb(15 23 42 / 6%);
min-width: 0;
gap: var(--mantine-spacing-xl);
background: var(--mantine-color-white);
border-color: var(--mantine-color-gray-3);
box-shadow: 0 4px 16px rgb(15 23 42 / 3%);
color: inherit;
text-decoration: none;
transition:
transform 120ms ease,
box-shadow 120ms ease;
transform 160ms ease,
box-shadow 160ms ease,
border-color 160ms ease;
}
.card:hover,
.card:focus-visible {
box-shadow: 0 16px 36px rgb(15 23 42 / 10%);
transform: translateY(-2px);
border-color: var(--mantine-color-blue-3);
box-shadow: 0 12px 28px rgb(15 23 42 / 8%);
transform: translateY(-3px);
}
.card:focus-visible {
outline: 2px solid var(--mantine-color-blue-6);
outline-offset: 3px;
}
.identity {
min-width: 0;
}
.kicker {
color: var(--mantine-color-blue-7);
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 5px 10px;
border-radius: var(--mantine-radius-sm);
background: var(--mantine-color-blue-0);
color: var(--mantine-color-blue-8);
letter-spacing: 0.02em;
}
.accountIcon {
width: 28px;
height: 28px;
flex-shrink: 0;
color: var(--mantine-color-blue-5);
}
.name {
margin: 0;
font-size: var(--mantine-h3-font-size);
line-height: 1.3;
overflow-wrap: anywhere;
}
.balance {
flex: 1;
}
.summary {
@@ -34,20 +63,104 @@
}
.total {
white-space: nowrap;
font-size: clamp(1.5rem, 2.8vw, 2.25rem);
letter-spacing: -0.035em;
line-height: 1.2;
overflow-wrap: anywhere;
}
.total + .total {
font-size: var(--mantine-font-size-lg);
font-weight: 500;
letter-spacing: 0;
color: var(--mantine-color-gray-7);
}
.positions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--mantine-spacing-sm);
margin: 0;
}
.positions > div {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 4px var(--mantine-spacing-sm);
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border-radius: var(--mantine-radius-md);
}
.stocks {
background: var(--mantine-color-blue-0);
color: var(--mantine-color-blue-8);
}
.bonds {
background: var(--mantine-color-grape-0);
color: var(--mantine-color-grape-8);
}
.positions dt {
font-size: var(--mantine-font-size-sm);
font-weight: 500;
}
.positions dd {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1.25;
overflow-wrap: anywhere;
}
.footer {
border-top: 1px solid var(--mantine-color-gray-2);
padding-top: var(--mantine-spacing-md);
}
.open {
display: inline-flex;
align-items: center;
gap: var(--mantine-spacing-xs);
margin-left: auto;
color: var(--mantine-color-blue-7);
}
@media (prefers-reduced-motion: reduce) {
.arrow {
font-size: var(--mantine-font-size-lg);
transition: transform 160ms ease;
}
.card:hover .arrow {
transform: translateX(3px);
}
@media (max-width: 36em) {
.card {
padding: var(--mantine-spacing-md);
gap: var(--mantine-spacing-lg);
}
.positions > div {
flex-direction: column;
align-items: flex-start;
padding: var(--mantine-spacing-sm);
}
}
@media (prefers-reduced-motion: reduce) {
.card,
.arrow {
transition: none;
}
.card:hover,
.card:focus-visible {
.card:focus-visible,
.card:hover .arrow {
transform: none;
}
}
@@ -10,6 +10,8 @@ const syntheticAccount = {
type: "BROKERAGE",
openedOn: "2025-03-15",
portfolioSummary: {
stockPositionCount: 5,
bondPositionCount: 12,
totalsByCurrency: [
{ currency: "RUB", value: "125000.50" },
{ currency: "USD", value: "320" },
@@ -36,7 +38,7 @@ const meta = {
docs: {
description: {
component:
"Карточки счетов WealthLedger со сводкой по валютам. Используйте на экране выбора счёта; отсутствие сводки и отсутствие сумм имеют разные сообщения. Примеры содержат вымышленные данные. Ссылки меняют только локальный маршрут MemoryRouter; страница назначения в этой истории не отображается.",
"Карточки счетов WealthLedger со сводкой по валютам и числом наименований акций и облигаций. Используйте на экране выбора счёта; отсутствие сводки и отсутствие сумм имеют разные сообщения. Примеры содержат вымышленные данные. Ссылки меняют только локальный маршрут MemoryRouter; страница назначения в этой истории не отображается.",
},
},
},
@@ -80,7 +82,14 @@ export const Empty: Story = {
name: "Нет текущих сумм",
args: {
accounts: brokerageAccountCards([
{ ...syntheticAccount, portfolioSummary: { totalsByCurrency: [] } },
{
...syntheticAccount,
portfolioSummary: {
totalsByCurrency: [],
stockPositionCount: 0,
bondPositionCount: 0,
},
},
]),
},
parameters: {
@@ -113,3 +122,66 @@ export const LongName: Story = {
},
},
};
export const AccountGrid: Story = {
name: "Несколько счетов",
args: {
accounts: brokerageAccountCards([
{
...syntheticAccount,
name: "Основной",
portfolioSummary: {
totalsByCurrency: [{ currency: "RUB", value: "1256430.50" }],
stockPositionCount: 8,
bondPositionCount: 14,
},
},
{
...syntheticAccount,
id: "storybook-iis",
name: "Долгосрочные инвестиции",
type: "INDIVIDUAL_INVESTMENT",
portfolioSummary: {
totalsByCurrency: [{ currency: "RUB", value: "480250" }],
stockPositionCount: 0,
bondPositionCount: 6,
},
},
]),
},
};
export const UnvaluedPositions: Story = {
name: "Бумаги без текущей оценки",
args: {
accounts: brokerageAccountCards([
{
...syntheticAccount,
portfolioSummary: {
totalsByCurrency: [],
stockPositionCount: 2,
bondPositionCount: 1,
},
},
]),
},
};
export const LargeValues: Story = {
name: "Длинные суммы",
args: {
accounts: brokerageAccountCards([
{
...syntheticAccount,
portfolioSummary: {
totalsByCurrency: [
{ currency: "RUB", value: "1234567890123456789.50" },
{ currency: "USD", value: "-9876543210987654321.50" },
],
stockPositionCount: 21,
bondPositionCount: 105,
},
},
]),
},
};
@@ -1,5 +1,5 @@
import { MantineProvider } from "@mantine/core";
import { render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import { brokerageAccountCards } from "../model/display";
@@ -18,6 +18,8 @@ describe("BrokerageAccountList", () => {
type: "BROKERAGE",
openedOn: "2024-08-13",
portfolioSummary: {
stockPositionCount: 5,
bondPositionCount: 12,
totalsByCurrency: [
{ currency: "RUB", value: "1500" },
{ currency: "USD", value: "20" },
@@ -37,7 +39,11 @@ describe("BrokerageAccountList", () => {
name: "Пустой",
type: "BROKERAGE",
openedOn: null,
portfolioSummary: { totalsByCurrency: [] },
portfolioSummary: {
totalsByCurrency: [],
stockPositionCount: 2,
bondPositionCount: 0,
},
},
])}
/>
@@ -57,5 +63,24 @@ describe("BrokerageAccountList", () => {
expect(screen.getByText("Сводка временно недоступна")).toBeInTheDocument();
expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument();
expect(screen.getAllByText("Открыть счет")).toHaveLength(3);
const available = within(screen.getByRole("link", { name: /Основной/ }));
expect(available.getByText("Акции").nextElementSibling).toHaveTextContent(
"5",
);
expect(
available.getByText("Облигации").nextElementSibling,
).toHaveTextContent("12");
const unvalued = within(screen.getByRole("link", { name: /Пустой/ }));
expect(unvalued.getByText("Акции").nextElementSibling).toHaveTextContent(
"2",
);
expect(
unvalued.getByText("Облигации").nextElementSibling,
).toHaveTextContent("0");
expect(
within(screen.getByRole("link", { name: /Недоступный/ })).queryByText(
"Акции",
),
).not.toBeInTheDocument();
});
});
@@ -1,8 +1,8 @@
import { Card, Group, Stack, Text } from "@mantine/core";
import { Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { formatMoney } from "@/shared/lib";
import { formatDate, formatMoney } from "@/shared/lib";
import type { BrokerageAccountCard } from "../model/display";
import { accountTypeKey } from "../model/display";
import classes from "./BrokerageAccountList.module.css";
@@ -30,7 +30,7 @@ const BrokerageAccountSummary: FC<BrokerageAccountSummaryProps> = (props) => {
);
return (
<Group className={classes.summary} gap="sm" wrap="wrap">
<Stack className={classes.summary} gap={4}>
{summary.totals.map((total) => (
<Text className={classes.total} fw={700} key={total.currency}>
{formatMoney(total)}
@@ -43,7 +43,7 @@ const BrokerageAccountSummary: FC<BrokerageAccountSummaryProps> = (props) => {
})}
</Text>
) : null}
</Group>
</Stack>
);
};
@@ -52,42 +52,81 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
const { t } = useTranslation();
return (
<Stack className={classes.list} gap="md">
{accounts.map(({ account, summary }) => (
<SimpleGrid className={classes.list} cols={{ base: 1, lg: 2 }} spacing="lg">
{accounts.map(({ account, summary, positionCounts }) => (
<Card
component={Link}
to={`/accounts/${account.id}`}
key={account.id}
className={classes.card}
padding="lg"
padding="xl"
radius="lg"
withBorder
>
<Stack gap="xs">
<Text className={classes.kicker} size="xs" fw={700}>
{t(accountTypeKey(account.type))}
</Text>
<Text fw={800} size="lg">
<Stack className={classes.identity} gap="sm">
<Group justify="space-between" wrap="nowrap" gap="sm">
<Text className={classes.kicker} size="xs" fw={700}>
{t(accountTypeKey(account.type))}
</Text>
<svg
className={classes.accountIcon}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<rect x="3" y="5" width="18" height="15" rx="3" />
<path d="M3 9h18M7 15h3M14 5V3H7a4 4 0 0 0-4 4" />
</svg>
</Group>
<Text component="h2" className={classes.name} fw={700}>
{account.name}
</Text>
</Stack>
<Stack className={classes.balance} gap="xs">
<Text c="dimmed" size="sm">
{t("accounts.currentValue")}
</Text>
<BrokerageAccountSummary summary={summary} />
</Stack>
{positionCounts ? (
<dl className={classes.positions}>
<div className={classes.stocks}>
<dt>{t("accounts.stocks")}</dt>
<dd>
{new Intl.NumberFormat("ru-RU").format(positionCounts.stocks)}
</dd>
</div>
<div className={classes.bonds}>
<dt>{t("accounts.bonds")}</dt>
<dd>
{new Intl.NumberFormat("ru-RU").format(positionCounts.bonds)}
</dd>
</div>
</dl>
) : null}
<Group className={classes.footer} justify="space-between" gap="sm">
{account.openedOn ? (
<Text c="dimmed" size="sm">
<Text c="dimmed" size="xs">
{t("accounts.openedOn", {
date: new Intl.DateTimeFormat("ru-RU", {
dateStyle: "long",
timeZone: "UTC",
}).format(new Date(`${account.openedOn}T00:00:00Z`)),
date: formatDate(account.openedOn),
})}
</Text>
) : null}
<Text className={classes.open} size="sm" fw={700}>
<Text component="span" className={classes.open} size="sm" fw={600}>
{t("accounts.openAccount")}
<span className={classes.arrow} aria-hidden="true">
→
</span>
</Text>
</Stack>
</Group>
</Card>
))}
</Stack>
</SimpleGrid>
);
};
@@ -1,6 +1,6 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { formatMoney, instrumentLogoUrl } from "@/shared/lib";
import { formatDate, formatMoney, instrumentLogoUrl } from "@/shared/lib";
import { EntryCard } from "@/shared/ui";
import type { BrokerageEvent } from "../api/useBrokerageEvents";
@@ -10,10 +10,7 @@ const BrokerageEventCard: FC<BrokerageEventCardProps> = (props) => {
const { event } = props;
const { t } = useTranslation();
const date = new Intl.DateTimeFormat("ru-RU", {
dateStyle: "medium",
timeZone: "UTC",
}).format(new Date(`${event.date}T00:00:00Z`));
const date = formatDate(event.date);
const metadata = [
date,
+110
View File
@@ -0,0 +1,110 @@
import {
apiQueryClient,
useAuthenticatedQueries,
useAuthenticatedQuery,
} from "@/shared/api";
type StockPeriod = "month" | "halfYear" | "year" | "all";
function useStockSearch(query: string) {
return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks", {
params: { query: { query } },
}),
enabled: query.length >= 2,
staleTime: 30_000,
});
}
function stockQueryOptions(uid: string) {
return {
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}", {
params: { path: { uid } },
}),
staleTime: 30_000,
};
}
function useStocks(uids: string[]) {
const uniqueUids = [...new Set(uids)];
const queries = useAuthenticatedQueries(uniqueUids.map(stockQueryOptions));
return new Map(
queries.map((query, index) => [uniqueUids[index], query.data]),
);
}
function useStock(identifier: string) {
const isUid =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
identifier,
);
const byUid = useAuthenticatedQuery({
...stockQueryOptions(identifier),
enabled: Boolean(identifier) && isUid,
});
const byTicker = useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks/by-ticker/{ticker}", {
params: { path: { ticker: identifier } },
}),
enabled: Boolean(identifier) && !isUid,
staleTime: 30_000,
});
return isUid ? byUid : byTicker;
}
function useStockQuote(uid: string) {
return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/quote", {
params: { path: { uid } },
}),
enabled: Boolean(uid),
staleTime: 30_000,
});
}
function useStockCandles(uid: string, period: StockPeriod) {
return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/candles", {
params: { path: { uid }, query: { period } },
}),
enabled: Boolean(uid),
staleTime: 30_000,
});
}
function useStockPosition(uid: string) {
return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/position", {
params: { path: { uid } },
}),
enabled: Boolean(uid),
staleTime: 30_000,
});
}
function useStockCompany(uid: string) {
return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/company", {
params: { path: { uid } },
}),
enabled: Boolean(uid),
staleTime: 30_000,
});
}
function useStockDividends(uid: string) {
return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/dividends", {
params: { path: { uid } },
}),
enabled: Boolean(uid),
staleTime: 30_000,
});
}
export {
type StockPeriod,
useStock,
useStockCandles,
useStockCompany,
useStockDividends,
useStockPosition,
useStockQuote,
useStockSearch,
useStocks,
};
+11
View File
@@ -0,0 +1,11 @@
export {
type StockPeriod,
useStock,
useStockCandles,
useStockCompany,
useStockDividends,
useStockPosition,
useStockQuote,
useStockSearch,
useStocks,
} from "./api/queries";
@@ -22,7 +22,9 @@ function commitTbankConnectionBoundary(
return (
method === "get" &&
typeof path === "string" &&
(path === brokerageAccountsPath ||
(path === "/api/v1/stocks" ||
path.startsWith("/api/v1/stocks/") ||
path === brokerageAccountsPath ||
path.startsWith(`${brokerageAccountsPath}/`))
);
},
@@ -22,6 +22,11 @@ const operationsQueryKey = apiQueryClient.queryOptions(
"/api/v1/brokerage-accounts/{accountId}/operations",
{ params: { path: { accountId: "account-1" } } },
).queryKey;
const stockQueryKey = apiQueryClient.queryOptions(
"get",
"/api/v1/stocks/{uid}/position",
{ params: { path: { uid: "e6123145-9665-43e0-8413-cd61b8aa9b13" } } },
).queryKey;
const unrelatedQueryKey = apiQueryClient.queryOptions(
"get",
"/api/v1/auth/me",
@@ -53,6 +58,7 @@ function seededClient() {
},
]);
client.setQueryData(portfolioQueryKey, { source: "previous-connection" });
client.setQueryData(stockQueryKey, { source: "previous-connection" });
client.setQueryData(unrelatedQueryKey, { id: "user-1" });
return client;
}
@@ -75,6 +81,7 @@ describe("TbankConnectionSection", () => {
);
expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined();
expect(client.getQueryData(portfolioQueryKey)).toBeUndefined();
expect(client.getQueryData(stockQueryKey)).toBeUndefined();
expect(client.getQueryData(unrelatedQueryKey)).toEqual({ id: "user-1" });
});
@@ -126,6 +133,7 @@ describe("TbankConnectionSection", () => {
);
expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined();
expect(client.getQueryData(portfolioQueryKey)).toBeUndefined();
expect(client.getQueryData(stockQueryKey)).toBeUndefined();
expect(client.getQueryData(unrelatedQueryKey)).toEqual({ id: "user-1" });
});
+1
View File
@@ -1,6 +1,7 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "@mantine/core/styles.css";
import "@mantine/charts/styles.css";
import "@mantine/dates/styles.css";
import "dayjs/locale/ru";
import "./shared/i18n";
@@ -108,9 +108,9 @@ describe("AccountEventsPage", () => {
expect(screen.getByText("Сумма неизвестна")).toBeInTheDocument();
expect(screen.getByText("Купон · ОФЗ 26238")).toBeVisible();
expect(screen.getByText("Оферта · ОФЗ 26238")).toBeVisible();
expect(screen.getByText("20 авг. 2026 г. · RU000A0JX0J2")).toBeVisible();
expect(screen.getByText("20.08.2026 · RU000A0JX0J2")).toBeVisible();
expect(screen.getAllByText(/RU000A0JX0J2$/)).toHaveLength(1);
expect(screen.getByText("25 авг. 2026 г.")).toBeVisible();
expect(screen.getByText("25.08.2026")).toBeVisible();
expect(screen.queryByText(/Дата события/)).not.toBeInTheDocument();
expect(screen.getAllByText("Ожидаемая сумма")).toHaveLength(2);
const logo = container.querySelector("img");
@@ -392,10 +392,10 @@ describe("AccountIncomePage", () => {
expect(eventsRequest).not.toHaveBeenCalled();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument();
const selected = screen.getByRole("region", {
name: /^1–27 авг\. 2026\sг\.$/,
name: "01.08.2026 – 27.08.2026",
});
expect(
screen.getByRole("region", { name: /^1–27 авг\. 2025\sг\.$/ }),
screen.getByRole("region", { name: "01.08.2025 – 27.08.2025" }),
).toBeInTheDocument();
expect(screen.queryByText(/Предыдущий период/)).not.toBeInTheDocument();
expect(screen.queryByText("Выбранный период")).not.toBeInTheDocument();
@@ -498,10 +498,10 @@ describe("income comparison display", () => {
},
});
expect(display?.comparison?.selectedPeriod.replaceAll(/\s/g, " ")).toBe(
"1 дек. 2025 г. – 9 янв. 2026 г.",
"01.12.2025 – 09.01.2026",
);
expect(display?.comparison?.comparedPeriod.replaceAll(/\s/g, " ")).toBe(
"1 дек. 2024 г. – 9 янв. 2025 г.",
"01.12.2024 – 09.01.2025",
);
expect(display?.comparison?.values.map((value) => value.currency)).toEqual([
"RUB",
@@ -5,6 +5,7 @@ import { useParams, useSearchParams } from "react-router";
import { z } from "zod";
import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
import { validationMessage } from "@/shared/i18n";
import { formatDate } from "@/shared/lib";
type IncomePeriod = { from: string; to: string };
type IncomeForm = IncomePeriod;
@@ -252,13 +253,7 @@ function toIncomeDisplayModel(
}
function formatComparisonPeriod(period: IncomePeriod): string {
return new Intl.DateTimeFormat("ru-RU", {
dateStyle: "medium",
timeZone: "UTC",
}).formatRange(
new Date(`${period.from}T00:00:00Z`),
new Date(`${period.to}T00:00:00Z`),
);
return `${formatDate(period.from)} – ${formatDate(period.to)}`;
}
function formatIncomeChange(
+79 -2
View File
@@ -121,6 +121,83 @@ function emptyProps() {
}
describe("AccountPage states", () => {
it.each(["account-1", "all"])(
"uses verified ticker hrefs for both name and logo in %s",
async (accountId) => {
const uid = "c190ff1f-1447-4227-b543-316332699ca5";
const requests = vi.fn();
const position = {
...portfolio.positions[0],
instrumentUid: uid,
ticker: "SBERP",
displayName: "Сбербанк привилегированные",
};
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json({
...portfolio,
positions:
accountId === "all"
? ["account-1", "account-2"].map((id) => ({
...position,
account: { id, name: id },
}))
: [position],
}),
),
http.get("*/api/v1/stocks/:uid", () => {
requests();
return HttpResponse.json({ instrumentUid: uid, slug: "SBERP" });
}),
);
configureTestAuth();
renderPage(<AccountPage accountId={accountId} />);
await waitFor(() => {
const links = screen.getAllByRole("link", {
name: position.displayName,
});
expect(links).toHaveLength(2);
for (const link of links)
expect(link).toHaveAttribute("href", "/stocks/SBERP");
});
expect(requests).toHaveBeenCalledOnce();
},
);
it.each(["unavailable", "noncanonical"])(
"keeps exact UID links and the portfolio visible when ticker is %s",
async (scenario) => {
const uid = "c190ff1f-1447-4227-b543-316332699ca5";
const requests = vi.fn();
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json({
...portfolio,
positions: [
{
...portfolio.positions[0],
instrumentUid: uid,
ticker: "SBERP",
},
],
}),
),
http.get("*/api/v1/stocks/:uid", () => {
requests();
return scenario === "unavailable"
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json({ instrumentUid: uid, slug: uid });
}),
);
renderAccountRoute(createTestQueryClient());
await waitFor(() => expect(requests).toHaveBeenCalledOnce());
const links = await screen.findAllByRole("link", { name: "Вторая" });
expect(links).toHaveLength(2);
for (const link of links)
expect(link).toHaveAttribute("href", `/stocks/${uid}`);
},
);
it("keeps the account and positions visible for an older HTTP response without structure", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
@@ -715,11 +792,11 @@ describe("AccountPage states", () => {
expect(titles.map((title) => text.indexOf(title))).toEqual(
titles.map((title) => text.indexOf(title)).sort((a, b) => a - b),
);
expect(content.getByText("10 сент. 2026 г. · RU000A0JX0J2")).toBeVisible();
expect(content.getByText("10.09.2026 · RU000A0JX0J2")).toBeVisible();
expect(content.getAllByText(/RU000A0JX0J2$/)).toHaveLength(4);
expect(content.queryByText(/Дата события/)).not.toBeInTheDocument();
expect(
content.getByText("11 сент. 2026 г. · Дата реестра · RU000A0JX0J2"),
content.getByText("11.09.2026 · Дата реестра · RU000A0JX0J2"),
).toBeVisible();
for (const amount of ["850,50 ₽", "0 USD", "1 000 ₽", "Сумма неизвестна"])
expect(content.getByText(amount)).toBeVisible();
+38 -10
View File
@@ -13,6 +13,7 @@ import {
import { type IncomeSummary, useIncomeSummary } from "@/entities/income";
import type { Portfolio } from "@/entities/portfolio";
import { formatShare, usePortfolio } from "@/entities/portfolio";
import { useStocks } from "@/entities/stock";
import { apiErrorCode } from "@/shared/api";
import { formatMoney, instrumentLogoUrl } from "@/shared/lib";
@@ -24,6 +25,7 @@ type AccountPagePosition = Portfolio["positions"][number] & {
returnText: string | null;
returnTone?: AccountPageReturnTone;
logoSrc: string | null;
stockTo?: string;
};
type AccountPagePositionGroup = {
@@ -85,6 +87,13 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId;
const portfolio = usePortfolio(accountId);
const stockDetails = useStocks(
portfolio.data?.positions.flatMap((position) =>
position.type === "STOCK" && position.instrumentUid
? [position.instrumentUid]
: [],
) ?? [],
);
const operations = useBrokerageOperations(accountId, {
limit: 5,
});
@@ -125,7 +134,10 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
account,
accountId,
portfolio: portfolio.data,
positionSections: toAccountPagePositionSections(portfolio.data),
positionSections: toAccountPagePositionSections(
portfolio.data,
stockDetails,
),
recentOperations,
recentEvents,
incomeSummary,
@@ -170,6 +182,7 @@ function toRecentEvents(
function toAccountPagePositionSections(
portfolio: Portfolio,
stockDetails: ReturnType<typeof useStocks> = new Map(),
): AccountPagePositionSection[] {
const denominators = new Map<string, number>();
for (const position of portfolio.positions) {
@@ -180,9 +193,9 @@ function toAccountPagePositionSections(
(denominators.get(currency) ?? 0) + Number(position.currentValue.value),
);
}
const stocks = toSection("stocks", portfolio, denominators);
const bonds = toSection("bonds", portfolio, denominators);
const other = toSection("other", portfolio, denominators);
const stocks = toSection("stocks", portfolio, denominators, stockDetails);
const bonds = toSection("bonds", portfolio, denominators, stockDetails);
const other = toSection("other", portfolio, denominators, stockDetails);
return [stocks, bonds, other].filter((section) => section.groups.length > 0);
}
@@ -190,6 +203,7 @@ function toSection(
kind: PositionSectionKind,
portfolio: Portfolio,
denominators: Map<string, number>,
stockDetails: ReturnType<typeof useStocks>,
): AccountPagePositionSection {
return {
kind,
@@ -204,7 +218,14 @@ function toSection(
position.type !== "CURRENCY"
);
})
.map((position) => toPosition(position, denominators, kind)),
.map((position) =>
toPosition(
position,
denominators,
kind,
stockDetails.get(position.instrumentUid ?? "")?.slug,
),
),
),
};
}
@@ -214,11 +235,13 @@ function groupPositions(
): AccountPagePositionGroup[] {
const groups = new Map<string, AccountPagePositionGroup>();
for (const [index, position] of positions.entries()) {
const identity = position.figi?.trim()
? ["figi", position.figi.trim()]
: position.isin?.trim()
? ["isin", position.isin.trim()]
: null;
const identity = position.instrumentUid?.trim()
? ["uid", position.instrumentUid.trim()]
: position.figi?.trim()
? ["figi", position.figi.trim()]
: position.isin?.trim()
? ["isin", position.isin.trim()]
: null;
const rowKey = JSON.stringify(["row", position.account?.id, index]);
const key =
position.account && identity
@@ -242,6 +265,7 @@ function toPosition(
position: Portfolio["positions"][number],
denominators: Map<string, number>,
kind: PositionSectionKind,
stockSlug: string | undefined,
): AccountPagePosition {
const expanded = kind !== "other";
const returnValue = position.absoluteReturn
@@ -255,6 +279,10 @@ function toPosition(
: "negative";
return {
...position,
stockTo:
kind === "stocks" && position.instrumentUid
? `/stocks/${encodeURIComponent(stockSlug ?? position.instrumentUid)}`
: undefined,
shareText: position.currentValue?.currency
? formatShare(position.currentValue, {
currency: position.currentValue.currency,
@@ -1,4 +1,3 @@
import "@mantine/charts/styles.css";
import { DonutChart } from "@mantine/charts";
import { Box, Group, Paper, Select, Stack, Text, Title } from "@mantine/core";
import { type FC, useId } from "react";
@@ -30,6 +30,7 @@ const PositionCard: FC<PositionCardProps> = (props) => {
value: position.returnText,
})
: null;
const stockTo = position.stockTo;
const identifier =
kind === "stocks" ? (position.ticker ?? position.isin) : position.isin;
const identifierText = [
@@ -68,11 +69,18 @@ const PositionCard: FC<PositionCardProps> = (props) => {
key={position.logoSrc ?? "no-logo"}
src={position.logoSrc}
label={position.displayName}
to={stockTo}
/>
<Stack gap={2} miw={0} className={classes.expandedIdentity}>
<Text fw={600} lineClamp={1}>
{position.displayName}
</Text>
{stockTo ? (
<Anchor component={Link} to={stockTo} c="inherit" fw={600}>
{position.displayName}
</Anchor>
) : (
<Text fw={600} lineClamp={1}>
{position.displayName}
</Text>
)}
{accountLink}
<Text c="dimmed" size="xs" className={classes.expandedIdentifiers}>
{identifierText}
@@ -19,6 +19,7 @@ const PositionGroupCard: FC<PositionGroupCardProps> = (props) => {
const { group, kind } = props;
const { t } = useTranslation();
const identity = group.positions[0];
const stockTo = identity.stockTo;
return (
<Card className={classes.tile} withBorder>
<Group wrap="nowrap" gap="sm">
@@ -27,10 +28,17 @@ const PositionGroupCard: FC<PositionGroupCardProps> = (props) => {
key={identity.logoSrc ?? "no-logo"}
src={identity.logoSrc}
label={identity.displayName}
to={stockTo}
/>
) : null}
<Stack gap={2} miw={0}>
<Text fw={600}>{identity.displayName}</Text>
{stockTo ? (
<Anchor component={Link} to={stockTo} c="inherit" fw={600}>
{identity.displayName}
</Anchor>
) : (
<Text fw={600}>{identity.displayName}</Text>
)}
<Text c="dimmed" size="xs">
{identity.ticker ?? identity.isin ?? identity.figi}
</Text>
@@ -1,18 +1,20 @@
import { Avatar } from "@mantine/core";
import { Anchor, Avatar } from "@mantine/core";
import type { FC } from "react";
import { useState } from "react";
import { Link } from "react-router";
type PositionLogoProps = {
src: string | null;
label: string;
to?: string;
};
const PositionLogo: FC<PositionLogoProps> = (props) => {
const { src, label } = props;
const [failed, setFailed] = useState(false);
if (src && !failed)
return (
const logo =
src && !failed ? (
<Avatar
src={src}
alt={label}
@@ -20,11 +22,25 @@ const PositionLogo: FC<PositionLogoProps> = (props) => {
radius="md"
onError={() => setFailed(true)}
/>
) : (
<Avatar size={40} radius="md">
{(label.trim()[0] ?? "?").toUpperCase()}
</Avatar>
);
return (
<Avatar size={40} radius="md">
{(label.trim()[0] ?? "?").toUpperCase()}
</Avatar>
return props.to ? (
<Anchor
component={Link}
to={props.to}
aria-label={label}
c="inherit"
underline="never"
display="block"
style={{ borderRadius: "var(--mantine-radius-md)" }}
>
{logo}
</Anchor>
) : (
logo
);
};
@@ -1,4 +1,4 @@
import { Button, Stack, Title } from "@mantine/core";
import { Button, Stack, Text, Title } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
@@ -19,8 +19,13 @@ const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
const { accounts, ...refresh } = props;
const { t } = useTranslation();
return (
<Stack>
<Title order={1}>{t("accounts.title")}</Title>
<Stack gap="lg">
<Stack gap={4}>
<Title order={1}>{t("accounts.title")}</Title>
<Text c="dimmed" size="sm">
{t("accounts.subtitle")}
</Text>
</Stack>
<AccountsRefreshState {...refresh} />
<Button
component={Link}
@@ -1,4 +1,4 @@
import { Skeleton } from "@mantine/core";
import { Card, Group, SimpleGrid, Skeleton, Stack } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { LoadingSkeleton } from "@/shared/ui";
@@ -8,9 +8,28 @@ const AccountsPageLoading: FC = () => {
return (
<LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="30%" />
{[0, 1, 2].map((index) => (
<Skeleton key={index} height={116} radius="md" />
))}
<Skeleton height={20} width="45%" />
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="lg" aria-hidden="true">
{[0, 1].map((index) => (
<Card key={index} padding="xl" radius="lg" withBorder>
<Stack gap="xl">
<Stack gap="sm">
<Skeleton height={24} width="35%" />
<Skeleton height={28} width="65%" />
</Stack>
<Stack gap="xs">
<Skeleton height={16} width="30%" />
<Skeleton height={40} width="60%" />
</Stack>
<Group grow>
<Skeleton height={56} />
<Skeleton height={56} />
</Group>
<Skeleton height={20} />
</Stack>
</Card>
))}
</SimpleGrid>
</LoadingSkeleton>
);
};
+1
View File
@@ -0,0 +1 @@
export { StockPage } from "./ui/StockPage";
+109
View File
@@ -0,0 +1,109 @@
.page {
max-width: 1120px;
margin: 0 auto;
min-width: 0;
}
.section {
padding: var(--mantine-spacing-lg);
border-radius: var(--mantine-radius-lg);
min-width: 0;
}
.identity {
display: flex;
align-items: center;
gap: var(--mantine-spacing-md);
min-width: 0;
}
.price {
font-size: clamp(2rem, 4vw, 3.2rem);
font-weight: 700;
letter-spacing: -0.04em;
font-variant-numeric: tabular-nums;
line-height: 1.2;
overflow-wrap: anywhere;
}
.positive {
color: var(--mantine-color-green-8);
}
.negative {
color: var(--mantine-color-red-8);
}
.metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--mantine-spacing-lg);
}
.metricValue {
font-variant-numeric: tabular-nums;
font-size: var(--mantine-font-size-xl);
font-weight: 600;
overflow-wrap: anywhere;
}
.holding {
background: var(--mantine-color-blue-0);
padding: var(--mantine-spacing-lg);
border-radius: var(--mantine-radius-md);
}
.accountRow {
display: grid;
grid-template-columns: 1.2fr repeat(4, minmax(0, 1fr));
gap: var(--mantine-spacing-sm);
padding: var(--mantine-spacing-md) 0;
border-top: 1px solid var(--wl-color-border);
align-items: start;
}
.dividend {
padding: var(--mantine-spacing-md);
background: var(--mantine-color-green-0);
border-radius: var(--mantine-radius-md);
}
.companyMetrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(135px, 1fr));
gap: var(--mantine-spacing-lg);
}
.table {
overflow-x: auto;
max-width: 100%;
}
.controls {
max-width: 100%;
overflow-x: auto;
}
@media (max-width: 48em) {
.section {
padding: var(--mantine-spacing-md);
}
.metrics,
.companyMetrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.accountRow {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.accountRow > :first-child {
grid-column: 1 / -1;
}
}
.embeddedSection {
margin-top: var(--mantine-spacing-lg);
padding-top: var(--mantine-spacing-lg);
border-top: 1px solid var(--wl-color-border);
}
.companyMetrics > div {
padding: var(--mantine-spacing-sm);
background: var(--wl-color-subtle);
border-radius: var(--mantine-radius-md);
}
.priceTable {
margin-top: var(--mantine-spacing-md);
}
.priceTable table {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.priceTable th:nth-child(2),
.priceTable td:nth-child(2) {
text-align: right;
}
+330
View File
@@ -0,0 +1,330 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { MemoryRouter, Route, Routes, useLocation } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import { StockPage } from "./StockPage";
const LocationProbe = () => {
const location = useLocation();
return (
<output data-testid="location">
{location.pathname}
{location.search}
</output>
);
};
const uid = "e6123145-9665-43e0-8413-cd61b8aa9b13";
const money = (value: string) => ({ value, currency: "RUB" });
const stock = {
instrumentUid: uid,
name: "Тестовая акция",
ticker: "TEST",
shareType: "SHARE_TYPE_COMMON",
currency: "RUB",
sector: "financial",
lot: 10,
logoName: null,
historyFrom: null,
};
const position = {
quantity: "10",
currentValue: money("3000"),
averagePurchasePrice: money("200"),
absoluteReturn: money("1000"),
returnPercent: "50",
accounts: [
{
instrumentUid: uid,
quantity: "10",
currentValue: money("3000"),
averagePurchasePrice: money("200"),
absoluteReturn: money("1000"),
returnPercent: "50",
account: { id: "main", name: "Основной" },
},
],
isStale: false,
observedAt: "2026-09-10T10:00:00Z",
};
const quote = {
price: money("300"),
priceAt: "2026-09-10T10:00:00Z",
tradingStatus: "SECURITY_TRADING_STATUS_NORMAL_TRADING",
change: money("10"),
changePercent: "3.448276",
previousCloseDate: "2026-09-09",
previousClose: money("290"),
unavailable: [],
observedAt: "2026-09-10T10:00:00Z",
};
function setup(
options: {
noPosition?: boolean;
failCompany?: boolean;
failStock?: boolean;
path?: string;
slug?: string;
ambiguousTicker?: boolean;
} = {},
) {
const requests: URL[] = [];
const resolved = { ...stock, slug: options.slug ?? uid };
let failQuote = false;
server.use(
http.get("*/api/v1/stocks/:uid", () =>
options.failStock
? HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 })
: HttpResponse.json(resolved),
),
http.get("*/api/v1/stocks/by-ticker/:ticker", ({ request }) => {
requests.push(new URL(request.url));
return options.ambiguousTicker
? HttpResponse.json({ code: "STOCK_TICKER_AMBIGUOUS" }, { status: 409 })
: HttpResponse.json(resolved);
}),
http.get("*/api/v1/stocks/:uid/:section", ({ params, request }) => {
requests.push(new URL(request.url));
if (params.section === "quote")
return failQuote
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(quote);
if (params.section === "position")
return HttpResponse.json(
options.noPosition
? { ...position, quantity: "0", accounts: [] }
: position,
);
if (params.section === "candles")
return HttpResponse.json({
candles: [],
currency: "RUB",
historyFrom: null,
observedAt: quote.observedAt,
});
if (params.section === "company")
return options.failCompany
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json({
description: "Описание компании",
metrics: [
{
key: "peRatioTtm",
value: 5.6,
unit: "ratio",
currency: null,
period: "TTM",
},
],
unavailable: [],
observedAt: quote.observedAt,
});
return HttpResponse.json({
dividends: [
{
perShare: money("32.88"),
recordDate: "2026-10-13",
lastBuyDate: "2026-10-12",
paymentDate: "2026-10-27",
timing: "upcoming",
estimatedAmount: options.noPosition ? null : money("328.8"),
},
{
perShare: money("20"),
recordDate: "2025-10-13",
paymentDate: null,
lastBuyDate: null,
timing: "past",
estimatedAmount: null,
},
],
from: "2021-09-10",
to: "2027-09-10",
observedAt: quote.observedAt,
positionAt: null,
estimateUnavailable: false,
positionIsStale: false,
});
}),
http.get("*/api/v1/brokerage-accounts/all/operations", ({ request }) => {
const url = new URL(request.url);
requests.push(url);
const next = url.searchParams.has("cursor");
return HttpResponse.json({
accountId: "all",
observedAt: quote.observedAt,
hasNext: !next,
nextCursor: next ? null : "next",
items: [
{
id: next ? "sold" : "dividend",
occurredAt: next ? "2022-01-02T10:00:00Z" : "2025-01-02T10:00:00Z",
type: next ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_DIVIDEND",
category: next ? "trade" : "income",
description: "",
account: { id: "main", name: "Основной" },
payment: money("200"),
quantity: next ? "2" : null,
price: next ? money("100") : null,
},
],
});
}),
);
configureAuthClient({
accessToken: () => "synthetic",
refresh: async () => false,
clear: vi.fn(),
});
const client = createTestQueryClient();
render(
<QueryClientProvider client={client}>
<MantineProvider>
<MemoryRouter initialEntries={[options.path ?? `/stocks/${uid}`]}>
<LocationProbe />
<Routes>
<Route path="/stocks/:stockId" element={<StockPage />} />
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
return {
requests,
failQuote: () => {
failQuote = true;
},
};
}
describe("stock page", () => {
it("opens a direct ticker URL and uses its resolved UID for market and personal requests", async () => {
const { requests } = setup({ path: "/stocks/SBERP", slug: "SBERP" });
await screen.findByText("Зачисление дивидендов");
expect(screen.getByTestId("location")).toHaveTextContent("/stocks/SBERP");
expect(
requests.some((url) => url.pathname === `/api/v1/stocks/${uid}/quote`),
).toBe(true);
expect(
requests.some((url) => url.searchParams.get("instrumentUid") === uid),
).toBe(true);
expect(requests.some((url) => url.pathname.includes("/SBERP/quote"))).toBe(
false,
);
});
it("replaces a legacy UID URL with the verified ticker while preserving filters", async () => {
setup({ path: `/stocks/${uid}?period=year&events=trades`, slug: "SBERP" });
await waitFor(() =>
expect(screen.getByTestId("location")).toHaveTextContent(
"/stocks/SBERP?period=year&events=trades",
),
);
expect(await screen.findByText("Зачисление дивидендов")).toBeVisible();
});
it("offers search for ambiguous tickers without requesting personal data", async () => {
const { requests } = setup({ path: "/stocks/SAME", ambiguousTicker: true });
expect(
await screen.findByText(/Этот тикер относится к нескольким выпускам/),
).toBeVisible();
expect(
requests.some(
(url) =>
url.pathname.endsWith("/operations") ||
url.pathname.endsWith("/position"),
),
).toBe(false);
});
it("opens by UID, displays server calculations and distinguishes company and personal dividends", async () => {
setup();
expect(
await screen.findByRole("heading", { name: "Тестовая акция" }),
).toBeVisible();
expect(
await screen.findByRole("heading", { name: "Моя позиция" }),
).toBeVisible();
expect(screen.getAllByText("3 000 ₽")[0]).toBeVisible();
expect(
screen.getByText("К закрытию предыдущего торгового дня, 09.09.2026"),
).toBeVisible();
expect(await screen.findByText("Зачисление дивидендов")).toBeVisible();
expect(screen.getByText("Событие компании")).toBeVisible();
expect(screen.getByText("328,8 ₽")).toBeVisible();
expect(screen.getByText("P/E · TTM")).toBeVisible();
expect(screen.getByText("Описание компании")).toBeVisible();
expect(
screen.queryByRole("button", { name: "О компании" }),
).not.toBeInTheDocument();
expect(screen.getByText("13.10.2026")).toBeVisible();
fireEvent.click(
screen.getByRole("button", { name: "История дивидендов компании" }),
);
await waitFor(() => expect(screen.getByText("13.10.2025")).toBeVisible());
});
it("keeps sold-position history and market content when company data fails", async () => {
setup({ noPosition: true, failCompany: true });
expect(await screen.findByText("Зачисление дивидендов")).toBeVisible();
expect(
screen.queryByRole("heading", { name: "Моя позиция" }),
).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "График цены" })).toBeVisible();
expect(await screen.findByRole("alert")).toHaveTextContent(
"Не удалось загрузить этот раздел",
);
});
it("sends instrument-filtered continuation, resets it on filter changes and changes chart period", async () => {
const { requests } = setup();
await screen.findByText("Зачисление дивидендов");
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
expect(await screen.findByText("Продажа")).toBeVisible();
expect(screen.getByText("2 шт.")).toBeVisible();
expect(screen.getByText("Цена: 100 ₽")).toBeVisible();
expect(screen.getByText(/02.01.2022, 10:00 UTC/)).toBeVisible();
expect(
requests.some(
(url) =>
url.searchParams.get("cursor") === "next" &&
url.searchParams.get("instrumentUid") === uid,
),
).toBe(true);
fireEvent.click(screen.getByRole("radio", { name: "Сделки" }));
await waitFor(() =>
expect(
requests.some(
(url) =>
url.searchParams.get("categories") === "trade" &&
!url.searchParams.has("cursor"),
),
).toBe(true),
);
fireEvent.click(screen.getByRole("radio", { name: "Год" }));
await waitFor(() =>
expect(
requests.some((url) => url.searchParams.get("period") === "year"),
).toBe(true),
);
});
it("keeps the last quote on a refresh failure", async () => {
const controls = setup();
await screen.findByText("300 ₽");
controls.failQuote();
fireEvent.click(screen.getByRole("button", { name: "Обновить цену" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
"последние полученные",
);
expect(screen.getByText("300 ₽")).toBeVisible();
});
it("offers recovery for an unavailable direct link", async () => {
setup({ failStock: true });
expect(
await screen.findByRole("heading", { name: "Акция недоступна" }),
).toBeVisible();
expect(screen.getByRole("link", { name: "Все акции" })).toHaveAttribute(
"href",
"/stocks",
);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { Anchor, Button, Skeleton, Stack, Text, Title } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link, Navigate } from "react-router";
import { LoadingSkeleton } from "@/shared/ui";
import { StockPageView } from "./StockPage.view";
import { useStockPageVm } from "./StockPage.vm";
const StockPage: FC = () => {
const vm = useStockPageVm();
const { t } = useTranslation();
if (vm.canonicalTo) return <Navigate to={vm.canonicalTo} replace />;
if (vm.stockState.loading)
return (
<LoadingSkeleton label={t("stock.loading")}>
<Stack>
<Skeleton h={110} />
<Skeleton h={350} />
<Skeleton h={170} />
</Stack>
</LoadingSkeleton>
);
if (!vm.stock)
return (
<Stack role="alert">
<Title order={1}>{t("stock.unavailable")}</Title>
<Text>
{t(
vm.code === "TBANK_NOT_CONNECTED" ||
vm.code === "INVALID_TBANK_TOKEN"
? "stock.connect"
: vm.code === "STOCK_TICKER_AMBIGUOUS"
? "stock.ambiguousTicker"
: "stock.unavailableText",
)}
</Text>
<Anchor component={Link} to="/stocks">
{t("stock.backToSearch")}
</Anchor>
<Anchor component={Link} to="/settings">
{t("accounts.openSettings")}
</Anchor>
<Button variant="default" onClick={vm.stockState.retry}>
{t("accounts.retry")}
</Button>
</Stack>
);
return <StockPageView vm={vm} />;
};
export { StockPage };
+224
View File
@@ -0,0 +1,224 @@
import {
Alert,
Avatar,
Badge,
Button,
Group,
Paper,
Skeleton,
Stack,
Text,
Title,
} from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import {
formatDate,
formatDecimal,
formatMoney,
formatSnapshotTime,
} from "@/shared/lib";
import { LoadingSkeleton } from "@/shared/ui";
import { StockChart } from "./components/StockChart.view";
import { StockDividends, StockEvents } from "./components/StockEvents.view";
import { StockPosition } from "./components/StockPosition.view";
import { StockSection } from "./components/StockSection.view";
import classes from "./StockPage.module.css";
import type { StockPageVm } from "./StockPage.vm";
type StockPageViewProps = { vm: StockPageVm };
const StockPageView: FC<StockPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
const stock = vm.stock;
if (!stock) return null;
return (
<Stack className={classes.page} gap="lg">
<Paper component="header" className={classes.section} withBorder>
<Group justify="space-between" align="start">
<div className={classes.identity}>
<Avatar
key={vm.logoSrc}
src={vm.logoSrc}
alt=""
size={56}
radius="md"
>
{stock.name[0]}
</Avatar>
<div>
<Title order={1} size="h2">
{stock.name}
</Title>
<Text size="sm" c="dimmed">
{stock.ticker} ·{" "}
{t(`stock.shareTypes.${stock.shareType}`, {
defaultValue: t("stock.share"),
})}
</Text>
</div>
</div>
<Button
variant="subtle"
onClick={() => {
vm.stockState.retry();
vm.quoteState.retry();
}}
>
{t("stock.refreshPrice")}
</Button>
</Group>
{vm.company?.description ? (
<Text size="sm" mt="md" style={{ whiteSpace: "pre-line" }}>
{vm.company.description}
</Text>
) : null}
{vm.stockState.error || vm.quoteState.error ? (
<Alert role="alert" color="orange" mt="md">
{t(vm.quote ? "accounts.refreshError" : "stock.sectionError")}
</Alert>
) : null}
{vm.quoteState.loading ? (
<LoadingSkeleton label={t("stock.loadingPrice")}>
<Skeleton mt="lg" h={55} w="50%" />
</LoadingSkeleton>
) : (
<div aria-live="polite">
<Text className={classes.price} mt="lg">
{formatMoney(vm.quote?.price ?? null)}
</Text>
{vm.quote?.change ? (
<Text
fw={600}
className={
vm.quote.change.value.startsWith("-")
? classes.negative
: classes.positive
}
>
{vm.quote.change.value.startsWith("-") ? "" : "+"}
{formatMoney(vm.quote.change)}
{vm.quote.changePercent !== null
? ` (${formatDecimal(vm.quote.changePercent)} %)`
: ""}
</Text>
) : null}
{vm.quote?.previousCloseDate ? (
<Text size="xs" c="dimmed">
{t("stock.changeBase", {
date: formatDate(vm.quote.previousCloseDate),
})}
</Text>
) : (
<Text size="xs" c="dimmed">
{t("stock.noComparison")}
</Text>
)}
<Group gap="sm" mt="sm">
<Badge variant="light" color="gray">
{t(`stock.trading.${vm.quote?.tradingStatus}`, {
defaultValue: t("stock.trading.unknown"),
})}
</Badge>
{vm.quote?.priceAt ? (
<Text size="xs" c="dimmed">
{t("stock.priceAt", {
date: formatSnapshotTime(vm.quote.priceAt),
})}
</Text>
) : null}
</Group>
{vm.quote?.unavailable.length ? (
<Text c="dimmed" size="xs" mt="xs">
{t("stock.partialQuote")}
</Text>
) : null}
{vm.quoteState.refreshing ? (
<Text role="status" size="xs" c="dimmed">
{t("accounts.refreshing")}
</Text>
) : null}
</div>
)}
<StockSection
title={t("stock.company")}
state={vm.companyState}
embedded
>
<Group gap="xl" mb="md">
{stock.sector ? (
<Text size="sm">
{t("stock.sector")}:{" "}
{t(`stock.sectors.${stock.sector}`, {
defaultValue: stock.sector,
})}
</Text>
) : null}
{stock.lot !== null ? (
<Text size="sm">{t("stock.lot", { value: stock.lot })}</Text>
) : null}
</Group>
{vm.company ? (
<Stack gap="lg">
{vm.company.unavailable.length ? (
<Alert color="orange">
<Group justify="space-between">
<Text size="sm">{t("stock.partialCompany")}</Text>
<Button
size="xs"
variant="default"
onClick={vm.companyState.retry}
>
{t("accounts.retry")}
</Button>
</Group>
</Alert>
) : null}
{vm.company.metrics.length ? (
<div className={classes.companyMetrics}>
{vm.company.metrics.map((metric) => (
<div key={metric.key}>
<Text size="xs" c="dimmed">
{t(`stock.metrics.${metric.key}`)}
{metric.period ? ` · ${metric.period}` : ""}
</Text>
<Text fw={600} size="lg">
{new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 2,
notation:
metric.unit === "money" ? "compact" : "standard",
}).format(metric.value)}{" "}
{metric.unit === "money"
? new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: metric.currency ?? "RUB",
currencyDisplay: "narrowSymbol",
})
.formatToParts(0)
.find((part) => part.type === "currency")?.value
: metric.unit === "percent"
? "%"
: "×"}
</Text>
</div>
))}
</div>
) : (
<Text c="dimmed">{t("stock.noFundamentals")}</Text>
)}
<Text size="xs" c="dimmed">
{t("stock.periodNote")}
</Text>
</Stack>
) : null}
</StockSection>
</Paper>
<StockChart vm={vm} />
<StockPosition vm={vm} />
<StockDividends vm={vm} />
<StockEvents vm={vm} />
</Stack>
);
};
export { StockPageView };
+175
View File
@@ -0,0 +1,175 @@
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useParams, useSearchParams } from "react-router";
import {
brokerageOperationsQueryOptions,
type OperationCategory,
useBrokerageOperations,
} from "@/entities/broker-operation";
import {
type StockPeriod,
useStock,
useStockCandles,
useStockCompany,
useStockDividends,
useStockPosition,
useStockQuote,
} from "@/entities/stock";
import { apiErrorCode } from "@/shared/api";
import { formatDate, instrumentLogoUrl } from "@/shared/lib";
type SectionState = {
hasData: boolean;
loading: boolean;
error: boolean;
refreshing: boolean;
retry: () => void;
};
function section(query: {
data: unknown;
isError: boolean;
isFetching: boolean;
refetch: () => unknown;
}): SectionState {
return {
hasData: Boolean(query.data),
loading: !query.data && !query.isError,
error: query.isError,
refreshing: Boolean(query.data) && query.isFetching,
retry: () => {
void query.refetch();
},
};
}
const periods: StockPeriod[] = ["month", "halfYear", "year", "all"];
const filters = ["all", "trades", "dividends", "other"] as const;
type StockEventFilter = (typeof filters)[number];
const categories: Record<StockEventFilter, OperationCategory[]> = {
all: [],
trades: ["trade"],
dividends: ["income"],
other: ["tax", "fee", "transfer", "other"],
};
function useStockPageVm() {
const { stockId = "" } = useParams();
const stock = useStock(stockId);
const uid = stock.data?.instrumentUid ?? "";
const [params, setParams] = useSearchParams();
const period =
periods.find((value) => value === params.get("period")) ?? "month";
const filter =
filters.find((value) => value === params.get("events")) ?? "all";
const scope = `${uid}:${filter}`;
const [pagination, setPagination] = useState<{
scope: string;
cursors: string[];
}>({ scope, cursors: [] });
const cursors = pagination.scope === scope ? pagination.cursors : [];
const client = useQueryClient();
const quote = useStockQuote(uid);
const candles = useStockCandles(uid, period);
const position = useStockPosition(uid);
const company = useStockCompany(uid);
const dividends = useStockDividends(uid);
const firstPage = {
instrumentUid: uid,
categories: categories[filter],
limit: 20,
};
const operations = useBrokerageOperations(uid ? "all" : undefined, {
...firstPage,
cursor: cursors.at(-1),
});
const setParam = (key: string, value: string) => {
const next = new URLSearchParams(params);
next.set(key, value);
setParams(next, { replace: true });
};
const retryOperations = () => {
if (apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") {
setPagination({ scope, cursors: [] });
void client.invalidateQueries({
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
exact: true,
});
} else void operations.refetch();
};
const chartData =
candles.data?.candles.map((candle) => ({
...candle,
date: formatDate(candle.time),
price: Number(candle.close),
})) ?? [];
const average = position.data?.averagePurchasePrice;
const averageLine =
average && average.currency === candles.data?.currency
? Number(average.value)
: null;
const companyData = company.data;
return {
canonicalTo:
stock.data?.slug && stock.data.slug !== stockId
? `/stocks/${encodeURIComponent(stock.data.slug)}${params.size ? `?${params.toString()}` : ""}`
: null,
stock: stock.data,
stockState: section(stock),
code: apiErrorCode(stock.error),
logoSrc: instrumentLogoUrl(stock.data?.logoName),
quote: quote.data,
quoteState: section(quote),
candles: candles.data,
chartData,
priceRows: chartData.toReversed(),
candlesState: section(candles),
period,
setPeriod: (value: string) => setParam("period", value),
averageLine:
averageLine !== null && Number.isFinite(averageLine) ? averageLine : null,
hasIncompleteCandle:
candles.data?.candles.some((candle) => !candle.isComplete) ?? false,
position: position.data,
positionState: section(position),
company: companyData,
companyState: section(company),
dividends: dividends.data,
dividendsState: section(dividends),
upcoming:
dividends.data?.dividends
.filter((event) => event.timing === "upcoming")
.sort((a, b) =>
(a.recordDate ?? a.paymentDate ?? "").localeCompare(
b.recordDate ?? b.paymentDate ?? "",
),
) ?? [],
pastDividends:
dividends.data?.dividends
.filter((event) => event.timing !== "upcoming")
.sort((a, b) =>
(b.recordDate ?? b.paymentDate ?? "").localeCompare(
a.recordDate ?? a.paymentDate ?? "",
),
) ?? [],
operations: operations.data,
operationsState: { ...section(operations), retry: retryOperations },
eventRows:
operations.data?.items.map((item) => ({
...item,
ticker: item.ticker ?? stock.data?.ticker ?? null,
logoName: item.logoName ?? stock.data?.logoName ?? null,
})) ?? [],
filter,
setFilter: (value: string) => setParam("events", value),
hasPrevious: cursors.length > 0,
onPrevious: () => setPagination({ scope, cursors: cursors.slice(0, -1) }),
onNext: () => {
if (operations.data?.nextCursor)
setPagination({
scope,
cursors: [...cursors, operations.data.nextCursor],
});
},
};
}
type StockPageVm = ReturnType<typeof useStockPageVm>;
export { type SectionState, type StockPageVm, useStockPageVm };
@@ -0,0 +1,173 @@
import { AreaChart } from "@mantine/charts";
import {
Accordion,
Badge,
ScrollArea,
SegmentedControl,
Table,
Text,
} from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { formatMoney } from "@/shared/lib";
import classes from "../StockPage.module.css";
import type { StockPageVm } from "../StockPage.vm";
import { StockSection } from "./StockSection.view";
type StockChartProps = { vm: StockPageVm };
const StockChart: FC<StockChartProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
return (
<StockSection
title={t("stock.chart")}
state={vm.candlesState}
large
controls={
<div className={classes.controls}>
<SegmentedControl
aria-label={t("stock.period")}
value={vm.period}
onChange={vm.setPeriod}
data={["month", "halfYear", "year", "all"].map((value) => ({
value,
label: t(`stock.periods.${value}`),
}))}
/>
</div>
}
>
{vm.candles ? (
vm.chartData.length ? (
<>
<AreaChart
h={310}
data={vm.chartData}
dataKey="date"
series={[
{ name: "price", color: "blue.6", label: t("stock.price") },
]}
curveType="linear"
withDots={vm.chartData.length === 1}
withGradient
gridAxis="y"
tickLine="none"
tooltipAnimationDuration={0}
areaProps={{ isAnimationActive: false }}
yAxisProps={{ domain: ["auto", "auto"], width: 64 }}
referenceLines={
vm.averageLine === null
? []
: [
{
y: vm.averageLine,
ifOverflow: "extendDomain",
color: "orange.7",
label: t("stock.averageLine"),
},
]
}
valueFormatter={(value) =>
new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 4,
}).format(value)
}
aria-label={t("stock.chartLabel", {
currency: vm.candles.currency ?? "",
})}
/>
<Text size="xs" c="dimmed" mt="sm">
{t("stock.chartNote")}
</Text>
{vm.hasIncompleteCandle ? (
<Text size="xs" c="dimmed">
{t("stock.incomplete")}
</Text>
) : null}
{vm.averageLine !== null ? (
<Text size="sm" mt="xs">
{t("stock.averageLine")}:{" "}
{formatMoney(vm.position?.averagePurchasePrice ?? null)}
</Text>
) : null}
<Accordion
className={classes.priceTable}
variant="contained"
radius="md"
>
<Accordion.Item value="prices">
<Accordion.Control>
<Text fw={600}>{t("stock.priceTable")}</Text>
<Text size="xs" c="dimmed">
{t("stock.priceTableHint", { count: vm.chartData.length })}
</Text>
</Accordion.Control>
<Accordion.Panel>
<ScrollArea.Autosize
mah={360}
type="auto"
viewportProps={{
tabIndex: 0,
role: "region",
"aria-label": t("stock.priceTable"),
}}
>
<Table
striped
highlightOnHover
stickyHeader
verticalSpacing="sm"
horizontalSpacing="md"
>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("stock.date")}</Table.Th>
<Table.Th>{t("stock.price")}</Table.Th>
<Table.Th>{t("stock.candle")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{vm.priceRows.map((candle) => (
<Table.Tr key={candle.time}>
<Table.Td>{candle.date}</Table.Td>
<Table.Td>
{formatMoney(
vm.candles?.currency
? {
value: candle.close,
currency: vm.candles.currency,
}
: null,
)}
</Table.Td>
<Table.Td>
<Badge
variant="light"
color={candle.isComplete ? "gray" : "blue"}
size="sm"
>
{t(
candle.isComplete
? "stock.complete"
: "stock.forming",
)}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea.Autosize>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</>
) : (
<Text c="dimmed">{t("stock.noCandles")}</Text>
)
) : null}
</StockSection>
);
};
export { StockChart };
@@ -0,0 +1,233 @@
import {
Accordion,
Alert,
Badge,
Button,
Group,
Paper,
SegmentedControl,
Stack,
Text,
} from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { LedgerEntryRow } from "@/entities/broker-operation";
import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib";
import classes from "../StockPage.module.css";
import type { StockPageVm } from "../StockPage.vm";
import { StockSection } from "./StockSection.view";
type StockEventsProps = { vm: StockPageVm };
const StockDividends: FC<StockEventsProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
return (
<StockSection title={t("stock.upcoming")} state={vm.dividendsState}>
{vm.dividends ? (
<Stack gap="md">
{vm.dividends.estimateUnavailable || vm.dividends.positionIsStale ? (
<Alert color="orange">{t("stock.estimateUnavailable")}</Alert>
) : null}
{vm.upcoming.length ? (
vm.upcoming.map((event) => (
<div
className={classes.dividend}
key={JSON.stringify([
event.recordDate,
event.paymentDate,
event.perShare,
])}
>
<Group justify="space-between" align="start">
<div>
<Badge color="green" variant="light">
{t("stock.companyEvent")}
</Badge>
<Text size="xl" fw={650} mt="xs">
{formatMoney(event.perShare)}{" "}
<Text component="span" size="sm" c="dimmed">
{t("stock.perShare")}
</Text>
</Text>
</div>
{event.estimatedAmount ? (
<div>
<Text size="xs" c="dimmed">
{t("stock.yourEstimate")}
</Text>
<Text fw={600}>{formatMoney(event.estimatedAmount)}</Text>
</div>
) : null}
</Group>
<Group gap="xl" mt="md">
{event.lastBuyDate ? (
<div>
<Text size="xs" c="dimmed">
{t("stock.lastBuy")}
</Text>
<Text>{formatDate(event.lastBuyDate)}</Text>
</div>
) : null}
{event.recordDate ? (
<div>
<Text size="xs" c="dimmed">
{t("stock.record")}
</Text>
<Text>{formatDate(event.recordDate)}</Text>
</div>
) : null}
{event.paymentDate ? (
<div>
<Text size="xs" c="dimmed">
{t("stock.payment")}
</Text>
<Text>{formatDate(event.paymentDate)}</Text>
</div>
) : null}
</Group>
</div>
))
) : (
<Text c="dimmed">
{t("stock.noUpcoming", { to: formatDate(vm.dividends.to) })}
</Text>
)}
<Text size="xs" c="dimmed">
{t("stock.dividendNote")}
</Text>
{vm.dividends.positionAt ? (
<Text size="xs" c="dimmed">
{t("stock.snapshotAt", {
date: formatSnapshotTime(vm.dividends.positionAt),
})}
</Text>
) : null}
<Accordion variant="contained" radius="md">
<Accordion.Item value="history">
<Accordion.Control>
<Text fw={600}>{t("stock.dividendHistory")}</Text>
</Accordion.Control>
<Accordion.Panel>
<Text size="xs" c="dimmed" my="sm">
{t("stock.dividendWindow", {
from: formatDate(vm.dividends.from),
to: formatDate(vm.dividends.to),
})}
</Text>
{vm.pastDividends.length ? (
<Stack gap="sm">
{vm.pastDividends.map((event) => (
<Paper
key={JSON.stringify([
event.recordDate,
event.paymentDate,
event.perShare,
])}
withBorder
p="md"
radius="md"
>
<Group justify="space-between" align="start">
<div>
<Text size="xs" c="dimmed">
{t("stock.record")}
</Text>
<Text fw={600}>
{event.recordDate
? formatDate(event.recordDate)
: t("stock.dateUnknown")}
</Text>
{event.paymentDate ? (
<Text size="xs" c="dimmed" mt="xs">
{t("stock.payment")}:{" "}
{formatDate(event.paymentDate)}
</Text>
) : null}
</div>
<div>
<Text fw={650} size="lg">
{formatMoney(event.perShare)}
</Text>
<Text size="xs" c="dimmed">
{t("stock.perShare")}
</Text>
</div>
</Group>
</Paper>
))}
</Stack>
) : (
<Text c="dimmed">{t("stock.noDividends")}</Text>
)}
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
) : null}
</StockSection>
);
};
const StockEvents: FC<StockEventsProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
return (
<StockSection
title={t("stock.history")}
state={vm.operationsState}
controls={
<div className={classes.controls}>
<SegmentedControl
aria-label={t("stock.eventFilter")}
value={vm.filter}
onChange={vm.setFilter}
data={["all", "trades", "dividends", "other"].map((value) => ({
value,
label: t(`stock.filters.${value}`),
}))}
/>
</div>
}
>
{vm.operations ? (
<>
<Text size="xs" c="dimmed">
{t("stock.historyNote")}
</Text>
{vm.eventRows.length ? (
<Stack gap="sm" mt="md">
{vm.eventRows.map((event) => (
<LedgerEntryRow
key={`${event.account?.id}:${event.id}`}
operation={event}
withDetails
/>
))}
</Stack>
) : (
<Text c="dimmed" py="lg">
{t("stock.noOperations")}
</Text>
)}
<Group justify="space-between" mt="md">
<Button
variant="default"
disabled={!vm.hasPrevious || vm.operationsState.refreshing}
onClick={vm.onPrevious}
>
{t("stock.previous")}
</Button>
<Button
variant="default"
disabled={!vm.operations.hasNext || vm.operationsState.refreshing}
onClick={vm.onNext}
>
{t("stock.next")}
</Button>
</Group>
</>
) : null}
</StockSection>
);
};
export { StockDividends, StockEvents };
@@ -0,0 +1,135 @@
import { Alert, Anchor, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { formatDecimal, formatMoney, formatSnapshotTime } from "@/shared/lib";
import classes from "../StockPage.module.css";
import type { StockPageVm } from "../StockPage.vm";
import { StockSection } from "./StockSection.view";
type StockPositionProps = { vm: StockPageVm };
const StockPosition: FC<StockPositionProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
if (vm.position && !vm.position.accounts.length) return null;
return (
<StockSection title={t("stock.position")} state={vm.positionState}>
{vm.position ? (
<Stack gap="md">
{vm.position.isStale ? (
<Alert color="orange">{t("portfolio.staleWarning")}</Alert>
) : null}
<div className={`${classes.metrics} ${classes.holding}`}>
<div>
<Text size="sm" c="dimmed">
{t("stock.quantity")}
</Text>
<Text className={classes.metricValue}>
{formatDecimal(vm.position.quantity)}
</Text>
</div>
<div>
<Text size="sm" c="dimmed">
{t("stock.value")}
</Text>
<Text className={classes.metricValue}>
{formatMoney(vm.position.currentValue)}
</Text>
</div>
<div>
<Text size="sm" c="dimmed">
{t("stock.average")}
</Text>
<Text className={classes.metricValue}>
{formatMoney(vm.position.averagePurchasePrice)}
</Text>
</div>
<div>
<Text size="sm" c="dimmed">
{t("stock.openReturn")}
</Text>
<Text
className={classes.metricValue}
c={
vm.position.absoluteReturn?.value.startsWith("-")
? "red.8"
: "green.8"
}
>
{formatMoney(vm.position.absoluteReturn)}
</Text>
{vm.position.returnPercent !== null ? (
<Text size="sm">
{formatDecimal(vm.position.returnPercent)} %
</Text>
) : null}
</div>
</div>
<Text size="xs" c="dimmed">
{t("stock.openReturnNote")}
</Text>
<div>
{vm.position.accounts.map((account) => (
<div className={classes.accountRow} key={account.account?.id}>
{account.account ? (
<Anchor
component={Link}
to={`/accounts/${encodeURIComponent(account.account.id)}`}
>
{account.account.name}
</Anchor>
) : (
<Text>{t("stock.portfolio")}</Text>
)}
<div>
<Text size="xs" c="dimmed">
{t("stock.quantity")}
</Text>
<Text>{formatDecimal(account.quantity)}</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("stock.average")}
</Text>
<Text>{formatMoney(account.averagePurchasePrice)}</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("stock.value")}
</Text>
<Text>{formatMoney(account.currentValue)}</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("stock.openReturn")}
</Text>
<Text
c={
account.absoluteReturn?.value.startsWith("-")
? "red.8"
: undefined
}
>
{formatMoney(account.absoluteReturn)}
</Text>
{account.returnPercent !== null ? (
<Text size="xs">
{formatDecimal(account.returnPercent)} %
</Text>
) : null}
</div>
</div>
))}
</div>
<Text size="xs" c="dimmed">
{t("stock.snapshotAt", {
date: formatSnapshotTime(vm.position.observedAt),
})}
</Text>
</Stack>
) : null}
</StockSection>
);
};
export { StockPosition };
@@ -0,0 +1,73 @@
import {
Alert,
Button,
Group,
Paper,
Skeleton,
Stack,
Text,
Title,
} from "@mantine/core";
import type { FC, PropsWithChildren, ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { LoadingSkeleton } from "@/shared/ui";
import classes from "../StockPage.module.css";
import type { SectionState } from "../StockPage.vm";
type StockSectionProps = PropsWithChildren<{
title: string;
state: SectionState;
controls?: ReactNode;
large?: boolean;
embedded?: boolean;
}>;
const StockSection: FC<StockSectionProps> = (props) => {
const { t } = useTranslation();
return (
<Paper
component="section"
className={props.embedded ? classes.embeddedSection : classes.section}
withBorder={!props.embedded}
>
<Group justify="space-between" mb="md">
<Title order={2} size="h3">
{props.title}
</Title>
{props.controls}
</Group>
{props.state.refreshing ? (
<Text role="status" size="xs" c="dimmed">
{t("accounts.refreshing")}
</Text>
) : null}
{props.state.error ? (
<Alert color="orange" mb="sm" role="alert">
<Group justify="space-between">
<Text size="sm">
{props.state.hasData
? t("accounts.refreshError")
: t("stock.sectionError")}
</Text>
<Button size="xs" variant="default" onClick={props.state.retry}>
{t("accounts.retry")}
</Button>
</Group>
</Alert>
) : null}
{props.state.loading ? (
<LoadingSkeleton
label={t("stock.loadingSection", { section: props.title })}
>
<Stack>
<Skeleton h={props.large ? 280 : 65} />
<Skeleton h={14} w="60%" />
</Stack>
</LoadingSkeleton>
) : (
props.children
)}
</Paper>
);
};
export { StockSection };
+1
View File
@@ -0,0 +1 @@
export { StocksPage } from "./ui/StocksPage";
+36
View File
@@ -0,0 +1,36 @@
.card {
display: grid;
grid-template-columns: auto minmax(0, 1fr) minmax(150px, 210px);
gap: var(--mantine-spacing-md);
align-items: start;
padding: var(--mantine-spacing-lg);
border-radius: var(--mantine-radius-lg);
color: inherit;
text-decoration: none;
}
.card:hover {
border-color: var(--mantine-color-blue-4);
background: var(--mantine-color-blue-0);
}
.card:focus-visible {
outline: 2px solid var(--mantine-color-blue-6);
outline-offset: 3px;
}
.identity {
min-width: 0;
overflow-wrap: anywhere;
}
.quote {
text-align: right;
font-variant-numeric: tabular-nums;
}
@media (max-width: 40em) {
.card {
grid-template-columns: auto minmax(0, 1fr);
padding: var(--mantine-spacing-md);
}
.quote {
grid-column: 2;
text-align: left;
}
}
+63
View File
@@ -0,0 +1,63 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { MemoryRouter } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import { StocksPage } from "./StocksPage";
describe("stock search", () => {
it("waits for a meaningful query and uses the server-verified ticker link", async () => {
const query = vi.fn();
server.use(
http.get("*/api/v1/stocks", ({ request }) => {
query(new URL(request.url).searchParams.get("query"));
return HttpResponse.json([
{
name: "Сбербанк",
ticker: "SBER",
instrumentUid: "unique-stock",
slug: "SBER",
logoName: "sber.png",
classCode: "TQBR",
isin: "RU0009029540",
realExchange: "REAL_EXCHANGE_MOEX",
shareType: "SHARE_TYPE_COMMON",
apiTradeAvailable: true,
price: { currency: "RUB", value: "306.12" },
priceAt: "2026-09-10T10:00:00Z",
},
]);
}),
);
configureAuthClient({
accessToken: () => "synthetic",
refresh: async () => false,
clear: vi.fn(),
});
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider>
<MemoryRouter>
<StocksPage />
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
expect(query).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText("Поиск акций"), {
target: { value: "SBER" },
});
expect(
await screen.findByRole("link", { name: /Сбербанк/ }),
).toHaveAttribute("href", "/stocks/SBER");
expect(screen.getByText("306,12 ₽")).toBeVisible();
expect(screen.getByText("Режим: TQBR")).toBeVisible();
expect(screen.getByText("ISIN: RU0009029540")).toBeVisible();
expect(screen.getByText("Мосбиржа")).toBeVisible();
await waitFor(() => expect(query).toHaveBeenCalledWith("SBER"));
});
});
+7
View File
@@ -0,0 +1,7 @@
import type { FC } from "react";
import { StocksPageView } from "./StocksPage.view";
import { useStocksPageVm } from "./StocksPage.vm";
const StocksPage: FC = () => <StocksPageView vm={useStocksPageVm()} />;
export { StocksPage };
+152
View File
@@ -0,0 +1,152 @@
import {
Alert,
Anchor,
Avatar,
Badge,
Button,
Group,
Paper,
Skeleton,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { formatMoney, formatSnapshotTime } from "@/shared/lib";
import { LoadingSkeleton } from "@/shared/ui";
import classes from "./StocksPage.module.css";
import type { StocksPageVm } from "./StocksPage.vm";
type StocksPageViewProps = { vm: StocksPageVm };
const StocksPageView: FC<StocksPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
return (
<Stack maw={860} mx="auto" gap="lg">
<div>
<Title order={1}>{t("stock.searchTitle")}</Title>
<Text c="dimmed" mt="xs">
{t("stock.searchSubtitle")}
</Text>
</div>
<TextInput
label={t("stock.searchLabel")}
placeholder={t("stock.searchPlaceholder")}
size="md"
maxLength={80}
value={vm.query}
onChange={(event) => vm.setQuery(event.currentTarget.value)}
/>
{vm.query.trim().length < 2 ? (
<Text c="dimmed">{t("stock.searchHint")}</Text>
) : null}
{vm.error ? (
<Alert role="alert" color="orange">
<Stack gap="xs">
<Text>
{t(
vm.code === "TBANK_NOT_CONNECTED" ||
vm.code === "INVALID_TBANK_TOKEN"
? "stock.connect"
: "stock.sectionError",
)}
</Text>
<Anchor component={Link} to="/settings">
{t("accounts.openSettings")}
</Anchor>
<Button variant="default" onClick={vm.retry}>
{t("accounts.retry")}
</Button>
</Stack>
</Alert>
) : null}
{vm.loading ? (
<LoadingSkeleton label={t("stock.searching")}>
<Stack>
<Skeleton h={78} />
<Skeleton h={78} />
<Skeleton h={78} />
</Stack>
</LoadingSkeleton>
) : vm.ready ? (
vm.items.length ? (
<Stack gap="sm">
<Text size="xs" c="dimmed">
{t("stock.searchVariants")}
</Text>
{vm.items.map((stock) => (
<Paper
component={Link}
to={stock.to}
className={classes.card}
withBorder
key={stock.instrumentUid}
>
<Avatar src={stock.logoSrc} alt="" radius="md" size={48}>
{stock.name[0]}
</Avatar>
<div className={classes.identity}>
<Text fw={600} size="lg">
{stock.name}
</Text>
<Text size="sm" c="dimmed">
{stock.ticker} ·{" "}
{t(`stock.shareTypes.${stock.shareType}`, {
defaultValue: t("stock.share"),
})}
</Text>
<Group gap="xs" mt="sm">
<Badge variant="light" color="gray">
{t(`stock.exchanges.${stock.realExchange}`, {
defaultValue: t("stock.exchangeUnknown"),
})}
</Badge>
{stock.classCode ? (
<Text size="xs" c="dimmed">
{t("stock.classCode", { code: stock.classCode })}
</Text>
) : null}
</Group>
{stock.isin ? (
<Text size="xs" c="dimmed" mt={4}>
{t("stock.isin", { value: stock.isin })}
</Text>
) : null}
{stock.apiTradeAvailable === false ? (
<Text size="xs" c="dimmed" mt={4}>
{t("stock.apiUnavailable")}
</Text>
) : null}
</div>
<div className={classes.quote}>
<Text fw={650} size="xl">
{stock.price ? formatMoney(stock.price) : "—"}
</Text>
<Text size="xs" c="dimmed">
{stock.price && stock.priceAt
? t("stock.priceAt", {
date: formatSnapshotTime(stock.priceAt),
})
: t("stock.priceUnavailable")}
</Text>
{stock.apiTradeAvailable === true ? (
<Text size="xs" c="green.8" mt="sm">
{t("stock.apiAvailable")}
</Text>
) : null}
</div>
</Paper>
))}
</Stack>
) : (
<Text c="dimmed">{t("stock.noSearchResults")}</Text>
)
) : null}
</Stack>
);
};
export { StocksPageView };
+33
View File
@@ -0,0 +1,33 @@
import { useDebouncedValue } from "@mantine/hooks";
import { useSearchParams } from "react-router";
import { useStockSearch } from "@/entities/stock";
import { apiErrorCode } from "@/shared/api";
import { instrumentLogoUrl } from "@/shared/lib";
function useStocksPageVm() {
const [params, setParams] = useSearchParams();
const query = params.get("q") ?? "";
const [debounced] = useDebouncedValue(query.trim(), 300);
const result = useStockSearch(debounced);
const waiting = query.trim() !== debounced;
return {
query,
setQuery: (q: string) => setParams(q ? { q } : {}, { replace: true }),
loading: query.trim().length >= 2 && (waiting || result.isLoading),
error: result.isError,
code: apiErrorCode(result.error),
retry: () => {
void result.refetch();
},
ready: debounced.length >= 2 && !waiting && Boolean(result.data),
items:
result.data?.map((stock) => ({
...stock,
to: `/stocks/${encodeURIComponent(stock.slug ?? stock.instrumentUid)}`,
logoSrc: instrumentLogoUrl(stock.logoName),
})) ?? [],
};
}
type StocksPageVm = ReturnType<typeof useStocksPageVm>;
export { type StocksPageVm, useStocksPageVm };
+51 -40
View File
@@ -3,53 +3,64 @@ import { HttpResponse, http } from "msw";
import { describe, expect, it, vi } from "vitest";
import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server";
import { useAuthenticatedQuery } from "./authenticated-query";
import {
useAuthenticatedQueries,
useAuthenticatedQuery,
} from "./authenticated-query";
import { apiQueryClient, configureAuthClient } from "./client";
describe("useAuthenticatedQuery", () => {
it("keeps protected queries idle without a session and loads after session configuration", async () => {
const requests = vi.fn();
server.use(
http.get("*/api/v1/brokerage-accounts", () => {
requests();
return HttpResponse.json([
{
id: "account-1",
name: "Основной",
type: "BROKERAGE",
openedOn: "2024-08-13",
},
]);
}),
);
it.each(["single", "multiple"])(
"keeps %s protected queries idle without a session and loads after session configuration",
async (mode) => {
const requests = vi.fn();
server.use(
http.get("*/api/v1/brokerage-accounts", () => {
requests();
return HttpResponse.json([
{
id: "account-1",
name: "Основной",
type: "BROKERAGE",
openedOn: "2024-08-13",
},
]);
}),
);
const { result } = renderHook(
() =>
const useSingleQuery = () =>
useAuthenticatedQuery(
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"),
),
{ wrapper: createQueryWrapper().wrapper },
);
);
const useMultipleQueries = () =>
useAuthenticatedQueries([
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"),
])[0];
const { result } = renderHook(
mode === "single" ? useSingleQuery : useMultipleQueries,
{ wrapper: createQueryWrapper().wrapper },
);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(requests).not.toHaveBeenCalled();
expect(result.current.fetchStatus).toBe("idle");
await new Promise((resolve) => setTimeout(resolve, 0));
expect(requests).not.toHaveBeenCalled();
expect(result.current.fetchStatus).toBe("idle");
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
});
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(requests).toHaveBeenCalledOnce();
expect(result.current.data).toEqual([
{
id: "account-1",
name: "Основной",
type: "BROKERAGE",
openedOn: "2024-08-13",
},
]);
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(requests).toHaveBeenCalledOnce();
expect(result.current.data).toEqual([
{
id: "account-1",
name: "Основной",
type: "BROKERAGE",
openedOn: "2024-08-13",
},
]);
},
);
});
+29 -6
View File
@@ -2,6 +2,7 @@ import {
type QueryKey,
type UseQueryOptions,
type UseQueryResult,
useQueries,
useQuery,
} from "@tanstack/react-query";
import { useSyncExternalStore } from "react";
@@ -18,11 +19,7 @@ function useAuthenticatedQuery<
>(
options: UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
): UseQueryResult<TData, TError> {
const hasSession = useSyncExternalStore(
subscribeAuthenticatedSession,
getAuthenticatedSessionSnapshot,
getAuthenticatedSessionSnapshot,
);
const hasSession = useHasAuthenticatedSession();
const enabled = options.enabled;
return useQuery({
@@ -33,4 +30,30 @@ function useAuthenticatedQuery<
});
}
export { useAuthenticatedQuery };
function useHasAuthenticatedSession() {
return useSyncExternalStore(
subscribeAuthenticatedSession,
getAuthenticatedSessionSnapshot,
getAuthenticatedSessionSnapshot,
);
}
function useAuthenticatedQueries<TData, TError, TQueryKey extends QueryKey>(
queries: UseQueryOptions<TData, TError, TData, TQueryKey>[],
) {
const hasSession = useHasAuthenticatedSession();
return useQueries({
queries: queries.map(
(options): UseQueryOptions<TData, TError, TData, TQueryKey> => ({
...options,
enabled: (query) =>
hasSession &&
(typeof options.enabled === "function"
? options.enabled(query)
: (options.enabled ?? true)),
}),
),
});
}
export { useAuthenticatedQueries, useAuthenticatedQuery };
+812 -8
View File
@@ -84,6 +84,134 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/stocks": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["StockController_search"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/stocks/by-ticker/{ticker}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["StockController_getByTicker"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/stocks/{uid}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["StockController_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/stocks/{uid}/quote": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["StockController_quote"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/stocks/{uid}/candles": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["StockController_candles"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/stocks/{uid}/position": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["StockController_position"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/stocks/{uid}/company": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["StockController_company"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/stocks/{uid}/dividends": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["StockController_dividends"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/tbank-connection": {
parameters: {
query?: never;
@@ -309,6 +437,151 @@ export interface components {
accessToken: string;
user: components["schemas"]["UserDto"];
};
MoneyDto: {
currency: string;
value: string;
};
StockSearchResultDto: {
/** Format: uuid */
instrumentUid: string;
/** @description Unambiguous ticker URL segment, or the exact instrument UID. */
slug: string;
name: string;
isin: string | null;
classCode: string | null;
realExchange: string | null;
apiTradeAvailable: boolean | null;
ticker: string | null;
logoName: string | null;
currency: string | null;
shareType: string | null;
sector: string | null;
lot: number | null;
/** Format: date-time */
historyFrom: string | null;
price: components["schemas"]["MoneyDto"] | null;
/** Format: date-time */
priceAt: string | null;
};
StockDto: {
/** Format: uuid */
instrumentUid: string;
/** @description Unambiguous ticker URL segment, or the exact instrument UID. */
slug: string;
name: string;
isin: string | null;
classCode: string | null;
realExchange: string | null;
apiTradeAvailable: boolean | null;
ticker: string | null;
logoName: string | null;
currency: string | null;
shareType: string | null;
sector: string | null;
lot: number | null;
/** Format: date-time */
historyFrom: string | null;
};
StockQuoteDto: {
price: components["schemas"]["MoneyDto"] | null;
/** Format: date-time */
priceAt: string | null;
tradingStatus: string | null;
change: components["schemas"]["MoneyDto"] | null;
changePercent: string | null;
/** Format: date */
previousCloseDate: string | null;
previousClose: components["schemas"]["MoneyDto"] | null;
unavailable: string[];
/** Format: date-time */
observedAt: string;
};
StockCandleDto: {
/** Format: date-time */
time: string;
close: string;
isComplete: boolean;
};
StockCandlesDto: {
candles: components["schemas"]["StockCandleDto"][];
currency: string | null;
/** Format: date-time */
historyFrom: string | null;
/** Format: date-time */
observedAt: string;
};
AccountReferenceDto: {
id: string;
name: string;
};
StockPositionRowDto: {
instrumentUid?: string | null;
account?: components["schemas"]["AccountReferenceDto"];
figi: string | null;
displayName: string;
/** @enum {string} */
type: "STOCK" | "BOND" | "FUND" | "CURRENCY" | "FUTURE" | "OPTION" | "STRUCTURED_NOTE" | "DIGITAL_FINANCIAL_ASSET" | "OTHER";
quantity: string;
currentValue: components["schemas"]["MoneyDto"] | null;
averagePurchasePrice: components["schemas"]["MoneyDto"] | null;
absoluteReturn: components["schemas"]["MoneyDto"] | null;
isin: string | null;
ticker: string | null;
logoName: string | null;
returnPercent: string | null;
};
StockPositionDto: {
quantity: string;
currentValue: components["schemas"]["MoneyDto"] | null;
averagePurchasePrice: components["schemas"]["MoneyDto"] | null;
absoluteReturn: components["schemas"]["MoneyDto"] | null;
returnPercent: string | null;
accounts: components["schemas"]["StockPositionRowDto"][];
isStale: boolean;
/** Format: date-time */
observedAt: string;
};
StockMetricDto: {
key: string;
value: number;
/** @enum {string} */
unit: "money" | "ratio" | "percent";
currency: string | null;
/** @enum {string|null} */
period: "TTM" | "MRQ" | null;
};
StockCompanyDto: {
description: string | null;
metrics: components["schemas"]["StockMetricDto"][];
unavailable: string[];
/** Format: date-time */
observedAt: string;
};
StockDividendDto: {
perShare: components["schemas"]["MoneyDto"] | null;
/** Format: date */
recordDate: string | null;
/** Format: date */
lastBuyDate: string | null;
/** Format: date */
paymentDate: string | null;
/** @enum {string} */
timing: "upcoming" | "past" | "undated";
estimatedAmount: components["schemas"]["MoneyDto"] | null;
};
StockDividendsDto: {
dividends: components["schemas"]["StockDividendDto"][];
/** Format: date */
from: string;
/** Format: date */
to: string;
/** Format: date-time */
observedAt: string;
/** Format: date-time */
positionAt: string | null;
estimateUnavailable: boolean;
positionIsStale: boolean;
};
TbankConnectionStatusDto: {
connected: boolean;
/** Format: date-time */
@@ -317,12 +590,12 @@ export interface components {
PutTbankConnectionDto: {
token: string;
};
MoneyDto: {
currency: string;
value: string;
};
PortfolioSummaryDto: {
totalsByCurrency: components["schemas"]["MoneyDto"][];
/** @description Number of non-zero stock positions, not the number of units. */
stockPositionCount: number;
/** @description Number of non-zero bond positions, not the number of units. */
bondPositionCount: number;
};
BrokerageAccountDto: {
id: string;
@@ -333,10 +606,6 @@ export interface components {
openedOn: string | null;
portfolioSummary: components["schemas"]["PortfolioSummaryDto"] | null;
};
AccountReferenceDto: {
id: string;
name: string;
};
BrokerageOperationDto: {
account?: components["schemas"]["AccountReferenceDto"];
id: string;
@@ -366,6 +635,7 @@ export interface components {
observedAt: string;
};
PortfolioPositionDto: {
instrumentUid?: string | null;
account?: components["schemas"]["AccountReferenceDto"];
figi: string | null;
displayName: string;
@@ -851,6 +1121,536 @@ export interface operations {
};
};
};
StockController_search: {
parameters: {
query: {
query: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StockSearchResultDto"][];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
StockController_getByTicker: {
parameters: {
query?: never;
header?: never;
path: {
ticker: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StockDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
StockController_get: {
parameters: {
query?: never;
header?: never;
path: {
uid: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StockDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
StockController_quote: {
parameters: {
query?: never;
header?: never;
path: {
uid: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StockQuoteDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
StockController_candles: {
parameters: {
query?: {
period?: "month" | "halfYear" | "year" | "all";
};
header?: never;
path: {
uid: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StockCandlesDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
StockController_position: {
parameters: {
query?: never;
header?: never;
path: {
uid: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StockPositionDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
StockController_company: {
parameters: {
query?: never;
header?: never;
path: {
uid: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StockCompanyDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
StockController_dividends: {
parameters: {
query?: never;
header?: never;
path: {
uid: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StockDividendsDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
TbankConnectionController_status: {
parameters: {
query?: never;
@@ -1112,6 +1912,8 @@ export interface operations {
BrokerageOperationsController_all: {
parameters: {
query?: {
/** @description Instrument UID. Without from, includes history since the earliest available account opening. */
instrumentUid?: string;
from?: string;
to?: string;
categories?: ("trade" | "income" | "tax" | "fee" | "transfer" | "other")[];
@@ -1182,6 +1984,8 @@ export interface operations {
BrokerageOperationsController_get: {
parameters: {
query?: {
/** @description Instrument UID. Without from, includes history since the earliest available account opening. */
instrumentUid?: string;
from?: string;
to?: string;
categories?: ("trade" | "income" | "tax" | "fee" | "transfer" | "other")[];
+4 -1
View File
@@ -1,4 +1,7 @@
export { useAuthenticatedQuery } from "./authenticated-query";
export {
useAuthenticatedQueries,
useAuthenticatedQuery,
} from "./authenticated-query";
export { apiClient, apiQueryClient, configureAuthClient } from "./client";
export type { paths } from "./generated/schema";
export { apiErrorCode } from "./problem-details";
File diff suppressed because it is too large Load Diff
+176 -1
View File
@@ -1,7 +1,7 @@
const ru = {
translation: {
appName: "WealthLedger",
navigation: { accounts: "Счета", settings: "Настройки" },
navigation: { accounts: "Счета", stocks: "Акции", settings: "Настройки" },
breadcrumbs: {
label: "Хлебные крошки",
accounts: "Брокерские счета",
@@ -11,6 +11,163 @@ const ru = {
events: "Будущие события",
settings: "Настройки",
},
stock: {
searchTitle: "Акции",
searchSubtitle:
"Найдите компанию, изучите цену и дивиденды, посмотрите свою историю владения.",
searchLabel: "Поиск акций",
searchPlaceholder: "Название или тикер, например Сбер или SBER",
searchHint: "Введите хотя бы два символа.",
searching: "Ищем акции",
noSearchResults:
"Акции не найдены. Попробуйте другое название или тикер.",
backToSearch: "Все акции",
searchVariants:
"Одна карточка на выпуск (ISIN). Обыкновенные и привилегированные акции показаны отдельно.",
apiUnavailable: "Торги через API недоступны",
apiAvailable: "Доступна через API",
exchangeUnknown: "Площадка не указана",
classCode: "Режим: {{code}}",
isin: "ISIN: {{value}}",
priceUnavailable: "Цена недоступна",
exchanges: {
REAL_EXCHANGE_MOEX: "Мосбиржа",
REAL_EXCHANGE_RTS: "СПБ Биржа",
REAL_EXCHANGE_OTC: "Внебиржевой рынок",
},
aboutCompany: "О компании",
priceTableHint: "Дневные закрытия · записей: {{count}} · сначала новые",
share: "Акция",
loading: "Загружаем страницу акции",
loadingSection: "Загружаем: {{section}}",
loadingPrice: "Загружаем текущую цену",
unavailable: "Акция недоступна",
ambiguousTicker:
"Этот тикер относится к нескольким выпускам. Выберите нужную акцию в поиске.",
unavailableText:
"Проверьте ссылку или попробуйте загрузить данные ещё раз.",
connect:
"Для просмотра акций подключите T-Банк в настройках или обновите подключение.",
sectionError: "Не удалось загрузить этот раздел. Попробуйте ещё раз.",
refreshPrice: "Обновить цену",
price: "Цена",
priceAt: "Цена на {{date}} UTC",
changeBase: "К закрытию предыдущего торгового дня, {{date}}",
noComparison:
"Изменение за день недоступно: нет подтверждённой цены сравнения.",
partialQuote: "Часть данных о цене или торгах временно недоступна.",
chart: "График цены",
period: "Период графика",
periods: {
month: "Месяц",
halfYear: "Полгода",
year: "Год",
all: "Вся история",
},
chartLabel: "Дневные цены акции, {{currency}}",
chartNote:
"Дневные цены по данным Т-Банка. Исторические цены могут учитывать корпоративные изменения.",
incomplete: "Последняя свеча ещё формируется: её цена может измениться.",
averageLine: "Текущая средняя покупка",
priceTable: "Цены в таблице",
date: "Дата",
candle: "Свеча",
complete: "Завершена",
forming: "Формируется",
noCandles:
"За этот период нет доступных цен. Возможно, торги ещё не начались.",
position: "Моя позиция",
quantity: "Количество акций",
value: "Текущая стоимость",
average: "Средняя покупка",
openReturn: "Результат позиции",
openReturnNote:
"Результат только открытой позиции: без проданных акций, дивидендов и полной доходности владения. «—» означает, что расчёт недоступен.",
portfolio: "Портфель",
snapshotAt: "Позиция на {{date}} UTC",
upcoming: "Предстоящие дивиденды",
companyEvent: "Событие компании",
perShare: "на акцию",
yourEstimate: "Оценка по вашей позиции",
record: "Дата реестра",
lastBuy: "Последний день покупки",
payment: "Выплата по данным источника",
noUpcoming: "Нет известных предстоящих выплат до {{to}}.",
dividendNote:
"Личная сумма — оценка по текущему количеству, не гарантия выплаты. Налоги не сопоставлены; дата выплаты компании может отличаться от дня зачисления на ваш счёт.",
estimateUnavailable:
"Данные позиции не удалось обновить. Личная оценка недоступна или рассчитана по сохранённой позиции.",
dividendHistory: "История дивидендов компании",
dividendWindow:
"События компании с {{from}} по {{to}}. Личные зачисления показаны отдельно в истории событий.",
noDividends: "Нет известных выплат за этот период.",
dateUnknown: "Дата не указана",
history: "Моя история событий",
historyNote:
"Операции по этой акции на доступных открытых счетах, включая периоды до текущего года. Дата сделки — дата операции брокера; количество — исполненная часть. История закрытых счетов не включена.",
eventFilter: "Фильтр событий",
filters: {
all: "Все",
trades: "Сделки",
dividends: "Дивиденды",
other: "Другие",
},
noOperations: "Нет доступных операций по выбранному фильтру.",
previous: "Назад",
next: "Далее",
eventQuantity: "{{value}} шт.",
eventPrice: "Цена: {{value}}",
company: "Компания и показатели",
sector: "Сектор",
lot: "Лот: {{value}} шт.",
partialCompany: "Часть сведений о компании временно недоступна.",
noFundamentals: "Нет доступных финансовых показателей.",
periodNote:
"TTM — последние 12 месяцев, MRQ — последний отчётный квартал. Дата отчётности не предоставлена источником. Отсутствующие значения не показаны.",
metrics: {
marketCapitalization: "Капитализация",
peRatioTtm: "P/E",
priceToBookTtm: "P/B",
revenueTtm: "Выручка",
ebitdaTtm: "EBITDA",
netIncomeTtm: "Чистая прибыль",
roe: "Рентабельность капитала (ROE)",
totalDebtMrq: "Общий долг",
netDebtToEbitda: "Чистый долг / EBITDA",
},
shareTypes: {
SHARE_TYPE_COMMON: "Обыкновенная акция",
SHARE_TYPE_PREFERRED: "Привилегированная акция",
SHARE_TYPE_ADR: "Депозитарная расписка",
SHARE_TYPE_GDR: "Депозитарная расписка",
},
sectors: {
financial: "Финансы",
energy: "Энергетика",
materials: "Материалы",
industrials: "Промышленность",
utilities: "Коммунальные услуги",
telecom: "Телекоммуникации",
it: "Информационные технологии",
consumer: "Потребительский сектор",
health_care: "Здравоохранение",
real_estate: "Недвижимость",
},
trading: {
unknown: "Статус торгов неизвестен",
SECURITY_TRADING_STATUS_NORMAL_TRADING: "Идут торги",
SECURITY_TRADING_STATUS_NOT_AVAILABLE_FOR_TRADING: "Торги недоступны",
SECURITY_TRADING_STATUS_OPENING_PERIOD: "Период открытия",
SECURITY_TRADING_STATUS_CLOSING_PERIOD: "Период закрытия",
SECURITY_TRADING_STATUS_BREAK_IN_TRADING: "Перерыв в торгах",
SECURITY_TRADING_STATUS_CLOSING_AUCTION: "Аукцион закрытия",
SECURITY_TRADING_STATUS_OPENING_AUCTION: "Аукцион открытия",
SECURITY_TRADING_STATUS_SESSION_OPEN: "Сессия открыта",
SECURITY_TRADING_STATUS_SESSION_CLOSE: "Сессия закрыта",
SECURITY_TRADING_STATUS_DEALER_NORMAL_TRADING: "Дилерские торги",
},
},
auth: {
login: "Вход",
register: "Регистрация",
@@ -33,6 +190,10 @@ const ru = {
scope: "Счёт",
allDescription: "Портфель по всем доступным инвестиционным счетам",
title: "Счета",
subtitle: "Ваши инвестиции по счетам",
currentValue: "Текущая стоимость",
stocks: "Акции",
bonds: "Облигации",
emptyTitle: "Нет инвестиционных счетов",
emptyText:
"Откройте инвестиционный счет в T-Bank, чтобы он появился здесь.",
@@ -120,6 +281,20 @@ const ru = {
share: "Доля: {{value}}",
},
ledger: {
occurredAt: "{{date}} UTC",
quantity: "{{value}} шт.",
price: "Цена: {{value}}",
operationType: {
buy: "Покупка",
sell: "Продажа",
dividend: "Зачисление дивидендов",
tax: "Налог",
fee: "Комиссия",
input: "Зачисление ценных бумаг",
output: "Вывод ценных бумаг",
transfer: "Перевод между счетами",
other: "Другая операция",
},
title: "Журнал операций",
back: "К счету",
open: "Открыть журнал",
+14 -1
View File
@@ -1,7 +1,20 @@
import { describe, expect, it } from "vitest";
import { formatMoney, instrumentLogoUrl } from "./display";
import {
formatDate,
formatMoney,
formatSnapshotTime,
instrumentLogoUrl,
} from "./display";
describe("shared display helpers", () => {
it("uses padded Russian dates and preserves the UTC date of timestamps", () => {
expect(formatDate("2027-09-10")).toBe("10.09.2027");
expect(formatDate("2024-02-29")).toBe("29.02.2024");
expect(formatDate("2027-09-10T00:30:00+03:00")).toBe("09.09.2027");
expect(formatSnapshotTime("2027-09-10T00:30:00+03:00")).toBe(
"09.09.2027, 21:30",
);
});
it("formats decimal strings without financial arithmetic", () => {
expect(formatMoney({ value: "-1234567.89", currency: "RUB" })).toBe(
"-1 234 567,89 ₽",
+17 -2
View File
@@ -15,9 +15,18 @@ function formatMoney(money: Money | null): string {
return `${formatDecimal(money.value)} ${money.currency === "RUB" ? "₽" : money.currency}`;
}
function formatDate(value: string): string {
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
timeZone: "UTC",
}).format(new Date(value));
}
function formatSnapshotTime(value: string): string {
return new Intl.DateTimeFormat("ru-RU", {
dateStyle: "medium",
dateStyle: "short",
timeStyle: "short",
timeZone: "UTC",
}).format(new Date(value));
@@ -32,4 +41,10 @@ function instrumentLogoUrl(logoName: string | null | undefined): string | null {
: null;
}
export { formatDecimal, formatMoney, formatSnapshotTime, instrumentLogoUrl };
export {
formatDate,
formatDecimal,
formatMoney,
formatSnapshotTime,
instrumentLogoUrl,
};
+1
View File
@@ -1,4 +1,5 @@
export {
formatDate,
formatDecimal,
formatMoney,
formatSnapshotTime,
+1
View File
@@ -14,6 +14,7 @@ const AppShell: FC<AppShellProps> = (props) => {
const { pathname } = useLocation();
const items = [
{ to: "/accounts", label: t("navigation.accounts") },
{ to: "/stocks", label: t("navigation.stocks") },
{ to: "/settings", label: t("navigation.settings") },
];
+3 -1
View File
@@ -1,10 +1,11 @@
import { Avatar, Paper, Stack, Text } from "@mantine/core";
import { type FC, useState } from "react";
import { type FC, type ReactNode, useState } from "react";
import classes from "./EntryCard.module.css";
type EntryCardProps = {
title: string;
metadata: string;
details?: ReactNode;
initial: string;
imageSrc?: string | null;
value: string;
@@ -33,6 +34,7 @@ const EntryCard: FC<EntryCardProps> = (props) => {
<Text c="dimmed" size="sm">
{props.metadata}
</Text>
{props.details}
</Stack>
<Stack className={classes.value} gap={2}>
<Text className={classes[props.tone]} fw={700}>