feat: replace paired date inputs with range pickers

This commit is contained in:
2026-09-12 22:41:40 +03:00
parent aba4cf1ce8
commit 24d332e063
8 changed files with 137 additions and 175 deletions
@@ -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}>
+2
View File
@@ -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: "Категории",