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);
|
||||
|
||||
Reference in New Issue
Block a user