feat(filters): единое поле «Период» без лейблов + упрощённый фильтр «Прозвонить»
This commit is contained in:
parent
64bcda8eed
commit
587e12f562
|
|
@ -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 (
|
||||||
|
<div ref={containerRef} className={`relative ${className}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen((v) => !v)}
|
||||||
|
className={[
|
||||||
|
"flex h-[46px] w-full items-center justify-between rounded-2xl border px-4 text-left text-sm transition",
|
||||||
|
isOpen
|
||||||
|
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)]"
|
||||||
|
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text)] hover:border-[var(--color-accent)]",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<span className={fromDate ? "" : "text-[var(--color-text-muted)]"}>{displayText}</span>
|
||||||
|
<span className="ml-2 flex items-center gap-1">
|
||||||
|
{fromDate && (
|
||||||
|
<span role="button" tabIndex={-1} onClick={handleClear} className="text-[var(--color-text-muted)] hover:text-[var(--color-danger)] text-xs">✕</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[var(--color-text-muted)]">📅</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute left-0 right-0 top-full z-30 mt-2 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-dropdown-surface)] shadow-soft" style={{ minWidth: "280px" }}>
|
||||||
|
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2">
|
||||||
|
<button type="button" onClick={prevMonth} className="flex h-8 w-8 items-center justify-center rounded-xl text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-text)] transition text-sm">◀</button>
|
||||||
|
<span className="text-sm font-semibold text-[var(--color-text)]">{MONTH_NAMES[viewMonth]} {viewYear}</span>
|
||||||
|
<button type="button" onClick={nextMonth} className="flex h-8 w-8 items-center justify-center rounded-xl text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-text)] transition text-sm">▶</button>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 pt-1.5 text-[11px] text-[var(--color-text-muted)]">
|
||||||
|
{selecting === "from" ? "Выберите начало периода" : "Выберите конец периода"}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 px-2 pt-1">
|
||||||
|
{WEEKDAY_SHORT.map((wd) => (
|
||||||
|
<div key={wd} className="flex h-8 items-center justify-center text-xs font-semibold text-[var(--color-text-muted)]">{wd}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-0 px-2 pb-1">
|
||||||
|
{cells.map((day, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
disabled={!day}
|
||||||
|
onClick={day ? () => handleDayClick(day) : undefined}
|
||||||
|
className={[
|
||||||
|
"flex h-9 w-full items-center justify-center rounded-xl text-sm transition",
|
||||||
|
!day ? "" :
|
||||||
|
isSelected(day) ? "bg-[var(--color-accent)] text-white font-bold shadow-sm" :
|
||||||
|
isInRange(day) ? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]" :
|
||||||
|
isToday(day) ? "border border-[var(--color-accent)] text-[var(--color-accent)] font-semibold" :
|
||||||
|
"text-[var(--color-text)] hover:bg-[var(--color-accent-soft)]",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{day || ""}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-[var(--color-border)] px-2 py-2">
|
||||||
|
<button type="button" onClick={() => { const now = new Date(); setViewYear(now.getFullYear()); setViewMonth(now.getMonth()); }} className="w-full rounded-xl py-2 text-xs font-semibold text-[var(--color-accent)] hover:bg-[var(--color-accent-soft)] transition">Сегодня</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -2,7 +2,7 @@ import React from "react";
|
||||||
import { Button } from "../UI/Button";
|
import { Button } from "../UI/Button";
|
||||||
import { Input } from "../UI/Input";
|
import { Input } from "../UI/Input";
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
import { DatePicker } from "../UI/DatePicker";
|
import { DateRangePicker } from "../UI/DateRangePicker";
|
||||||
import { Switch } from "../UI/Switch";
|
import { Switch } from "../UI/Switch";
|
||||||
|
|
||||||
const DropdownSelect = ({ value, options, onChange, placeholder }) => {
|
const DropdownSelect = ({ value, options, onChange, placeholder }) => {
|
||||||
|
|
@ -129,7 +129,7 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities =
|
||||||
|
|
||||||
const callStatusOptions = [
|
const callStatusOptions = [
|
||||||
{ value: "all", label: "Прозвонить" },
|
{ value: "all", label: "Прозвонить" },
|
||||||
{ value: "need_call", label: "Нужно прозвонить (SMS/автодозвон)" },
|
{ value: "need_call", label: "Нужно прозвонить" },
|
||||||
{ value: "sms_problem", label: "Проблема с SMS" },
|
{ value: "sms_problem", label: "Проблема с SMS" },
|
||||||
{ value: "autocall_problem", label: "Проблема с автодозвоном" },
|
{ value: "autocall_problem", label: "Проблема с автодозвоном" },
|
||||||
];
|
];
|
||||||
|
|
@ -212,29 +212,22 @@ export const OrderFilters = ({ filters, setFilters, statusOptions = [], cities =
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 2: Date range + SMS problems + прозвонить + hide-completed toggle.
|
{/* Row 2: Период + Проблемы SMS + Прозвонить + hide-completed toggle.
|
||||||
Выравнивание по верхней линии полей ввода — чтобы переключатель
|
Выравнивание по верхней линии полей ввода. */}
|
||||||
«Скрыть завершённые» стоял на уровне лейблов, а не прижимался к низу. */}
|
|
||||||
<div className="flex flex-wrap items-start gap-3">
|
<div className="flex flex-wrap items-start gap-3">
|
||||||
<DatePicker
|
<DateRangePicker
|
||||||
value={filters.dateFrom || ""}
|
valueFrom={filters.dateFrom || ""}
|
||||||
onChange={(v) => updateFilter("dateFrom", v)}
|
valueTo={filters.dateTo || ""}
|
||||||
placeholder="С даты"
|
onChange={(from, to) => {
|
||||||
label="Период: с"
|
setFilters((current) => ({ ...current, dateFrom: from, dateTo: to }));
|
||||||
className="min-w-[140px] flex-1 md:max-w-[200px]"
|
}}
|
||||||
/>
|
className="min-w-[180px] flex-1 md:max-w-[240px]"
|
||||||
<DatePicker
|
|
||||||
value={filters.dateTo || ""}
|
|
||||||
onChange={(v) => updateFilter("dateTo", v)}
|
|
||||||
placeholder="По дату"
|
|
||||||
label="по"
|
|
||||||
className="min-w-[140px] flex-1 md:max-w-[200px]"
|
|
||||||
/>
|
/>
|
||||||
{hasDateFilter && (
|
{hasDateFilter && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={clearDateFilter}
|
onClick={clearDateFilter}
|
||||||
className="mb-0.5 h-[46px] gap-1.5 rounded-2xl border border-[var(--color-danger)] px-4 text-sm font-semibold text-[var(--color-danger)] hover:bg-[rgba(201,61,61,0.08)]"
|
className="h-[46px] gap-1.5 rounded-2xl border border-[var(--color-danger)] px-4 text-sm font-semibold text-[var(--color-danger)] hover:bg-[rgba(201,61,61,0.08)]"
|
||||||
>
|
>
|
||||||
✕ Сбросить
|
✕ Сбросить
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue