feat: replace paired date inputs with range pickers
This commit is contained in:
@@ -55,7 +55,7 @@ describe("Upcoming event date filters", () => {
|
|||||||
);
|
);
|
||||||
render(
|
render(
|
||||||
<QueryClientProvider client={createTestQueryClient()}>
|
<QueryClientProvider client={createTestQueryClient()}>
|
||||||
<MantineProvider>
|
<MantineProvider env="test">
|
||||||
<MemoryRouter
|
<MemoryRouter
|
||||||
initialEntries={[
|
initialEntries={[
|
||||||
`/accounts/${accountId}/events?from=2099-02-01&to=2099-02-28`,
|
`/accounts/${accountId}/events?from=2099-02-01&to=2099-02-28`,
|
||||||
@@ -72,31 +72,37 @@ describe("Upcoming event date filters", () => {
|
|||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
const from = await screen.findByLabelText("С");
|
const from = await screen.findByLabelText("Период");
|
||||||
const to = screen.getByLabelText("По");
|
expect(from).toHaveTextContent("01.02.2099 – 28.02.2099");
|
||||||
expect(from).toHaveValue("01.02.2099");
|
|
||||||
expect(to).toHaveValue("28.02.2099");
|
|
||||||
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
|
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
|
||||||
expect(searches).toEqual(["?from=2099-02-01&to=2099-02-28"]);
|
expect(searches).toEqual(["?from=2099-02-01&to=2099-02-28"]);
|
||||||
fireEvent.change(from, { target: { value: "10.02.2099" } });
|
fireEvent.click(from);
|
||||||
fireEvent.blur(from);
|
fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: /10 February 2099/i }),
|
||||||
|
);
|
||||||
|
fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: /28 February 2099/i }),
|
||||||
|
);
|
||||||
expect(searches).toHaveLength(1);
|
expect(searches).toHaveLength(1);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
|
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(searches.at(-1)).toBe("?from=2099-02-10&to=2099-02-28"),
|
expect(searches.at(-1)).toBe("?from=2099-02-10&to=2099-02-28"),
|
||||||
);
|
);
|
||||||
expect(await screen.findByLabelText("С")).toHaveValue("10.02.2099");
|
expect(await screen.findByLabelText("Период")).toHaveTextContent(
|
||||||
|
"10.02.2099 – 28.02.2099",
|
||||||
|
);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "History back" }));
|
fireEvent.click(screen.getByRole("button", { name: "History back" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByLabelText("С")).toHaveValue("01.02.2099"),
|
expect(screen.getByLabelText("Период")).toHaveTextContent(
|
||||||
|
"01.02.2099 – 28.02.2099",
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(screen.getByLabelText("URL")).toHaveTextContent(
|
expect(screen.getByLabelText("URL")).toHaveTextContent(
|
||||||
"?from=2099-02-01&to=2099-02-28",
|
"?from=2099-02-01&to=2099-02-28",
|
||||||
);
|
);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
|
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
|
||||||
await waitFor(() => expect(searches.at(-1)).toBe(""));
|
await waitFor(() => expect(searches.at(-1)).toBe(""));
|
||||||
expect(await screen.findByLabelText("С")).toHaveValue("");
|
expect(await screen.findByLabelText("Период")).toHaveTextContent("");
|
||||||
expect(screen.getByLabelText("По")).toHaveValue("");
|
|
||||||
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
|
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("За выбранный период событий не найдено"),
|
screen.getByText("За выбранный период событий не найдено"),
|
||||||
|
|||||||
@@ -7,22 +7,13 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { DateInput } from "@mantine/dates";
|
import { DatePickerInput } 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 { BrokerageEventCard } from "@/entities/brokerage-event";
|
import { BrokerageEventCard } from "@/entities/brokerage-event";
|
||||||
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
|
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
|
||||||
import { AccountEventsPageEmpty } from "./AccountEventsPageEmpty.view";
|
import { AccountEventsPageEmpty } from "./AccountEventsPageEmpty.view";
|
||||||
|
|
||||||
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<
|
type AccountEventsPageLoadedProps = Extract<
|
||||||
AccountEventsPageViewModel,
|
AccountEventsPageViewModel,
|
||||||
{ state: "loaded" }
|
{ state: "loaded" }
|
||||||
@@ -46,25 +37,22 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
|
|||||||
) : null}
|
) : null}
|
||||||
<Text c="dimmed">{t("events.hint")}</Text>
|
<Text c="dimmed">{t("events.hint")}</Text>
|
||||||
<Group align="end">
|
<Group align="end">
|
||||||
<DateInput
|
<DatePickerInput
|
||||||
label={t("ledger.from")}
|
type="range"
|
||||||
value={props.draftFrom}
|
allowSingleDateInRange
|
||||||
onChange={props.onDraftFromChange}
|
clearable
|
||||||
maxDate={props.draftTo ?? undefined}
|
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
|
||||||
|
label={t("ledger.period")}
|
||||||
|
value={[props.draftFrom, props.draftTo]}
|
||||||
|
onChange={([from, to]) => {
|
||||||
|
props.onDraftFromChange(from);
|
||||||
|
props.onDraftToChange(to);
|
||||||
|
}}
|
||||||
valueFormat="DD.MM.YYYY"
|
valueFormat="DD.MM.YYYY"
|
||||||
dateParser={parseDate}
|
popoverProps={{
|
||||||
allowDeselect
|
returnFocus: true,
|
||||||
popoverProps={{ middlewares: { shift: { crossAxis: true } } }}
|
middlewares: { shift: { crossAxis: true } },
|
||||||
/>
|
}}
|
||||||
<DateInput
|
|
||||||
label={t("ledger.to")}
|
|
||||||
value={props.draftTo}
|
|
||||||
onChange={props.onDraftToChange}
|
|
||||||
minDate={props.draftFrom ?? undefined}
|
|
||||||
valueFormat="DD.MM.YYYY"
|
|
||||||
dateParser={parseDate}
|
|
||||||
allowDeselect
|
|
||||||
popoverProps={{ middlewares: { shift: { crossAxis: true } } }}
|
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Group>
|
<Group>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
.fields {
|
.fields {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: minmax(0, 1fr);
|
||||||
gap: var(--mantine-spacing-sm);
|
gap: var(--mantine-spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -523,12 +523,10 @@ const LocationProbe: FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function inputDate(label: string, value: string) {
|
function selectPeriod() {
|
||||||
const field = screen.getByLabelText(label);
|
fireEvent.click(screen.getByLabelText("Период"));
|
||||||
fireEvent.focus(field);
|
fireEvent.click(screen.getByRole("button", { name: /^2 августа 2026$/i }));
|
||||||
fireEvent.input(field, { target: { value } });
|
fireEvent.click(screen.getByRole("button", { name: /^27 августа 2026$/i }));
|
||||||
fireEvent.blur(field);
|
|
||||||
return field;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function respondWithRequestedPeriod(requests: string[]) {
|
function respondWithRequestedPeriod(requests: string[]) {
|
||||||
@@ -557,9 +555,10 @@ describe("income period and snapshot boundary", () => {
|
|||||||
const requests: string[] = [];
|
const requests: string[] = [];
|
||||||
respondWithRequestedPeriod(requests);
|
respondWithRequestedPeriod(requests);
|
||||||
renderPage();
|
renderPage();
|
||||||
expect(await screen.findByLabelText("С")).toHaveValue("01.08.2026");
|
expect(await screen.findByLabelText("Период")).toHaveTextContent(
|
||||||
expect(screen.getByLabelText("По")).toHaveValue("27.08.2026");
|
"01.08.2026 – 27.08.2026",
|
||||||
inputDate("С", "02.08.2026");
|
);
|
||||||
|
selectPeriod();
|
||||||
expect(requests).toHaveLength(1);
|
expect(requests).toHaveLength(1);
|
||||||
expect(
|
expect(
|
||||||
screen.queryByText("2026-08-01 — 2026-08-27"),
|
screen.queryByText("2026-08-01 — 2026-08-27"),
|
||||||
@@ -573,66 +572,37 @@ describe("income period and snapshot boundary", () => {
|
|||||||
.closest("form") as HTMLFormElement,
|
.closest("form") as HTMLFormElement,
|
||||||
);
|
);
|
||||||
await waitFor(() => expect(requests).toHaveLength(2));
|
await waitFor(() => expect(requests).toHaveLength(2));
|
||||||
expect(await screen.findByLabelText("С")).toHaveValue("02.08.2026");
|
expect(await screen.findByLabelText("Период")).toHaveTextContent(
|
||||||
|
"02.08.2026 – 27.08.2026",
|
||||||
|
);
|
||||||
expect(screen.getByTestId("location")).toHaveTextContent(
|
expect(screen.getByTestId("location")).toHaveTextContent(
|
||||||
"from=2026-08-02&to=2026-08-27",
|
"from=2026-08-02&to=2026-08-27",
|
||||||
);
|
);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Back" }));
|
fireEvent.click(screen.getByRole("button", { name: "Back" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByLabelText("С")).toHaveValue("01.08.2026"),
|
expect(screen.getByLabelText("Период")).toHaveTextContent(
|
||||||
|
"01.08.2026 – 27.08.2026",
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it("rejects an incomplete range without replacing the URL and allows a single day", async () => {
|
||||||
["С", "", "Введите существующую дату"],
|
const requests: string[] = [];
|
||||||
["С", "31.02.2026", "Введите существующую дату"],
|
respondWithRequestedPeriod(requests);
|
||||||
["По", "29.02.2026", "Введите существующую дату"],
|
|
||||||
["По", "2026-08-02", "Введите существующую дату"],
|
|
||||||
["С", "01.01.2099", "Дата не может быть позже"],
|
|
||||||
["По", "01.01.2099", "Дата не может быть позже"],
|
|
||||||
["По", "31.07.2026", "Укажите корректный период"],
|
|
||||||
])(
|
|
||||||
"rejects visible %s=%s without replacing data or URL",
|
|
||||||
async (label, value, error) => {
|
|
||||||
const requests: string[] = [];
|
|
||||||
respondWithRequestedPeriod(requests);
|
|
||||||
renderPage();
|
|
||||||
await screen.findByLabelText("С");
|
|
||||||
const field = inputDate(label, value);
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
|
|
||||||
expect(await screen.findByText(new RegExp(error))).toBeInTheDocument();
|
|
||||||
expect(field).toHaveValue(value);
|
|
||||||
expect(field).toHaveAttribute("aria-invalid", "true");
|
|
||||||
await waitFor(() => expect(field).toHaveFocus());
|
|
||||||
expect(requests).toHaveLength(1);
|
|
||||||
expect(screen.getByTestId("location")).toHaveTextContent(
|
|
||||||
"from=2026-08-01&to=2026-08-27",
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
screen.queryByText("2026-08-01 — 2026-08-27"),
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it("replaces invalid raw text with a calendar choice and closes the calendar with Escape", async () => {
|
|
||||||
respondWithRequestedPeriod([]);
|
|
||||||
renderPage();
|
renderPage();
|
||||||
const from = await screen.findByLabelText("С");
|
const period = await screen.findByLabelText("Период");
|
||||||
act(() => from.focus());
|
fireEvent.click(period);
|
||||||
fireEvent.input(from, { target: { value: "invalid" } });
|
fireEvent.click(screen.getByRole("button", { name: /^2 августа 2026$/i }));
|
||||||
// The selected month stays available even when the visible draft is invalid.
|
fireEvent.keyDown(period, { key: "Escape" });
|
||||||
const day = await screen.findByRole("button", {
|
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
|
||||||
name: /^2 августа 2026$/i,
|
expect(await screen.findByText(/Введите существующую дату/)).toBeVisible();
|
||||||
});
|
expect(requests).toHaveLength(1);
|
||||||
fireEvent.click(day);
|
if (period.getAttribute("aria-expanded") !== "true")
|
||||||
expect(from).toHaveValue("02.08.2026");
|
fireEvent.click(period);
|
||||||
act(() => from.focus());
|
fireEvent.click(screen.getByRole("button", { name: /^2 августа 2026$/i }));
|
||||||
fireEvent.keyDown(from, { key: "Escape" });
|
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(
|
expect(requests.at(-1)).toBe("?from=2026-08-02&to=2026-08-02"),
|
||||||
screen.queryByRole("button", { name: /^2 августа 2026$/i }),
|
|
||||||
).not.toBeInTheDocument(),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -642,19 +612,23 @@ describe("income period and snapshot boundary", () => {
|
|||||||
const requests: string[] = [];
|
const requests: string[] = [];
|
||||||
respondWithRequestedPeriod(requests);
|
respondWithRequestedPeriod(requests);
|
||||||
renderPage("");
|
renderPage("");
|
||||||
expect(await screen.findByLabelText("С")).toHaveValue("01.09.2026");
|
expect(await screen.findByLabelText("Период")).toHaveTextContent(
|
||||||
expect(screen.getByLabelText("По")).toHaveValue("01.09.2026");
|
"01.09.2026 – 01.09.2026",
|
||||||
inputDate("С", "wrong");
|
);
|
||||||
|
fireEvent.click(screen.getByLabelText("Период"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^1 сентября 2026$/i }));
|
||||||
|
fireEvent.keyDown(screen.getByLabelText("Период"), { key: "Escape" });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
|
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
|
||||||
await screen.findByText(/Введите существующую дату/);
|
await screen.findByText(/Введите существующую дату/);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
|
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByLabelText("С")).toHaveValue("01.09.2026"),
|
expect(screen.getByLabelText("Период")).toHaveTextContent(
|
||||||
|
"01.09.2026 – 01.09.2026",
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
screen.queryByText(/Введите существующую дату/),
|
screen.queryByText(/Введите существующую дату/),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
expect(screen.getByLabelText("По")).toHaveValue("01.09.2026");
|
|
||||||
expect(screen.getByTestId("location")).toHaveTextContent(
|
expect(screen.getByTestId("location")).toHaveTextContent(
|
||||||
"from=2026-09-01&to=2026-09-01",
|
"from=2026-09-01&to=2026-09-01",
|
||||||
);
|
);
|
||||||
@@ -664,8 +638,8 @@ describe("income period and snapshot boundary", () => {
|
|||||||
it("keeps valid data and draft during refetch, warns on failure and retries", async () => {
|
it("keeps valid data and draft during refetch, warns on failure and retries", async () => {
|
||||||
respondWithRequestedPeriod([]);
|
respondWithRequestedPeriod([]);
|
||||||
const { queryClient } = renderPage();
|
const { queryClient } = renderPage();
|
||||||
await screen.findByLabelText("С");
|
await screen.findByLabelText("Период");
|
||||||
inputDate("С", "02.08.2026");
|
selectPeriod();
|
||||||
let release!: () => void;
|
let release!: () => void;
|
||||||
const pending = new Promise<void>((resolve) => {
|
const pending = new Promise<void>((resolve) => {
|
||||||
release = resolve;
|
release = resolve;
|
||||||
@@ -686,7 +660,9 @@ describe("income period and snapshot boundary", () => {
|
|||||||
await screen.findByRole("status", { name: "Обновляем пассивный доход" }),
|
await screen.findByRole("status", { name: "Обновляем пассивный доход" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible();
|
expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible();
|
||||||
expect(screen.getByLabelText("С")).toHaveValue("02.08.2026");
|
expect(screen.getByLabelText("Период")).toHaveTextContent(
|
||||||
|
"02.08.2026 – 27.08.2026",
|
||||||
|
);
|
||||||
release();
|
release();
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText(/Показан последний загруженный результат/),
|
await screen.findByText(/Показан последний загруженный результат/),
|
||||||
@@ -699,13 +675,15 @@ describe("income period and snapshot boundary", () => {
|
|||||||
screen.queryByText(/Показан последний загруженный результат/),
|
screen.queryByText(/Показан последний загруженный результат/),
|
||||||
).not.toBeInTheDocument(),
|
).not.toBeInTheDocument(),
|
||||||
);
|
);
|
||||||
expect(screen.getByLabelText("С")).toHaveValue("02.08.2026");
|
expect(screen.getByLabelText("Период")).toHaveTextContent(
|
||||||
|
"02.08.2026 – 27.08.2026",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows busy skeleton for a new uncached period without relabeling the previous result", async () => {
|
it("shows busy skeleton for a new uncached period without relabeling the previous result", async () => {
|
||||||
respondWithRequestedPeriod([]);
|
respondWithRequestedPeriod([]);
|
||||||
renderPage();
|
renderPage();
|
||||||
await screen.findByLabelText("С");
|
await screen.findByLabelText("Период");
|
||||||
let release!: () => void;
|
let release!: () => void;
|
||||||
const pending = new Promise<void>((resolve) => {
|
const pending = new Promise<void>((resolve) => {
|
||||||
release = resolve;
|
release = resolve;
|
||||||
@@ -722,7 +700,7 @@ describe("income period and snapshot boundary", () => {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
inputDate("С", "02.08.2026");
|
selectPeriod();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
|
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole("status", { name: "Загрузка" }),
|
await screen.findByRole("status", { name: "Загрузка" }),
|
||||||
@@ -734,7 +712,9 @@ describe("income period and snapshot boundary", () => {
|
|||||||
screen.queryByText("2026-08-02 — 2026-08-27"),
|
screen.queryByText("2026-08-02 — 2026-08-27"),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
release();
|
release();
|
||||||
expect(await screen.findByLabelText("С")).toHaveValue("02.08.2026");
|
expect(await screen.findByLabelText("Период")).toHaveTextContent(
|
||||||
|
"02.08.2026 – 27.08.2026",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows one flat payment card per entry in server order without currency or instrument groups", () => {
|
it("shows one flat payment card per entry in server order without currency or instrument groups", () => {
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ const useAccountIncomePageViewModel = (
|
|||||||
const onReset = () => {
|
const onReset = () => {
|
||||||
const next = defaultPeriod();
|
const next = defaultPeriod();
|
||||||
form.reset(draftPeriod(next));
|
form.reset(draftPeriod(next));
|
||||||
// DateInput retains raw text while its calendar is open; reset that UI state too.
|
// Reset the open calendar along with the form.
|
||||||
setFormRevision((revision) => revision + 1);
|
setFormRevision((revision) => revision + 1);
|
||||||
setSearchParams(next, { replace: true });
|
setSearchParams(next, { replace: true });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Button, Paper, Stack, Text } from "@mantine/core";
|
import { Button, Paper, Stack, Text } from "@mantine/core";
|
||||||
import { DateInput } from "@mantine/dates";
|
import { DatePickerInput } from "@mantine/dates";
|
||||||
import type { FC, FormEventHandler } from "react";
|
import type { FC, FormEventHandler } from "react";
|
||||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
import { useController, useFormContext, useWatch } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import classes from "../AccountIncomePage.module.css";
|
import classes from "../AccountIncomePage.module.css";
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +22,8 @@ const IncomePeriodForm: FC<IncomePeriodFormProps> = (props) => {
|
|||||||
control,
|
control,
|
||||||
formState: { isSubmitting },
|
formState: { isSubmitting },
|
||||||
} = useFormContext<IncomeForm>();
|
} = useFormContext<IncomeForm>();
|
||||||
|
const fromField = useController({ control, name: "from" });
|
||||||
|
const toField = useController({ control, name: "to" });
|
||||||
const [from, to] = useWatch({ control, name: ["from", "to"] });
|
const [from, to] = useWatch({ control, name: ["from", "to"] });
|
||||||
const today = moscowDate(new Date());
|
const today = moscowDate(new Date());
|
||||||
const start = parseDraftDate(from);
|
const start = parseDraftDate(from);
|
||||||
@@ -37,39 +39,34 @@ const IncomePeriodForm: FC<IncomePeriodFormProps> = (props) => {
|
|||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Text fw={700}>{t("income.period")}</Text>
|
<Text fw={700}>{t("income.period")}</Text>
|
||||||
<div className={classes.fields}>
|
<div className={classes.fields}>
|
||||||
{(["from", "to"] as const).map((name) => (
|
<DatePickerInput
|
||||||
<Controller
|
type="range"
|
||||||
key={name}
|
allowSingleDateInRange
|
||||||
name={name}
|
clearable
|
||||||
control={control}
|
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
|
||||||
render={({ field, fieldState }) => (
|
ref={fromField.field.ref}
|
||||||
<DateInput
|
label={t("income.period")}
|
||||||
ref={field.ref}
|
valueFormat="DD.MM.YYYY"
|
||||||
label={t(`income.${name}`)}
|
value={[start, end]}
|
||||||
placeholder={t("income.datePlaceholder")}
|
onChange={([from, to]) => {
|
||||||
valueFormat="DD.MM.YYYY"
|
fromField.field.onChange(from ? formatDraftDate(from) : "");
|
||||||
value={parseDraftDate(field.value)}
|
toField.field.onChange(to ? formatDraftDate(to) : "");
|
||||||
dateParser={parseDraftDate}
|
}}
|
||||||
fixOnBlur={false}
|
onBlur={() => {
|
||||||
popoverProps={{ middlewares: { shift: { crossAxis: true } } }}
|
fromField.field.onBlur();
|
||||||
onInput={(event) => field.onChange(event.currentTarget.value)}
|
toField.field.onBlur();
|
||||||
onChange={(value) =>
|
}}
|
||||||
field.onChange(value ? formatDraftDate(value) : "")
|
maxDate={today}
|
||||||
}
|
error={
|
||||||
onBlur={(event) => {
|
fromField.fieldState.error?.message ??
|
||||||
field.onChange(event.currentTarget.value);
|
toField.fieldState.error?.message
|
||||||
field.onBlur();
|
}
|
||||||
}}
|
disabled={isSubmitting}
|
||||||
minDate={
|
popoverProps={{
|
||||||
name === "to" && start && start <= today ? start : undefined
|
returnFocus: true,
|
||||||
}
|
middlewares: { shift: { crossAxis: true } },
|
||||||
maxDate={name === "from" && end && end <= today ? end : today}
|
}}
|
||||||
error={fieldState.error?.message}
|
/>
|
||||||
disabled={isSubmitting}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<div className={classes.actions}>
|
<div className={classes.actions}>
|
||||||
<Button type="submit" loading={isSubmitting}>
|
<Button type="submit" loading={isSubmitting}>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { DateInput } from "@mantine/dates";
|
import { DatePickerInput } from "@mantine/dates";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -25,21 +25,6 @@ type AccountOperationsPageLoadedProps = Extract<
|
|||||||
|
|
||||||
const operationCategorySet = new Set<string>(operationCategories);
|
const operationCategorySet = new Set<string>(operationCategories);
|
||||||
|
|
||||||
function toDateValue(value: string | null): Date | null {
|
|
||||||
if (!value) return null;
|
|
||||||
const [year, month, day] = value.split("-").map(Number);
|
|
||||||
return new Date(year, month - 1, day);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fromDateValue(value: Date | string | null): string | null {
|
|
||||||
if (!value) return null;
|
|
||||||
if (typeof value === "string") return value;
|
|
||||||
const year = value.getFullYear();
|
|
||||||
const month = String(value.getMonth() + 1).padStart(2, "0");
|
|
||||||
const day = String(value.getDate()).padStart(2, "0");
|
|
||||||
return `${year}-${month}-${day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fromMultiSelectValue(value: string[]) {
|
function fromMultiSelectValue(value: string[]) {
|
||||||
return value.filter((item): item is OperationCategory =>
|
return value.filter((item): item is OperationCategory =>
|
||||||
operationCategorySet.has(item),
|
operationCategorySet.has(item),
|
||||||
@@ -87,19 +72,22 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
<Group align="end">
|
<Group align="end">
|
||||||
<DateInput
|
<DatePickerInput
|
||||||
label={t("ledger.from")}
|
type="range"
|
||||||
value={toDateValue(draftFrom)}
|
allowSingleDateInRange
|
||||||
onChange={(value) => onDraftFromChange(fromDateValue(value))}
|
clearable
|
||||||
maxDate={toDateValue(draftTo) ?? undefined}
|
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
|
||||||
valueFormat="DD.MM.YYYY"
|
label={t("ledger.period")}
|
||||||
/>
|
value={[draftFrom, draftTo]}
|
||||||
<DateInput
|
onChange={([from, to]) => {
|
||||||
label={t("ledger.to")}
|
onDraftFromChange(from);
|
||||||
value={toDateValue(draftTo)}
|
onDraftToChange(to);
|
||||||
onChange={(value) => onDraftToChange(fromDateValue(value))}
|
}}
|
||||||
minDate={toDateValue(draftFrom) ?? undefined}
|
|
||||||
valueFormat="DD.MM.YYYY"
|
valueFormat="DD.MM.YYYY"
|
||||||
|
popoverProps={{
|
||||||
|
returnFocus: true,
|
||||||
|
middlewares: { shift: { crossAxis: true } },
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
@@ -111,6 +99,7 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
|
|||||||
onDraftCategoriesChange(fromMultiSelectValue(value))
|
onDraftCategoriesChange(fromMultiSelectValue(value))
|
||||||
}
|
}
|
||||||
clearable
|
clearable
|
||||||
|
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
|
||||||
/>
|
/>
|
||||||
<Group>
|
<Group>
|
||||||
<Button onClick={onApply} disabled={!canApply}>
|
<Button onClick={onApply} disabled={!canApply}>
|
||||||
|
|||||||
@@ -529,6 +529,7 @@ const ru = {
|
|||||||
share: "Доля: {{value}}",
|
share: "Доля: {{value}}",
|
||||||
},
|
},
|
||||||
ledger: {
|
ledger: {
|
||||||
|
clearPeriod: "Очистить период",
|
||||||
occurredAt: "{{date}} UTC",
|
occurredAt: "{{date}} UTC",
|
||||||
quantity: "{{value}} шт.",
|
quantity: "{{value}} шт.",
|
||||||
price: "Цена: {{value}}",
|
price: "Цена: {{value}}",
|
||||||
@@ -550,6 +551,7 @@ const ru = {
|
|||||||
recentLoading: "Загрузка последних событий",
|
recentLoading: "Загрузка последних событий",
|
||||||
recentError: "Не удалось загрузить последние события.",
|
recentError: "Не удалось загрузить последние события.",
|
||||||
recentEmpty: "Последних событий пока нет.",
|
recentEmpty: "Последних событий пока нет.",
|
||||||
|
period: "Период",
|
||||||
from: "С",
|
from: "С",
|
||||||
to: "По",
|
to: "По",
|
||||||
categories: "Категории",
|
categories: "Категории",
|
||||||
|
|||||||
Reference in New Issue
Block a user