feat: add bond pages and portfolio navigation

This commit is contained in:
2026-09-10 22:30:47 +03:00
parent 561222e468
commit 232d7f9990
39 changed files with 4217 additions and 221 deletions
+24
View File
@@ -4,6 +4,14 @@ import { Navigate, Route, Routes } from "react-router";
import { LazyPage } from "./RouteFallback"; import { LazyPage } from "./RouteFallback";
import { Protected, Public } from "./RouteGuards"; import { Protected, Public } from "./RouteGuards";
const BondPage = lazy(async () => {
const { BondPage } = await import("@/pages/bond");
return { default: BondPage };
});
const BondsPage = lazy(async () => {
const { BondsPage } = await import("@/pages/bonds");
return { default: BondsPage };
});
const StockPage = lazy(async () => { const StockPage = lazy(async () => {
const { StockPage } = await import("@/pages/stock"); const { StockPage } = await import("@/pages/stock");
return { default: StockPage }; return { default: StockPage };
@@ -85,6 +93,22 @@ const AppRoutes: FC = () => (
</Protected> </Protected>
} }
> >
<Route
path="/bonds"
element={
<LazyPage variant="content">
<BondsPage />
</LazyPage>
}
/>
<Route
path="/bonds/:bondId"
element={
<LazyPage variant="content">
<BondPage />
</LazyPage>
}
/>
<Route <Route
path="/stocks" path="/stocks"
element={ element={
+3
View File
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { useMatch } from "react-router"; import { useMatch } from "react-router";
import { AccountBreadcrumbs } from "./AccountBreadcrumbs"; import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
import { AccountScopeSwitch } from "./AccountScopeSwitch"; import { AccountScopeSwitch } from "./AccountScopeSwitch";
import { BondBreadcrumbs } from "./BondBreadcrumbs";
import { BreadcrumbTrail } from "./BreadcrumbTrail"; import { BreadcrumbTrail } from "./BreadcrumbTrail";
import { StockBreadcrumbs } from "./StockBreadcrumbs"; import { StockBreadcrumbs } from "./StockBreadcrumbs";
@@ -20,6 +21,8 @@ const AppBreadcrumbs: FC = () => {
const settings = useMatch({ path: "/settings", end: true }); const settings = useMatch({ path: "/settings", end: true });
const stock = useMatch({ path: "/stocks/:stockId", end: true }); const stock = useMatch({ path: "/stocks/:stockId", end: true });
const bond = useMatch({ path: "/bonds/:bondId", end: true });
if (bond) return <BondBreadcrumbs identifier={bond.params.bondId ?? ""} />;
if (stock) if (stock)
return <StockBreadcrumbs identifier={stock.params.stockId ?? ""} />; return <StockBreadcrumbs identifier={stock.params.stockId ?? ""} />;
if (operations) if (operations)
+20
View File
@@ -0,0 +1,20 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useBond } from "@/entities/bond";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
type BondBreadcrumbsProps = { identifier: string };
const BondBreadcrumbs: FC<BondBreadcrumbsProps> = (props) => {
const { data } = useBond(props.identifier);
const { t } = useTranslation();
return (
<BreadcrumbTrail
items={[
{ label: t("bond.searchTitle"), to: "/bonds" },
{ label: data?.name ?? t("bond.instrument") },
]}
/>
);
};
export { BondBreadcrumbs };
+97
View File
@@ -0,0 +1,97 @@
import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
type BondPeriod = "month" | "halfYear" | "year" | "all";
function useBondSearch(query: string) {
return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds", {
params: { query: { query } },
}),
enabled: query.length >= 2,
staleTime: 30_000,
});
}
function bondQueryOptions(uid: string) {
return {
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}", {
params: { path: { uid } },
}),
staleTime: 30_000,
};
}
function useBond(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({
...bondQueryOptions(identifier),
enabled: Boolean(identifier) && isUid,
});
const byTicker = useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds/by-ticker/{ticker}", {
params: { path: { ticker: identifier } },
}),
enabled: Boolean(identifier) && !isUid,
staleTime: 30_000,
});
return isUid ? byUid : byTicker;
}
function useBondMetrics(uid: string) {
return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/metrics", {
params: { path: { uid } },
}),
enabled: Boolean(uid),
staleTime: 30_000,
});
}
function useBondQuote(uid: string) {
return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/quote", {
params: { path: { uid } },
}),
enabled: Boolean(uid),
staleTime: 30_000,
});
}
function useBondCandles(uid: string, period: BondPeriod) {
return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/candles", {
params: { path: { uid }, query: { period } },
}),
enabled: Boolean(uid),
staleTime: 30_000,
});
}
function useBondPosition(uid: string) {
return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/position", {
params: { path: { uid } },
}),
enabled: Boolean(uid),
staleTime: 30_000,
});
}
function useBondCoupons(uid: string) {
return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/coupons", {
params: { path: { uid } },
}),
enabled: Boolean(uid),
staleTime: 30_000,
});
}
export {
type BondPeriod,
useBond,
useBondCandles,
useBondCoupons,
useBondMetrics,
useBondPosition,
useBondQuote,
useBondSearch,
};
+10
View File
@@ -0,0 +1,10 @@
export {
type BondPeriod,
useBond,
useBondCandles,
useBondCoupons,
useBondMetrics,
useBondPosition,
useBondQuote,
useBondSearch,
} from "./api/queries";
+5
View File
@@ -0,0 +1,5 @@
export { section } from "./model/section";
export { InstrumentChart } from "./ui/InstrumentChart.view";
export { InstrumentEvents } from "./ui/InstrumentEvents.view";
export { InstrumentPosition } from "./ui/InstrumentPosition.view";
export { InstrumentSection } from "./ui/InstrumentSection.view";
@@ -0,0 +1,25 @@
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();
},
};
}
export { type SectionState, section };
@@ -0,0 +1,36 @@
import type { BrokerageOperation } from "@/entities/broker-operation";
import type { paths } from "@/shared/api";
import type { SectionState } from "./section";
type Position =
paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"];
type Candles =
paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"];
type ChartRow = Candles["candles"][number] & { date: string; price: number };
type InstrumentPositionVm = {
position?: Position;
positionState: SectionState;
};
type InstrumentChartVm = {
candles?: Candles;
candlesState: SectionState;
chartData: ChartRow[];
priceRows: ChartRow[];
period: string;
setPeriod: (value: string) => void;
averageLine: number | null;
hasIncompleteCandle: boolean;
position?: Position;
};
type InstrumentEventsVm = {
operations?: { hasNext: boolean };
operationsState: SectionState;
filter: string;
setFilter: (value: string) => void;
eventRows: BrokerageOperation[];
hasPrevious: boolean;
onPrevious: () => void;
onNext: () => void;
};
export type { InstrumentChartVm, InstrumentEventsVm, InstrumentPositionVm };
@@ -9,17 +9,17 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import type { FC } from "react"; import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatMoney } from "@/shared/lib"; import { formatDecimal, formatMoney } from "@/shared/lib";
import classes from "../StockPage.module.css"; import type { InstrumentChartVm } from "../model/types";
import type { StockPageVm } from "../StockPage.vm"; import classes from "./InstrumentDetails.module.css";
import { StockSection } from "./StockSection.view"; import { InstrumentSection } from "./InstrumentSection.view";
type StockChartProps = { vm: StockPageVm }; type InstrumentChartProps = { vm: InstrumentChartVm; priceUnit?: "percent" };
const StockChart: FC<StockChartProps> = (props) => { const InstrumentChart: FC<InstrumentChartProps> = (props) => {
const { vm } = props; const { vm } = props;
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<StockSection <InstrumentSection
title={t("stock.chart")} title={t("stock.chart")}
state={vm.candlesState} state={vm.candlesState}
large large
@@ -72,12 +72,24 @@ const StockChart: FC<StockChartProps> = (props) => {
maximumFractionDigits: 4, maximumFractionDigits: 4,
}).format(value) }).format(value)
} }
aria-label={t("stock.chartLabel", { aria-label={t(
currency: vm.candles.currency ?? "", props.priceUnit === "percent"
})} ? "bond.chartLabel"
: "stock.chartLabel",
{
currency:
props.priceUnit === "percent"
? t("bond.percentOfNominal")
: (vm.candles.currency ?? ""),
},
)}
/> />
<Text size="xs" c="dimmed" mt="sm"> <Text size="xs" c="dimmed" mt="sm">
{t("stock.chartNote")} {t(
props.priceUnit === "percent"
? "bond.chartNote"
: "stock.chartNote",
)}
</Text> </Text>
{vm.hasIncompleteCandle ? ( {vm.hasIncompleteCandle ? (
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
@@ -131,7 +143,9 @@ const StockChart: FC<StockChartProps> = (props) => {
<Table.Tr key={candle.time}> <Table.Tr key={candle.time}>
<Table.Td>{candle.date}</Table.Td> <Table.Td>{candle.date}</Table.Td>
<Table.Td> <Table.Td>
{formatMoney( {props.priceUnit === "percent"
? `${formatDecimal(candle.close)} %`
: formatMoney(
vm.candles?.currency vm.candles?.currency
? { ? {
value: candle.close, value: candle.close,
@@ -166,8 +180,8 @@ const StockChart: FC<StockChartProps> = (props) => {
<Text c="dimmed">{t("stock.noCandles")}</Text> <Text c="dimmed">{t("stock.noCandles")}</Text>
) )
) : null} ) : null}
</StockSection> </InstrumentSection>
); );
}; };
export { StockChart }; export { InstrumentChart };
@@ -0,0 +1,62 @@
.section {
padding: var(--mantine-spacing-lg);
border-radius: var(--mantine-radius-lg);
min-width: 0;
}
.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;
}
.controls {
max-width: 100%;
overflow-x: auto;
}
.embeddedSection {
margin-top: var(--mantine-spacing-lg);
padding-top: var(--mantine-spacing-lg);
border-top: 1px solid var(--wl-color-border);
}
.priceTable {
margin-top: var(--mantine-spacing-md);
}
.priceTable table {
min-width: 430px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.priceTable th:nth-child(2),
.priceTable td:nth-child(2) {
text-align: right;
}
@media (max-width: 48em) {
.section {
padding: var(--mantine-spacing-md);
}
.metrics,
.accountRow {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.accountRow > :first-child {
grid-column: 1 / -1;
}
}
@@ -0,0 +1,88 @@
import { Button, Group, SegmentedControl, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { LedgerEntryRow } from "@/entities/broker-operation";
import type { InstrumentEventsVm } from "../model/types";
import classes from "./InstrumentDetails.module.css";
import { InstrumentSection } from "./InstrumentSection.view";
type InstrumentEventsProps = {
vm: InstrumentEventsVm;
incomeLabel?: string;
incomeFilter?: string;
historyNote?: string;
};
const InstrumentEvents: FC<InstrumentEventsProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
return (
<InstrumentSection
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",
props.incomeFilter ?? "dividends",
"other",
].map((value) => ({
value,
label:
value === (props.incomeFilter ?? "dividends") &&
props.incomeLabel
? props.incomeLabel
: t(`stock.filters.${value}`),
}))}
/>
</div>
}
>
{vm.operations ? (
<>
<Text size="xs" c="dimmed">
{props.historyNote ?? 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}
</InstrumentSection>
);
};
export { InstrumentEvents };
@@ -3,17 +3,21 @@ import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { formatDecimal, formatMoney, formatSnapshotTime } from "@/shared/lib"; import { formatDecimal, formatMoney, formatSnapshotTime } from "@/shared/lib";
import classes from "../StockPage.module.css"; import type { InstrumentPositionVm } from "../model/types";
import type { StockPageVm } from "../StockPage.vm"; import classes from "./InstrumentDetails.module.css";
import { StockSection } from "./StockSection.view"; import { InstrumentSection } from "./InstrumentSection.view";
type StockPositionProps = { vm: StockPageVm }; type InstrumentPositionProps = {
const StockPosition: FC<StockPositionProps> = (props) => { vm: InstrumentPositionVm;
returnNote?: string;
quantityLabel?: string;
};
const InstrumentPosition: FC<InstrumentPositionProps> = (props) => {
const { vm } = props; const { vm } = props;
const { t } = useTranslation(); const { t } = useTranslation();
if (vm.position && !vm.position.accounts.length) return null; if (vm.position && !vm.position.accounts.length) return null;
return ( return (
<StockSection title={t("stock.position")} state={vm.positionState}> <InstrumentSection title={t("stock.position")} state={vm.positionState}>
{vm.position ? ( {vm.position ? (
<Stack gap="md"> <Stack gap="md">
{vm.position.isStale ? ( {vm.position.isStale ? (
@@ -22,7 +26,7 @@ const StockPosition: FC<StockPositionProps> = (props) => {
<div className={`${classes.metrics} ${classes.holding}`}> <div className={`${classes.metrics} ${classes.holding}`}>
<div> <div>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{t("stock.quantity")} {props.quantityLabel ?? t("stock.quantity")}
</Text> </Text>
<Text className={classes.metricValue}> <Text className={classes.metricValue}>
{formatDecimal(vm.position.quantity)} {formatDecimal(vm.position.quantity)}
@@ -66,7 +70,7 @@ const StockPosition: FC<StockPositionProps> = (props) => {
</div> </div>
</div> </div>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{t("stock.openReturnNote")} {props.returnNote ?? t("stock.openReturnNote")}
</Text> </Text>
<div> <div>
{vm.position.accounts.map((account) => ( {vm.position.accounts.map((account) => (
@@ -83,7 +87,7 @@ const StockPosition: FC<StockPositionProps> = (props) => {
)} )}
<div> <div>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{t("stock.quantity")} {props.quantityLabel ?? t("stock.quantity")}
</Text> </Text>
<Text>{formatDecimal(account.quantity)}</Text> <Text>{formatDecimal(account.quantity)}</Text>
</div> </div>
@@ -128,8 +132,8 @@ const StockPosition: FC<StockPositionProps> = (props) => {
</Text> </Text>
</Stack> </Stack>
) : null} ) : null}
</StockSection> </InstrumentSection>
); );
}; };
export { StockPosition }; export { InstrumentPosition };
@@ -11,17 +11,17 @@ import {
import type { FC, PropsWithChildren, ReactNode } from "react"; import type { FC, PropsWithChildren, ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { LoadingSkeleton } from "@/shared/ui"; import { LoadingSkeleton } from "@/shared/ui";
import classes from "../StockPage.module.css"; import type { SectionState } from "../model/section";
import type { SectionState } from "../StockPage.vm"; import classes from "./InstrumentDetails.module.css";
type StockSectionProps = PropsWithChildren<{ type InstrumentSectionProps = PropsWithChildren<{
title: string; title: string;
state: SectionState; state: SectionState;
controls?: ReactNode; controls?: ReactNode;
large?: boolean; large?: boolean;
embedded?: boolean; embedded?: boolean;
}>; }>;
const StockSection: FC<StockSectionProps> = (props) => { const InstrumentSection: FC<InstrumentSectionProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Paper <Paper
@@ -70,4 +70,4 @@ const StockSection: FC<StockSectionProps> = (props) => {
); );
}; };
export { StockSection }; export { InstrumentSection };
@@ -22,7 +22,9 @@ function commitTbankConnectionBoundary(
return ( return (
method === "get" && method === "get" &&
typeof path === "string" && typeof path === "string" &&
(path === "/api/v1/stocks" || (path === "/api/v1/bonds" ||
path.startsWith("/api/v1/bonds/") ||
path === "/api/v1/stocks" ||
path.startsWith("/api/v1/stocks/") || path.startsWith("/api/v1/stocks/") ||
path === brokerageAccountsPath || path === brokerageAccountsPath ||
path.startsWith(`${brokerageAccountsPath}/`)) path.startsWith(`${brokerageAccountsPath}/`))
@@ -27,6 +27,11 @@ const stockQueryKey = apiQueryClient.queryOptions(
"/api/v1/stocks/{uid}/position", "/api/v1/stocks/{uid}/position",
{ params: { path: { uid: "e6123145-9665-43e0-8413-cd61b8aa9b13" } } }, { params: { path: { uid: "e6123145-9665-43e0-8413-cd61b8aa9b13" } } },
).queryKey; ).queryKey;
const bondQueryKey = apiQueryClient.queryOptions(
"get",
"/api/v1/bonds/{uid}/position",
{ params: { path: { uid: "e6123145-9665-43e0-8413-cd61b8aa9b13" } } },
).queryKey;
const unrelatedQueryKey = apiQueryClient.queryOptions( const unrelatedQueryKey = apiQueryClient.queryOptions(
"get", "get",
"/api/v1/auth/me", "/api/v1/auth/me",
@@ -59,6 +64,7 @@ function seededClient() {
]); ]);
client.setQueryData(portfolioQueryKey, { source: "previous-connection" }); client.setQueryData(portfolioQueryKey, { source: "previous-connection" });
client.setQueryData(stockQueryKey, { source: "previous-connection" }); client.setQueryData(stockQueryKey, { source: "previous-connection" });
client.setQueryData(bondQueryKey, { source: "previous-connection" });
client.setQueryData(unrelatedQueryKey, { id: "user-1" }); client.setQueryData(unrelatedQueryKey, { id: "user-1" });
return client; return client;
} }
@@ -82,6 +88,7 @@ describe("TbankConnectionSection", () => {
expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined(); expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined();
expect(client.getQueryData(portfolioQueryKey)).toBeUndefined(); expect(client.getQueryData(portfolioQueryKey)).toBeUndefined();
expect(client.getQueryData(stockQueryKey)).toBeUndefined(); expect(client.getQueryData(stockQueryKey)).toBeUndefined();
expect(client.getQueryData(bondQueryKey)).toBeUndefined();
expect(client.getQueryData(unrelatedQueryKey)).toEqual({ id: "user-1" }); expect(client.getQueryData(unrelatedQueryKey)).toEqual({ id: "user-1" });
}); });
@@ -134,6 +141,7 @@ describe("TbankConnectionSection", () => {
expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined(); expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined();
expect(client.getQueryData(portfolioQueryKey)).toBeUndefined(); expect(client.getQueryData(portfolioQueryKey)).toBeUndefined();
expect(client.getQueryData(stockQueryKey)).toBeUndefined(); expect(client.getQueryData(stockQueryKey)).toBeUndefined();
expect(client.getQueryData(bondQueryKey)).toBeUndefined();
expect(client.getQueryData(unrelatedQueryKey)).toEqual({ id: "user-1" }); expect(client.getQueryData(unrelatedQueryKey)).toEqual({ id: "user-1" });
}); });
+18 -8
View File
@@ -121,15 +121,22 @@ function emptyProps() {
} }
describe("AccountPage states", () => { describe("AccountPage states", () => {
it.each(["account-1", "all"])( it.each(
"uses verified ticker hrefs for both name and logo in %s", ["account-1", "all"].flatMap((accountId) =>
async (accountId) => { ["STOCK", "BOND"].map((type) => ({ accountId, type })),
),
)(
"links name and logo to the exact issue in $accountId for $type",
async ({ accountId, type }) => {
const collection = type === "STOCK" ? "stocks" : "bonds";
const slug = type === "STOCK" ? "SBERP" : "SU26238RMFS4";
const uid = "c190ff1f-1447-4227-b543-316332699ca5"; const uid = "c190ff1f-1447-4227-b543-316332699ca5";
const requests = vi.fn(); const requests = vi.fn();
const position = { const position = {
...portfolio.positions[0], ...portfolio.positions[0],
type,
instrumentUid: uid, instrumentUid: uid,
ticker: "SBERP", ticker: slug,
displayName: "Сбербанк привилегированные", displayName: "Сбербанк привилегированные",
}; };
server.use( server.use(
@@ -145,9 +152,9 @@ describe("AccountPage states", () => {
: [position], : [position],
}), }),
), ),
http.get("*/api/v1/stocks/:uid", () => { http.get(`*/api/v1/${collection}/:uid`, () => {
requests(); requests();
return HttpResponse.json({ instrumentUid: uid, slug: "SBERP" }); return HttpResponse.json({ instrumentUid: uid, slug });
}), }),
); );
configureTestAuth(); configureTestAuth();
@@ -158,9 +165,12 @@ describe("AccountPage states", () => {
}); });
expect(links).toHaveLength(2); expect(links).toHaveLength(2);
for (const link of links) for (const link of links)
expect(link).toHaveAttribute("href", "/stocks/SBERP"); expect(link).toHaveAttribute(
"href",
`/${collection}/${type === "STOCK" ? slug : uid}`,
);
}); });
expect(requests).toHaveBeenCalledOnce(); expect(requests).toHaveBeenCalledTimes(type === "STOCK" ? 1 : 0);
}, },
); );
+19 -13
View File
@@ -25,7 +25,7 @@ type AccountPagePosition = Portfolio["positions"][number] & {
returnText: string | null; returnText: string | null;
returnTone?: AccountPageReturnTone; returnTone?: AccountPageReturnTone;
logoSrc: string | null; logoSrc: string | null;
stockTo?: string; instrumentTo?: string;
}; };
type AccountPagePositionGroup = { type AccountPagePositionGroup = {
@@ -87,13 +87,14 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
const { accountId: routeAccountId } = useParams(); const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId; const accountId = scopeId ?? routeAccountId;
const portfolio = usePortfolio(accountId); const portfolio = usePortfolio(accountId);
const stockDetails = useStocks( const stocks = useStocks(
portfolio.data?.positions.flatMap((position) => portfolio.data?.positions.flatMap((position) =>
position.type === "STOCK" && position.instrumentUid position.type === "STOCK" && position.instrumentUid
? [position.instrumentUid] ? [position.instrumentUid]
: [], : [],
) ?? [], ) ?? [],
); );
const instrumentDetails = stocks;
const operations = useBrokerageOperations(accountId, { const operations = useBrokerageOperations(accountId, {
limit: 5, limit: 5,
}); });
@@ -136,7 +137,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
portfolio: portfolio.data, portfolio: portfolio.data,
positionSections: toAccountPagePositionSections( positionSections: toAccountPagePositionSections(
portfolio.data, portfolio.data,
stockDetails, instrumentDetails,
), ),
recentOperations, recentOperations,
recentEvents, recentEvents,
@@ -182,7 +183,7 @@ function toRecentEvents(
function toAccountPagePositionSections( function toAccountPagePositionSections(
portfolio: Portfolio, portfolio: Portfolio,
stockDetails: ReturnType<typeof useStocks> = new Map(), instrumentDetails: Map<string, { slug: string } | undefined> = new Map(),
): AccountPagePositionSection[] { ): AccountPagePositionSection[] {
const denominators = new Map<string, number>(); const denominators = new Map<string, number>();
for (const position of portfolio.positions) { for (const position of portfolio.positions) {
@@ -193,9 +194,14 @@ function toAccountPagePositionSections(
(denominators.get(currency) ?? 0) + Number(position.currentValue.value), (denominators.get(currency) ?? 0) + Number(position.currentValue.value),
); );
} }
const stocks = toSection("stocks", portfolio, denominators, stockDetails); const stocks = toSection(
const bonds = toSection("bonds", portfolio, denominators, stockDetails); "stocks",
const other = toSection("other", portfolio, denominators, stockDetails); portfolio,
denominators,
instrumentDetails,
);
const bonds = toSection("bonds", portfolio, denominators, instrumentDetails);
const other = toSection("other", portfolio, denominators, instrumentDetails);
return [stocks, bonds, other].filter((section) => section.groups.length > 0); return [stocks, bonds, other].filter((section) => section.groups.length > 0);
} }
@@ -203,7 +209,7 @@ function toSection(
kind: PositionSectionKind, kind: PositionSectionKind,
portfolio: Portfolio, portfolio: Portfolio,
denominators: Map<string, number>, denominators: Map<string, number>,
stockDetails: ReturnType<typeof useStocks>, instrumentDetails: Map<string, { slug: string } | undefined>,
): AccountPagePositionSection { ): AccountPagePositionSection {
return { return {
kind, kind,
@@ -223,7 +229,7 @@ function toSection(
position, position,
denominators, denominators,
kind, kind,
stockDetails.get(position.instrumentUid ?? "")?.slug, instrumentDetails.get(position.instrumentUid ?? "")?.slug,
), ),
), ),
), ),
@@ -265,7 +271,7 @@ function toPosition(
position: Portfolio["positions"][number], position: Portfolio["positions"][number],
denominators: Map<string, number>, denominators: Map<string, number>,
kind: PositionSectionKind, kind: PositionSectionKind,
stockSlug: string | undefined, instrumentSlug: string | undefined,
): AccountPagePosition { ): AccountPagePosition {
const expanded = kind !== "other"; const expanded = kind !== "other";
const returnValue = position.absoluteReturn const returnValue = position.absoluteReturn
@@ -279,9 +285,9 @@ function toPosition(
: "negative"; : "negative";
return { return {
...position, ...position,
stockTo: instrumentTo:
kind === "stocks" && position.instrumentUid (kind === "stocks" || kind === "bonds") && position.instrumentUid
? `/stocks/${encodeURIComponent(stockSlug ?? position.instrumentUid)}` ? `/${kind}/${encodeURIComponent(instrumentSlug ?? position.instrumentUid)}`
: undefined, : undefined,
shareText: position.currentValue?.currency shareText: position.currentValue?.currency
? formatShare(position.currentValue, { ? formatShare(position.currentValue, {
@@ -30,7 +30,7 @@ const PositionCard: FC<PositionCardProps> = (props) => {
value: position.returnText, value: position.returnText,
}) })
: null; : null;
const stockTo = position.stockTo; const instrumentTo = position.instrumentTo;
const identifier = const identifier =
kind === "stocks" ? (position.ticker ?? position.isin) : position.isin; kind === "stocks" ? (position.ticker ?? position.isin) : position.isin;
const identifierText = [ const identifierText = [
@@ -69,11 +69,11 @@ const PositionCard: FC<PositionCardProps> = (props) => {
key={position.logoSrc ?? "no-logo"} key={position.logoSrc ?? "no-logo"}
src={position.logoSrc} src={position.logoSrc}
label={position.displayName} label={position.displayName}
to={stockTo} to={instrumentTo}
/> />
<Stack gap={2} miw={0} className={classes.expandedIdentity}> <Stack gap={2} miw={0} className={classes.expandedIdentity}>
{stockTo ? ( {instrumentTo ? (
<Anchor component={Link} to={stockTo} c="inherit" fw={600}> <Anchor component={Link} to={instrumentTo} c="inherit" fw={600}>
{position.displayName} {position.displayName}
</Anchor> </Anchor>
) : ( ) : (
@@ -19,7 +19,7 @@ const PositionGroupCard: FC<PositionGroupCardProps> = (props) => {
const { group, kind } = props; const { group, kind } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const identity = group.positions[0]; const identity = group.positions[0];
const stockTo = identity.stockTo; const instrumentTo = identity.instrumentTo;
return ( return (
<Card className={classes.tile} withBorder> <Card className={classes.tile} withBorder>
<Group wrap="nowrap" gap="sm"> <Group wrap="nowrap" gap="sm">
@@ -28,12 +28,12 @@ const PositionGroupCard: FC<PositionGroupCardProps> = (props) => {
key={identity.logoSrc ?? "no-logo"} key={identity.logoSrc ?? "no-logo"}
src={identity.logoSrc} src={identity.logoSrc}
label={identity.displayName} label={identity.displayName}
to={stockTo} to={instrumentTo}
/> />
) : null} ) : null}
<Stack gap={2} miw={0}> <Stack gap={2} miw={0}>
{stockTo ? ( {instrumentTo ? (
<Anchor component={Link} to={stockTo} c="inherit" fw={600}> <Anchor component={Link} to={instrumentTo} c="inherit" fw={600}>
{identity.displayName} {identity.displayName}
</Anchor> </Anchor>
) : ( ) : (
+1
View File
@@ -0,0 +1 @@
export { BondPage } from "./ui/BondPage";
+47
View File
@@ -0,0 +1,47 @@
.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;
}
.companyMetrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(135px, 1fr));
gap: var(--mantine-spacing-lg);
}
.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);
}
@media (max-width: 48em) {
.section {
padding: var(--mantine-spacing-md);
}
.companyMetrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
+357
View File
@@ -0,0 +1,357 @@
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 { BondPage } from "./BondPage";
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 bond = {
instrumentUid: uid,
name: "Тестовая облигация",
ticker: "TEST",
nominal: money("500"),
initialNominal: money("1000"),
aci: money("12"),
maturityDate: "2030-05-15",
callDate: null,
couponQuantityPerYear: 4,
floatingCoupon: true,
amortization: true,
perpetual: false,
subordinated: false,
isin: "TESTISIN",
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 = {
pricePercent: "98.125",
cleanPrice: money("490.625"),
aci: money("12"),
dirtyPrice: money("502.625"),
priceAt: "2026-09-10T10:00:00Z",
tradingStatus: null,
changePercentagePoints: "-0.875",
previousCloseDate: "2026-09-09",
unavailable: [],
observedAt: "2026-09-10T10:00:00Z",
};
function setup(
options: {
noPosition?: boolean;
failCoupons?: boolean;
ratingState?: "active" | "withdrawn" | "missing" | "unavailable";
failBond?: boolean;
path?: string;
slug?: string;
ambiguousTicker?: boolean;
} = {},
) {
const requests: URL[] = [];
const resolved = { ...bond, slug: options.slug ?? uid };
let failQuote = false;
server.use(
http.get("*/api/v1/bonds/:uid", () =>
options.failBond
? HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 })
: HttpResponse.json(resolved),
),
http.get("*/api/v1/bonds/by-ticker/:ticker", ({ request }) => {
requests.push(new URL(request.url));
return options.ambiguousTicker
? HttpResponse.json({ code: "BOND_TICKER_AMBIGUOUS" }, { status: 409 })
: HttpResponse.json(resolved);
}),
http.get("*/api/v1/bonds/:uid/:section", ({ params, request }) => {
requests.push(new URL(request.url));
if (params.section === "metrics")
return HttpResponse.json({
creditRating:
options.ratingState === "missing" ||
options.ratingState === "unavailable"
? null
: {
agency: "NCR",
value: options.ratingState === "withdrawn" ? null : "AAA.ru",
status:
options.ratingState === "withdrawn"
? "withdrawn"
: "active",
date: "2026-04-17",
sourceUrl: "https://ratings.ru/ratings/issues/123/",
observedAt: quote.observedAt,
},
coupon: money("32.88"),
couponDate: "2026-10-27",
couponType: "COUPON_TYPE_FLOATING",
couponPeriodDays: 90,
couponYieldPercent: "27.18",
couponYieldPriceAt: quote.priceAt,
effectiveYield: {
value: "15.6262",
pricePercent: "53.384",
date: "2030-05-15",
dateType: "MATDATE",
tradeAt: quote.priceAt,
},
unavailable: options.ratingState === "unavailable" ? ["rating"] : [],
observedAt: quote.observedAt,
});
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: [
{ time: "2026-09-09T00:00:00Z", close: "99.25", isComplete: true },
],
currency: null,
historyFrom: null,
observedAt: quote.observedAt,
});
if (options.failCoupons)
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
return HttpResponse.json({
coupons: [
{
perBond: money("32.88"),
recordDate: "2026-10-13",
number: "1",
couponType: "COUPON_TYPE_FLOATING",
periodDays: 90,
paymentDate: "2026-10-27",
timing: "upcoming",
estimatedAmount: options.noPosition ? null : money("328.8"),
},
{
perBond: money("20"),
recordDate: "2025-10-13",
paymentDate: null,
number: "2",
couponType: null,
periodDays: 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_COUPON",
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 ?? `/bonds/${uid}`]}>
<LocationProbe />
<Routes>
<Route path="/bonds/:bondId" element={<BondPage />} />
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
return {
requests,
failQuote: () => {
failQuote = true;
},
};
}
describe("bond page", () => {
it("opens a direct ticker, keeps percentages distinct from money, and filters personal history by UID", async () => {
const { requests } = setup({ path: "/bonds/TEST", slug: "TEST" });
expect(
await screen.findByRole("heading", { name: "Тестовая облигация" }),
).toBeVisible();
expect(await screen.findByText("98,125 %")).toBeVisible();
expect(screen.getByText("490,625 ₽")).toBeVisible();
expect(screen.getByText("502,625 ₽")).toBeVisible();
expect(screen.getByText("Плавающий купон")).toBeVisible();
expect(screen.getByText("15.05.2030")).toBeVisible();
expect(await screen.findByText("15,6262 %")).toBeVisible();
expect(screen.getByText("AAA.ru")).toBeVisible();
expect(
screen.getByRole("link", { name: /НКР · рейтинг выпуска/ }),
).toHaveAttribute("href", "https://ratings.ru/ratings/issues/123/");
expect(screen.getByText("27,18 %")).toBeVisible();
expect(screen.getByText(/MOEX · к погашению/)).toBeVisible();
expect(await screen.findByText("328,8 ₽")).toBeVisible();
await waitFor(() =>
expect(
requests.some((url) => url.searchParams.get("instrumentUid") === uid),
).toBe(true),
);
expect(
requests.some((url) => url.pathname === `/api/v1/bonds/${uid}/quote`),
).toBe(true);
fireEvent.click(screen.getByRole("button", { name: /Цены в таблице/ }));
await waitFor(() => expect(screen.getByText("99,25 %")).toBeVisible());
expect(screen.queryByText("99,25 ₽")).not.toBeInTheDocument();
fireEvent.click(
screen.getByRole("button", { name: "Прошедшие и недатированные купоны" }),
);
await waitFor(() => expect(screen.getByText("20 ₽")).toBeVisible());
});
it.each([
["withdrawn", "Отозван"],
["missing", "В реестре НКР не найден"],
["unavailable", "Источник недоступен"],
] as const)(
"distinguishes %s credit rating while keeping yield visible",
async (ratingState, expected) => {
setup({ ratingState });
expect(await screen.findByText(expected)).toBeVisible();
expect(screen.queryByText("AAA.ru")).not.toBeInTheDocument();
expect(screen.getByText("15,6262 %")).toBeVisible();
},
);
it("canonicalizes a UID link while preserving period and event filters", async () => {
setup({ path: `/bonds/${uid}?period=year&events=trades`, slug: "TEST" });
await waitFor(() =>
expect(screen.getByTestId("location")).toHaveTextContent(
"/bonds/TEST?period=year&events=trades",
),
);
});
it("keeps market content and sold-position history when coupons fail", async () => {
setup({ noPosition: true, failCoupons: true });
await screen.findByText("98,125 %");
expect(await screen.findByRole("alert")).toHaveTextContent(
"Не удалось загрузить этот раздел",
);
expect(
screen.queryByRole("heading", { name: "Моя позиция" }),
).not.toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Моя история событий" }),
).toBeVisible();
});
it("preserves cached quote on refresh failure", async () => {
const controls = setup();
await screen.findByText("98,125 %");
controls.failQuote();
fireEvent.click(screen.getByRole("button", { name: "Обновить цену" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
"последние полученные",
);
expect(screen.getByText("98,125 %")).toBeVisible();
});
it("offers search for an ambiguous or missing instrument without loading personal data", async () => {
const { requests } = setup({ path: "/bonds/SAME", ambiguousTicker: true });
expect(
await screen.findByText(/Тикер соответствует нескольким выпускам/),
).toBeVisible();
expect(
screen.getByRole("link", { name: "К поиску облигаций" }),
).toHaveAttribute("href", "/bonds");
expect(requests.some((url) => url.pathname.endsWith("/position"))).toBe(
false,
);
});
it("resets continuation when selecting coupons and supports changing the chart period", async () => {
const { requests } = setup();
await screen.findByText("98,125 %");
fireEvent.click(await screen.findByRole("button", { name: "Далее" }));
expect(await screen.findByText("Продажа")).toBeVisible();
fireEvent.click(screen.getByRole("radio", { name: "Купоны" }));
await waitFor(() =>
expect(
requests.some(
(url) =>
url.searchParams.get("categories") === "income" &&
!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),
);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { Anchor, Button, Skeleton, Stack, Text, Title } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link, Navigate } from "react-router";
import { LoadingSkeleton } from "@/shared/ui";
import { BondPageView } from "./BondPage.view";
import { useBondPageVm } from "./BondPage.vm";
const BondPage: FC = () => {
const vm = useBondPageVm();
const { t } = useTranslation();
if (vm.canonicalTo) return <Navigate to={vm.canonicalTo} replace />;
if (vm.bondState.loading)
return (
<LoadingSkeleton label={t("bond.loading")}>
<Stack>
<Skeleton h={110} />
<Skeleton h={350} />
<Skeleton h={170} />
</Stack>
</LoadingSkeleton>
);
if (!vm.bond)
return (
<Stack role="alert">
<Title order={1}>{t("bond.unavailable")}</Title>
<Text>
{t(
vm.code === "TBANK_NOT_CONNECTED" ||
vm.code === "INVALID_TBANK_TOKEN"
? "bond.connect"
: vm.code === "BOND_TICKER_AMBIGUOUS"
? "bond.ambiguousTicker"
: "bond.unavailableText",
)}
</Text>
<Anchor component={Link} to="/bonds">
{t("bond.backToSearch")}
</Anchor>
<Anchor component={Link} to="/settings">
{t("accounts.openSettings")}
</Anchor>
<Button variant="default" onClick={vm.bondState.retry}>
{t("accounts.retry")}
</Button>
</Stack>
);
return <BondPageView vm={vm} />;
};
export { BondPage };
+446
View File
@@ -0,0 +1,446 @@
import {
Accordion,
Alert,
Anchor,
Avatar,
Badge,
Button,
Group,
Paper,
Skeleton,
Stack,
Text,
Title,
} from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import {
InstrumentChart,
InstrumentEvents,
InstrumentPosition,
InstrumentSection,
} from "@/features/instrument-details";
import {
formatDate,
formatDecimal,
formatMoney,
formatSnapshotTime,
} from "@/shared/lib";
import { LoadingSkeleton } from "@/shared/ui";
import classes from "./BondPage.module.css";
import type { BondPageVm } from "./BondPage.vm";
type BondPageViewProps = { vm: BondPageVm };
const BondPageView: FC<BondPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
const bond = vm.bond;
if (!bond) 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 src={vm.logoSrc} alt="" size={56} radius="md">
{bond.name[0]}
</Avatar>
<div>
<Title order={1} size="h2">
{bond.name}
</Title>
<Text size="sm" c="dimmed">
{bond.ticker} · {bond.isin}
</Text>
</div>
</div>
<Button
variant="subtle"
onClick={() => {
vm.bondState.retry();
vm.quoteState.retry();
vm.metricsState.retry();
}}
>
{t("stock.refreshPrice")}
</Button>
</Group>
<Group mt="md" gap="xs">
{bond.floatingCoupon ? (
<Badge variant="light">{t("bond.floatingCoupon")}</Badge>
) : null}
{bond.amortization ? (
<Badge variant="light">{t("bond.amortization")}</Badge>
) : null}
{bond.perpetual ? (
<Badge variant="light">{t("bond.perpetual")}</Badge>
) : null}
{bond.subordinated ? (
<Badge variant="light" color="orange">
{t("bond.subordinated")}
</Badge>
) : null}
</Group>
{vm.bondState.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>
) : (
<Stack gap="xs" mt="lg" aria-live="polite">
<Text size="sm" c="dimmed">
{t("bond.percentOfNominal")}
</Text>
<Text className={classes.price}>
{vm.quote?.pricePercent != null
? `${formatDecimal(vm.quote.pricePercent)} %`
: t("bond.unknown")}
</Text>
{vm.quote?.changePercentagePoints != null ? (
<Text fw={600}>
{formatDecimal(vm.quote.changePercentagePoints)}{" "}
{t("bond.percentagePoints")}
</Text>
) : null}
<Text size="xs" c="dimmed">
{vm.quote?.previousCloseDate
? t("stock.changeBase", {
date: formatDate(vm.quote.previousCloseDate),
})
: t("stock.noComparison")}
</Text>
<Group gap="xl" my="sm">
<Metric
label={t("bond.cleanPrice")}
value={formatMoney(vm.quote?.cleanPrice ?? null)}
/>
<Metric
label={t("bond.aci")}
value={formatMoney(vm.quote?.aci ?? null)}
/>
<Metric
label={t("bond.dirtyPrice")}
value={formatMoney(vm.quote?.dirtyPrice ?? null)}
/>
</Group>
<Text size="xs" c="dimmed">
{t("bond.indicativePrice")}
</Text>
<Group>
<Badge color="gray" variant="light">
{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 size="xs" c="dimmed">
{t("stock.partialQuote")}
</Text>
) : null}
</Stack>
)}
{vm.quoteState.refreshing || vm.bondState.refreshing ? (
<Text role="status" size="xs" c="dimmed">
{t("accounts.refreshing")}
</Text>
) : null}
<div className={classes.embeddedSection}>
<Title order={2} size="h3" mb="md">
{t("bond.terms")}
</Title>
<div className={classes.companyMetrics}>
<Metric
label={t("bond.nominal")}
value={formatMoney(bond.nominal)}
/>
<Metric
label={t("bond.initialNominal")}
value={formatMoney(bond.initialNominal)}
/>
<Metric
label={t("bond.maturity")}
value={
bond.perpetual
? t("bond.perpetual")
: bond.maturityDate
? formatDate(bond.maturityDate)
: t("bond.unknown")
}
/>
<Metric
label={t("bond.callDate")}
value={
bond.callDate ? formatDate(bond.callDate) : t("bond.unknown")
}
/>
<Metric
label={t("bond.couponFrequency")}
value={
bond.couponQuantityPerYear != null
? String(bond.couponQuantityPerYear)
: t("bond.unknown")
}
/>
<Metric
label={t("bond.lot")}
value={bond.lot != null ? String(bond.lot) : t("bond.unknown")}
/>
</div>
</div>
</Paper>
<InstrumentSection title={t("bond.metrics")} state={vm.metricsState}>
{vm.metrics ? (
<Stack gap="md">
<div className={classes.companyMetrics}>
<Metric
label={t("bond.creditRating")}
value={
vm.metrics.creditRating
? vm.metrics.creditRating.status === "withdrawn"
? t("bond.ratingWithdrawn")
: (vm.metrics.creditRating.value ?? t("bond.unknown"))
: vm.metrics.unavailable.includes("rating")
? t("bond.ratingUnavailable")
: t("bond.ratingMissing")
}
/>
<Metric
label={t("bond.couponAmount")}
value={formatMoney(vm.metrics.coupon)}
/>
<Metric
label={t("bond.couponType")}
value={t(`bond.couponTypes.${vm.metrics.couponType}`, {
defaultValue: t("bond.unknown"),
})}
/>
<Metric
label={t("bond.couponDate")}
value={
vm.metrics.couponDate
? formatDate(vm.metrics.couponDate)
: t("bond.unknown")
}
/>
<Metric
label={t("bond.effectiveYield")}
value={
vm.metrics.effectiveYield
? `${formatDecimal(vm.metrics.effectiveYield.value)} %`
: t("bond.unknown")
}
/>
<Metric
label={t("bond.couponYield")}
value={
vm.metrics.couponYieldPercent !== null
? `${formatDecimal(vm.metrics.couponYieldPercent)} %`
: t("bond.unknown")
}
/>
</div>
{vm.metrics.creditRating ? (
<Group gap="xs">
<Anchor
href={vm.metrics.creditRating.sourceUrl}
target="_blank"
rel="noopener noreferrer"
size="sm"
>
{t("bond.ratingSource", {
date: formatDate(vm.metrics.creditRating.date),
})}
</Anchor>
<Text size="xs" c="dimmed">
{t("bond.ratingChecked", {
date: formatSnapshotTime(
vm.metrics.creditRating.observedAt,
),
})}
</Text>
</Group>
) : null}
<Text size="xs" c="dimmed">
{t("bond.ratingCoverage")}
</Text>
{vm.metrics.effectiveYield ? (
<Text size="xs" c="dimmed">
{t("bond.yieldSource", {
target: t(
`bond.yieldTargets.${vm.metrics.effectiveYield.dateType}`,
{ defaultValue: t("bond.yieldTargets.unknown") },
),
date: formatDate(vm.metrics.effectiveYield.date),
price: formatDecimal(vm.metrics.effectiveYield.pricePercent),
at: formatSnapshotTime(vm.metrics.effectiveYield.tradeAt),
})}
</Text>
) : (
<Text size="xs" c="dimmed">
{t("bond.yieldUnavailable")}
</Text>
)}
<Text size="xs" c="dimmed">
{t("bond.couponYieldNote")}
</Text>
{vm.metrics.couponYieldPriceAt ? (
<Text size="xs" c="dimmed">
{t("bond.couponYieldPriceAt", {
date: formatSnapshotTime(vm.metrics.couponYieldPriceAt),
})}
</Text>
) : null}
{vm.metrics.unavailable.length ? (
<Text size="xs" c="dimmed">
{t("bond.metricsPartial")}
</Text>
) : null}
</Stack>
) : null}
</InstrumentSection>
<InstrumentChart vm={vm} priceUnit="percent" />
<InstrumentPosition
vm={vm}
returnNote={t("bond.returnNote")}
quantityLabel={t("bond.quantity")}
/>
<InstrumentSection
title={t("bond.upcomingCoupons")}
state={vm.couponsState}
>
{vm.coupons ? (
<Stack gap="md">
{vm.coupons.estimateUnavailable || vm.coupons.positionIsStale ? (
<Alert color="orange">{t("stock.estimateUnavailable")}</Alert>
) : null}
{vm.upcoming.length ? (
vm.upcoming.map((coupon) => (
<Coupon
key={`${coupon.number}:${coupon.paymentDate}`}
coupon={coupon}
/>
))
) : (
<Text c="dimmed">{t("bond.noCoupons")}</Text>
)}
<Text size="xs" c="dimmed">
{t("bond.couponWindow", {
from: formatDate(vm.coupons.from),
to: formatDate(vm.coupons.to),
})}
</Text>
<Text size="xs" c="dimmed">
{t("bond.couponNote")}
</Text>
{vm.coupons.positionAt ? (
<Text size="xs" c="dimmed">
{t("stock.snapshotAt", {
date: formatSnapshotTime(vm.coupons.positionAt),
})}
</Text>
) : null}
<Accordion variant="contained" radius="md">
<Accordion.Item value="history">
<Accordion.Control>{t("bond.couponHistory")}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
{vm.pastCoupons.length ? (
vm.pastCoupons.map((coupon) => (
<Coupon
key={`${coupon.number}:${coupon.paymentDate}`}
coupon={coupon}
/>
))
) : (
<Text c="dimmed">{t("bond.noCoupons")}</Text>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
) : null}
</InstrumentSection>
<InstrumentEvents
vm={vm}
incomeLabel={t("bond.coupons")}
incomeFilter="coupons"
historyNote={t("bond.historyNote")}
/>
</Stack>
);
};
type MetricProps = { label: string; value: string };
const Metric: FC<MetricProps> = (props) => (
<div>
<Text size="xs" c="dimmed">
{props.label}
</Text>
<Text fw={600} size="lg">
{props.value}
</Text>
</div>
);
type CouponProps = {
coupon: NonNullable<BondPageVm["coupons"]>["coupons"][number];
};
const Coupon: FC<CouponProps> = (props) => {
const { coupon } = props;
const { t } = useTranslation();
return (
<Paper withBorder p="md" radius="md">
<Group justify="space-between" align="start">
<div>
<Text fw={600}>
{coupon.paymentDate
? formatDate(coupon.paymentDate)
: t("stock.dateUnknown")}
</Text>
<Text size="xs" c="dimmed">
{t("bond.couponNumber", {
number: coupon.number ?? t("bond.unknown"),
})}
</Text>
</div>
<Metric label={t("bond.perBond")} value={formatMoney(coupon.perBond)} />
{coupon.estimatedAmount ? (
<Metric
label={t("stock.yourEstimate")}
value={formatMoney(coupon.estimatedAmount)}
/>
) : null}
</Group>
<Group mt="sm" gap="md">
<Badge variant="light" color="gray">
{t(`bond.couponTypes.${coupon.couponType}`, {
defaultValue: t("bond.unknown"),
})}
</Badge>
{coupon.periodDays != null ? (
<Text size="xs" c="dimmed">
{t("bond.couponDays", { count: coupon.periodDays })}
</Text>
) : null}
{coupon.recordDate ? (
<Text size="xs" c="dimmed">
{t("stock.record")}: {formatDate(coupon.recordDate)}
</Text>
) : null}
</Group>
</Paper>
);
};
export { BondPageView };
+146
View File
@@ -0,0 +1,146 @@
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useParams, useSearchParams } from "react-router";
import {
type BondPeriod,
useBond,
useBondCandles,
useBondCoupons,
useBondMetrics,
useBondPosition,
useBondQuote,
} from "@/entities/bond";
import {
brokerageOperationsQueryOptions,
type OperationCategory,
useBrokerageOperations,
} from "@/entities/broker-operation";
import { section } from "@/features/instrument-details";
import { apiErrorCode } from "@/shared/api";
import { formatDate, instrumentLogoUrl } from "@/shared/lib";
const periods: BondPeriod[] = ["month", "halfYear", "year", "all"];
const filters = ["all", "trades", "coupons", "other"] as const;
type BondEventFilter = (typeof filters)[number];
const categories: Record<BondEventFilter, OperationCategory[]> = {
all: [],
trades: ["trade"],
coupons: ["income"],
other: ["tax", "fee", "transfer", "other"],
};
function useBondPageVm() {
const { bondId = "" } = useParams();
const bond = useBond(bondId);
const uid = bond.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 = useBondQuote(uid);
const metrics = useBondMetrics(uid);
const candles = useBondCandles(uid, period);
const position = useBondPosition(uid);
const coupons = useBondCoupons(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),
})) ?? [];
return {
canonicalTo:
bond.data?.slug && bond.data.slug !== bondId
? `/bonds/${encodeURIComponent(bond.data.slug)}${params.size ? `?${params.toString()}` : ""}`
: null,
bond: bond.data,
bondState: section(bond),
code: apiErrorCode(bond.error),
logoSrc: instrumentLogoUrl(bond.data?.logoName),
metrics: metrics.data,
metricsState: section(metrics),
quote: quote.data,
quoteState: section(quote),
candles: candles.data,
chartData,
priceRows: chartData.toReversed(),
candlesState: section(candles),
period,
setPeriod: (value: string) => setParam("period", value),
averageLine: null,
hasIncompleteCandle:
candles.data?.candles.some((candle) => !candle.isComplete) ?? false,
position: position.data,
positionState: section(position),
coupons: coupons.data,
couponsState: section(coupons),
upcoming:
coupons.data?.coupons
.filter((event) => event.timing === "upcoming")
.sort((a, b) =>
(a.paymentDate ?? a.recordDate ?? "").localeCompare(
b.paymentDate ?? b.recordDate ?? "",
),
) ?? [],
pastCoupons:
coupons.data?.coupons
.filter((event) => event.timing !== "upcoming")
.sort((a, b) =>
(b.paymentDate ?? b.recordDate ?? "").localeCompare(
a.paymentDate ?? a.recordDate ?? "",
),
) ?? [],
operations: operations.data,
operationsState: { ...section(operations), retry: retryOperations },
eventRows:
operations.data?.items.map((item) => ({
...item,
ticker: item.ticker ?? bond.data?.ticker ?? null,
logoName: item.logoName ?? bond.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 BondPageVm = ReturnType<typeof useBondPageVm>;
export { type BondPageVm, useBondPageVm };
+1
View File
@@ -0,0 +1 @@
export { BondsPage } from "./ui/BondsPage";
+36
View File
@@ -0,0 +1,36 @@
.card {
display: grid;
grid-template-columns: auto minmax(0, 1fr) minmax(150px, 210px);
gap: var(--mantine-spacing-md);
align-items: start;
padding: var(--mantine-spacing-lg);
border-radius: var(--mantine-radius-lg);
color: inherit;
text-decoration: none;
}
.card:hover {
border-color: var(--mantine-color-blue-4);
background: var(--mantine-color-blue-0);
}
.card:focus-visible {
outline: 2px solid var(--mantine-color-blue-6);
outline-offset: 3px;
}
.identity {
min-width: 0;
overflow-wrap: anywhere;
}
.quote {
text-align: right;
font-variant-numeric: tabular-nums;
}
@media (max-width: 40em) {
.card {
grid-template-columns: auto minmax(0, 1fr);
padding: var(--mantine-spacing-md);
}
.quote {
grid-column: 2;
text-align: left;
}
}
+66
View File
@@ -0,0 +1,66 @@
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 { BondsPage } from "./BondsPage";
describe("bond 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/bonds", ({ request }) => {
query(new URL(request.url).searchParams.get("query"));
return HttpResponse.json([
{
name: "ОФЗ",
ticker: "SU26238RMFS4",
instrumentUid: "unique-bond",
slug: "SU26238RMFS4",
logoName: "sber.png",
classCode: "TQOB",
isin: "RU0009029540",
realExchange: "REAL_EXCHANGE_MOEX",
shareType: "SHARE_TYPE_COMMON",
apiTradeAvailable: true,
pricePercent: "98.125",
maturityDate: "2030-05-15",
perpetual: false,
priceAt: "2026-09-10T10:00:00Z",
},
]);
}),
);
configureAuthClient({
accessToken: () => "synthetic",
refresh: async () => false,
clear: vi.fn(),
});
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider>
<MemoryRouter>
<BondsPage />
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
expect(query).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText("Поиск облигаций"), {
target: { value: "SU26238RMFS4" },
});
expect(await screen.findByRole("link", { name: /ОФЗ/ })).toHaveAttribute(
"href",
"/bonds/SU26238RMFS4",
);
expect(screen.getByText("98,125 %")).toBeVisible();
expect(screen.getByText("Режим: TQOB")).toBeVisible();
expect(screen.getByText("ISIN: RU0009029540")).toBeVisible();
expect(screen.getByText("Мосбиржа")).toBeVisible();
await waitFor(() => expect(query).toHaveBeenCalledWith("SU26238RMFS4"));
});
});
+7
View File
@@ -0,0 +1,7 @@
import type { FC } from "react";
import { BondsPageView } from "./BondsPage.view";
import { useBondsPageVm } from "./BondsPage.vm";
const BondsPage: FC = () => <BondsPageView vm={useBondsPageVm()} />;
export { BondsPage };
+176
View File
@@ -0,0 +1,176 @@
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 {
formatDate,
formatDecimal,
formatMoney,
formatSnapshotTime,
} from "@/shared/lib";
import { LoadingSkeleton } from "@/shared/ui";
import classes from "./BondsPage.module.css";
import type { BondsPageVm } from "./BondsPage.vm";
type BondsPageViewProps = { vm: BondsPageVm };
const BondsPageView: FC<BondsPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
return (
<Stack maw={860} mx="auto" gap="lg">
<div>
<Title order={1}>{t("bond.searchTitle")}</Title>
<Text c="dimmed" mt="xs">
{t("bond.searchSubtitle")}
</Text>
</div>
<TextInput
label={t("bond.searchLabel")}
placeholder={t("bond.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"
? "bond.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("bond.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("bond.searchVariants")}
</Text>
{vm.items.map((bond) => (
<Paper
component={Link}
to={bond.to}
className={classes.card}
withBorder
key={bond.instrumentUid}
>
<Avatar src={bond.logoSrc} alt="" radius="md" size={48}>
{bond.name[0]}
</Avatar>
<div className={classes.identity}>
<Text fw={600} size="lg">
{bond.name}
</Text>
<Text size="sm" c="dimmed">
{bond.ticker} ·{" "}
{bond.perpetual
? t("bond.perpetual")
: t("bond.matures", {
date: bond.maturityDate
? formatDate(bond.maturityDate)
: t("bond.unknown"),
})}
</Text>
<Group gap="xs" mt="sm">
<Badge variant="light" color="gray">
{t(`stock.exchanges.${bond.realExchange}`, {
defaultValue: t("stock.exchangeUnknown"),
})}
</Badge>
{bond.classCode ? (
<Text size="xs" c="dimmed">
{t("stock.classCode", { code: bond.classCode })}
</Text>
) : null}
</Group>
{bond.isin ? (
<Text size="xs" c="dimmed" mt={4}>
{t("stock.isin", { value: bond.isin })}
</Text>
) : null}
<Text size="xs" c="dimmed" mt={4}>
{t("bond.nominal")}: {formatMoney(bond.nominal)} ·{" "}
{t("bond.couponFrequency")}:{" "}
{bond.couponQuantityPerYear ?? t("bond.unknown")}
</Text>
{bond.floatingCoupon ? (
<Badge variant="light" mt="xs">
{t("bond.floatingCoupon")}
</Badge>
) : null}
{bond.apiTradeAvailable === false ? (
<Text size="xs" c="dimmed" mt={4}>
{t("stock.apiUnavailable")}
</Text>
) : null}
</div>
<div className={classes.quote}>
<Text size="xs" c="dimmed">
{t("bond.percentOfNominal")}
</Text>
<Text fw={650} size="xl">
{bond.pricePercent !== null
? `${formatDecimal(bond.pricePercent)} %`
: t("bond.unknown")}
</Text>
<Text size="xs" c="dimmed">
{bond.pricePercent !== null && bond.priceAt
? t("stock.priceAt", {
date: formatSnapshotTime(bond.priceAt),
})
: t("stock.priceUnavailable")}
</Text>
{bond.apiTradeAvailable === true ? (
<Text size="xs" c="green.8" mt="sm">
{t("stock.apiAvailable")}
</Text>
) : null}
</div>
</Paper>
))}
</Stack>
) : (
<Text c="dimmed">{t("bond.noSearchResults")}</Text>
)
) : null}
</Stack>
);
};
export { BondsPageView };
+33
View File
@@ -0,0 +1,33 @@
import { useDebouncedValue } from "@mantine/hooks";
import { useSearchParams } from "react-router";
import { useBondSearch } from "@/entities/bond";
import { apiErrorCode } from "@/shared/api";
import { instrumentLogoUrl } from "@/shared/lib";
function useBondsPageVm() {
const [params, setParams] = useSearchParams();
const query = params.get("q") ?? "";
const [debounced] = useDebouncedValue(query.trim(), 300);
const result = useBondSearch(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((bond) => ({
...bond,
to: `/bonds/${encodeURIComponent(bond.slug ?? bond.instrumentUid)}`,
logoSrc: instrumentLogoUrl(bond.logoName),
})) ?? [],
};
}
type BondsPageVm = ReturnType<typeof useBondsPageVm>;
export { type BondsPageVm, useBondsPageVm };
+5 -56
View File
@@ -28,30 +28,6 @@
.negative { .negative {
color: var(--mantine-color-red-8); 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 { .dividend {
padding: var(--mantine-spacing-md); padding: var(--mantine-spacing-md);
background: var(--mantine-color-green-0); background: var(--mantine-color-green-0);
@@ -62,30 +38,6 @@
grid-template-columns: repeat(auto-fit, minmax(135px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(135px, 1fr));
gap: var(--mantine-spacing-lg); 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 { .embeddedSection {
margin-top: var(--mantine-spacing-lg); margin-top: var(--mantine-spacing-lg);
padding-top: var(--mantine-spacing-lg); padding-top: var(--mantine-spacing-lg);
@@ -96,14 +48,11 @@
background: var(--wl-color-subtle); background: var(--wl-color-subtle);
border-radius: var(--mantine-radius-md); border-radius: var(--mantine-radius-md);
} }
.priceTable { @media (max-width: 48em) {
margin-top: var(--mantine-spacing-md); .section {
padding: var(--mantine-spacing-md);
} }
.priceTable table { .companyMetrics {
font-variant-numeric: tabular-nums; grid-template-columns: repeat(2, minmax(0, 1fr));
white-space: nowrap;
} }
.priceTable th:nth-child(2),
.priceTable td:nth-child(2) {
text-align: right;
} }
+7 -4
View File
@@ -12,6 +12,12 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import type { FC } from "react"; import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import {
InstrumentChart as StockChart,
InstrumentEvents as StockEvents,
InstrumentPosition as StockPosition,
InstrumentSection as StockSection,
} from "@/features/instrument-details";
import { import {
formatDate, formatDate,
formatDecimal, formatDecimal,
@@ -19,10 +25,7 @@ import {
formatSnapshotTime, formatSnapshotTime,
} from "@/shared/lib"; } from "@/shared/lib";
import { LoadingSkeleton } from "@/shared/ui"; import { LoadingSkeleton } from "@/shared/ui";
import { StockChart } from "./components/StockChart.view"; import { StockDividends } from "./components/StockEvents.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 classes from "./StockPage.module.css";
import type { StockPageVm } from "./StockPage.vm"; import type { StockPageVm } from "./StockPage.vm";
+2 -24
View File
@@ -15,32 +15,10 @@ import {
useStockPosition, useStockPosition,
useStockQuote, useStockQuote,
} from "@/entities/stock"; } from "@/entities/stock";
import { section } from "@/features/instrument-details";
import { apiErrorCode } from "@/shared/api"; import { apiErrorCode } from "@/shared/api";
import { formatDate, instrumentLogoUrl } from "@/shared/lib"; 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 periods: StockPeriod[] = ["month", "halfYear", "year", "all"];
const filters = ["all", "trades", "dividends", "other"] as const; const filters = ["all", "trades", "dividends", "other"] as const;
type StockEventFilter = (typeof filters)[number]; type StockEventFilter = (typeof filters)[number];
@@ -172,4 +150,4 @@ function useStockPageVm() {
} }
type StockPageVm = ReturnType<typeof useStockPageVm>; type StockPageVm = ReturnType<typeof useStockPageVm>;
export { type SectionState, type StockPageVm, useStockPageVm }; export { type StockPageVm, useStockPageVm };
@@ -2,20 +2,17 @@ import {
Accordion, Accordion,
Alert, Alert,
Badge, Badge,
Button,
Group, Group,
Paper, Paper,
SegmentedControl,
Stack, Stack,
Text, Text,
} from "@mantine/core"; } from "@mantine/core";
import type { FC } from "react"; import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { LedgerEntryRow } from "@/entities/broker-operation"; import { InstrumentSection as StockSection } from "@/features/instrument-details";
import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib"; import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib";
import classes from "../StockPage.module.css"; import classes from "../StockPage.module.css";
import type { StockPageVm } from "../StockPage.vm"; import type { StockPageVm } from "../StockPage.vm";
import { StockSection } from "./StockSection.view";
type StockEventsProps = { vm: StockPageVm }; type StockEventsProps = { vm: StockPageVm };
const StockDividends: FC<StockEventsProps> = (props) => { const StockDividends: FC<StockEventsProps> = (props) => {
@@ -167,67 +164,5 @@ const StockDividends: FC<StockEventsProps> = (props) => {
</StockSection> </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 }; export { StockDividends };
+817
View File
@@ -212,6 +212,134 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/bonds/{uid}/metrics": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["BondController_metrics"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/bonds": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["BondController_search"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/bonds/by-ticker/{ticker}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["BondController_getByTicker"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/bonds/{uid}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["BondController_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/bonds/{uid}/quote": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["BondController_quote"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/bonds/{uid}/candles": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["BondController_candles"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/bonds/{uid}/position": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["BondController_position"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/bonds/{uid}/coupons": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["BondController_coupons"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/tbank-connection": { "/api/v1/tbank-connection": {
parameters: { parameters: {
query?: never; query?: never;
@@ -582,6 +710,165 @@ export interface components {
estimateUnavailable: boolean; estimateUnavailable: boolean;
positionIsStale: boolean; positionIsStale: boolean;
}; };
BondCreditRatingDto: {
/** @enum {string} */
agency: "NCR";
value: string | null;
/** @enum {string} */
status: "active" | "withdrawn";
/** Format: date */
date: string;
/** Format: uri */
sourceUrl: string;
/** Format: date-time */
observedAt: string;
};
BondYieldDto: {
/** @description MOEX effective annual yield in percent. */
value: string;
pricePercent: string;
/** Format: date */
date: string;
/** @description MOEX yield target, e.g. MATDATE or OFFERDATE. */
dateType: string | null;
/** Format: date-time */
tradeAt: string;
};
BondMetricsDto: {
creditRating: components["schemas"]["BondCreditRatingDto"] | null;
coupon: components["schemas"]["MoneyDto"] | null;
/** Format: date */
couponDate: string | null;
couponType: string | null;
couponPeriodDays: number | null;
/** @description Next coupon × 365 / period days / indicative clean price × 100. Not yield to maturity. */
couponYieldPercent: string | null;
/** Format: date-time */
couponYieldPriceAt: string | null;
effectiveYield: components["schemas"]["BondYieldDto"] | null;
unavailable: string[];
/** Format: date-time */
observedAt: string;
};
BondSearchResultDto: {
/** 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;
sector: string | null;
lot: number | null;
/** Format: date-time */
historyFrom: string | null;
nominal: components["schemas"]["MoneyDto"] | null;
initialNominal: components["schemas"]["MoneyDto"] | null;
aci: components["schemas"]["MoneyDto"] | null;
maturityDate: string | null;
callDate: string | null;
bondType: string | null;
riskLevel: string | null;
couponQuantityPerYear: number | null;
floatingCoupon: boolean | null;
perpetual: boolean | null;
amortization: boolean | null;
subordinated: boolean | null;
/** @description Clean price in percent of nominal, not money. */
pricePercent: string | null;
/** Format: date-time */
priceAt: string | null;
};
BondDto: {
/** 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;
sector: string | null;
lot: number | null;
/** Format: date-time */
historyFrom: string | null;
nominal: components["schemas"]["MoneyDto"] | null;
initialNominal: components["schemas"]["MoneyDto"] | null;
aci: components["schemas"]["MoneyDto"] | null;
maturityDate: string | null;
callDate: string | null;
bondType: string | null;
riskLevel: string | null;
couponQuantityPerYear: number | null;
floatingCoupon: boolean | null;
perpetual: boolean | null;
amortization: boolean | null;
subordinated: boolean | null;
};
BondQuoteDto: {
pricePercent: string | null;
/** @description Indicative clean value at the current nominal; no FX conversion. */
cleanPrice: components["schemas"]["MoneyDto"] | null;
aci: components["schemas"]["MoneyDto"] | null;
/** @description Indicative clean value plus current accrued interest, excluding fees. */
dirtyPrice: components["schemas"]["MoneyDto"] | null;
changePercentagePoints: string | null;
previousCloseDate: string | null;
tradingStatus: string | null;
/** Format: date-time */
priceAt: string | null;
unavailable: string[];
/** Format: date-time */
observedAt: string;
};
BondCandlesDto: {
candles: components["schemas"]["StockCandleDto"][];
currency: string | null;
/** Format: date-time */
historyFrom: string | null;
/** Format: date-time */
observedAt: string;
};
BondPositionDto: {
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;
};
BondCouponDto: {
number: string | null;
paymentDate: string | null;
recordDate: string | null;
couponType: string | null;
periodDays: number | null;
perBond: components["schemas"]["MoneyDto"] | null;
estimatedAmount: components["schemas"]["MoneyDto"] | null;
/** @enum {string} */
timing: "upcoming" | "past" | "undated";
};
BondCouponsDto: {
coupons: components["schemas"]["BondCouponDto"][];
from: string;
to: string;
observedAt: string;
positionAt: string | null;
estimateUnavailable: boolean;
positionIsStale: boolean;
};
TbankConnectionStatusDto: { TbankConnectionStatusDto: {
connected: boolean; connected: boolean;
/** Format: date-time */ /** Format: date-time */
@@ -1651,6 +1938,536 @@ export interface operations {
}; };
}; };
}; };
BondController_metrics: {
parameters: {
query?: never;
header?: never;
path: {
uid: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["BondMetricsDto"];
};
};
/** @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"];
};
};
};
};
BondController_search: {
parameters: {
query: {
query: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["BondSearchResultDto"][];
};
};
/** @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"];
};
};
};
};
BondController_getByTicker: {
parameters: {
query?: never;
header?: never;
path: {
ticker: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["BondDto"];
};
};
/** @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"];
};
};
};
};
BondController_get: {
parameters: {
query?: never;
header?: never;
path: {
uid: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["BondDto"];
};
};
/** @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"];
};
};
};
};
BondController_quote: {
parameters: {
query?: never;
header?: never;
path: {
uid: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["BondQuoteDto"];
};
};
/** @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"];
};
};
};
};
BondController_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"]["BondCandlesDto"];
};
};
/** @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"];
};
};
};
};
BondController_position: {
parameters: {
query?: never;
header?: never;
path: {
uid: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["BondPositionDto"];
};
};
/** @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"];
};
};
};
};
BondController_coupons: {
parameters: {
query?: never;
header?: never;
path: {
uid: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["BondCouponsDto"];
};
};
/** @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: { TbankConnectionController_status: {
parameters: { parameters: {
query?: never; query?: never;
File diff suppressed because it is too large Load Diff
+104 -1
View File
@@ -1,7 +1,12 @@
const ru = { const ru = {
translation: { translation: {
appName: "WealthLedger", appName: "WealthLedger",
navigation: { accounts: "Счета", stocks: "Акции", settings: "Настройки" }, navigation: {
accounts: "Счета",
stocks: "Акции",
bonds: "Облигации",
settings: "Настройки",
},
breadcrumbs: { breadcrumbs: {
label: "Хлебные крошки", label: "Хлебные крошки",
accounts: "Брокерские счета", accounts: "Брокерские счета",
@@ -11,6 +16,104 @@ const ru = {
events: "Будущие события", events: "Будущие события",
settings: "Настройки", settings: "Настройки",
}, },
bond: {
instrument: "Облигация",
searching: "Ищем облигации",
noSearchResults:
"Облигации не найдены. Попробуйте другое название, тикер или ISIN.",
searchVariants:
"Одна карточка на выпуск (ISIN). Разные выпуски одного эмитента показаны отдельно.",
chartLabel: "Дневные цены облигации, {{currency}}",
quantity: "Количество облигаций",
historyNote:
"Операции по этой облигации на доступных открытых счетах, включая прошлые годы. Дата сделки — дата операции брокера; количество — исполненная часть. История закрытых счетов не включена.",
couponWindow:
"Календарь выпуска с {{from}} по {{to}}. Личные зачисления показаны в истории событий.",
searchTitle: "Облигации",
searchSubtitle: "Найдите выпуск по названию, тикеру или ISIN.",
searchLabel: "Поиск облигаций",
searchPlaceholder: "Например, ОФЗ или SU26238RMFS4",
loading: "Загрузка облигации",
unavailable: "Облигация недоступна",
unavailableText: "Не удалось найти выпуск или загрузить его данные.",
connect: "Подключите Т-Банк в настройках, чтобы открыть облигации.",
ambiguousTicker:
"Тикер соответствует нескольким выпускам. Выберите облигацию через поиск.",
backToSearch: "К поиску облигаций",
lot: "Размер лота",
unknown: "—",
percentOfNominal: "% от номинала",
chartNote:
"Дневная цена закрытия в процентах от номинала, без НКД. Исторические цены не пересчитаны в деньги по сегодняшнему номиналу.",
terms: "Условия выпуска",
nominal: "Текущий номинал",
initialNominal: "Первоначальный номинал",
maturity: "Погашение",
matures: "Погашение {{date}}",
callDate: "Оферта",
couponFrequency: "Купонов в год",
floatingCoupon: "Плавающий купон",
perpetual: "Бессрочная",
amortization: "С амортизацией",
subordinated: "Субординированная",
aci: "НКД на одну облигацию",
cleanPrice: "Без НКД",
dirtyPrice: "С НКД, без комиссии",
indicativePrice:
"Денежная оценка рассчитана по текущему номиналу и НКД. Котировка может относиться к более ранним торгам.",
percentagePoints: "п. п.",
coupons: "Купоны",
upcomingCoupons: "Ближайшие купоны",
couponHistory: "Прошедшие и недатированные купоны",
couponNumber: "Купон № {{number}}",
perBond: "На одну облигацию",
couponDays: "Купонный период: {{count}} дн.",
couponNote:
"Календарь выпуска, не личные зачисления. Оценка по текущей позиции не подтверждает право на выплату и не учитывает налоги и будущие сделки. Неизвестная сумма не означает нулевой купон.",
returnNote:
"Изменение стоимости открытой позиции по ценам портфеля. Не включает выплаченные купоны и результат продаж; это не доходность к погашению.",
noCoupons: "В выбранном периоде известных купонов нет.",
metrics: "Купон, рейтинг и доходность",
creditRating: "Кредитный рейтинг НКР",
ratingWithdrawn: "Отозван",
ratingUnavailable: "Источник недоступен",
ratingMissing: "В реестре НКР не найден",
ratingSource: "НКР · рейтинг выпуска от {{date}}",
ratingChecked: "Реестр получен {{date}}",
ratingCoverage:
"Источник — реестр эмиссий НКР. Рейтинг сопоставлен по ISIN; отсутствие записи не исключает рейтингов других агентств.",
couponAmount: "Размер ближайшего купона",
couponType: "Тип купона",
couponDate: "Дата выплаты купона",
effectiveYield: "Эффективная доходность, годовых",
couponYield: "Текущая купонная доходность, годовых",
yieldUnavailable:
"MOEX не предоставила доходность для этого выпуска и режима торгов.",
yieldSource:
"MOEX · {{target}} {{date}} · цена расчёта {{price}} % · торги {{at}}. Данные могут поступать с задержкой.",
yieldTargets: {
MATDATE: "к погашению",
OFFERDATE: "к оферте",
PUTOPTIONDATE: "к оферте",
CALLOPTIONDATE: "к досрочному погашению",
unknown: "к дате",
},
couponYieldNote:
"Купонная доходность = ближайший купон × 365 / дней в купонном периоде / цена без НКД × 100 %. Это годовая оценка при сохранении размера купона и номинала, без налогов, комиссий и реинвестирования; для плавающего или переменного купона будущий доход изменится.",
couponYieldPriceAt:
"Для купонной доходности использована котировка Т-Банка на {{date}}.",
metricsPartial:
"Часть показателей временно недоступна. Обновите раздел, чтобы повторить запрос.",
couponTypes: {
COUPON_TYPE_CONSTANT: "Постоянный",
COUPON_TYPE_FLOATING: "Плавающий",
COUPON_TYPE_DISCOUNT: "Дисконтный",
COUPON_TYPE_MORTGAGE: "Ипотечный",
COUPON_TYPE_FIX: "Фиксированный",
COUPON_TYPE_VARIABLE: "Переменный",
COUPON_TYPE_OTHER: "Другой",
},
},
stock: { stock: {
searchTitle: "Акции", searchTitle: "Акции",
searchSubtitle: searchSubtitle:
+1
View File
@@ -15,6 +15,7 @@ const AppShell: FC<AppShellProps> = (props) => {
const items = [ const items = [
{ to: "/accounts", label: t("navigation.accounts") }, { to: "/accounts", label: t("navigation.accounts") },
{ to: "/stocks", label: t("navigation.stocks") }, { to: "/stocks", label: t("navigation.stocks") },
{ to: "/bonds", label: t("navigation.bonds") },
{ to: "/settings", label: t("navigation.settings") }, { to: "/settings", label: t("navigation.settings") },
]; ];