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 ( + <> + {location.search} + + + ); +}; + +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: "Не удалось загрузить будущие события",