diff --git a/src/entities/brokerage-event/api/useBrokerageEvents.ts b/src/entities/brokerage-event/api/useBrokerageEvents.ts
index d7c74be..7970606 100644
--- a/src/entities/brokerage-event/api/useBrokerageEvents.ts
+++ b/src/entities/brokerage-event/api/useBrokerageEvents.ts
@@ -9,12 +9,15 @@ type EventsEndpoint =
type BrokerageEvent =
EventsEndpoint["responses"][200]["content"]["application/json"]["events"][number];
-function useBrokerageEvents(accountId: string | undefined) {
+function useBrokerageEvents(
+ accountId: string | undefined,
+ query: EventsEndpoint["parameters"]["query"] = {},
+) {
return useAuthenticatedQuery({
...apiQueryClient.queryOptions(
"get",
"/api/v1/brokerage-accounts/{accountId}/events",
- { params: { path: { accountId: accountId ?? "" } } },
+ { params: { path: { accountId: accountId ?? "" }, query } },
),
enabled: Boolean(accountId),
staleTime: 30_000,
diff --git a/src/pages/account-events/ui/AccountEventsPage.filters.test.tsx b/src/pages/account-events/ui/AccountEventsPage.filters.test.tsx
new file mode 100644
index 0000000..bd09a61
--- /dev/null
+++ b/src/pages/account-events/ui/AccountEventsPage.filters.test.tsx
@@ -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 (
+ <>
+
+
+ >
+ );
+};
+
+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(
+
+
+
+
+
+ }
+ />
+
+
+
+ ,
+ );
+ 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();
+ },
+ );
+});
diff --git a/src/pages/account-events/ui/AccountEventsPage.test.tsx b/src/pages/account-events/ui/AccountEventsPage.test.tsx
index 11e25d7..d4a94ba 100644
--- a/src/pages/account-events/ui/AccountEventsPage.test.tsx
+++ b/src/pages/account-events/ui/AccountEventsPage.test.tsx
@@ -49,7 +49,7 @@ describe("AccountEventsPage", () => {
,
);
- expect(query).toHaveBeenCalledWith("selected-account");
+ expect(query).toHaveBeenCalledWith("selected-account", {});
});
it("keeps the loading fallback when no result or account is available", () => {
@@ -60,7 +60,7 @@ describe("AccountEventsPage", () => {
refetch: vi.fn(),
} as never);
renderPage();
- expect(query).toHaveBeenCalledWith(undefined);
+ expect(query).toHaveBeenCalledWith(undefined, {});
expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute(
"aria-busy",
"true",
@@ -129,7 +129,7 @@ describe("AccountEventsPage", () => {
const refetch = vi.fn();
const { rerender } = renderPage();
expect(
- screen.getByText("На ближайшие 30 дней событий не найдено"),
+ screen.getByText("За выбранный период событий не найдено"),
).toBeInTheDocument();
query.mockReturnValue({
data: undefined,
diff --git a/src/pages/account-events/ui/AccountEventsPage.tsx b/src/pages/account-events/ui/AccountEventsPage.tsx
index 9638ca3..19224e0 100644
--- a/src/pages/account-events/ui/AccountEventsPage.tsx
+++ b/src/pages/account-events/ui/AccountEventsPage.tsx
@@ -1,6 +1,5 @@
import type { FC } from "react";
import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm";
-import { AccountEventsPageEmpty } from "./components/AccountEventsPageEmpty.view";
import { AccountEventsPageError } from "./components/AccountEventsPageError.view";
import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view";
import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view";
@@ -12,8 +11,6 @@ const AccountEventsPage: FC = (props) => {
if (viewModel.state === "loading") return ;
if (viewModel.state === "error")
return ;
- if (viewModel.state === "empty")
- return ;
return ;
};
diff --git a/src/pages/account-events/ui/AccountEventsPage.vm.ts b/src/pages/account-events/ui/AccountEventsPage.vm.ts
index e5984ca..9737804 100644
--- a/src/pages/account-events/ui/AccountEventsPage.vm.ts
+++ b/src/pages/account-events/ui/AccountEventsPage.vm.ts
@@ -1,40 +1,81 @@
-import { useParams } from "react-router";
+import { useEffect, useState } from "react";
+import { useParams, useSearchParams } from "react-router";
import {
type BrokerageEvent,
useBrokerageEvents,
} from "@/entities/brokerage-event";
-type EventsRefreshState = {
- refreshing?: boolean;
- refreshError?: boolean;
- retry?: () => void;
-};
-
type AccountEventsPageViewModel =
| { state: "loading" }
- | ({ state: "empty" } & EventsRefreshState)
| { 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(
scopeId?: string,
): AccountEventsPageViewModel {
const { accountId: routeAccountId } = useParams();
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(appliedFrom);
+ const [draftTo, setDraftTo] = useState(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.isError && !events.data)
- return { state: "error", retry: () => events.refetch() };
+ return {
+ state: "error",
+ retry: () => {
+ void events.refetch();
+ },
+ };
if (!events.data) return { state: "loading" };
- const refresh = {
+ return {
+ state: "loaded",
+ events: events.data.events,
refreshing: events.isFetching,
refreshError: events.isError,
retry: () => {
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 };
diff --git a/src/pages/account-events/ui/components/AccountEventsPageEmpty.view.tsx b/src/pages/account-events/ui/components/AccountEventsPageEmpty.view.tsx
index 6318a6c..5e09eb9 100644
--- a/src/pages/account-events/ui/components/AccountEventsPageEmpty.view.tsx
+++ b/src/pages/account-events/ui/components/AccountEventsPageEmpty.view.tsx
@@ -1,28 +1,11 @@
-import { Alert, Button, Progress, Stack, Text, Title } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { StateView } from "@/shared/ui";
-import type { EventsRefreshState } from "../AccountEventsPage.vm";
-const AccountEventsPageEmpty: FC = (props) => {
+const AccountEventsPageEmpty: FC = () => {
const { t } = useTranslation();
return (
-
- {t("events.title")}
- {props.refreshing ? (
-
- ) : null}
- {props.refreshError ? (
-
- {t("accounts.refreshError")}
-
-
- ) : null}
- {t("events.hint")}
-
-
+
);
};
diff --git a/src/pages/account-events/ui/components/AccountEventsPageLoaded.view.tsx b/src/pages/account-events/ui/components/AccountEventsPageLoaded.view.tsx
index 6067a8d..ccc82e5 100644
--- a/src/pages/account-events/ui/components/AccountEventsPageLoaded.view.tsx
+++ b/src/pages/account-events/ui/components/AccountEventsPageLoaded.view.tsx
@@ -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 { useTranslation } from "react-i18next";
-import {
- type BrokerageEvent,
- BrokerageEventCard,
-} from "@/entities/brokerage-event";
-import type { EventsRefreshState } from "../AccountEventsPage.vm";
+import { BrokerageEventCard } from "@/entities/brokerage-event";
+import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
+import { AccountEventsPageEmpty } from "./AccountEventsPageEmpty.view";
-type AccountEventsPageLoadedProps = EventsRefreshState & {
- events: BrokerageEvent[];
+dayjs.extend(customParseFormat);
+
+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 = (props) => {
const { t } = useTranslation();
return (
@@ -28,22 +45,56 @@ const AccountEventsPageLoaded: FC = (props) => {
) : null}
{t("events.hint")}
-
- {props.events.map((event) => (
-
- ))}
-
+
+
+
+
+
+
+
+
+ {props.events.length ? (
+
+ {props.events.map((event) => (
+
+ ))}
+
+ ) : (
+
+ )}
);
};
diff --git a/src/pages/account-events/ui/components/AccountEventsPageLoading.view.tsx b/src/pages/account-events/ui/components/AccountEventsPageLoading.view.tsx
index 1f91713..6745ff2 100644
--- a/src/pages/account-events/ui/components/AccountEventsPageLoading.view.tsx
+++ b/src/pages/account-events/ui/components/AccountEventsPageLoading.view.tsx
@@ -1,4 +1,4 @@
-import { Skeleton } from "@mantine/core";
+import { Group, Skeleton } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { LoadingSkeleton } from "@/shared/ui";
@@ -9,6 +9,14 @@ const AccountEventsPageLoading: FC = () => {
+
+
+
+
+
+
+
+
{[0, 1, 2, 3].map((index) => (
))}
diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts
index 1bc836a..e57ad76 100644
--- a/src/shared/api/generated/schema.d.ts
+++ b/src/shared/api/generated/schema.d.ts
@@ -3004,7 +3004,12 @@ export interface operations {
};
UpcomingEventsController_all: {
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;
path?: never;
cookie?: never;
@@ -3068,7 +3073,12 @@ export interface operations {
};
UpcomingEventsController_get: {
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;
path: {
accountId: string;
diff --git a/src/shared/api/spec/openapi.json b/src/shared/api/spec/openapi.json
index f322455..4780769 100644
--- a/src/shared/api/spec/openapi.json
+++ b/src/shared/api/spec/openapi.json
@@ -2544,7 +2544,28 @@
"/api/v1/brokerage-accounts/all/events": {
"get": {
"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": {
"200": {
"description": "",
@@ -2628,6 +2649,26 @@
"schema": {
"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": {
diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts
index 8b7bad3..83f3712 100644
--- a/src/shared/i18n/locales/ru.ts
+++ b/src/shared/i18n/locales/ru.ts
@@ -443,7 +443,7 @@ const ru = {
recentError: "Не удалось загрузить будущие события.",
recentEmpty: "На ближайшие 30 дней событий не найдено.",
hint: "Прогноз рассчитан по текущей позиции до налогов. Покрытие зависит от доступных данных MOEX.",
- emptyTitle: "На ближайшие 30 дней событий не найдено",
+ emptyTitle: "За выбранный период событий не найдено",
emptyText:
"Доступные источники не сообщили о купонах, дивидендах, погашениях или офертах в этом периоде.",
errorTitle: "Не удалось загрузить будущие события",