refactor: enforce frontend view model boundaries
Frontend verify / commits (pull_request) Successful in 28s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 8m19s

This commit is contained in:
2026-09-28 18:48:23 +03:00
parent 5b69003516
commit ca0ad4f824
287 changed files with 8558 additions and 4538 deletions
+48 -26
View File
@@ -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([
+15 -1
View File
@@ -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} />;
};
+254 -191
View File
@@ -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>
);
+37 -6
View File
@@ -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: