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:
2026-03-27 19:13:43 +03:00
parent d5541a8bc9
commit a69c08482f
17 changed files with 755 additions and 266 deletions
+43 -28
View File
@@ -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
View File
@@ -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>
</>
);
}