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