feat: add stock pages and ticker navigation

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

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

Update the generated API contract.
This commit is contained in:
2026-09-10 21:23:41 +03:00
parent 0306526d33
commit 561222e468
56 changed files with 5051 additions and 190 deletions
@@ -25,6 +25,7 @@ const operationCategories = [
function brokerageOperationsQueryParams(query: BrokerageOperationsQuery) {
return {
...(query.instrumentUid ? { instrumentUid: query.instrumentUid } : {}),
...(query.from ? { from: query.from } : {}),
...(query.to ? { to: query.to } : {}),
...(query.categories?.length
+37 -1
View File
@@ -50,4 +50,40 @@ function isZeroDecimal(value: string): boolean {
return /^-?0(?:\.0+)?$/.test(value);
}
export { paymentPresentation };
function operationLabelKey(type: string): string {
if (
[
"OPERATION_TYPE_BUY",
"OPERATION_TYPE_BUY_CARD",
"OPERATION_TYPE_BUY_MARGIN",
].includes(type)
)
return "ledger.operationType.buy";
if (
[
"OPERATION_TYPE_SELL",
"OPERATION_TYPE_SELL_CARD",
"OPERATION_TYPE_SELL_MARGIN",
].includes(type)
)
return "ledger.operationType.sell";
if (
[
"OPERATION_TYPE_DIVIDEND",
"OPERATION_TYPE_DIV_EXT",
"OPERATION_TYPE_DIVIDEND_TRANSFER",
].includes(type)
)
return "ledger.operationType.dividend";
if (type.includes("TAX")) return "ledger.operationType.tax";
if (type.includes("FEE")) return "ledger.operationType.fee";
if (type === "OPERATION_TYPE_INPUT_SECURITIES")
return "ledger.operationType.input";
if (type === "OPERATION_TYPE_OUTPUT_SECURITIES")
return "ledger.operationType.output";
if (type.startsWith("OPERATION_TYPE_TRANS_"))
return "ledger.operationType.transfer";
return "ledger.operationType.other";
}
export { operationLabelKey, paymentPresentation };
@@ -1,12 +1,20 @@
import { Anchor, Group, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { formatSnapshotTime, instrumentLogoUrl } from "@/shared/lib";
import { Link } from "react-router";
import {
formatDecimal,
formatMoney,
formatSnapshotTime,
instrumentLogoUrl,
} from "@/shared/lib";
import { EntryCard } from "@/shared/ui";
import type { BrokerageOperation } from "../api/useBrokerageOperations";
import { paymentPresentation } from "../model/display";
import { operationLabelKey, paymentPresentation } from "../model/display";
type LedgerEntryRowProps = {
operation: BrokerageOperation;
withDetails?: boolean;
};
const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
@@ -16,17 +24,46 @@ const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
operation.ticker ?? operation.instrumentType ?? t("ledger.noInstrument");
const payment = paymentPresentation(operation);
const metadata = [
formatSnapshotTime(operation.occurredAt),
t("ledger.occurredAt", { date: formatSnapshotTime(operation.occurredAt) }),
instrument,
operation.account?.name,
props.withDetails ? null : operation.account?.name,
]
.filter(Boolean)
.join(" · ");
return (
<EntryCard
title={operation.description || operation.type}
title={operation.description || t(operationLabelKey(operation.type))}
metadata={metadata}
details={
props.withDetails ? (
<>
<Group gap="sm">
{operation.quantity != null ? (
<Text size="xs" c="dimmed">
{t("ledger.quantity", {
value: formatDecimal(operation.quantity),
})}
</Text>
) : null}
{operation.price ? (
<Text size="xs" c="dimmed">
{t("ledger.price", { value: formatMoney(operation.price) })}
</Text>
) : null}
</Group>
{operation.account ? (
<Anchor
component={Link}
size="sm"
to={`/accounts/${encodeURIComponent(operation.account.id)}`}
>
{operation.account.name}
</Anchor>
) : null}
</>
) : undefined
}
initial={(instrument.trim()[0] ?? "?").toUpperCase()}
imageSrc={instrumentLogoUrl(operation.logoName)}
value={payment.amount}
@@ -6,6 +6,7 @@ type Money = NonNullable<
type BrokerageAccountCard = {
account: BrokerageAccount;
positionCounts: { stocks: number; bonds: number } | null;
summary:
| { state: "unavailable" }
| { state: "empty" }
@@ -21,11 +22,21 @@ function brokerageAccountCards(
): BrokerageAccountCard[] {
return accounts.map((account) => {
const summary = account.portfolioSummary;
if (!summary) return { account, summary: { state: "unavailable" } };
if (!summary)
return {
account,
positionCounts: null,
summary: { state: "unavailable" },
};
const positionCounts = {
stocks: summary.stockPositionCount,
bonds: summary.bondPositionCount,
};
if (!summary.totalsByCurrency.length)
return { account, summary: { state: "empty" } };
return { account, positionCounts, summary: { state: "empty" } };
return {
account,
positionCounts,
summary: {
state: "available",
totals: summary.totalsByCurrency.slice(0, 2),
@@ -3,30 +3,59 @@
}
.card {
background: linear-gradient(
135deg,
var(--mantine-color-blue-0) 0%,
var(--mantine-color-body) 72%
);
border-color: var(--mantine-color-blue-2);
box-shadow: 0 10px 28px rgb(15 23 42 / 6%);
min-width: 0;
gap: var(--mantine-spacing-xl);
background: var(--mantine-color-white);
border-color: var(--mantine-color-gray-3);
box-shadow: 0 4px 16px rgb(15 23 42 / 3%);
color: inherit;
text-decoration: none;
transition:
transform 120ms ease,
box-shadow 120ms ease;
transform 160ms ease,
box-shadow 160ms ease,
border-color 160ms ease;
}
.card:hover,
.card:focus-visible {
box-shadow: 0 16px 36px rgb(15 23 42 / 10%);
transform: translateY(-2px);
border-color: var(--mantine-color-blue-3);
box-shadow: 0 12px 28px rgb(15 23 42 / 8%);
transform: translateY(-3px);
}
.card:focus-visible {
outline: 2px solid var(--mantine-color-blue-6);
outline-offset: 3px;
}
.identity {
min-width: 0;
}
.kicker {
color: var(--mantine-color-blue-7);
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 5px 10px;
border-radius: var(--mantine-radius-sm);
background: var(--mantine-color-blue-0);
color: var(--mantine-color-blue-8);
letter-spacing: 0.02em;
}
.accountIcon {
width: 28px;
height: 28px;
flex-shrink: 0;
color: var(--mantine-color-blue-5);
}
.name {
margin: 0;
font-size: var(--mantine-h3-font-size);
line-height: 1.3;
overflow-wrap: anywhere;
}
.balance {
flex: 1;
}
.summary {
@@ -34,20 +63,104 @@
}
.total {
white-space: nowrap;
font-size: clamp(1.5rem, 2.8vw, 2.25rem);
letter-spacing: -0.035em;
line-height: 1.2;
overflow-wrap: anywhere;
}
.total + .total {
font-size: var(--mantine-font-size-lg);
font-weight: 500;
letter-spacing: 0;
color: var(--mantine-color-gray-7);
}
.positions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--mantine-spacing-sm);
margin: 0;
}
.positions > div {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 4px var(--mantine-spacing-sm);
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border-radius: var(--mantine-radius-md);
}
.stocks {
background: var(--mantine-color-blue-0);
color: var(--mantine-color-blue-8);
}
.bonds {
background: var(--mantine-color-grape-0);
color: var(--mantine-color-grape-8);
}
.positions dt {
font-size: var(--mantine-font-size-sm);
font-weight: 500;
}
.positions dd {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1.25;
overflow-wrap: anywhere;
}
.footer {
border-top: 1px solid var(--mantine-color-gray-2);
padding-top: var(--mantine-spacing-md);
}
.open {
display: inline-flex;
align-items: center;
gap: var(--mantine-spacing-xs);
margin-left: auto;
color: var(--mantine-color-blue-7);
}
@media (prefers-reduced-motion: reduce) {
.arrow {
font-size: var(--mantine-font-size-lg);
transition: transform 160ms ease;
}
.card:hover .arrow {
transform: translateX(3px);
}
@media (max-width: 36em) {
.card {
padding: var(--mantine-spacing-md);
gap: var(--mantine-spacing-lg);
}
.positions > div {
flex-direction: column;
align-items: flex-start;
padding: var(--mantine-spacing-sm);
}
}
@media (prefers-reduced-motion: reduce) {
.card,
.arrow {
transition: none;
}
.card:hover,
.card:focus-visible {
.card:focus-visible,
.card:hover .arrow {
transform: none;
}
}
@@ -10,6 +10,8 @@ const syntheticAccount = {
type: "BROKERAGE",
openedOn: "2025-03-15",
portfolioSummary: {
stockPositionCount: 5,
bondPositionCount: 12,
totalsByCurrency: [
{ currency: "RUB", value: "125000.50" },
{ currency: "USD", value: "320" },
@@ -36,7 +38,7 @@ const meta = {
docs: {
description: {
component:
"Карточки счетов WealthLedger со сводкой по валютам. Используйте на экране выбора счёта; отсутствие сводки и отсутствие сумм имеют разные сообщения. Примеры содержат вымышленные данные. Ссылки меняют только локальный маршрут MemoryRouter; страница назначения в этой истории не отображается.",
"Карточки счетов WealthLedger со сводкой по валютам и числом наименований акций и облигаций. Используйте на экране выбора счёта; отсутствие сводки и отсутствие сумм имеют разные сообщения. Примеры содержат вымышленные данные. Ссылки меняют только локальный маршрут MemoryRouter; страница назначения в этой истории не отображается.",
},
},
},
@@ -80,7 +82,14 @@ export const Empty: Story = {
name: "Нет текущих сумм",
args: {
accounts: brokerageAccountCards([
{ ...syntheticAccount, portfolioSummary: { totalsByCurrency: [] } },
{
...syntheticAccount,
portfolioSummary: {
totalsByCurrency: [],
stockPositionCount: 0,
bondPositionCount: 0,
},
},
]),
},
parameters: {
@@ -113,3 +122,66 @@ export const LongName: Story = {
},
},
};
export const AccountGrid: Story = {
name: "Несколько счетов",
args: {
accounts: brokerageAccountCards([
{
...syntheticAccount,
name: "Основной",
portfolioSummary: {
totalsByCurrency: [{ currency: "RUB", value: "1256430.50" }],
stockPositionCount: 8,
bondPositionCount: 14,
},
},
{
...syntheticAccount,
id: "storybook-iis",
name: "Долгосрочные инвестиции",
type: "INDIVIDUAL_INVESTMENT",
portfolioSummary: {
totalsByCurrency: [{ currency: "RUB", value: "480250" }],
stockPositionCount: 0,
bondPositionCount: 6,
},
},
]),
},
};
export const UnvaluedPositions: Story = {
name: "Бумаги без текущей оценки",
args: {
accounts: brokerageAccountCards([
{
...syntheticAccount,
portfolioSummary: {
totalsByCurrency: [],
stockPositionCount: 2,
bondPositionCount: 1,
},
},
]),
},
};
export const LargeValues: Story = {
name: "Длинные суммы",
args: {
accounts: brokerageAccountCards([
{
...syntheticAccount,
portfolioSummary: {
totalsByCurrency: [
{ currency: "RUB", value: "1234567890123456789.50" },
{ currency: "USD", value: "-9876543210987654321.50" },
],
stockPositionCount: 21,
bondPositionCount: 105,
},
},
]),
},
};
@@ -1,5 +1,5 @@
import { MantineProvider } from "@mantine/core";
import { render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import { brokerageAccountCards } from "../model/display";
@@ -18,6 +18,8 @@ describe("BrokerageAccountList", () => {
type: "BROKERAGE",
openedOn: "2024-08-13",
portfolioSummary: {
stockPositionCount: 5,
bondPositionCount: 12,
totalsByCurrency: [
{ currency: "RUB", value: "1500" },
{ currency: "USD", value: "20" },
@@ -37,7 +39,11 @@ describe("BrokerageAccountList", () => {
name: "Пустой",
type: "BROKERAGE",
openedOn: null,
portfolioSummary: { totalsByCurrency: [] },
portfolioSummary: {
totalsByCurrency: [],
stockPositionCount: 2,
bondPositionCount: 0,
},
},
])}
/>
@@ -57,5 +63,24 @@ describe("BrokerageAccountList", () => {
expect(screen.getByText("Сводка временно недоступна")).toBeInTheDocument();
expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument();
expect(screen.getAllByText("Открыть счет")).toHaveLength(3);
const available = within(screen.getByRole("link", { name: /Основной/ }));
expect(available.getByText("Акции").nextElementSibling).toHaveTextContent(
"5",
);
expect(
available.getByText("Облигации").nextElementSibling,
).toHaveTextContent("12");
const unvalued = within(screen.getByRole("link", { name: /Пустой/ }));
expect(unvalued.getByText("Акции").nextElementSibling).toHaveTextContent(
"2",
);
expect(
unvalued.getByText("Облигации").nextElementSibling,
).toHaveTextContent("0");
expect(
within(screen.getByRole("link", { name: /Недоступный/ })).queryByText(
"Акции",
),
).not.toBeInTheDocument();
});
});
@@ -1,8 +1,8 @@
import { Card, Group, Stack, Text } from "@mantine/core";
import { Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { formatMoney } from "@/shared/lib";
import { formatDate, formatMoney } from "@/shared/lib";
import type { BrokerageAccountCard } from "../model/display";
import { accountTypeKey } from "../model/display";
import classes from "./BrokerageAccountList.module.css";
@@ -30,7 +30,7 @@ const BrokerageAccountSummary: FC<BrokerageAccountSummaryProps> = (props) => {
);
return (
<Group className={classes.summary} gap="sm" wrap="wrap">
<Stack className={classes.summary} gap={4}>
{summary.totals.map((total) => (
<Text className={classes.total} fw={700} key={total.currency}>
{formatMoney(total)}
@@ -43,7 +43,7 @@ const BrokerageAccountSummary: FC<BrokerageAccountSummaryProps> = (props) => {
})}
</Text>
) : null}
</Group>
</Stack>
);
};
@@ -52,42 +52,81 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
const { t } = useTranslation();
return (
<Stack className={classes.list} gap="md">
{accounts.map(({ account, summary }) => (
<SimpleGrid className={classes.list} cols={{ base: 1, lg: 2 }} spacing="lg">
{accounts.map(({ account, summary, positionCounts }) => (
<Card
component={Link}
to={`/accounts/${account.id}`}
key={account.id}
className={classes.card}
padding="lg"
padding="xl"
radius="lg"
withBorder
>
<Stack gap="xs">
<Text className={classes.kicker} size="xs" fw={700}>
{t(accountTypeKey(account.type))}
</Text>
<Text fw={800} size="lg">
<Stack className={classes.identity} gap="sm">
<Group justify="space-between" wrap="nowrap" gap="sm">
<Text className={classes.kicker} size="xs" fw={700}>
{t(accountTypeKey(account.type))}
</Text>
<svg
className={classes.accountIcon}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<rect x="3" y="5" width="18" height="15" rx="3" />
<path d="M3 9h18M7 15h3M14 5V3H7a4 4 0 0 0-4 4" />
</svg>
</Group>
<Text component="h2" className={classes.name} fw={700}>
{account.name}
</Text>
</Stack>
<Stack className={classes.balance} gap="xs">
<Text c="dimmed" size="sm">
{t("accounts.currentValue")}
</Text>
<BrokerageAccountSummary summary={summary} />
</Stack>
{positionCounts ? (
<dl className={classes.positions}>
<div className={classes.stocks}>
<dt>{t("accounts.stocks")}</dt>
<dd>
{new Intl.NumberFormat("ru-RU").format(positionCounts.stocks)}
</dd>
</div>
<div className={classes.bonds}>
<dt>{t("accounts.bonds")}</dt>
<dd>
{new Intl.NumberFormat("ru-RU").format(positionCounts.bonds)}
</dd>
</div>
</dl>
) : null}
<Group className={classes.footer} justify="space-between" gap="sm">
{account.openedOn ? (
<Text c="dimmed" size="sm">
<Text c="dimmed" size="xs">
{t("accounts.openedOn", {
date: new Intl.DateTimeFormat("ru-RU", {
dateStyle: "long",
timeZone: "UTC",
}).format(new Date(`${account.openedOn}T00:00:00Z`)),
date: formatDate(account.openedOn),
})}
</Text>
) : null}
<Text className={classes.open} size="sm" fw={700}>
<Text component="span" className={classes.open} size="sm" fw={600}>
{t("accounts.openAccount")}
<span className={classes.arrow} aria-hidden="true">
→
</span>
</Text>
</Stack>
</Group>
</Card>
))}
</Stack>
</SimpleGrid>
);
};
@@ -1,6 +1,6 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { formatMoney, instrumentLogoUrl } from "@/shared/lib";
import { formatDate, formatMoney, instrumentLogoUrl } from "@/shared/lib";
import { EntryCard } from "@/shared/ui";
import type { BrokerageEvent } from "../api/useBrokerageEvents";
@@ -10,10 +10,7 @@ const BrokerageEventCard: FC<BrokerageEventCardProps> = (props) => {
const { event } = props;
const { t } = useTranslation();
const date = new Intl.DateTimeFormat("ru-RU", {
dateStyle: "medium",
timeZone: "UTC",
}).format(new Date(`${event.date}T00:00:00Z`));
const date = formatDate(event.date);
const metadata = [
date,
+110
View File
@@ -0,0 +1,110 @@
import {
apiQueryClient,
useAuthenticatedQueries,
useAuthenticatedQuery,
} from "@/shared/api";
type StockPeriod = "month" | "halfYear" | "year" | "all";
function useStockSearch(query: string) {
return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks", {
params: { query: { query } },
}),
enabled: query.length >= 2,
staleTime: 30_000,
});
}
function stockQueryOptions(uid: string) {
return {
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}", {
params: { path: { uid } },
}),
staleTime: 30_000,
};
}
function useStocks(uids: string[]) {
const uniqueUids = [...new Set(uids)];
const queries = useAuthenticatedQueries(uniqueUids.map(stockQueryOptions));
return new Map(
queries.map((query, index) => [uniqueUids[index], query.data]),
);
}
function useStock(identifier: string) {
const isUid =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
identifier,
);
const byUid = useAuthenticatedQuery({
...stockQueryOptions(identifier),
enabled: Boolean(identifier) && isUid,
});
const byTicker = useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks/by-ticker/{ticker}", {
params: { path: { ticker: identifier } },
}),
enabled: Boolean(identifier) && !isUid,
staleTime: 30_000,
});
return isUid ? byUid : byTicker;
}
function useStockQuote(uid: string) {
return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/quote", {
params: { path: { uid } },
}),
enabled: Boolean(uid),
staleTime: 30_000,
});
}
function useStockCandles(uid: string, period: StockPeriod) {
return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/candles", {
params: { path: { uid }, query: { period } },
}),
enabled: Boolean(uid),
staleTime: 30_000,
});
}
function useStockPosition(uid: string) {
return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/position", {
params: { path: { uid } },
}),
enabled: Boolean(uid),
staleTime: 30_000,
});
}
function useStockCompany(uid: string) {
return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/company", {
params: { path: { uid } },
}),
enabled: Boolean(uid),
staleTime: 30_000,
});
}
function useStockDividends(uid: string) {
return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/dividends", {
params: { path: { uid } },
}),
enabled: Boolean(uid),
staleTime: 30_000,
});
}
export {
type StockPeriod,
useStock,
useStockCandles,
useStockCompany,
useStockDividends,
useStockPosition,
useStockQuote,
useStockSearch,
useStocks,
};
+11
View File
@@ -0,0 +1,11 @@
export {
type StockPeriod,
useStock,
useStockCandles,
useStockCompany,
useStockDividends,
useStockPosition,
useStockQuote,
useStockSearch,
useStocks,
} from "./api/queries";