feat: unify upcoming event cards across pages

This commit is contained in:
2026-09-09 17:58:02 +03:00
parent 3017fc810e
commit 451400b63b
20 changed files with 313 additions and 140 deletions
@@ -1,11 +1,12 @@
import { MantineProvider } from "@mantine/core";
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useBrokerageEvents } from "@/entities/brokerage-event";
import { AccountEventsPage } from "./AccountEventsPage";
vi.mock("@/entities/brokerage-event", () => ({
vi.mock("@/entities/brokerage-event", async (importOriginal) => ({
...(await importOriginal<typeof import("@/entities/brokerage-event")>()),
useBrokerageEvents: vi.fn(),
}));
@@ -81,6 +82,8 @@ describe("AccountEventsPage", () => {
projectedAmount: { currency: "RUB", value: "1250" },
ticker: "OFZ",
type: "coupon",
logoName: "ofz.png",
isin: "RU000A0JX0J2",
},
{
date: "2026-08-25",
@@ -91,6 +94,8 @@ describe("AccountEventsPage", () => {
projectedAmount: null,
ticker: "OFZ",
type: "offer",
logoName: null,
isin: null,
},
],
},
@@ -98,9 +103,26 @@ describe("AccountEventsPage", () => {
isLoading: false,
refetch: vi.fn(),
} as never);
renderPage();
const { container } = renderPage();
expect(screen.getByText("1 250 ₽")).toBeInTheDocument();
expect(screen.getByText("Сумма неизвестна")).toBeInTheDocument();
expect(screen.getByText("Купон · ОФЗ 26238")).toBeVisible();
expect(screen.getByText("Оферта · ОФЗ 26238")).toBeVisible();
expect(screen.getByText("20 авг. 2026 г. · RU000A0JX0J2")).toBeVisible();
expect(screen.getAllByText(/RU000A0JX0J2$/)).toHaveLength(1);
expect(screen.getByText("25 авг. 2026 г.")).toBeVisible();
expect(screen.queryByText(/Дата события/)).not.toBeInTheDocument();
expect(screen.getAllByText("Ожидаемая сумма")).toHaveLength(2);
const logo = container.querySelector("img");
expect(logo).toHaveAttribute(
"src",
"https://invest-brands.cdn-tinkoff.ru/ofzx160.png",
);
expect(screen.getAllByText("O")).toHaveLength(1);
if (!logo) throw new Error("Expected the event logo");
fireEvent.error(logo);
expect(container.querySelector("img")).toBeNull();
expect(screen.getAllByText("O")).toHaveLength(2);
});
it("renders empty and retryable error states", () => {
@@ -1,44 +0,0 @@
import { Card, Group, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import type { BrokerageEvent } from "@/entities/brokerage-event";
import { formatMoney } from "@/shared/lib";
type AccountEventRowProps = { event: BrokerageEvent };
const AccountEventRow: FC<AccountEventRowProps> = (props) => {
const { event } = props;
const { t } = useTranslation();
return (
<Card withBorder>
<Group justify="space-between" align="flex-start" wrap="wrap">
<Stack gap={2}>
<Text fw={700}>{event.displayName}</Text>
<Text c="dimmed" size="sm">
{formatCalendarDate(event.date)} ·{" "}
{t(`events.dateKind.${event.dateKind}`)}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Text fw={700}>
{event.projectedAmount
? formatMoney(event.projectedAmount)
: t("events.noAmount")}
</Text>
<Text c="dimmed" size="sm">
{t(`events.type.${event.type}`)}
</Text>
</Stack>
</Group>
</Card>
);
};
function formatCalendarDate(date: string) {
return new Intl.DateTimeFormat("ru-RU", {
dateStyle: "medium",
timeZone: "UTC",
}).format(new Date(`${date}T00:00:00Z`));
}
export { AccountEventRow };
@@ -1,8 +1,10 @@
import { Stack, Text, Title } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import type { BrokerageEvent } from "@/entities/brokerage-event";
import { AccountEventRow } from "./AccountEventRow.view";
import {
type BrokerageEvent,
BrokerageEventCard,
} from "@/entities/brokerage-event";
type AccountEventsPageLoadedProps = { events: BrokerageEvent[] };
@@ -12,12 +14,14 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
<Stack maw={960} gap="lg">
<Title order={1}>{t("events.title")}</Title>
<Text c="dimmed">{t("events.hint")}</Text>
{props.events.map((event) => (
<AccountEventRow
event={event}
key={`${event.type}-${event.date}-${event.ticker}`}
/>
))}
<Stack gap="sm">
{props.events.map((event) => (
<BrokerageEventCard
event={event}
key={`${event.type}-${event.date}-${event.ticker}`}
/>
))}
</Stack>
</Stack>
);
};
+105
View File
@@ -10,6 +10,7 @@ import {
import { HttpResponse, http } from "msw";
import { MemoryRouter, Route, Routes } from "react-router";
import { describe, expect, it, vi } from "vitest";
import type { BrokerageEvent } from "@/entities/brokerage-event";
import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
@@ -637,6 +638,110 @@ describe("AccountPage states", () => {
expect(screen.getByText("−12 ₽")).toBeInTheDocument();
});
it("keeps future events ordered with calendar dates and projected rather than posted amounts", () => {
const event: BrokerageEvent = {
type: "coupon",
date: "2026-09-10",
dateKind: "event",
figi: null,
instrumentUid: null,
ticker: "DEMO",
displayName: "Облигация",
logoName: null,
isin: "RU000A0JX0J2",
projectedAmount: { currency: "RUB", value: "850.50" },
};
renderPage(
<AccountPageLoaded
{...loadedProps()}
recentEvents={{
state: "loaded",
to: "/accounts/account-1/events",
items: [
event,
{
...event,
type: "dividend",
date: "2026-09-11",
dateKind: "registry",
displayName: "Акция",
projectedAmount: { currency: "USD", value: "0" },
},
{
...event,
type: "maturity",
date: "2026-09-12",
projectedAmount: { currency: "RUB", value: "1000" },
},
{
...event,
type: "offer",
date: "2026-09-13",
projectedAmount: null,
},
],
}}
/>,
);
const section = screen.getByRole("region", { name: "Будущие события" });
const content = within(section);
const text = section.textContent ?? "";
const titles = [
"Купон · Облигация",
"Дивиденд · Акция",
"Погашение · Облигация",
"Оферта · Облигация",
];
for (const title of titles) expect(content.getByText(title)).toBeVisible();
expect(titles.map((title) => text.indexOf(title))).toEqual(
titles.map((title) => text.indexOf(title)).sort((a, b) => a - b),
);
expect(content.getByText("10 сент. 2026 г. · RU000A0JX0J2")).toBeVisible();
expect(content.getAllByText(/RU000A0JX0J2$/)).toHaveLength(4);
expect(content.queryByText(/Дата события/)).not.toBeInTheDocument();
expect(
content.getByText("11 сент. 2026 г. · Дата реестра · RU000A0JX0J2"),
).toBeVisible();
for (const amount of ["850,50 ₽", "0 USD", "1 000 ₽", "Сумма неизвестна"])
expect(content.getByText(amount)).toBeVisible();
expect(content.getAllByText("Ожидаемая сумма")).toHaveLength(4);
expect(content.queryByText("Без движения денег")).not.toBeInTheDocument();
expect(content.queryByText(/^\+/)).not.toBeInTheDocument();
expect(
content.getByRole("link", { name: "Открыть будущие события" }),
).toHaveAttribute("href", "/accounts/account-1/events");
});
it.each(["loading", "empty"] as const)(
"keeps the upcoming %s state and calendar action",
(state) => {
renderPage(
<AccountPageLoaded
{...loadedProps()}
recentEvents={{ state, to: "/accounts/account-1/events" }}
/>,
);
const content = within(
screen.getByRole("region", { name: "Будущие события" }),
);
if (state === "loading") {
const skeleton = content.getByRole("status", {
name: "Загрузка будущих событий",
});
expect(skeleton).toHaveAttribute("aria-busy", "true");
expect(within(skeleton).queryByRole("link")).not.toBeInTheDocument();
} else {
expect(
content.getByText("На ближайшие 30 дней событий не найдено."),
).toBeVisible();
}
expect(
content.getByRole("link", { name: "Открыть будущие события" }),
).toHaveAttribute("href", "/accounts/account-1/events");
},
);
it("keeps the portfolio visible when upcoming events fail", () => {
renderPage(
<AccountPageLoaded
+2 -11
View File
@@ -7,7 +7,7 @@ import {
brokerageAccountsQueryKey,
} from "@/entities/brokerage-account";
import {
type BrokerageEvent,
type BrokerageEventsPreviewState,
useBrokerageEvents,
} from "@/entities/brokerage-event";
import { type IncomeSummary, useIncomeSummary } from "@/entities/income";
@@ -42,16 +42,7 @@ type AccountPageRecentOperations =
items: BrokerageOperation[];
};
type AccountPageRecentEvents =
| {
state: "loading" | "error" | "empty";
to: string;
}
| {
state: "loaded";
to: string;
items: BrokerageEvent[];
};
type AccountPageRecentEvents = BrokerageEventsPreviewState;
type AccountPageIncomeSummary =
| { state: "loading" | "empty"; to: string }
@@ -2,6 +2,7 @@ import { Stack } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
import { StateView } from "@/shared/ui";
import type {
AccountPageIncomeSummary,
@@ -11,7 +12,6 @@ import type {
import { AccountHeading } from "./AccountHeading.view";
import { IncomeSummary } from "./IncomeSummary.view";
import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
import { RecentEvents } from "./RecentEvents.view";
import { RecentOperations } from "./RecentOperations.view";
type AccountPageEmptyProps = {
@@ -39,7 +39,7 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
text={t("portfolio.emptyText")}
/>
<RecentOperations operations={props.recentOperations} />
<RecentEvents events={props.recentEvents} />
<BrokerageEventsPreview events={props.recentEvents} />
{props.incomeSummary ? <IncomeSummary {...props.incomeSummary} /> : null}
</Stack>
);
@@ -2,6 +2,7 @@ import { Accordion, Alert, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
import type { Portfolio } from "@/entities/portfolio";
import { formatSnapshotTime } from "@/shared/lib";
import classes from "../AccountPage.module.css";
@@ -16,7 +17,6 @@ import { CurrencyCards } from "./CurrencyCards.view";
import { IncomeSummary } from "./IncomeSummary.view";
import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
import { PositionSection } from "./PositionSection.view";
import { RecentEvents } from "./RecentEvents.view";
import { RecentOperations } from "./RecentOperations.view";
type AccountPageLoadedProps = {
@@ -91,7 +91,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<Accordion.Item value="recentEvents">
<Accordion.Control>{t("events.recentTitle")}</Accordion.Control>
<Accordion.Panel>
<RecentEvents events={props.recentEvents} />
<BrokerageEventsPreview events={props.recentEvents} />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="incomeSummary">
@@ -1,133 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { MemoryRouter } from "react-router";
import type { BrokerageEvent } from "@/entities/brokerage-event";
import { RecentEvents } from "./RecentEvents.view";
const syntheticEvent = {
type: "coupon",
date: "2026-10-15",
dateKind: "event",
figi: null,
instrumentUid: null,
ticker: "DEMO",
displayName: "Учебные облигации",
projectedAmount: { currency: "RUB", value: "850.50" },
} satisfies BrokerageEvent;
const eventsPath = "/accounts/storybook-account/events";
const meta = {
title: "Product/RecentEvents",
component: RecentEvents,
tags: ["autodocs"],
argTypes: {
events: { control: false },
},
decorators: [
(Story) => (
<MemoryRouter initialEntries={["/accounts/storybook-account"]}>
<Story />
</MemoryRouter>
),
],
parameters: {
docs: {
description: {
component:
"Раздел ближайших событий на странице счёта WealthLedger. Раздел самостоятельно отображает загрузку, ошибку, отсутствие событий и полученные данные. Все события и суммы вымышлены. Ссылка на календарь меняет только локальный маршрут MemoryRouter; сам календарь в этой истории не отображается.",
},
},
},
args: {
events: {
state: "loaded",
to: eventsPath,
items: [
syntheticEvent,
{
...syntheticEvent,
type: "dividend",
date: "2026-11-01",
dateKind: "registry",
ticker: "EXAMPLE",
displayName: "Учебные технологии",
projectedAmount: { currency: "RUB", value: "1200" },
},
],
},
},
} satisfies Meta<typeof RecentEvents>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Loaded: Story = {
name: "События получены",
parameters: {
docs: {
description: {
story:
"Предстоящие купоны и дивиденды показываются с ожидаемой суммой. Это прогноз событий, а не подтверждение получения дохода.",
},
},
},
};
export const Loading: Story = {
name: "Загрузка раздела",
args: { events: { state: "loading", to: eventsPath } },
parameters: {
docs: {
description: {
story:
"Локальный skeleton занимает место строк событий и имеет доступное обозначение загрузки. Остальные разделы страницы могут оставаться доступными.",
},
},
},
};
export const Failed: Story = {
name: "Ошибка получения событий",
args: { events: { state: "error", to: eventsPath } },
parameters: {
docs: {
description: {
story:
"Ошибка ограничена разделом событий. Сохраняется переход к полному календарю; недоступные события не выдаются за пустой список.",
},
},
},
};
export const Empty: Story = {
name: "Нет ближайших событий",
args: { events: { state: "empty", to: eventsPath } },
parameters: {
docs: {
description: {
story:
"Успешно полученный пустой результат объясняется отдельным сообщением и сохраняет переход к календарю.",
},
},
},
};
export const MissingAmount: Story = {
name: "Событие без ожидаемой суммы",
args: {
events: {
state: "loaded",
to: eventsPath,
items: [{ ...syntheticEvent, projectedAmount: null }],
},
},
parameters: {
docs: {
description: {
story:
"Событие остаётся видимым при неизвестной ожидаемой сумме. Отсутствие оценки не подменяется нулевой выплатой.",
},
},
},
};
@@ -1,69 +0,0 @@
import { Alert, Button, Paper, Skeleton, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { BrokerageEvent } from "@/entities/brokerage-event";
import { formatMoney } from "@/shared/lib";
import { LoadingSkeleton } from "@/shared/ui";
import classes from "../AccountPage.module.css";
import type { AccountPageRecentEvents } from "../AccountPage.vm";
type RecentEventsProps = {
events: AccountPageRecentEvents;
};
type RecentEventProps = {
event: BrokerageEvent;
};
const RecentEvents: FC<RecentEventsProps> = (props) => {
const { events } = props;
const { t } = useTranslation();
return (
<Stack gap="sm">
{events.state === "loading" ? (
<LoadingSkeleton label={t("events.recentLoading")}>
{[0, 1, 2].map((index) => (
<Skeleton key={index} height={48} radius="sm" />
))}
</LoadingSkeleton>
) : null}
{events.state === "error" ? (
<Alert color="red">{t("events.recentError")}</Alert>
) : null}
{events.state === "empty" ? (
<Text c="dimmed">{t("events.recentEmpty")}</Text>
) : null}
{events.state === "loaded"
? events.items.map((event) => (
<RecentEvent
key={`${event.type}-${event.date}-${event.ticker}`}
event={event}
/>
))
: null}
<Button component={Link} to={events.to} variant="subtle">
{t("events.open")}
</Button>
</Stack>
);
};
const RecentEvent: FC<RecentEventProps> = (props) => {
const { event } = props;
const { t } = useTranslation();
return (
<Paper className={classes.cash} withBorder>
<Text>{`${t(`events.type.${event.type}`)} · ${event.displayName}`}</Text>
<Text fw={700}>
{event.projectedAmount
? formatMoney(event.projectedAmount)
: t("events.noAmount")}
</Text>
</Paper>
);
};
export { RecentEvents };