refactor: enforce frontend view model boundaries
This commit is contained in:
@@ -14,9 +14,11 @@ import { BondPage } from "./BondPage";
|
||||
|
||||
const LocationProbe = () => {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<output data-testid="location">
|
||||
{location.pathname}
|
||||
|
||||
{location.search}
|
||||
</output>
|
||||
);
|
||||
@@ -24,8 +26,11 @@ const LocationProbe = () => {
|
||||
|
||||
const uid = "e6123145-9665-43e0-8413-cd61b8aa9b13";
|
||||
const money = (value: string) => ({ currency: "RUB", value });
|
||||
const bond = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
|
||||
const bond
|
||||
= {
|
||||
aci: money("12"),
|
||||
amortization: true,
|
||||
callDate: null,
|
||||
@@ -45,8 +50,9 @@ const bond = (/* eslint-disable unicorn/no-null -- Fixture preserves the product
|
||||
sector: "financial",
|
||||
subordinated: false,
|
||||
ticker: "TEST",
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */);
|
||||
};
|
||||
|
||||
/* eslint-enable unicorn/no-null */
|
||||
const position = {
|
||||
absoluteReturn: money("1000"),
|
||||
accounts: [
|
||||
@@ -67,8 +73,11 @@ const position = {
|
||||
quantity: "10",
|
||||
returnPercent: "50",
|
||||
};
|
||||
const quote = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
{
|
||||
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
|
||||
const quote
|
||||
= {
|
||||
aci: money("12"),
|
||||
changePercentagePoints: "-0.875",
|
||||
cleanPrice: money("490.625"),
|
||||
@@ -79,8 +88,9 @@ const quote = (/* eslint-disable unicorn/no-null -- Fixture preserves the produc
|
||||
pricePercent: "98.125",
|
||||
tradingStatus: null,
|
||||
unavailable: [],
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */);
|
||||
};
|
||||
|
||||
/* eslint-enable unicorn/no-null */
|
||||
function setup(
|
||||
options: {
|
||||
ambiguousTicker?: boolean;
|
||||
@@ -103,14 +113,17 @@ function setup(
|
||||
),
|
||||
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(resource(resolved));
|
||||
}),
|
||||
http.get("*/api/v1/bonds/:uid/metrics", ({ request }) => {
|
||||
requests.push(new URL(request.url));
|
||||
|
||||
return HttpResponse.json(
|
||||
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
resource(
|
||||
{
|
||||
coupon: money("32.88"),
|
||||
couponDate: "2026-10-27",
|
||||
@@ -144,8 +157,9 @@ function setup(
|
||||
observedAt: quote.observedAt,
|
||||
unavailable:
|
||||
options.ratingState === "unavailable" ? ["rating"] : [],
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */)),
|
||||
},
|
||||
/* eslint-enable unicorn/no-null */
|
||||
),
|
||||
);
|
||||
}),
|
||||
http.get("*/api/v1/bonds/:uid/:section", ({ params, request }) => {
|
||||
@@ -165,7 +179,8 @@ function setup(
|
||||
);
|
||||
if (params.section === "candles")
|
||||
return HttpResponse.json(
|
||||
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
resource(
|
||||
{
|
||||
candles: [
|
||||
{
|
||||
@@ -177,16 +192,19 @@ function setup(
|
||||
currency: null,
|
||||
historyFrom: null,
|
||||
observedAt: quote.observedAt,
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */)),
|
||||
},
|
||||
/* eslint-enable unicorn/no-null */
|
||||
),
|
||||
);
|
||||
if (options.failCoupons)
|
||||
return HttpResponse.json(
|
||||
{ code: "TBANK_UNAVAILABLE" },
|
||||
{ status: 503 },
|
||||
);
|
||||
|
||||
return HttpResponse.json(
|
||||
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
resource(
|
||||
{
|
||||
coupons: [
|
||||
{
|
||||
@@ -216,16 +234,19 @@ function setup(
|
||||
positionAt: null,
|
||||
positionIsStale: false,
|
||||
to: "2027-09-10",
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */)),
|
||||
},
|
||||
/* eslint-enable unicorn/no-null */
|
||||
),
|
||||
);
|
||||
}),
|
||||
http.get("*/api/v1/brokerage-accounts/all/operations", ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
requests.push(url);
|
||||
const isNext = url.searchParams.has("cursor");
|
||||
|
||||
return HttpResponse.json(
|
||||
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
resource(
|
||||
{
|
||||
accountId: "all",
|
||||
hasNext: !isNext,
|
||||
@@ -246,8 +267,9 @@ function setup(
|
||||
],
|
||||
nextCursor: isNext ? null : "next",
|
||||
observedAt: quote.observedAt,
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */)),
|
||||
},
|
||||
/* eslint-enable unicorn/no-null */
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
@@ -262,6 +284,7 @@ function setup(
|
||||
<MantineProvider>
|
||||
<MemoryRouter initialEntries={[options.path ?? `/bonds/${uid}`]}>
|
||||
<LocationProbe />
|
||||
|
||||
<Routes>
|
||||
<Route element={<BondPage />} path="/bonds/:bondId" />
|
||||
</Routes>
|
||||
@@ -269,6 +292,7 @@ function setup(
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
return {
|
||||
failQuote: () => {
|
||||
isFailQuote = true;
|
||||
@@ -276,6 +300,7 @@ function setup(
|
||||
requests,
|
||||
};
|
||||
}
|
||||
|
||||
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" });
|
||||
@@ -339,9 +364,7 @@ describe("bond page", () => {
|
||||
const message = await screen.findByText(/Не удалось загрузить этот раздел/);
|
||||
expect(
|
||||
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
|
||||
message.closest(
|
||||
"[role=\"status\"]",
|
||||
),
|
||||
message.closest("[role=\"status\"]"),
|
||||
).toHaveAttribute("aria-live", "polite");
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "Моя позиция" }),
|
||||
@@ -355,6 +378,7 @@ describe("bond page", () => {
|
||||
server.use(
|
||||
http.post("*/api/v1/tbank-sync", async ({ request }) => {
|
||||
scopes.push(await request.json());
|
||||
|
||||
return HttpResponse.json(
|
||||
{ code: "TBANK_UNAVAILABLE" },
|
||||
{ status: 503 },
|
||||
@@ -368,9 +392,7 @@ describe("bond page", () => {
|
||||
const message = await screen.findByText(/последние полученные/);
|
||||
expect(
|
||||
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
|
||||
message.closest(
|
||||
"[role=\"status\"]",
|
||||
),
|
||||
message.closest("[role=\"status\"]"),
|
||||
).toHaveAttribute("aria-live", "polite");
|
||||
expect(screen.getByText("98,125 %")).toBeVisible();
|
||||
expect(scopes).toEqual([
|
||||
|
||||
@@ -19,16 +19,26 @@ const BondPage: FC = () => {
|
||||
<LoadingSkeleton label={t("bond.loading")}>
|
||||
<Stack>
|
||||
<Skeleton h={110} />
|
||||
|
||||
<Skeleton h={350} />
|
||||
|
||||
<Skeleton h={170} />
|
||||
</Stack>
|
||||
</LoadingSkeleton>
|
||||
);
|
||||
const unavailableMessage = isApiErrorCode(vm.code, ApiErrorCode.BondTickerAmbiguous) ? "bond.ambiguousTicker" : "bond.unavailableText";
|
||||
|
||||
const unavailableMessage = isApiErrorCode(
|
||||
vm.code,
|
||||
ApiErrorCode.BondTickerAmbiguous,
|
||||
)
|
||||
? "bond.ambiguousTicker"
|
||||
: "bond.unavailableText";
|
||||
|
||||
if (!vm.bond)
|
||||
return (
|
||||
<Stack role="alert">
|
||||
<Title order={1}>{t("bond.unavailable")}</Title>
|
||||
|
||||
<Text>
|
||||
{t(
|
||||
isApiErrorCode(vm.code, ApiErrorCode.TbankNotConnected)
|
||||
@@ -37,17 +47,21 @@ const BondPage: FC = () => {
|
||||
: unavailableMessage,
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Anchor component={Link} to="/bonds">
|
||||
{t("bond.backToSearch")}
|
||||
</Anchor>
|
||||
|
||||
<Anchor component={Link} to="/settings">
|
||||
{t("accounts.openSettings")}
|
||||
</Anchor>
|
||||
|
||||
<Button onClick={vm.bondState.retry} variant="default">
|
||||
{t("accounts.retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
return <BondPageView vm={vm} />;
|
||||
};
|
||||
|
||||
|
||||
@@ -35,12 +35,113 @@ import type { BondPageVm } from "./BondPage.vm";
|
||||
import classes from "./BondPage.module.css";
|
||||
|
||||
type BondPageViewProps = { vm: BondPageVm };
|
||||
|
||||
const BondPageView: FC<BondPageViewProps> = (props) => {
|
||||
const { vm } = props;
|
||||
const { t } = useTranslation();
|
||||
const bond = vm.bond;
|
||||
if (!bond) return;
|
||||
const maturity = bond.maturityDate ? formatDate(bond.maturityDate) : t("bond.unknown");
|
||||
|
||||
const maturity = bond.maturityDate
|
||||
? formatDate(bond.maturityDate)
|
||||
: t("bond.unknown");
|
||||
|
||||
const maturityDate = bond.perpetual ? t("bond.perpetual") : maturity;
|
||||
|
||||
const callDate = bond.callDate
|
||||
? formatDate(bond.callDate)
|
||||
: t("bond.unknown");
|
||||
|
||||
const couponFrequency
|
||||
= bond.couponQuantityPerYear == undefined
|
||||
? t("bond.unknown")
|
||||
: String(bond.couponQuantityPerYear);
|
||||
|
||||
const lotSize = bond.lot == undefined ? t("bond.unknown") : String(bond.lot);
|
||||
|
||||
let quoteContent;
|
||||
|
||||
if (vm.quoteState.loading) {
|
||||
quoteContent = (
|
||||
<LoadingSkeleton label={t("stock.loadingPrice")}>
|
||||
<Skeleton h={55} mt="lg" w="50%" />
|
||||
</LoadingSkeleton>
|
||||
);
|
||||
}
|
||||
else {
|
||||
quoteContent = (
|
||||
<Stack aria-live="polite" gap="xs" mt="lg">
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("bond.percentOfNominal")}
|
||||
</Text>
|
||||
|
||||
<Text className={classes.price}>
|
||||
{vm.quote?.pricePercent == undefined
|
||||
? t("bond.unknown")
|
||||
: `${formatDecimal(vm.quote.pricePercent)} %`}
|
||||
</Text>
|
||||
|
||||
{vm.quote?.changePercentagePoints != undefined && (
|
||||
<Text fw={600}>
|
||||
{formatDecimal(vm.quote.changePercentagePoints)}{" "}
|
||||
{t("bond.percentagePoints")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{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)}
|
||||
/>
|
||||
|
||||
<Metric
|
||||
label={t("bond.aci")}
|
||||
value={formatMoney(vm.quote?.aci)}
|
||||
/>
|
||||
|
||||
<Metric
|
||||
label={t("bond.dirtyPrice")}
|
||||
value={formatMoney(vm.quote?.dirtyPrice)}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{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 c="dimmed" size="xs">
|
||||
{t("stock.priceAt", {
|
||||
date: formatSnapshotTime(vm.quote.priceAt),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{!!vm.quote?.unavailable.length && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.partialQuote")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack className={classes.page} gap="lg">
|
||||
<Paper className={classes.section} component="header" withBorder>
|
||||
@@ -49,19 +150,18 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
|
||||
<Avatar alt="" radius="md" size={56} src={vm.logoSrc}>
|
||||
{bond.name[0]}
|
||||
</Avatar>
|
||||
|
||||
<div>
|
||||
<Title order={1} size="h2">
|
||||
{bond.name}
|
||||
</Title>
|
||||
|
||||
<Text c="dimmed" size="sm">
|
||||
{bond.ticker}
|
||||
{" "}
|
||||
·
|
||||
{" "}
|
||||
{bond.isin}
|
||||
{bond.ticker} · {bond.isin}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
disabled={vm.refreshing}
|
||||
onClick={vm.refresh}
|
||||
@@ -70,150 +170,80 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
|
||||
{t("investment.refresh")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" mt="md">
|
||||
{!!(bond.floatingCoupon) && ((
|
||||
{!!bond.floatingCoupon && (
|
||||
<Badge variant="light">{t("bond.floatingCoupon")}</Badge>
|
||||
))}
|
||||
{!!(bond.amortization) && ((
|
||||
)}
|
||||
|
||||
{!!bond.amortization && (
|
||||
<Badge variant="light">{t("bond.amortization")}</Badge>
|
||||
))}
|
||||
{!!(bond.perpetual) && ((
|
||||
)}
|
||||
|
||||
{!!bond.perpetual && (
|
||||
<Badge variant="light">{t("bond.perpetual")}</Badge>
|
||||
))}
|
||||
{!!(bond.subordinated) && ((
|
||||
)}
|
||||
|
||||
{!!bond.subordinated && (
|
||||
<Badge color="orange" variant="light">
|
||||
{t("bond.subordinated")}
|
||||
</Badge>
|
||||
))}
|
||||
)}
|
||||
</Group>
|
||||
{!!(vm.refreshError || vm.bondState.error || vm.quoteState.error) && ((
|
||||
|
||||
{!!(vm.refreshError || vm.bondState.error || vm.quoteState.error) && (
|
||||
<Alert aria-live="polite" color="orange" mt="md" role="status">
|
||||
{t(vm.quote ? "accounts.refreshError" : "stock.sectionError")}
|
||||
</Alert>
|
||||
))}
|
||||
{vm.quoteState.loading
|
||||
? (
|
||||
<LoadingSkeleton label={t("stock.loadingPrice")}>
|
||||
<Skeleton h={55} mt="lg" w="50%" />
|
||||
</LoadingSkeleton>
|
||||
)
|
||||
: (
|
||||
<Stack aria-live="polite" gap="xs" mt="lg">
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("bond.percentOfNominal")}
|
||||
</Text>
|
||||
<Text className={classes.price}>
|
||||
{vm.quote?.pricePercent == undefined
|
||||
? t("bond.unknown")
|
||||
: `${formatDecimal(vm.quote.pricePercent)} %`}
|
||||
</Text>
|
||||
{vm.quote?.changePercentagePoints != undefined && ((
|
||||
<Text fw={600}>
|
||||
{formatDecimal(vm.quote.changePercentagePoints)}
|
||||
{" "}
|
||||
{t("bond.percentagePoints")}
|
||||
</Text>
|
||||
))}
|
||||
<Text c="dimmed" size="xs">
|
||||
{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)}
|
||||
/>
|
||||
<Metric
|
||||
label={t("bond.aci")}
|
||||
value={formatMoney(vm.quote?.aci)}
|
||||
/>
|
||||
<Metric
|
||||
label={t("bond.dirtyPrice")}
|
||||
value={formatMoney(vm.quote?.dirtyPrice)}
|
||||
/>
|
||||
</Group>
|
||||
<Text c="dimmed" size="xs">
|
||||
{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 c="dimmed" size="xs">
|
||||
{t("stock.priceAt", {
|
||||
date: formatSnapshotTime(vm.quote.priceAt),
|
||||
})}
|
||||
</Text>
|
||||
))}
|
||||
</Group>
|
||||
{!!(vm.quote?.unavailable.length) && ((
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.partialQuote")}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
{!!(vm.quoteState.refreshing || vm.bondState.refreshing) && ((
|
||||
)}
|
||||
|
||||
{quoteContent}
|
||||
|
||||
{!!(vm.quoteState.refreshing || vm.bondState.refreshing) && (
|
||||
<Text c="dimmed" role="status" size="xs">
|
||||
{t("accounts.refreshing")}
|
||||
</Text>
|
||||
))}
|
||||
)}
|
||||
|
||||
<div className={classes.embeddedSection}>
|
||||
<Title mb="md" order={2} size="h3">
|
||||
{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")
|
||||
: maturity
|
||||
}
|
||||
/>
|
||||
<Metric
|
||||
label={t("bond.callDate")}
|
||||
value={
|
||||
bond.callDate ? formatDate(bond.callDate) : t("bond.unknown")
|
||||
}
|
||||
/>
|
||||
<Metric
|
||||
label={t("bond.couponFrequency")}
|
||||
value={
|
||||
bond.couponQuantityPerYear == undefined
|
||||
? t("bond.unknown")
|
||||
: String(bond.couponQuantityPerYear)
|
||||
}
|
||||
/>
|
||||
<Metric
|
||||
label={t("bond.lot")}
|
||||
value={bond.lot == undefined ? t("bond.unknown") : String(bond.lot)}
|
||||
/>
|
||||
|
||||
<Metric label={t("bond.maturity")} value={maturityDate} />
|
||||
|
||||
<Metric label={t("bond.callDate")} value={callDate} />
|
||||
|
||||
<Metric label={t("bond.couponFrequency")} value={couponFrequency} />
|
||||
|
||||
<Metric label={t("bond.lot")} value={lotSize} />
|
||||
</div>
|
||||
</div>
|
||||
</Paper>
|
||||
|
||||
<BondMetrics vm={vm} />
|
||||
|
||||
<InstrumentChart priceUnit="percent" vm={vm} />
|
||||
|
||||
<InstrumentPosition
|
||||
quantityLabel={t("bond.quantity")}
|
||||
returnNote={t("bond.returnNote")}
|
||||
vm={vm}
|
||||
/>
|
||||
|
||||
<BondCoupons vm={vm} />
|
||||
|
||||
<InstrumentEvents
|
||||
historyNote={t("bond.historyNote")}
|
||||
incomeFilter="coupons"
|
||||
@@ -228,55 +258,87 @@ const BondMetrics: FC<BondPageViewProps> = (props) => {
|
||||
const { vm } = props;
|
||||
const { t } = useTranslation();
|
||||
const creditRating = vm.metrics?.creditRating;
|
||||
let rating = t(vm.metrics?.unavailable.includes("rating") ? "bond.ratingUnavailable" : "bond.ratingMissing");
|
||||
if (creditRating) rating = creditRating.status === "withdrawn" ? t("bond.ratingWithdrawn") : (creditRating.value ?? t("bond.unknown"));
|
||||
|
||||
let rating = t(
|
||||
vm.metrics?.unavailable.includes("rating")
|
||||
? "bond.ratingUnavailable"
|
||||
: "bond.ratingMissing",
|
||||
);
|
||||
|
||||
if (creditRating) {
|
||||
rating
|
||||
= creditRating.status === "withdrawn"
|
||||
? t("bond.ratingWithdrawn")
|
||||
: (creditRating.value ?? t("bond.unknown"));
|
||||
}
|
||||
|
||||
const couponDate = vm.metrics?.couponDate
|
||||
? formatDate(vm.metrics?.couponDate)
|
||||
: t("bond.unknown");
|
||||
|
||||
const effectiveYield = vm.metrics?.effectiveYield
|
||||
? `${formatDecimal(vm.metrics?.effectiveYield.value)} %`
|
||||
: t("bond.unknown");
|
||||
|
||||
const couponYield
|
||||
= vm.metrics?.couponYieldPercent == undefined
|
||||
? t("bond.unknown")
|
||||
: `${formatDecimal(vm.metrics?.couponYieldPercent)} %`;
|
||||
|
||||
let yieldContent;
|
||||
|
||||
if (vm.metrics?.effectiveYield) {
|
||||
yieldContent = (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.yieldSource", {
|
||||
at: formatSnapshotTime(vm.metrics?.effectiveYield.tradeAt),
|
||||
date: formatDate(vm.metrics?.effectiveYield.date),
|
||||
price: formatDecimal(
|
||||
vm.metrics?.effectiveYield.pricePercent,
|
||||
),
|
||||
target: t(
|
||||
`bond.yieldTargets.${vm.metrics?.effectiveYield.dateType}`,
|
||||
{ defaultValue: t("bond.yieldTargets.unknown") },
|
||||
),
|
||||
})}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
else {
|
||||
yieldContent = (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.yieldUnavailable")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InstrumentSection state={vm.metricsState} title={t("bond.metrics")}>
|
||||
{!!(vm.metrics) && ((
|
||||
{!!vm.metrics && (
|
||||
<Stack gap="md">
|
||||
<div className={classes.companyMetrics}>
|
||||
<Metric
|
||||
label={t("bond.creditRating")}
|
||||
value={
|
||||
rating
|
||||
}
|
||||
/>
|
||||
<Metric label={t("bond.creditRating")} value={rating} />
|
||||
|
||||
<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
|
||||
? t("bond.unknown")
|
||||
: `${formatDecimal(vm.metrics.couponYieldPercent)} %`
|
||||
}
|
||||
/>
|
||||
|
||||
<Metric label={t("bond.couponDate")} value={couponDate} />
|
||||
|
||||
<Metric label={t("bond.effectiveYield")} value={effectiveYield} />
|
||||
|
||||
<Metric label={t("bond.couponYield")} value={couponYield} />
|
||||
</div>
|
||||
{!!(vm.metrics.creditRating) && ((
|
||||
|
||||
{!!vm.metrics.creditRating && (
|
||||
<Group gap="xs">
|
||||
<Anchor
|
||||
href={vm.metrics.creditRating.sourceUrl}
|
||||
@@ -288,54 +350,40 @@ const BondMetrics: FC<BondPageViewProps> = (props) => {
|
||||
date: formatDate(vm.metrics.creditRating.date),
|
||||
})}
|
||||
</Anchor>
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.ratingChecked", {
|
||||
date: formatSnapshotTime(
|
||||
vm.metrics.creditRating.observedAt,
|
||||
),
|
||||
date: formatSnapshotTime(vm.metrics.creditRating.observedAt),
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
)}
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.ratingCoverage")}
|
||||
</Text>
|
||||
{vm.metrics.effectiveYield
|
||||
? (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.yieldSource", {
|
||||
at: formatSnapshotTime(vm.metrics.effectiveYield.tradeAt),
|
||||
date: formatDate(vm.metrics.effectiveYield.date),
|
||||
price: formatDecimal(vm.metrics.effectiveYield.pricePercent),
|
||||
target: t(
|
||||
`bond.yieldTargets.${vm.metrics.effectiveYield.dateType}`,
|
||||
{ defaultValue: t("bond.yieldTargets.unknown") },
|
||||
),
|
||||
})}
|
||||
</Text>
|
||||
)
|
||||
: (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.yieldUnavailable")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{yieldContent}
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.couponYieldNote")}
|
||||
</Text>
|
||||
{!!(vm.metrics.couponYieldPriceAt) && ((
|
||||
|
||||
{!!vm.metrics?.couponYieldPriceAt && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.couponYieldPriceAt", {
|
||||
date: formatSnapshotTime(vm.metrics.couponYieldPriceAt),
|
||||
date: formatSnapshotTime(vm.metrics?.couponYieldPriceAt),
|
||||
})}
|
||||
</Text>
|
||||
))}
|
||||
{vm.metrics.unavailable.length > 0 && ((
|
||||
)}
|
||||
|
||||
{vm.metrics.unavailable.length > 0 && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.metricsPartial")}
|
||||
</Text>
|
||||
))}
|
||||
)}
|
||||
</Stack>
|
||||
))}
|
||||
)}
|
||||
</InstrumentSection>
|
||||
);
|
||||
};
|
||||
@@ -343,18 +391,20 @@ const BondMetrics: FC<BondPageViewProps> = (props) => {
|
||||
const BondCoupons: FC<BondPageViewProps> = (props) => {
|
||||
const { vm } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<InstrumentSection
|
||||
state={vm.couponsState}
|
||||
title={t("bond.upcomingCoupons")}
|
||||
>
|
||||
{!!(vm.coupons) && ((
|
||||
{!!vm.coupons && (
|
||||
<Stack gap="md">
|
||||
{!!(vm.coupons.estimateUnavailable || vm.coupons.positionIsStale) && ((
|
||||
{!!(vm.coupons.estimateUnavailable || vm.coupons.positionIsStale) && (
|
||||
<Alert aria-live="polite" color="orange" role="status">
|
||||
{t("stock.estimateUnavailable")}
|
||||
</Alert>
|
||||
))}
|
||||
)}
|
||||
|
||||
{vm.upcoming.length > 0
|
||||
? (
|
||||
vm.upcoming.map(coupon => (
|
||||
@@ -367,25 +417,30 @@ const BondCoupons: FC<BondPageViewProps> = (props) => {
|
||||
: (
|
||||
<Text c="dimmed">{t("bond.noCoupons")}</Text>
|
||||
)}
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.couponWindow", {
|
||||
from: formatDate(vm.coupons.from),
|
||||
to: formatDate(vm.coupons.to),
|
||||
})}
|
||||
</Text>
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.couponNote")}
|
||||
</Text>
|
||||
{!!(vm.coupons.positionAt) && ((
|
||||
|
||||
{!!vm.coupons.positionAt && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.snapshotAt", {
|
||||
date: formatSnapshotTime(vm.coupons.positionAt),
|
||||
})}
|
||||
</Text>
|
||||
))}
|
||||
)}
|
||||
|
||||
<Accordion radius="md" variant="contained">
|
||||
<Accordion.Item value="history">
|
||||
<Accordion.Control>{t("bond.couponHistory")}</Accordion.Control>
|
||||
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
{vm.pastCoupons.length > 0
|
||||
@@ -405,28 +460,33 @@ const BondCoupons: FC<BondPageViewProps> = (props) => {
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</Stack>
|
||||
))}
|
||||
)}
|
||||
</InstrumentSection>
|
||||
);
|
||||
};
|
||||
|
||||
type MetricProps = { label: string; value: string };
|
||||
|
||||
const Metric: FC<MetricProps> = props => (
|
||||
<div>
|
||||
<Text c="dimmed" size="xs">
|
||||
{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 p="md" radius="md" withBorder>
|
||||
<Group align="start" justify="space-between">
|
||||
@@ -436,39 +496,42 @@ const Coupon: FC<CouponProps> = (props) => {
|
||||
? formatDate(coupon.paymentDate)
|
||||
: t("stock.dateUnknown")}
|
||||
</Text>
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.couponNumber", {
|
||||
number: coupon.number ?? t("bond.unknown"),
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Metric label={t("bond.perBond")} value={formatMoney(coupon.perBond)} />
|
||||
{!!(coupon.estimatedAmount) && ((
|
||||
|
||||
{!!coupon.estimatedAmount && (
|
||||
<Metric
|
||||
label={t("stock.yourEstimate")}
|
||||
value={formatMoney(coupon.estimatedAmount)}
|
||||
/>
|
||||
))}
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Group gap="md" mt="sm">
|
||||
<Badge color="gray" variant="light">
|
||||
{t(`bond.couponTypes.${coupon.couponType}`, {
|
||||
defaultValue: t("bond.unknown"),
|
||||
})}
|
||||
</Badge>
|
||||
{coupon.periodDays != undefined && ((
|
||||
|
||||
{coupon.periodDays != undefined && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("bond.couponDays", { count: coupon.periodDays })}
|
||||
</Text>
|
||||
))}
|
||||
{!!(coupon.recordDate) && ((
|
||||
)}
|
||||
|
||||
{!!coupon.recordDate && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.record")}
|
||||
:
|
||||
{" "}
|
||||
{formatDate(coupon.recordDate)}
|
||||
{t("stock.record")}: {formatDate(coupon.recordDate)}
|
||||
</Text>
|
||||
))}
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -21,55 +21,78 @@ 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: [],
|
||||
coupons: ["income"],
|
||||
other: ["tax", "fee", "transfer", "other"],
|
||||
trades: ["trade"],
|
||||
};
|
||||
|
||||
type BondPageVm = ReturnType<typeof useBondPageVm>;
|
||||
|
||||
function useBondPageVm() {
|
||||
const { bondId = "" } = useParams();
|
||||
const bond = useBond(bondId);
|
||||
const uid = bond.data?.instrumentUid ?? "";
|
||||
const [parameters, setParameters] = useSearchParams();
|
||||
|
||||
const period
|
||||
= periods.find(value => value === parameters.get("period")) ?? "month";
|
||||
|
||||
const filter
|
||||
= filters.find(value => value === parameters.get("events")) ?? "all";
|
||||
|
||||
const quote = useBondQuote(uid);
|
||||
const metrics = useBondMetrics(uid);
|
||||
const candles = useBondCandles(uid, period);
|
||||
const position = useBondPosition(uid);
|
||||
const coupons = useBondCoupons(uid);
|
||||
const history = useInstrumentOperations(uid, filter, /* eslint-disable-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. */ categories[filter]);
|
||||
|
||||
const history = useInstrumentOperations(
|
||||
uid,
|
||||
filter,
|
||||
// eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name.
|
||||
categories[filter],
|
||||
);
|
||||
|
||||
const { operations } = history;
|
||||
|
||||
const sync = useScreenSync(
|
||||
{
|
||||
accountId: "all",
|
||||
candlePeriod: period,
|
||||
categories: /* eslint-disable-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. */ categories[filter],
|
||||
categories:
|
||||
// eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name.
|
||||
categories[filter],
|
||||
instrumentUid: uid,
|
||||
limit: 20,
|
||||
screen: "bond",
|
||||
},
|
||||
[bond, quote, candles, position, metrics, coupons, operations],
|
||||
);
|
||||
|
||||
const setParameter = (key: string, value: string) => {
|
||||
const next = new URLSearchParams(parameters);
|
||||
next.set(key, value);
|
||||
setParameters(next, { replace: true });
|
||||
};
|
||||
|
||||
const chartData
|
||||
= candles.data?.candles.map(candle => ({
|
||||
...candle,
|
||||
date: formatDate(candle.time),
|
||||
price: Number(candle.close),
|
||||
})) ?? [];
|
||||
|
||||
const search = parameters.size > 0 ? `?${parameters.toString()}` : "";
|
||||
|
||||
return {
|
||||
averageLine: /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
|
||||
averageLine:
|
||||
// eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
|
||||
null,
|
||||
bond: bond.data,
|
||||
bondState: section(bond, uid ? sync.retry : () => bond.refetch()),
|
||||
candles: candles.data,
|
||||
@@ -77,7 +100,7 @@ function useBondPageVm() {
|
||||
canonicalTo:
|
||||
bond.data?.slug && bond.data.slug !== bondId
|
||||
? `/bonds/${encodeURIComponent(bond.data.slug)}${search}`
|
||||
: /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
|
||||
: null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
|
||||
chartData,
|
||||
code: apiErrorCode(bond.error),
|
||||
coupons: coupons.data,
|
||||
@@ -85,8 +108,16 @@ function useBondPageVm() {
|
||||
eventRows:
|
||||
operations.data?.items.map(item => ({
|
||||
...item,
|
||||
logoName: item.logoName ?? bond.data?.logoName ?? /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
|
||||
ticker: item.ticker ?? bond.data?.ticker ?? /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
|
||||
logoName:
|
||||
item.logoName
|
||||
?? bond.data?.logoName
|
||||
// eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
|
||||
?? null,
|
||||
ticker:
|
||||
item.ticker
|
||||
?? bond.data?.ticker
|
||||
// eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
|
||||
?? null,
|
||||
})) ?? [],
|
||||
filter,
|
||||
hasIncompleteCandle:
|
||||
|
||||
Reference in New Issue
Block a user