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
+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>
</>
);
}