From 587e12f562b51b0b7b9875efbd52976c233dc415 Mon Sep 17 00:00:00 2001 From: Hermes Date: Wed, 9 Sep 2026 11:59:32 +0200 Subject: [PATCH] =?UTF-8?q?feat(filters):=20=D0=B5=D0=B4=D0=B8=D0=BD=D0=BE?= =?UTF-8?q?=D0=B5=20=D0=BF=D0=BE=D0=BB=D0=B5=20=C2=AB=D0=9F=D0=B5=D1=80?= =?UTF-8?q?=D0=B8=D0=BE=D0=B4=C2=BB=20=D0=B1=D0=B5=D0=B7=20=D0=BB=D0=B5?= =?UTF-8?q?=D0=B9=D0=B1=D0=BB=D0=BE=D0=B2=20+=20=D1=83=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=89=D1=91=D0=BD=D0=BD=D1=8B=D0=B9=20=D1=84=D0=B8=D0=BB=D1=8C?= =?UTF-8?q?=D1=82=D1=80=20=C2=AB=D0=9F=D1=80=D0=BE=D0=B7=D0=B2=D0=BE=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/UI/DateRangePicker.jsx | 180 +++++++++++++++++++++++++ src/components/orders/OrderFilters.jsx | 31 ++--- 2 files changed, 192 insertions(+), 19 deletions(-) create mode 100644 src/components/UI/DateRangePicker.jsx diff --git a/src/components/UI/DateRangePicker.jsx b/src/components/UI/DateRangePicker.jsx new file mode 100644 index 0000000..cc70f4f --- /dev/null +++ b/src/components/UI/DateRangePicker.jsx @@ -0,0 +1,180 @@ +import React, { useState, useRef, useEffect } from "react"; + +const MONTH_NAMES = [ + "Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", + "Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь", +]; +const WEEKDAY_SHORT = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]; + +function getDaysInMonth(year, month) { return new Date(year, month + 1, 0).getDate(); } +function getFirstDayOfWeek(year, month) { const d = new Date(year, month, 1).getDay(); return d === 0 ? 6 : d - 1; } +function formatDateISO(date) { + if (!date) return ""; + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; +} +function formatDateDisplay(date) { + if (!date) return ""; + return `${String(date.getDate()).padStart(2, "0")}.${String(date.getMonth() + 1).padStart(2, "0")}.${date.getFullYear()}`; +} +function parseDateFromISO(str) { + if (!str) return null; + const [y, m, d] = str.split("-").map(Number); + if (!y || !m || !d) return null; + return new Date(y, m - 1, d); +} + +/** + * DateRangePicker — одно поле «Период» без лейбла. + * При клике открывает календарь: первый клик = «с», второй = «по». + * Показывает «С даты — По дату» или «Период» если пусто. + */ +export const DateRangePicker = ({ valueFrom = "", valueTo = "", onChange, className = "" }) => { + const [isOpen, setIsOpen] = useState(false); + const [viewYear, setViewYear] = useState(() => { + const d = parseDateFromISO(valueFrom) || new Date(); + return d.getFullYear(); + }); + const [viewMonth, setViewMonth] = useState(() => { + const d = parseDateFromISO(valueFrom) || new Date(); + return d.getMonth(); + }); + const [selecting, setSelecting] = useState("from"); // "from" | "to" + const containerRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + const handle = (e) => { + if (containerRef.current && !containerRef.current.contains(e.target)) setIsOpen(false); + }; + document.addEventListener("pointerdown", handle); + return () => document.removeEventListener("pointerdown", handle); + }, [isOpen]); + + useEffect(() => { + const handle = (e) => { if (e.key === "Escape" && isOpen) setIsOpen(false); }; + document.addEventListener("keydown", handle); + return () => document.removeEventListener("keydown", handle); + }, [isOpen]); + + const fromDate = parseDateFromISO(valueFrom); + const toDate = parseDateFromISO(valueTo); + + const handleDayClick = (day) => { + const d = new Date(viewYear, viewMonth, day); + if (selecting === "from") { + onChange(formatDateISO(d), valueTo); + setSelecting("to"); + } else { + // Если выбрали дату раньше «с» — поменяем местами + const from = parseDateFromISO(valueFrom); + if (from && d < from) { + onChange(formatDateISO(d), valueFrom); + } else { + onChange(valueFrom, formatDateISO(d)); + } + setIsOpen(false); + setSelecting("from"); + } + }; + + const handleClear = (e) => { + e.stopPropagation(); + onChange("", ""); + setSelecting("from"); + }; + + const prevMonth = () => { + if (viewMonth === 0) { setViewMonth(11); setViewYear((y) => y - 1); } + else setViewMonth((m) => m - 1); + }; + const nextMonth = () => { + if (viewMonth === 11) { setViewMonth(0); setViewYear((y) => y + 1); } + else setViewMonth((m) => m + 1); + }; + + const daysInMonth = getDaysInMonth(viewYear, viewMonth); + const firstDay = getFirstDayOfWeek(viewYear, viewMonth); + const today = new Date(); + const isToday = (day) => day === today.getDate() && viewMonth === today.getMonth() && viewYear === today.getFullYear(); + const isInRange = (day) => { + const d = new Date(viewYear, viewMonth, day); + if (fromDate && toDate) return d >= fromDate && d <= toDate; + return false; + }; + const isSelected = (day) => { + const d = new Date(viewYear, viewMonth, day); + return (fromDate && d.getTime() === fromDate.getTime()) || (toDate && d.getTime() === toDate.getTime()); + }; + + const cells = []; + for (let i = 0; i < firstDay; i++) cells.push(null); + for (let d = 1; d <= daysInMonth; d++) cells.push(d); + + const displayText = fromDate + ? `${formatDateDisplay(fromDate)} — ${toDate ? formatDateDisplay(toDate) : "…"}` + : "Период"; + + return ( +
+ + + {isOpen && ( +
+
+ + {MONTH_NAMES[viewMonth]} {viewYear} + +
+
+ {selecting === "from" ? "Выберите начало периода" : "Выберите конец периода"} +
+
+ {WEEKDAY_SHORT.map((wd) => ( +
{wd}
+ ))} +
+
+ {cells.map((day, i) => ( + + ))} +
+
+ +
+
+ )} +
+ ); +}; diff --git a/src/components/orders/OrderFilters.jsx b/src/components/orders/OrderFilters.jsx index 32321c2..b5cb003 100644 --- a/src/components/orders/OrderFilters.jsx +++ b/src/components/orders/OrderFilters.jsx @@ -2,7 +2,7 @@ import React from "react"; import { Button } from "../UI/Button"; import { Input } from "../UI/Input"; import { Panel } from "../UI/Panel"; -import { DatePicker } from "../UI/DatePicker"; +import { DateRangePicker } from "../UI/DateRangePicker"; import { Switch } from "../UI/Switch"; const DropdownSelect = ({ value, options, onChange, placeholder }) => { @@ -129,7 +129,7 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities = const callStatusOptions = [ { value: "all", label: "Прозвонить" }, - { value: "need_call", label: "Нужно прозвонить (SMS/автодозвон)" }, + { value: "need_call", label: "Нужно прозвонить" }, { value: "sms_problem", label: "Проблема с SMS" }, { value: "autocall_problem", label: "Проблема с автодозвоном" }, ]; @@ -212,29 +212,22 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities = /> - {/* Row 2: Date range + SMS problems + прозвонить + hide-completed toggle. - Выравнивание по верхней линии полей ввода — чтобы переключатель - «Скрыть завершённые» стоял на уровне лейблов, а не прижимался к низу. */} + {/* Row 2: Период + Проблемы SMS + Прозвонить + hide-completed toggle. + Выравнивание по верхней линии полей ввода. */}
- updateFilter("dateFrom", v)} - placeholder="С даты" - label="Период: с" - className="min-w-[140px] flex-1 md:max-w-[200px]" - /> - updateFilter("dateTo", v)} - placeholder="По дату" - label="по" - className="min-w-[140px] flex-1 md:max-w-[200px]" + { + setFilters((current) => ({ ...current, dateFrom: from, dateTo: to })); + }} + className="min-w-[180px] flex-1 md:max-w-[240px]" /> {hasDateFilter && (