feat: add bond pages and portfolio navigation
This commit is contained in:
@@ -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={
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export {
|
||||||
|
type BondPeriod,
|
||||||
|
useBond,
|
||||||
|
useBondCandles,
|
||||||
|
useBondCoupons,
|
||||||
|
useBondMetrics,
|
||||||
|
useBondPosition,
|
||||||
|
useBondQuote,
|
||||||
|
useBondSearch,
|
||||||
|
} from "./api/queries";
|
||||||
@@ -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 };
|
||||||
+28
-14
@@ -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 };
|
||||||
+15
-11
@@ -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 };
|
||||||
+5
-5
@@ -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" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { BondPage } from "./ui/BondPage";
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { BondsPage } from "./ui/BondsPage";
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
.card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) minmax(150px, 210px);
|
||||||
|
gap: var(--mantine-spacing-md);
|
||||||
|
align-items: start;
|
||||||
|
padding: var(--mantine-spacing-lg);
|
||||||
|
border-radius: var(--mantine-radius-lg);
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.card:hover {
|
||||||
|
border-color: var(--mantine-color-blue-4);
|
||||||
|
background: var(--mantine-color-blue-0);
|
||||||
|
}
|
||||||
|
.card:focus-visible {
|
||||||
|
outline: 2px solid var(--mantine-color-blue-6);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
.identity {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.quote {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
@media (max-width: 40em) {
|
||||||
|
.card {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
padding: var(--mantine-spacing-md);
|
||||||
|
}
|
||||||
|
.quote {
|
||||||
|
grid-column: 2;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
Vendored
+817
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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") },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user