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
+58 -27
View File
@@ -16,9 +16,11 @@ import { StockPage } from "./StockPage";
const LocationProbe = () => {
const location = useLocation();
return (
<output data-testid="location">
{location.pathname}
{location.search}
</output>
);
@@ -26,8 +28,11 @@ const LocationProbe = () => {
const uid = "e6123145-9665-43e0-8413-cd61b8aa9b13";
const money = (value: string) => ({ currency: "RUB", value });
const stock = (/* 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 stock
= {
currency: "RUB",
historyFrom: null,
instrumentUid: uid,
@@ -37,8 +42,9 @@ const stock = (/* eslint-disable unicorn/no-null -- Fixture preserves the produc
sector: "financial",
shareType: "SHARE_TYPE_COMMON",
ticker: "TEST",
}
/* eslint-enable unicorn/no-null */);
};
/* eslint-enable unicorn/no-null */
const position = {
absoluteReturn: money("1000"),
accounts: [
@@ -59,6 +65,7 @@ const position = {
quantity: "10",
returnPercent: "50",
};
const quote = {
change: money("10"),
changePercent: "3.448276",
@@ -70,6 +77,7 @@ const quote = {
tradingStatus: "SECURITY_TRADING_STATUS_NORMAL_TRADING",
unavailable: [],
};
function setup(
options: {
ambiguousTicker?: boolean;
@@ -95,6 +103,7 @@ function setup(
),
http.get("*/api/v1/stocks/by-ticker/:ticker", ({ request }) => {
requests.push(new URL(request.url));
return options.ambiguousTicker
? HttpResponse.json({ code: "STOCK_TICKER_AMBIGUOUS" }, { status: 409 })
: HttpResponse.json(resource(resolved));
@@ -115,20 +124,23 @@ 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: [],
currency: "RUB",
historyFrom: null,
observedAt: quote.observedAt,
}
/* eslint-enable unicorn/no-null */)),
},
/* eslint-enable unicorn/no-null */
),
);
if (params.section === "company")
return options.failCompany
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: 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(
{
description: "Описание компании",
metrics: options.metrics ?? [
@@ -142,11 +154,14 @@ function setup(
],
observedAt: quote.observedAt,
unavailable: [],
}
/* eslint-enable unicorn/no-null */)),
},
/* eslint-enable unicorn/no-null */
),
);
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(
{
dividends: [
{
@@ -172,8 +187,9 @@ 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 }) => {
@@ -182,8 +198,10 @@ function setup(
const isNext = url.searchParams.has("cursor");
if (isNext && cursorError)
return HttpResponse.json({ code: cursorError }, { status: 400 });
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,
@@ -199,13 +217,16 @@ function setup(
payment: money(operationPayment),
price: isNext ? money("100") : null,
quantity: isNext ? "2" : null,
type: isNext ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_DIVIDEND",
type: isNext
? "OPERATION_TYPE_SELL"
: "OPERATION_TYPE_DIVIDEND",
},
],
nextCursor: isNext ? null : "next",
observedAt: quote.observedAt,
}
/* eslint-enable unicorn/no-null */)),
},
/* eslint-enable unicorn/no-null */
),
);
}),
);
@@ -220,6 +241,7 @@ function setup(
<MantineProvider>
<MemoryRouter initialEntries={[options.path ?? `/stocks/${uid}`]}>
<LocationProbe />
<Routes>
<Route element={<StockPage />} path="/stocks/:stockId" />
</Routes>
@@ -227,6 +249,7 @@ function setup(
</MantineProvider>
</QueryClientProvider>,
);
return {
failQuote: () => {
isFailQuote = true;
@@ -238,9 +261,11 @@ function setup(
requests,
};
}
describe("stock page", () => {
it("preserves unknown and unambiguous currencies with compact, ratio and percent formatting", async () => {
setup((/* 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. */
setup(
{
metrics: [
{
@@ -286,8 +311,9 @@ describe("stock page", () => {
value: 12.3456,
},
],
}
/* eslint-enable unicorn/no-null */));
},
/* eslint-enable unicorn/no-null */
);
expect(await screen.findByText("1,23 млн валюта неизвестна")).toBeVisible();
for (const value of [
"1,23 млн ₽",
@@ -370,9 +396,7 @@ describe("stock 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");
});
it("recovers an initially missing company section without reloading successful sections", async () => {
@@ -383,6 +407,7 @@ describe("stock page", () => {
server.use(
http.get("*/api/v1/stocks/:uid/company", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return HttpResponse.json(
resource({
description: "Восстановленное описание",
@@ -441,7 +466,12 @@ describe("stock page", () => {
);
});
it.each([
["INVALID_OPERATIONS_CURSOR", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null, null],
[
"INVALID_OPERATIONS_CURSOR",
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
null,
null, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture.
],
["TBANK_UNAVAILABLE", "next", "observe"],
])(
"recovers history after %s with fresh data and the correct cursor",
@@ -475,24 +505,25 @@ describe("stock page", () => {
server.use(
http.post("*/api/v1/tbank-sync", async ({ request }) => {
scopes.push(await request.json());
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
}),
);
const controls = setup({
path: `/stocks/${uid}?period=year&events=trades`,
});
await screen.findByText("300 ₽");
controls.failQuote();
fireEvent.click(screen.getByRole("button", { name: "Обновить" }));
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("300 ₽")).toBeVisible();
expect(scopes).toEqual([
+15 -1
View File
@@ -19,16 +19,26 @@ const StockPage: FC = () => {
<LoadingSkeleton label={t("stock.loading")}>
<Stack>
<Skeleton h={110} />
<Skeleton h={350} />
<Skeleton h={170} />
</Stack>
</LoadingSkeleton>
);
const unavailableMessage = isApiErrorCode(vm.code, ApiErrorCode.StockTickerAmbiguous) ? "stock.ambiguousTicker" : "stock.unavailableText";
const unavailableMessage = isApiErrorCode(
vm.code,
ApiErrorCode.StockTickerAmbiguous,
)
? "stock.ambiguousTicker"
: "stock.unavailableText";
if (!vm.stock)
return (
<Stack role="alert">
<Title order={1}>{t("stock.unavailable")}</Title>
<Text>
{t(
isApiErrorCode(vm.code, ApiErrorCode.TbankNotConnected)
@@ -37,17 +47,21 @@ const StockPage: FC = () => {
: unavailableMessage,
)}
</Text>
<Anchor component={Link} to="/stocks">
{t("stock.backToSearch")}
</Anchor>
<Anchor component={Link} to="/settings">
{t("accounts.openSettings")}
</Anchor>
<Button onClick={vm.stockState.retry} variant="default">
{t("accounts.retry")}
</Button>
</Stack>
);
return <StockPageView vm={vm} />;
};
+154 -112
View File
@@ -34,11 +34,124 @@ import { StockDividends } from "./components/StockEvents.view";
import classes from "./StockPage.module.css";
type StockPageViewProps = { vm: StockPageVm };
const StockPageView: FC<StockPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
const stock = vm.stock;
if (!stock) return;
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={classes.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">
<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" 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={classes.companyMetrics}>
{vm.company.metrics.map(metric => (
<div key={metric.key}>
<Text c="dimmed" size="xs">
{t(`stock.metrics.${metric.key}`)}
{metric.period ? ` · ${metric.period}` : ""}
</Text>
<Text fw={600} size="lg">
{new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 2,
notation:
metric.unit === "money" ? "compact" : "standard",
}).format(metric.value)}{" "}
{metricUnit(metric, t("stock.currencyUnknown"))}
</Text>
</div>
))}
</div>
);
}
else {
companyMetricsContent = (
<Text c="dimmed">{t("stock.noFundamentals")}</Text>
);
}
}
return (
<Stack className={classes.page} gap="lg">
<Paper className={classes.section} component="header" withBorder>
@@ -53,21 +166,21 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
>
{stock.name[0]}
</Avatar>
<div>
<Title order={1} size="h2">
{stock.name}
</Title>
<Text c="dimmed" size="sm">
{stock.ticker}
{" "}
·
{" "}
{stock.ticker} ·{" "}
{t(`stock.shareTypes.${stock.shareType}`, {
defaultValue: t("stock.share"),
})}
</Text>
</div>
</div>
<Button
disabled={vm.refreshing}
onClick={vm.refresh}
@@ -76,108 +189,48 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
{t("investment.refresh")}
</Button>
</Group>
{!!(vm.company?.description) && ((
{!!vm.company?.description && (
<Text mt="md" size="sm" style={{ whiteSpace: "pre-line" }}>
{vm.company.description}
</Text>
))}
{!!(vm.refreshError || vm.stockState.error || vm.quoteState.error) && ((
)}
{!!(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>
))}
{vm.quoteState.loading
? (
<LoadingSkeleton label={t("stock.loadingPrice")}>
<Skeleton h={55} mt="lg" w="50%" />
</LoadingSkeleton>
)
: (
<div aria-live="polite">
<Text className={classes.price} mt="lg">
{formatMoney(vm.quote?.price)}
</Text>
{!!(vm.quote?.change) && ((
<Text
className={
vm.quote.change.value.startsWith("-")
? classes.negative
: classes.positive
}
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">
<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" mt="xs" size="xs">
{t("stock.partialQuote")}
</Text>
))}
{!!(vm.quoteState.refreshing) && ((
<Text c="dimmed" role="status" size="xs">
{t("accounts.refreshing")}
</Text>
))}
</div>
)}
)}
{quoteContent}
<StockSection
embedded
state={vm.companyState}
title={t("stock.company")}
>
<Group gap="xl" mb="md">
{!!(stock.sector) && ((
{!!stock.sector && (
<Text size="sm">
{t("stock.sector")}
:
{" "}
{t("stock.sector")}:{" "}
{t(`stock.sectors.${stock.sector}`, {
defaultValue: stock.sector,
})}
</Text>
))}
{stock.lot !== null && ((
)}
{stock.lot !== null && (
<Text size="sm">{t("stock.lot", { value: stock.lot })}</Text>
))}
)}
</Group>
{!!(vm.company) && ((
{!!vm.company && (
<Stack gap="lg">
{vm.company.unavailable.length > 0 && ((
{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"
@@ -187,49 +240,38 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
</Button>
</Group>
</Alert>
))}
{vm.company.metrics.length > 0
? (
<div className={classes.companyMetrics}>
{vm.company.metrics.map(metric => (
<div key={metric.key}>
<Text c="dimmed" size="xs">
{t(`stock.metrics.${metric.key}`)}
{metric.period ? ` · ${metric.period}` : ""}
</Text>
<Text fw={600} size="lg">
{new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 2,
notation:
metric.unit === "money" ? "compact" : "standard",
}).format(metric.value)}
{" "}
{metricUnit(metric, t("stock.currencyUnknown"))}
</Text>
</div>
))}
</div>
)
: (
<Text c="dimmed">{t("stock.noFundamentals")}</Text>
)}
)}
{companyMetricsContent}
<Text c="dimmed" size="xs">
{t("stock.periodNote")}
</Text>
</Stack>
))}
)}
</StockSection>
</Paper>
<StockChart vm={vm} />
<StockPosition vm={vm} />
<StockDividends vm={vm} />
<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);
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" ? "%" : "×";
}
+40 -7
View File
@@ -21,68 +21,93 @@ import { formatDate, instrumentLogoUrl } from "@/shared/lib";
const periods: StockPeriod[] = ["month", "halfYear", "year", "all"];
const filters = ["all", "trades", "dividends", "other"] as const;
type StockEventFilter = (typeof filters)[number];
const categories: Record<StockEventFilter, OperationCategory[]> = {
all: [],
dividends: ["income"],
other: ["tax", "fee", "transfer", "other"],
trades: ["trade"],
};
type StockPageVm = ReturnType<typeof useStockPageVm>;
function useStockPageVm() {
const { stockId = "" } = useParams();
const stock = useStock(stockId);
const uid = stock.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 = useStockQuote(uid);
const candles = useStockCandles(uid, period);
const position = useStockPosition(uid);
const company = useStockCompany(uid);
const dividends = useStockDividends(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: "stock",
},
[stock, quote, candles, position, company, dividends, 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 average = position.data?.averagePurchasePrice;
const averageLine
= average && average.currency === candles.data?.currency
? Number(average.value)
: /* 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.
const companyData = company.data;
const search = parameters.size > 0 ? `?${parameters.toString()}` : "";
return {
averageLine:
averageLine !== null && Number.isFinite(averageLine) ? averageLine : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
averageLine !== null && Number.isFinite(averageLine)
? averageLine
: null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
candles: candles.data,
candlesState: section(candles, sync.retry),
canonicalTo:
stock.data?.slug && stock.data.slug !== stockId
? `/stocks/${encodeURIComponent(stock.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(stock.error),
company: companyData,
@@ -92,8 +117,16 @@ function useStockPageVm() {
eventRows:
operations.data?.items.map(item => ({
...item,
logoName: item.logoName ?? stock.data?.logoName ?? /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
ticker: item.ticker ?? stock.data?.ticker ?? /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null,
logoName:
item.logoName
?? stock.data?.logoName
// eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
?? null,
ticker:
item.ticker
?? stock.data?.ticker
// eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
?? null,
})) ?? [],
filter,
hasIncompleteCandle:
@@ -19,100 +19,186 @@ import type { StockPageVm } from "../StockPage.vm";
import classes from "../StockPage.module.css";
type StockEventsProps = { vm: StockPageVm };
const StockDividends: FC<StockEventsProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
let upcomingContent;
if (vm.dividends) {
if (vm.upcoming.length > 0) {
upcomingContent = (
vm.upcoming.map(event => (
<div
className={classes.dividend}
key={JSON.stringify([
event.recordDate,
event.paymentDate,
event.perShare,
])}
>
<Group align="start" justify="space-between">
<div>
<Badge color="green" variant="light">
{t("stock.companyEvent")}
</Badge>
<Text fw={650} mt="xs" size="xl">
{formatMoney(event.perShare)}
{" "}
<Text c="dimmed" component="span" size="sm">
{t("stock.perShare")}
</Text>
</Text>
</div>
{!!event.estimatedAmount && (
<div>
<Text c="dimmed" size="xs">
{t("stock.yourEstimate")}
</Text>
<Text fw={600}>
{formatMoney(event.estimatedAmount)}
</Text>
</div>
)}
</Group>
<Group gap="xl" mt="md">
{!!event.lastBuyDate && (
<div>
<Text c="dimmed" size="xs">
{t("stock.lastBuy")}
</Text>
<Text>{formatDate(event.lastBuyDate)}</Text>
</div>
)}
{!!event.recordDate && (
<div>
<Text c="dimmed" size="xs">
{t("stock.record")}
</Text>
<Text>{formatDate(event.recordDate)}</Text>
</div>
)}
{!!event.paymentDate && (
<div>
<Text c="dimmed" size="xs">
{t("stock.payment")}
</Text>
<Text>{formatDate(event.paymentDate)}</Text>
</div>
)}
</Group>
</div>
))
);
}
else {
upcomingContent = (
<Text c="dimmed">
{t("stock.noUpcoming", { to: formatDate(vm.dividends.to) })}
</Text>
);
}
}
let pastDividendsContent;
if (vm.pastDividends.length > 0) {
pastDividendsContent = (
<Stack gap="sm">
{vm.pastDividends.map(event => (
<Paper
key={JSON.stringify([
event.recordDate,
event.paymentDate,
event.perShare,
])}
p="md"
radius="md"
withBorder
>
<Group align="start" justify="space-between">
<div>
<Text c="dimmed" size="xs">
{t("stock.record")}
</Text>
<Text fw={600}>
{event.recordDate
? formatDate(event.recordDate)
: t("stock.dateUnknown")}
</Text>
{!!event.paymentDate && (
<Text c="dimmed" mt="xs" size="xs">
{t("stock.payment")}:{" "}
{formatDate(event.paymentDate)}
</Text>
)}
</div>
<div>
<Text fw={650} size="lg">
{formatMoney(event.perShare)}
</Text>
<Text c="dimmed" size="xs">
{t("stock.perShare")}
</Text>
</div>
</Group>
</Paper>
))}
</Stack>
);
}
else {
pastDividendsContent = (
<Text c="dimmed">{t("stock.noDividends")}</Text>
);
}
return (
<StockSection state={vm.dividendsState} title={t("stock.upcoming")}>
{!!(vm.dividends) && ((
{!!vm.dividends && (
<Stack gap="md">
{!!(vm.dividends.estimateUnavailable || vm.dividends.positionIsStale) && ((
{!!(
vm.dividends.estimateUnavailable || vm.dividends.positionIsStale
) && (
<Alert aria-live="polite" color="orange" role="status">
{t("stock.estimateUnavailable")}
</Alert>
))}
{vm.upcoming.length > 0
? (
vm.upcoming.map(event => (
<div
className={classes.dividend}
key={JSON.stringify([
event.recordDate,
event.paymentDate,
event.perShare,
])}
>
<Group align="start" justify="space-between">
<div>
<Badge color="green" variant="light">
{t("stock.companyEvent")}
</Badge>
<Text fw={650} mt="xs" size="xl">
{formatMoney(event.perShare)}
{" "}
<Text c="dimmed" component="span" size="sm">
{t("stock.perShare")}
</Text>
</Text>
</div>
{!!(event.estimatedAmount) && ((
<div>
<Text c="dimmed" size="xs">
{t("stock.yourEstimate")}
</Text>
<Text fw={600}>{formatMoney(event.estimatedAmount)}</Text>
</div>
))}
</Group>
<Group gap="xl" mt="md">
{!!(event.lastBuyDate) && ((
<div>
<Text c="dimmed" size="xs">
{t("stock.lastBuy")}
</Text>
<Text>{formatDate(event.lastBuyDate)}</Text>
</div>
))}
{!!(event.recordDate) && ((
<div>
<Text c="dimmed" size="xs">
{t("stock.record")}
</Text>
<Text>{formatDate(event.recordDate)}</Text>
</div>
))}
{!!(event.paymentDate) && ((
<div>
<Text c="dimmed" size="xs">
{t("stock.payment")}
</Text>
<Text>{formatDate(event.paymentDate)}</Text>
</div>
))}
</Group>
</div>
))
)
: (
<Text c="dimmed">
{t("stock.noUpcoming", { to: formatDate(vm.dividends.to) })}
</Text>
)}
)}
{upcomingContent}
<Text c="dimmed" size="xs">
{t("stock.dividendNote")}
</Text>
{!!(vm.dividends.positionAt) && ((
{!!vm.dividends.positionAt && (
<Text c="dimmed" size="xs">
{t("stock.snapshotAt", {
date: formatSnapshotTime(vm.dividends.positionAt),
})}
</Text>
))}
)}
<Accordion radius="md" variant="contained">
<Accordion.Item value="history">
<Accordion.Control>
<Text fw={600}>{t("stock.dividendHistory")}</Text>
</Accordion.Control>
<Accordion.Panel>
<Text c="dimmed" my="sm" size="xs">
{t("stock.dividendWindow", {
@@ -120,60 +206,13 @@ const StockDividends: FC<StockEventsProps> = (props) => {
to: formatDate(vm.dividends.to),
})}
</Text>
{vm.pastDividends.length > 0
? (
<Stack gap="sm">
{vm.pastDividends.map(event => (
<Paper
key={JSON.stringify([
event.recordDate,
event.paymentDate,
event.perShare,
])}
p="md"
radius="md"
withBorder
>
<Group align="start" justify="space-between">
<div>
<Text c="dimmed" size="xs">
{t("stock.record")}
</Text>
<Text fw={600}>
{event.recordDate
? formatDate(event.recordDate)
: t("stock.dateUnknown")}
</Text>
{!!(event.paymentDate) && ((
<Text c="dimmed" mt="xs" size="xs">
{t("stock.payment")}
:
{" "}
{formatDate(event.paymentDate)}
</Text>
))}
</div>
<div>
<Text fw={650} size="lg">
{formatMoney(event.perShare)}
</Text>
<Text c="dimmed" size="xs">
{t("stock.perShare")}
</Text>
</div>
</Group>
</Paper>
))}
</Stack>
)
: (
<Text c="dimmed">{t("stock.noDividends")}</Text>
)}
{pastDividendsContent}
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
))}
)}
</StockSection>
);
};