Files
frontend/src/pages/stock/ui/StockPage/StockPage.view.tsx
T
ksv741 7c28a423dd
Frontend verify / commits (pull_request) Successful in 24s
Frontend verify / security (pull_request) Failing after 9s
Frontend verify / verify (pull_request) Skipped
refactor: tighten types and reuse instrument UI
2026-09-29 06:15:31 +03:00

249 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { FC } from "react";
import {
Alert,
Button,
Group,
Skeleton,
Stack,
Text,
} from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import {
instrumentDetailsStyles,
InstrumentHeader,
InstrumentMetric,
InstrumentQuoteStatus,
InstrumentChart as StockChart,
InstrumentEvents as StockEvents,
InstrumentPosition as StockPosition,
InstrumentSection as StockSection,
} from "@/features/instrument-details";
import {
formatDate,
formatDecimal,
formatMoney,
} from "@/shared/lib";
import type { StockPageVm } from "./StockPage.vm";
import { StockDividends } from "./components/StockDividends.view";
import classes from "./StockPage.module.css";
const compactMetricFormatter = new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 2,
notation: "compact",
});
const metricFormatter = new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 2,
notation: "standard",
});
type StockPageViewProps = { vm: StockPageVm };
const StockPageView: FC<StockPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
const stock = vm.stock;
if (!stock) return;
const subtitle = (
<>
{stock.ticker} ·{" "}
{t(`stock.shareTypes.${stock.shareType}`, {
defaultValue: t("stock.share"),
})}
</>
);
const appearanceClass = vm.quote?.change?.value.startsWith("-")
? classes.negative
: classes.positive;
let quoteContent;
if (vm.quoteState.loading) {
quoteContent = (
<LoadingSkeleton label={t("stock.loadingPrice")}>
<Skeleton h={55} mt="lg" w="50%" />
</LoadingSkeleton>
);
}
else {
quoteContent = (
<div aria-live="polite">
<Text className={instrumentDetailsStyles.price} mt="lg">
{formatMoney(vm.quote?.price)}
</Text>
{!!vm.quote?.change && (
<Text className={appearanceClass} fw={600}>
{vm.quote.change.value.startsWith("-") ? "" : "+"}
{formatMoney(vm.quote.change)}
{vm.quote.changePercent === null
? ""
: ` (${formatDecimal(vm.quote.changePercent)} %)`}
</Text>
)}
{vm.quote?.previousCloseDate
? (
<Text c="dimmed" size="xs">
{t("stock.changeBase", {
date: formatDate(vm.quote.previousCloseDate),
})}
</Text>
)
: (
<Text c="dimmed" size="xs">
{t("stock.noComparison")}
</Text>
)}
<Group gap="sm" mt="sm">
<InstrumentQuoteStatus quote={vm.quote} />
</Group>
{!!vm.quote?.unavailable.length && (
<Text c="dimmed" mt="xs" size="xs">
{t("stock.partialQuote")}
</Text>
)}
{!!vm.quoteState.refreshing && (
<Text c="dimmed" role="status" size="xs">
{t("accounts.refreshing")}
</Text>
)}
</div>
);
}
let companyMetricsContent;
if (vm.company) {
if (vm.company.metrics.length > 0) {
companyMetricsContent = (
<div className={instrumentDetailsStyles.metricsGrid}>
{vm.company.metrics.map((metric) => {
const label = [t(`stock.metrics.${metric.key}`), metric.period].filter(Boolean).join(" · ");
const formatter = metric.unit === "money" ? compactMetricFormatter : metricFormatter;
const value = `${formatter.format(metric.value)} ${metricUnit(metric, t("stock.currencyUnknown"))}`;
return (
<InstrumentMetric key={metric.key} label={label} value={value} />
);
})}
</div>
);
}
else {
companyMetricsContent = <Text c="dimmed">{t("stock.noFundamentals")}</Text>;
}
}
return (
<Stack className={instrumentDetailsStyles.page} gap="lg">
<InstrumentHeader
logoSrc={vm.logoSrc}
name={stock.name}
onRefresh={vm.refresh}
refreshing={vm.refreshing}
subtitle={subtitle}
>
{!!vm.company?.description && (
<Text mt="md" size="sm" style={{ whiteSpace: "pre-line" }}>
{vm.company.description}
</Text>
)}
{!!(vm.refreshError || vm.stockState.error || vm.quoteState.error) && (
<Alert aria-live="polite" color="orange" mt="md" role="status">
{t(vm.quote ? "accounts.refreshError" : "stock.sectionError")}
</Alert>
)}
{quoteContent}
<StockSection
embedded
state={vm.companyState}
title={t("stock.company")}
>
<Group gap="xl" mb="md">
{!!stock.sector && (
<Text size="sm">
{t("stock.sector")}:{" "}
{t(`stock.sectors.${stock.sector}`, {
defaultValue: stock.sector,
})}
</Text>
)}
{stock.lot !== null && <Text size="sm">{t("stock.lot", { value: stock.lot })}</Text>}
</Group>
{!!vm.company && (
<Stack gap="lg">
{vm.company.unavailable.length > 0 && (
<Alert aria-live="polite" color="orange" role="status">
<Group justify="space-between">
<Text size="sm">{t("stock.partialCompany")}</Text>
<Button
onClick={vm.companyState.retry}
size="xs"
variant="default"
>
{t("accounts.retry")}
</Button>
</Group>
</Alert>
)}
{companyMetricsContent}
<Text c="dimmed" size="xs">
{t("stock.periodNote")}
</Text>
</Stack>
)}
</StockSection>
</InstrumentHeader>
<StockChart vm={vm} />
<StockPosition vm={vm} />
<StockDividends
dividends={vm.dividends}
pastDividends={vm.pastDividends}
state={vm.dividendsState}
upcoming={vm.upcoming}
/>
<StockEvents vm={vm} />
</Stack>
);
};
function metricUnit(
metric: NonNullable<StockPageVm["company"]>["metrics"][number],
unknownCurrency: string,
) {
if (metric.unit === "money")
return metric.currency === "RUB"
? "₽"
: (metric.currency ?? unknownCurrency);
return metric.unit === "percent" ? "%" : "×";
}
export { StockPageView };