refactor: clarify frontend module ownership
This commit is contained in:
@@ -2,45 +2,23 @@ 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,
|
||||
InstrumentPage,
|
||||
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 { StockCompany } from "./components/StockCompany.view";
|
||||
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",
|
||||
});
|
||||
import { StockQuote } from "./components/StockQuote.view";
|
||||
|
||||
type StockPageViewProps = { vm: StockPageVm };
|
||||
|
||||
@@ -59,96 +37,8 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
|
||||
</>
|
||||
);
|
||||
|
||||
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">
|
||||
<InstrumentPage>
|
||||
<InstrumentHeader
|
||||
logoSrc={vm.logoSrc}
|
||||
name={stock.name}
|
||||
@@ -169,52 +59,13 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{quoteContent}
|
||||
<StockQuote quote={vm.quote} quoteState={vm.quoteState} />
|
||||
|
||||
<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>
|
||||
<StockCompany
|
||||
company={vm.company}
|
||||
companyState={vm.companyState}
|
||||
stock={stock}
|
||||
/>
|
||||
</InstrumentHeader>
|
||||
|
||||
<StockChart vm={vm} />
|
||||
@@ -229,20 +80,8 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
|
||||
/>
|
||||
|
||||
<StockEvents vm={vm} />
|
||||
</Stack>
|
||||
</InstrumentPage>
|
||||
);
|
||||
};
|
||||
|
||||
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 };
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
InstrumentMetric,
|
||||
InstrumentMetrics,
|
||||
InstrumentSection as StockSection,
|
||||
} from "@/features/instrument-details";
|
||||
|
||||
import type { StockPageVm } from "../StockPage.vm";
|
||||
|
||||
const compactMetricFormatter = new Intl.NumberFormat("ru-RU", {
|
||||
maximumFractionDigits: 2,
|
||||
notation: "compact",
|
||||
});
|
||||
|
||||
const metricFormatter = new Intl.NumberFormat("ru-RU", {
|
||||
maximumFractionDigits: 2,
|
||||
notation: "standard",
|
||||
});
|
||||
|
||||
type StockCompanyProps = Pick<StockPageVm, "company" | "companyState"> & {
|
||||
stock: NonNullable<StockPageVm["stock"]>;
|
||||
};
|
||||
|
||||
const StockCompany: FC<StockCompanyProps> = (props) => {
|
||||
const { stock } = props;
|
||||
const { t } = useTranslation();
|
||||
let companyMetricsContent;
|
||||
|
||||
if (props.company) {
|
||||
if (props.company.metrics.length > 0) {
|
||||
companyMetricsContent = (
|
||||
<InstrumentMetrics>
|
||||
{props.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} />
|
||||
);
|
||||
})}
|
||||
</InstrumentMetrics>
|
||||
);
|
||||
}
|
||||
else {
|
||||
companyMetricsContent = <Text c="dimmed">{t("stock.noFundamentals")}</Text>;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<StockSection
|
||||
embedded
|
||||
state={props.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>
|
||||
|
||||
{!!props.company && (
|
||||
<Stack gap="lg">
|
||||
{props.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={props.companyState.retry}
|
||||
size="xs"
|
||||
variant="default"
|
||||
>
|
||||
{t("accounts.retry")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{companyMetricsContent}
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.periodNote")}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</StockSection>
|
||||
);
|
||||
};
|
||||
|
||||
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 { StockCompany };
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import {
|
||||
Group,
|
||||
Skeleton,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { LoadingSkeleton } from "@wealthledger/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
InstrumentPrice,
|
||||
InstrumentQuoteStatus,
|
||||
} from "@/features/instrument-details";
|
||||
import {
|
||||
formatDate,
|
||||
formatDecimal,
|
||||
formatMoney,
|
||||
} from "@/shared/lib";
|
||||
|
||||
import type { StockPageVm } from "../StockPage.vm";
|
||||
|
||||
import classes from "../StockPage.module.css";
|
||||
|
||||
type StockQuoteProps = Pick<StockPageVm, "quote" | "quoteState">;
|
||||
|
||||
const StockQuote: FC<StockQuoteProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const appearanceClass = props.quote?.change?.value.startsWith("-")
|
||||
? classes.negative
|
||||
: classes.positive;
|
||||
|
||||
let quoteContent;
|
||||
|
||||
if (props.quoteState.loading) {
|
||||
quoteContent = (
|
||||
<LoadingSkeleton label={t("stock.loadingPrice")}>
|
||||
<Skeleton h={55} mt="lg" w="50%" />
|
||||
</LoadingSkeleton>
|
||||
);
|
||||
}
|
||||
else {
|
||||
quoteContent = (
|
||||
<div aria-live="polite">
|
||||
<InstrumentPrice mt="lg">
|
||||
{formatMoney(props.quote?.price)}
|
||||
</InstrumentPrice>
|
||||
|
||||
{!!props.quote?.change && (
|
||||
<Text className={appearanceClass} fw={600}>
|
||||
{props.quote.change.value.startsWith("-") ? "" : "+"}
|
||||
|
||||
{formatMoney(props.quote.change)}
|
||||
|
||||
{props.quote.changePercent === null
|
||||
? ""
|
||||
: ` (${formatDecimal(props.quote.changePercent)} %)`}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{props.quote?.previousCloseDate
|
||||
? (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.changeBase", {
|
||||
date: formatDate(props.quote.previousCloseDate),
|
||||
})}
|
||||
</Text>
|
||||
)
|
||||
: (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.noComparison")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Group gap="sm" mt="sm">
|
||||
<InstrumentQuoteStatus quote={props.quote} />
|
||||
</Group>
|
||||
|
||||
{!!props.quote?.unavailable.length && (
|
||||
<Text c="dimmed" mt="xs" size="xs">
|
||||
{t("stock.partialQuote")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!!props.quoteState.refreshing && (
|
||||
<Text c="dimmed" role="status" size="xs">
|
||||
{t("accounts.refreshing")}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return quoteContent;
|
||||
};
|
||||
|
||||
export { StockQuote };
|
||||
Reference in New Issue
Block a user