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);