feat(events): add date filters to upcoming events

This commit is contained in:
2026-09-12 11:57:42 +03:00
parent 2580134683
commit 9469dd2fdc
11 changed files with 311 additions and 71 deletions
@@ -9,12 +9,15 @@ type EventsEndpoint =
type BrokerageEvent = type BrokerageEvent =
EventsEndpoint["responses"][200]["content"]["application/json"]["events"][number]; EventsEndpoint["responses"][200]["content"]["application/json"]["events"][number];
function useBrokerageEvents(accountId: string | undefined) { function useBrokerageEvents(
accountId: string | undefined,
query: EventsEndpoint["parameters"]["query"] = {},
) {
return useAuthenticatedQuery({ return useAuthenticatedQuery({
...apiQueryClient.queryOptions( ...apiQueryClient.queryOptions(
"get", "get",
"/api/v1/brokerage-accounts/{accountId}/events", "/api/v1/brokerage-accounts/{accountId}/events",
{ params: { path: { accountId: accountId ?? "" } } }, { params: { path: { accountId: accountId ?? "" }, query } },
), ),
enabled: Boolean(accountId), enabled: Boolean(accountId),
staleTime: 30_000, staleTime: 30_000,
@@ -0,0 +1,106 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import type { FC } from "react";
import {
MemoryRouter,
Route,
Routes,
useLocation,
useNavigate,
} from "react-router";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import { AccountEventsPage } from "./AccountEventsPage";
const Navigation: FC = () => {
const location = useLocation();
const navigate = useNavigate();
return (
<>
<output aria-label="URL">{location.search}</output>
<button type="button" onClick={() => navigate(-1)}>
History back
</button>
</>
);
};
describe("Upcoming event date filters", () => {
it.each(["account-1", "all"])(
"applies dates only on submit, restores URL dates, and resets from empty results for %s",
async (accountId) => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
});
const searches: string[] = [];
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/events",
({ request }) => {
searches.push(new URL(request.url).search);
return HttpResponse.json({
accountId,
calculatedAt: "2099-01-01T00:00:00Z",
events: [],
incomeTotalsByCurrency: [],
});
},
),
);
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider>
<MemoryRouter
initialEntries={[
`/accounts/${accountId}/events?from=2099-02-01&to=2099-02-28`,
]}
>
<Navigation />
<Routes>
<Route
path="/accounts/:accountId/events"
element={<AccountEventsPage />}
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
const from = await screen.findByLabelText("С");
const to = screen.getByLabelText("По");
expect(from).toHaveValue("01.02.2099");
expect(to).toHaveValue("28.02.2099");
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
expect(searches).toEqual(["?from=2099-02-01&to=2099-02-28"]);
fireEvent.change(from, { target: { value: "10.02.2099" } });
fireEvent.blur(from);
expect(searches).toHaveLength(1);
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
await waitFor(() =>
expect(searches.at(-1)).toBe("?from=2099-02-10&to=2099-02-28"),
);
expect(await screen.findByLabelText("С")).toHaveValue("10.02.2099");
fireEvent.click(screen.getByRole("button", { name: "History back" }));
await waitFor(() =>
expect(screen.getByLabelText("С")).toHaveValue("01.02.2099"),
);
expect(screen.getByLabelText("URL")).toHaveTextContent(
"?from=2099-02-01&to=2099-02-28",
);
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
await waitFor(() => expect(searches.at(-1)).toBe(""));
expect(await screen.findByLabelText("С")).toHaveValue("");
expect(screen.getByLabelText("По")).toHaveValue("");
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
expect(
screen.getByText("За выбранный период событий не найдено"),
).toBeVisible();
},
);
});
@@ -49,7 +49,7 @@ describe("AccountEventsPage", () => {
</MemoryRouter> </MemoryRouter>
</MantineProvider>, </MantineProvider>,
); );
expect(query).toHaveBeenCalledWith("selected-account"); expect(query).toHaveBeenCalledWith("selected-account", {});
}); });
it("keeps the loading fallback when no result or account is available", () => { it("keeps the loading fallback when no result or account is available", () => {
@@ -60,7 +60,7 @@ describe("AccountEventsPage", () => {
refetch: vi.fn(), refetch: vi.fn(),
} as never); } as never);
renderPage(); renderPage();
expect(query).toHaveBeenCalledWith(undefined); expect(query).toHaveBeenCalledWith(undefined, {});
expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute( expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute(
"aria-busy", "aria-busy",
"true", "true",
@@ -129,7 +129,7 @@ describe("AccountEventsPage", () => {
const refetch = vi.fn(); const refetch = vi.fn();
const { rerender } = renderPage(); const { rerender } = renderPage();
expect( expect(
screen.getByText("На ближайшие 30 дней событий не найдено"), screen.getByText("За выбранный период событий не найдено"),
).toBeInTheDocument(); ).toBeInTheDocument();
query.mockReturnValue({ query.mockReturnValue({
data: undefined, data: undefined,
@@ -1,6 +1,5 @@
import type { FC } from "react"; import type { FC } from "react";
import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm"; import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm";
import { AccountEventsPageEmpty } from "./components/AccountEventsPageEmpty.view";
import { AccountEventsPageError } from "./components/AccountEventsPageError.view"; import { AccountEventsPageError } from "./components/AccountEventsPageError.view";
import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view"; import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view";
import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view"; import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view";
@@ -12,8 +11,6 @@ const AccountEventsPage: FC<AccountEventsPageProps> = (props) => {
if (viewModel.state === "loading") return <AccountEventsPageLoading />; if (viewModel.state === "loading") return <AccountEventsPageLoading />;
if (viewModel.state === "error") if (viewModel.state === "error")
return <AccountEventsPageError retry={viewModel.retry} />; return <AccountEventsPageError retry={viewModel.retry} />;
if (viewModel.state === "empty")
return <AccountEventsPageEmpty {...viewModel} />;
return <AccountEventsPageLoaded {...viewModel} />; return <AccountEventsPageLoaded {...viewModel} />;
}; };
@@ -1,40 +1,81 @@
import { useParams } from "react-router"; import { useEffect, useState } from "react";
import { useParams, useSearchParams } from "react-router";
import { import {
type BrokerageEvent, type BrokerageEvent,
useBrokerageEvents, useBrokerageEvents,
} from "@/entities/brokerage-event"; } from "@/entities/brokerage-event";
type EventsRefreshState = {
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
};
type AccountEventsPageViewModel = type AccountEventsPageViewModel =
| { state: "loading" } | { state: "loading" }
| ({ state: "empty" } & EventsRefreshState)
| { state: "error"; retry: () => void } | { state: "error"; retry: () => void }
| ({ state: "loaded"; events: BrokerageEvent[] } & EventsRefreshState); | {
state: "loaded";
events: BrokerageEvent[];
refreshing: boolean;
refreshError: boolean;
retry: () => void;
draftFrom: string | null;
draftTo: string | null;
canApply: boolean;
onDraftFromChange: (value: string | null) => void;
onDraftToChange: (value: string | null) => void;
onApply: () => void;
onReset: () => void;
};
function useAccountEventsPageViewModel( function useAccountEventsPageViewModel(
scopeId?: string, scopeId?: string,
): AccountEventsPageViewModel { ): AccountEventsPageViewModel {
const { accountId: routeAccountId } = useParams(); const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId; const accountId = scopeId ?? routeAccountId;
const events = useBrokerageEvents(accountId); const [searchParams, setSearchParams] = useSearchParams();
const appliedFrom = searchParams.get("from");
const appliedTo = searchParams.get("to");
const [draftFrom, setDraftFrom] = useState<string | null>(appliedFrom);
const [draftTo, setDraftTo] = useState<string | null>(appliedTo);
useEffect(() => {
setDraftFrom(appliedFrom);
setDraftTo(appliedTo);
}, [appliedFrom, appliedTo]);
const events = useBrokerageEvents(accountId, {
...(appliedFrom ? { from: appliedFrom } : {}),
...(appliedTo ? { to: appliedTo } : {}),
});
if (events.isLoading) return { state: "loading" }; if (events.isLoading) return { state: "loading" };
if (events.isError && !events.data) if (events.isError && !events.data)
return { state: "error", retry: () => events.refetch() }; return {
state: "error",
retry: () => {
void events.refetch();
},
};
if (!events.data) return { state: "loading" }; if (!events.data) return { state: "loading" };
const refresh = { return {
state: "loaded",
events: events.data.events,
refreshing: events.isFetching, refreshing: events.isFetching,
refreshError: events.isError, refreshError: events.isError,
retry: () => { retry: () => {
void events.refetch(); void events.refetch();
}, },
draftFrom,
draftTo,
canApply:
(draftFrom !== appliedFrom || draftTo !== appliedTo) &&
(!draftFrom || !draftTo || draftFrom <= draftTo),
onDraftFromChange: setDraftFrom,
onDraftToChange: setDraftTo,
onApply: () =>
setSearchParams({
...(draftFrom ? { from: draftFrom } : {}),
...(draftTo ? { to: draftTo } : {}),
}),
onReset: () => {
setDraftFrom(null);
setDraftTo(null);
setSearchParams({});
},
}; };
if (!events.data.events.length) return { state: "empty", ...refresh };
return { state: "loaded", events: events.data.events, ...refresh };
} }
export { type EventsRefreshState, useAccountEventsPageViewModel }; export { type AccountEventsPageViewModel, useAccountEventsPageViewModel };
@@ -1,28 +1,11 @@
import { Alert, Button, Progress, Stack, Text, Title } from "@mantine/core";
import type { FC } from "react"; import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { StateView } from "@/shared/ui"; import { StateView } from "@/shared/ui";
import type { EventsRefreshState } from "../AccountEventsPage.vm";
const AccountEventsPageEmpty: FC<EventsRefreshState> = (props) => { const AccountEventsPageEmpty: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Stack gap="lg"> <StateView title={t("events.emptyTitle")} text={t("events.emptyText")} />
<Title order={1}>{t("events.title")}</Title>
{props.refreshing ? (
<Progress value={100} animated aria-label={t("accounts.refreshing")} />
) : null}
{props.refreshError ? (
<Alert color="yellow">
{t("accounts.refreshError")}
<Button variant="subtle" onClick={props.retry}>
{t("accounts.retry")}
</Button>
</Alert>
) : null}
<Text c="dimmed">{t("events.hint")}</Text>
<StateView title={t("events.emptyTitle")} text={t("events.emptyText")} />
</Stack>
); );
}; };
@@ -1,16 +1,33 @@
import { Alert, Button, Progress, Stack, Text, Title } from "@mantine/core"; import {
Alert,
Button,
Group,
Progress,
Stack,
Text,
Title,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import dayjs from "dayjs";
import customParseFormat from "dayjs/plugin/customParseFormat";
import type { FC } from "react"; import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { BrokerageEventCard } from "@/entities/brokerage-event";
type BrokerageEvent, import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
BrokerageEventCard, import { AccountEventsPageEmpty } from "./AccountEventsPageEmpty.view";
} from "@/entities/brokerage-event";
import type { EventsRefreshState } from "../AccountEventsPage.vm";
type AccountEventsPageLoadedProps = EventsRefreshState & { dayjs.extend(customParseFormat);
events: BrokerageEvent[];
const parseDate = (value: string) => {
const date = dayjs(value, "DD.MM.YYYY", true);
return date.isValid() ? date.format("YYYY-MM-DD") : null;
}; };
type AccountEventsPageLoadedProps = Extract<
AccountEventsPageViewModel,
{ state: "loaded" }
>;
const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => { const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
@@ -28,22 +45,56 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
</Alert> </Alert>
) : null} ) : null}
<Text c="dimmed">{t("events.hint")}</Text> <Text c="dimmed">{t("events.hint")}</Text>
<Stack gap="sm"> <Group align="end">
{props.events.map((event) => ( <DateInput
<BrokerageEventCard label={t("ledger.from")}
event={event} value={props.draftFrom}
key={JSON.stringify([ onChange={props.onDraftFromChange}
event.account?.id, maxDate={props.draftTo ?? undefined}
event.type, valueFormat="DD.MM.YYYY"
event.date, dateParser={parseDate}
event.instrumentUid, allowDeselect
event.figi, popoverProps={{ middlewares: { shift: { crossAxis: true } } }}
event.ticker, />
event.logoName, <DateInput
])} label={t("ledger.to")}
/> value={props.draftTo}
))} onChange={props.onDraftToChange}
</Stack> minDate={props.draftFrom ?? undefined}
valueFormat="DD.MM.YYYY"
dateParser={parseDate}
allowDeselect
popoverProps={{ middlewares: { shift: { crossAxis: true } } }}
/>
</Group>
<Group>
<Button onClick={props.onApply} disabled={!props.canApply}>
{t("ledger.apply")}
</Button>
<Button variant="default" onClick={props.onReset}>
{t("ledger.reset")}
</Button>
</Group>
{props.events.length ? (
<Stack gap="sm">
{props.events.map((event) => (
<BrokerageEventCard
event={event}
key={JSON.stringify([
event.account?.id,
event.type,
event.date,
event.instrumentUid,
event.figi,
event.ticker,
event.logoName,
])}
/>
))}
</Stack>
) : (
<AccountEventsPageEmpty />
)}
</Stack> </Stack>
); );
}; };
@@ -1,4 +1,4 @@
import { Skeleton } from "@mantine/core"; import { Group, Skeleton } from "@mantine/core";
import type { FC } from "react"; import type { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { LoadingSkeleton } from "@/shared/ui"; import { LoadingSkeleton } from "@/shared/ui";
@@ -9,6 +9,14 @@ const AccountEventsPageLoading: FC = () => {
<LoadingSkeleton label={t("states.loadingTitle")}> <LoadingSkeleton label={t("states.loadingTitle")}>
<Skeleton height={32} width="40%" /> <Skeleton height={32} width="40%" />
<Skeleton height={18} width="70%" /> <Skeleton height={18} width="70%" />
<Group>
<Skeleton height={60} width={220} />
<Skeleton height={60} width={220} />
</Group>
<Group>
<Skeleton height={36} width={116} />
<Skeleton height={36} width={108} />
</Group>
{[0, 1, 2, 3].map((index) => ( {[0, 1, 2, 3].map((index) => (
<Skeleton key={index} height={82} radius="md" /> <Skeleton key={index} height={82} radius="md" />
))} ))}
+12 -2
View File
@@ -3004,7 +3004,12 @@ export interface operations {
}; };
UpcomingEventsController_all: { UpcomingEventsController_all: {
parameters: { parameters: {
query?: never; query?: {
/** @description Inclusive start date (YYYY-MM-DD). Defaults to today in UTC. */
from?: string;
/** @description Inclusive end date (YYYY-MM-DD). Defaults to 30 days after from. */
to?: string;
};
header?: never; header?: never;
path?: never; path?: never;
cookie?: never; cookie?: never;
@@ -3068,7 +3073,12 @@ export interface operations {
}; };
UpcomingEventsController_get: { UpcomingEventsController_get: {
parameters: { parameters: {
query?: never; query?: {
/** @description Inclusive start date (YYYY-MM-DD). Defaults to today in UTC. */
from?: string;
/** @description Inclusive end date (YYYY-MM-DD). Defaults to 30 days after from. */
to?: string;
};
header?: never; header?: never;
path: { path: {
accountId: string; accountId: string;
+42 -1
View File
@@ -2544,7 +2544,28 @@
"/api/v1/brokerage-accounts/all/events": { "/api/v1/brokerage-accounts/all/events": {
"get": { "get": {
"operationId": "UpcomingEventsController_all", "operationId": "UpcomingEventsController_all",
"parameters": [], "parameters": [
{
"name": "from",
"required": false,
"in": "query",
"description": "Inclusive start date (YYYY-MM-DD). Defaults to today in UTC.",
"schema": {
"format": "date",
"type": "string"
}
},
{
"name": "to",
"required": false,
"in": "query",
"description": "Inclusive end date (YYYY-MM-DD). Defaults to 30 days after from.",
"schema": {
"format": "date",
"type": "string"
}
}
],
"responses": { "responses": {
"200": { "200": {
"description": "", "description": "",
@@ -2628,6 +2649,26 @@
"schema": { "schema": {
"type": "string" "type": "string"
} }
},
{
"name": "from",
"required": false,
"in": "query",
"description": "Inclusive start date (YYYY-MM-DD). Defaults to today in UTC.",
"schema": {
"format": "date",
"type": "string"
}
},
{
"name": "to",
"required": false,
"in": "query",
"description": "Inclusive end date (YYYY-MM-DD). Defaults to 30 days after from.",
"schema": {
"format": "date",
"type": "string"
}
} }
], ],
"responses": { "responses": {
+1 -1
View File
@@ -443,7 +443,7 @@ const ru = {
recentError: "Не удалось загрузить будущие события.", recentError: "Не удалось загрузить будущие события.",
recentEmpty: "На ближайшие 30 дней событий не найдено.", recentEmpty: "На ближайшие 30 дней событий не найдено.",
hint: "Прогноз рассчитан по текущей позиции до налогов. Покрытие зависит от доступных данных MOEX.", hint: "Прогноз рассчитан по текущей позиции до налогов. Покрытие зависит от доступных данных MOEX.",
emptyTitle: "На ближайшие 30 дней событий не найдено", emptyTitle: "За выбранный период событий не найдено",
emptyText: emptyText:
"Доступные источники не сообщили о купонах, дивидендах, погашениях или офертах в этом периоде.", "Доступные источники не сообщили о купонах, дивидендах, погашениях или офертах в этом периоде.",
errorTitle: "Не удалось загрузить будущие события", errorTitle: "Не удалось загрузить будущие события",