feat: schedule filters overhaul, local fonts, configurable statuses/levels
Schedule filters: - Airbnb-style filter modal with sections: directions, trainer, status, level, days, time - Multi-select trainer filter with search input - Custom time range (from-to) with preset shortcuts - Gold tag design for class types, statuses, and levels - Hover tooltips on level/status options with descriptions from config - Filter icon button inline with view toggle (По дням / По группам) Admin schedule: - Configurable experience levels and statuses (add/edit/reorder/delete) - New scheduleConfig DB section with auto-save - Status/level dropdowns in class editor read from config - Status select built dynamically from config - New status field on ScheduleClass for custom statuses Other: - Local fonts (Inter + Oswald) bundled in public/fonts — no Google Fonts dependency - SelectField combobox: search in main input field, no separate search inside dropdown - Fix carousel trainer label flash on drag release
This commit is contained in:
@@ -159,6 +159,7 @@ interface SelectFieldProps {
|
||||
onChange: (value: string) => void;
|
||||
options: { value: string; label: string }[];
|
||||
placeholder?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export function SelectField({
|
||||
@@ -167,6 +168,7 @@ export function SelectField({
|
||||
onChange,
|
||||
options,
|
||||
placeholder,
|
||||
hint,
|
||||
}: SelectFieldProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -181,6 +183,8 @@ export function SelectField({
|
||||
})
|
||||
: options;
|
||||
|
||||
const showSearch = options.length > 3;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handle(e: MouseEvent) {
|
||||
@@ -195,43 +199,54 @@ export function SelectField({
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
{label && <label className="block text-sm text-neutral-400 mb-1.5">{label}</label>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(!open);
|
||||
setSearch("");
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}}
|
||||
className={`w-full rounded-lg border bg-neutral-800 text-left outline-none transition-colors ${
|
||||
label ? "px-4 py-2.5" : "px-2 py-1 text-xs"
|
||||
} ${open ? "border-gold" : "border-white/10"} ${value ? "text-white" : "text-neutral-500"}`}
|
||||
>
|
||||
{selectedLabel || placeholder || "Выберите..."}
|
||||
</button>
|
||||
{label && (
|
||||
<label className="flex items-center gap-1.5 text-sm text-neutral-400 mb-1.5">
|
||||
{label}
|
||||
{hint && (
|
||||
<span className="group relative">
|
||||
<span className="flex h-4 w-4 items-center justify-center rounded-full border border-white/15 text-[10px] text-neutral-500 hover:text-white hover:border-white/30 transition-colors cursor-help">?</span>
|
||||
<span className="absolute left-6 top-1/2 -translate-y-1/2 z-50 w-52 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2 text-[11px] leading-relaxed text-neutral-300 shadow-xl opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity">
|
||||
{hint}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
{showSearch ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={open ? search : selectedLabel}
|
||||
onChange={(e) => { setSearch(e.target.value); if (!open) setOpen(true); }}
|
||||
onFocus={() => { setOpen(true); setSearch(""); }}
|
||||
placeholder={placeholder || "Выберите..."}
|
||||
className={`w-full rounded-lg border bg-neutral-800 outline-none transition-colors ${
|
||||
label ? "px-4 py-2.5" : "px-2 py-1 text-xs"
|
||||
} ${open ? "border-gold" : "border-white/10"} ${!open && value ? "text-white" : "text-white"} placeholder-neutral-500`}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className={`w-full rounded-lg border bg-neutral-800 text-left outline-none transition-colors ${
|
||||
label ? "px-4 py-2.5" : "px-2 py-1 text-xs"
|
||||
} ${open ? "border-gold" : "border-white/10"} ${value ? "text-white" : "text-neutral-500"}`}
|
||||
>
|
||||
{selectedLabel || placeholder || "Выберите..."}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
|
||||
{options.length > 3 && (
|
||||
<div className="p-1.5">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Поиск..."
|
||||
className="w-full rounded-md border border-white/10 bg-neutral-900 px-3 py-1.5 text-sm text-white outline-none focus:border-gold/50 placeholder:text-neutral-600"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-4 py-2 text-sm text-neutral-500">Ничего не найдено</div>
|
||||
)}
|
||||
{filtered.map((opt) => (
|
||||
{filtered.map((opt, idx) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
key={opt.value || `opt-${idx}`}
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
onChange(opt.value);
|
||||
setOpen(false);
|
||||
|
||||
+160
-17
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { SectionEditor } from "../_components/SectionEditor";
|
||||
import { InputField, SelectField, TimeRangeField } from "../_components/FormField";
|
||||
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||
import { Plus, X, Trash2 } from "lucide-react";
|
||||
import { adminFetch } from "@/lib/csrf";
|
||||
import type { ScheduleLocation, ScheduleDay, ScheduleClass } from "@/types/content";
|
||||
@@ -26,18 +27,36 @@ const DAY_ORDER: Record<string, number> = Object.fromEntries(
|
||||
DAYS.map((d, i) => [d.day, i])
|
||||
);
|
||||
|
||||
const LEVELS = [
|
||||
{ value: "", label: "Без уровня" },
|
||||
{ value: "Начинающий/Без опыта", label: "Начинающий/Без опыта" },
|
||||
{ value: "Продвинутый", label: "Продвинутый" },
|
||||
];
|
||||
interface ScheduleConfig {
|
||||
levels: { value: string; description: string }[];
|
||||
statuses: { key: string; label: string; description: string }[];
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "", label: "Без статуса" },
|
||||
{ value: "hasSlots", label: "Есть места" },
|
||||
{ value: "recruiting", label: "Набор открыт" },
|
||||
{ value: "both", label: "Есть места + Набор" },
|
||||
];
|
||||
const DEFAULT_CONFIG: ScheduleConfig = {
|
||||
levels: [
|
||||
{ value: "Начинающий/Без опыта", description: "Для тех, кто только начинает" },
|
||||
{ value: "Продвинутый", description: "Для учеников с опытом от 6 месяцев" },
|
||||
],
|
||||
statuses: [
|
||||
{ key: "hasSlots", label: "Есть места", description: "В группе есть свободные места" },
|
||||
{ key: "recruiting", label: "Набор открыт", description: "Идёт набор в новую группу" },
|
||||
],
|
||||
};
|
||||
|
||||
function buildLevelOptions(config: ScheduleConfig) {
|
||||
return [
|
||||
{ value: "", label: "Без уровня" },
|
||||
...config.levels.map((l) => ({ value: l.value, label: l.value })),
|
||||
];
|
||||
}
|
||||
|
||||
function buildStatusOptions(config: ScheduleConfig) {
|
||||
const statuses = config.statuses ?? DEFAULT_CONFIG.statuses;
|
||||
return [
|
||||
{ value: "", label: "Без статуса" },
|
||||
...statuses.map((s) => ({ value: s.key, label: s.label })),
|
||||
];
|
||||
}
|
||||
|
||||
const GROUP_PALETTE = [
|
||||
"bg-rose-500/80 border-rose-400",
|
||||
@@ -238,6 +257,10 @@ function ClassModal({
|
||||
onClose,
|
||||
allDays,
|
||||
currentDay,
|
||||
levelOptions,
|
||||
levelHint,
|
||||
statusOptions,
|
||||
statusHint,
|
||||
}: {
|
||||
cls: ScheduleClass;
|
||||
trainers: string[];
|
||||
@@ -247,6 +270,10 @@ function ClassModal({
|
||||
onClose: () => void;
|
||||
allDays: ScheduleDay[];
|
||||
currentDay: string;
|
||||
levelOptions: { value: string; label: string }[];
|
||||
levelHint: string;
|
||||
statusOptions: { value: string; label: string }[];
|
||||
statusHint: string;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<ScheduleClass>(cls);
|
||||
const trainerOptions = trainers.map((t) => ({ value: t, label: t }));
|
||||
@@ -479,20 +506,23 @@ function ClassModal({
|
||||
placeholder="Выберите тип"
|
||||
/>
|
||||
<SelectField
|
||||
label="Уровень"
|
||||
label="Опыт"
|
||||
value={draft.level || ""}
|
||||
onChange={(v) => setDraft({ ...draft, level: v || undefined })}
|
||||
options={LEVELS}
|
||||
options={levelOptions}
|
||||
hint={levelHint}
|
||||
/>
|
||||
<SelectField
|
||||
label="Статус"
|
||||
value={draft.hasSlots && draft.recruiting ? "both" : draft.recruiting ? "recruiting" : draft.hasSlots ? "hasSlots" : ""}
|
||||
value={draft.status || (draft.recruiting ? "recruiting" : draft.hasSlots ? "hasSlots" : "")}
|
||||
onChange={(v) => setDraft({
|
||||
...draft,
|
||||
hasSlots: v === "hasSlots" || v === "both",
|
||||
recruiting: v === "recruiting" || v === "both",
|
||||
status: v || undefined,
|
||||
hasSlots: v === "hasSlots",
|
||||
recruiting: v === "recruiting",
|
||||
})}
|
||||
options={STATUS_OPTIONS}
|
||||
options={statusOptions}
|
||||
hint={statusHint}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -559,12 +589,20 @@ function CalendarGrid({
|
||||
addresses,
|
||||
classTypes,
|
||||
onChange,
|
||||
levelOptions,
|
||||
levelHint,
|
||||
statusOptions,
|
||||
statusHint,
|
||||
}: {
|
||||
location: ScheduleLocation;
|
||||
trainers: string[];
|
||||
addresses: string[];
|
||||
classTypes: string[];
|
||||
onChange: (loc: ScheduleLocation) => void;
|
||||
levelOptions: { value: string; label: string }[];
|
||||
levelHint: string;
|
||||
statusOptions: { value: string; label: string }[];
|
||||
statusHint: string;
|
||||
}) {
|
||||
const [editingClass, setEditingClass] = useState<{
|
||||
dayIndex: number;
|
||||
@@ -1084,6 +1122,10 @@ function CalendarGrid({
|
||||
onChange({ ...location, days: updatedDays });
|
||||
}}
|
||||
onClose={() => setEditingClass(null)}
|
||||
levelOptions={levelOptions}
|
||||
levelHint={levelHint}
|
||||
statusOptions={statusOptions}
|
||||
statusHint={statusHint}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1105,6 +1147,10 @@ function CalendarGrid({
|
||||
onChange({ ...location, days: updatedDays });
|
||||
}}
|
||||
onClose={() => setNewClass(null)}
|
||||
levelOptions={levelOptions}
|
||||
levelHint={levelHint}
|
||||
statusOptions={statusOptions}
|
||||
statusHint={statusHint}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1112,11 +1158,85 @@ function CalendarGrid({
|
||||
}
|
||||
|
||||
// ---------- Main Page ----------
|
||||
function ConfigEditor({ cfg, updateCfg, onSync }: { cfg: ScheduleConfig; updateCfg: (c: ScheduleConfig) => void; onSync: (c: ScheduleConfig) => void }) {
|
||||
const normalized = useMemo<ScheduleConfig>(() => ({
|
||||
levels: cfg.levels ?? DEFAULT_CONFIG.levels,
|
||||
statuses: cfg.statuses ?? DEFAULT_CONFIG.statuses,
|
||||
}), [cfg]);
|
||||
|
||||
useEffect(() => { onSync(normalized); }, [normalized, onSync]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Levels — collapsible + drag-and-drop via ArrayEditor */}
|
||||
<ArrayEditor
|
||||
items={normalized.levels}
|
||||
onChange={(levels) => updateCfg({ ...normalized, levels })}
|
||||
createItem={() => ({ value: "", description: "" })}
|
||||
label="Уровни опыта"
|
||||
addLabel="Добавить уровень"
|
||||
collapsible
|
||||
getItemTitle={(item) => item.value || "Новый уровень"}
|
||||
renderItem={(item, _i, update) => (
|
||||
<div className="space-y-2">
|
||||
<InputField
|
||||
label="Название"
|
||||
value={item.value}
|
||||
onChange={(v) => update({ ...item, value: v })}
|
||||
placeholder="Название уровня"
|
||||
/>
|
||||
<InputField
|
||||
label="Описание"
|
||||
value={item.description}
|
||||
onChange={(v) => update({ ...item, description: v })}
|
||||
placeholder="Описание (для подсказки)"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Statuses — collapsible + drag-and-drop */}
|
||||
<ArrayEditor
|
||||
items={normalized.statuses}
|
||||
onChange={(statuses) => updateCfg({ ...normalized, statuses })}
|
||||
createItem={() => ({ key: `status_${Date.now()}`, label: "", description: "" })}
|
||||
label="Статусы групп"
|
||||
addLabel="Добавить статус"
|
||||
collapsible
|
||||
getItemTitle={(item) => item.label || "Новый статус"}
|
||||
renderItem={(item, _i, update) => (
|
||||
<div className="space-y-2">
|
||||
<InputField
|
||||
label="Ключ (латиницей)"
|
||||
value={item.key}
|
||||
onChange={(v) => update({ ...item, key: v.replace(/[^a-zA-Z0-9_]/g, "") })}
|
||||
placeholder="например: intensive"
|
||||
/>
|
||||
<InputField
|
||||
label="Название"
|
||||
value={item.label}
|
||||
onChange={(v) => update({ ...item, label: v })}
|
||||
placeholder="Название статуса"
|
||||
/>
|
||||
<InputField
|
||||
label="Описание"
|
||||
value={item.description}
|
||||
onChange={(v) => update({ ...item, description: v })}
|
||||
placeholder="Описание (для подсказки)"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ScheduleEditorPage() {
|
||||
const [activeLocation, setActiveLocation] = useState(0);
|
||||
const [trainers, setTrainers] = useState<string[]>([]);
|
||||
const [addresses, setAddresses] = useState<string[]>([]);
|
||||
const [classTypes, setClassTypes] = useState<string[]>([]);
|
||||
const [config, setConfig] = useState<ScheduleConfig>(DEFAULT_CONFIG);
|
||||
|
||||
useEffect(() => {
|
||||
adminFetch("/api/admin/team")
|
||||
@@ -1139,9 +1259,19 @@ export default function ScheduleEditorPage() {
|
||||
setClassTypes((classes.items ?? []).map((c) => c.name));
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
adminFetch("/api/admin/sections/scheduleConfig")
|
||||
.then((r) => r.json())
|
||||
.then((c: ScheduleConfig) => { if (c?.levels) setConfig(c); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const levelOptions = buildLevelOptions(config);
|
||||
const levelHint = config.levels.map((l) => `${l.value} — ${l.description}`).join(". ") + ".";
|
||||
const statusOptions = useMemo(() => buildStatusOptions(config), [config]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionEditor<ScheduleData> sectionKey="schedule" title="Расписание">
|
||||
{(data, update) => {
|
||||
const location = data.locations[activeLocation];
|
||||
@@ -1222,11 +1352,24 @@ export default function ScheduleEditorPage() {
|
||||
addresses={addresses}
|
||||
classTypes={classTypes}
|
||||
onChange={updateLocation}
|
||||
levelOptions={levelOptions}
|
||||
levelHint={levelHint}
|
||||
statusOptions={statusOptions}
|
||||
statusHint={(config.statuses ?? DEFAULT_CONFIG.statuses).map((s) => `${s.label} — ${s.description.toLowerCase()}`).join(". ") + "."}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</SectionEditor>
|
||||
|
||||
<div className="mt-10">
|
||||
<SectionEditor<ScheduleConfig> sectionKey="scheduleConfig" title="Настройки фильтров">
|
||||
{(cfg, updateCfg) => (
|
||||
<ConfigEditor cfg={cfg} updateCfg={updateCfg} onSync={setConfig} />
|
||||
)}
|
||||
</SectionEditor>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user