feat: add агегку events

This commit is contained in:
2026-08-14 15:50:25 +03:00
parent 719a6ed8b7
commit da55de5b50
13 changed files with 855 additions and 6 deletions
+1
View File
@@ -0,0 +1 @@
export { AccountEventsPage } from "./ui/AccountEventsPage";
@@ -0,0 +1,102 @@
import { MantineProvider } from "@mantine/core";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } 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", () => ({
useBrokerageEvents: vi.fn(),
}));
vi.mock("@/features/auth", () => ({
useAuth: () => ({ accessToken: "token" }),
}));
const query = vi.mocked(useBrokerageEvents);
function renderPage() {
return render(
<MantineProvider>
<MemoryRouter>
<AccountEventsPage />
</MemoryRouter>
</MantineProvider>,
);
}
describe("AccountEventsPage", () => {
beforeEach(() => {
query.mockReturnValue({
data: {
accountId: "account",
calculatedAt: "2026-08-14T00:00:00Z",
events: [],
},
isError: false,
isLoading: false,
refetch: vi.fn(),
} as never);
});
it("renders events and marks an offer without an amount", () => {
query.mockReturnValue({
data: {
accountId: "account",
calculatedAt: "2026-08-14T00:00:00Z",
events: [
{
amount: "1250",
currency: "RUB",
date: "2026-08-20",
dateKind: "event",
displayName: "ОФЗ 26238",
figi: "FIGI",
instrumentUid: "UID",
ticker: "OFZ",
type: "coupon",
},
{
amount: null,
currency: null,
date: "2026-08-25",
dateKind: "event",
displayName: "ОФЗ 26238",
figi: "FIGI",
instrumentUid: "UID",
ticker: "OFZ",
type: "offer",
},
],
},
isError: false,
isLoading: false,
refetch: vi.fn(),
} as never);
renderPage();
expect(screen.getByText("1 250 ₽")).toBeInTheDocument();
expect(screen.getByText("Сумма неизвестна")).toBeInTheDocument();
});
it("renders empty and retryable error states", () => {
const refetch = vi.fn();
const { rerender } = renderPage();
expect(
screen.getByText("На ближайшие 30 дней событий не найдено"),
).toBeInTheDocument();
query.mockReturnValue({
data: undefined,
isError: true,
isLoading: false,
refetch,
} as never);
rerender(
<MantineProvider>
<MemoryRouter>
<AccountEventsPage />
</MemoryRouter>
</MantineProvider>,
);
screen.getByRole("button", { name: "Повторить" }).click();
expect(refetch).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,105 @@
import { Button, Card, Group, Stack, Text, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router";
import {
type BrokerageEvent,
useBrokerageEvents,
} from "@/entities/brokerage-event";
import { formatMoney } from "@/entities/portfolio";
import { useAuth } from "@/features/auth";
import { StateView } from "@/shared/ui";
export function AccountEventsPage() {
const { accountId } = useParams();
const { accessToken } = useAuth();
const events = useBrokerageEvents(accountId, accessToken);
if (events.isLoading) return <AccountEventsPageLoading />;
if (events.isError)
return <AccountEventsPageError retry={() => events.refetch()} />;
if (!events.data.events.length) return <AccountEventsPageEmpty />;
return <AccountEventsPageLoaded events={events.data.events} />;
}
function AccountEventsPageLoading() {
const { t } = useTranslation();
return (
<StateView
loading
title={t("states.loadingTitle")}
text={t("states.loadingText")}
/>
);
}
function AccountEventsPageError({ retry }: { retry: () => void }) {
const { t } = useTranslation();
return (
<StateView
error
title={t("events.errorTitle")}
text={t("events.errorText")}
action={<Button onClick={retry}>{t("events.retry")}</Button>}
/>
);
}
function AccountEventsPageEmpty() {
const { t } = useTranslation();
return (
<Stack maw={960} gap="lg">
<Title order={1}>{t("events.title")}</Title>
<Text c="dimmed">{t("events.hint")}</Text>
<StateView title={t("events.emptyTitle")} text={t("events.emptyText")} />
</Stack>
);
}
function AccountEventsPageLoaded({ events }: { events: BrokerageEvent[] }) {
const { t } = useTranslation();
return (
<Stack maw={960} gap="lg">
<Title order={1}>{t("events.title")}</Title>
<Text c="dimmed">{t("events.hint")}</Text>
{events.map((event) => (
<AccountEventRow
event={event}
key={`${event.type}-${event.date}-${event.ticker}`}
/>
))}
</Stack>
);
}
function AccountEventRow({ event }: { event: BrokerageEvent }) {
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.amount && event.currency
? formatMoney({ currency: event.currency, value: event.amount })
: 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`));
}
+22
View File
@@ -141,4 +141,26 @@ describe("AccountPage states", () => {
);
expect(screen.getByText("Вторая")).toBeInTheDocument();
});
it("keeps the portfolio visible when upcoming events fail", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
HttpResponse.json({ code: "MOEX_UNAVAILABLE" }, { status: 503 }),
),
);
renderPage(
<AccountPageLoaded
accountId="account-1"
accessToken="token"
portfolio={portfolio}
/>,
);
await waitFor(() =>
expect(
screen.getByText("Не удалось загрузить будущие события."),
).toBeInTheDocument(),
);
expect(screen.getByText("Вторая")).toBeInTheDocument();
});
});
+55
View File
@@ -14,6 +14,10 @@ import { Link } from "react-router";
import { useBrokerageOperations } from "@/entities/broker-operation";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import { accountTypeKey } from "@/entities/brokerage-account";
import {
type BrokerageEvent,
useBrokerageEvents,
} from "@/entities/brokerage-event";
import type { Portfolio } from "@/entities/portfolio";
import { formatMoney, formatSnapshotTime } from "@/entities/portfolio";
import { StateView } from "@/shared/ui";
@@ -99,6 +103,7 @@ export function AccountPageEmpty({
text={t("portfolio.emptyText")}
/>
<RecentOperations accountId={accountId} accessToken={accessToken} />
<RecentEvents accountId={accountId} accessToken={accessToken} />
</Stack>
);
}
@@ -166,6 +171,7 @@ export function AccountPageLoaded({
accountId={accountId}
accessToken={accessToken ?? null}
/>
<RecentEvents accountId={accountId} accessToken={accessToken ?? null} />
</Stack>
);
}
@@ -211,6 +217,55 @@ function RecentOperations({
</Section>
);
}
function RecentEvents({
accountId,
accessToken,
}: {
accountId: string | undefined;
accessToken: string | null;
}) {
const { t } = useTranslation();
const events = useBrokerageEvents(accountId, accessToken);
return (
<Section title={t("events.recentTitle")}>
{events.isLoading ? (
<Text c="dimmed">{t("events.recentLoading")}</Text>
) : null}
{events.isError ? (
<Alert color="red">{t("events.recentError")}</Alert>
) : null}
{events.data?.events.length === 0 ? (
<Text c="dimmed">{t("events.recentEmpty")}</Text>
) : null}
{events.data?.events.slice(0, 5).map((event) => (
<RecentEvent
key={`${event.type}-${event.date}-${event.ticker}`}
event={event}
/>
))}
<Button
component={Link}
to={`/accounts/${accountId}/events`}
variant="subtle"
>
{t("events.open")}
</Button>
</Section>
);
}
function RecentEvent({ event }: { event: BrokerageEvent }) {
const { t } = useTranslation();
return (
<Paper className={classes.cash} withBorder>
<Text>{`${t(`events.type.${event.type}`)} · ${event.displayName}`}</Text>
<Text fw={700}>
{event.amount && event.currency
? formatMoney({ currency: event.currency, value: event.amount })
: t("events.noAmount")}
</Text>
</Paper>
);
}
function PositionSection({
title,
positions,