feat: upgrade schedule with cross-location views, day/time filters, and clickable trainers

- Add "Все студии" tab merging all locations by weekday with location sub-headers
- Location tabs show hall name + address subtitle for clarity
- Add day multi-select and time-of-day preset filters (Утро/День/Вечер) behind collapsible "Когда" button
- Make trainer and type names clickable in day cards for inline filtering
- Add group view clustering classes by trainer+type+location
- Remove trainer dropdown from filter bar — filter by clicking names in schedule
- Add searchable icon picker and lucide-react icon rendering for classes admin/section

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 21:25:11 +03:00
parent 8ff7713cf2
commit 46ad10e8a0
8 changed files with 891 additions and 222 deletions

View File

@@ -1,13 +1,127 @@
"use client";
import { useState, useRef, useEffect, useMemo } from "react";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor";
import { icons, type LucideIcon } from "lucide-react";
const ICON_OPTIONS = [
"sparkles", "flame", "wind", "zap", "star", "monitor",
"heart", "music", "dumbbell", "trophy",
];
// PascalCase "HeartPulse" → kebab "heart-pulse"
function toKebab(name: string) {
return name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
}
// All icons as { key: kebab-name, Icon: component, label: PascalCase }
const ALL_ICONS = Object.entries(icons).map(([name, Icon]) => ({
key: toKebab(name),
Icon: Icon as LucideIcon,
label: name,
}));
const ICON_BY_KEY = Object.fromEntries(ALL_ICONS.map((i) => [i.key, i]));
function IconPicker({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const ref = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const selected = ICON_BY_KEY[value];
useEffect(() => {
if (!open) return;
function handle(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
setSearch("");
}
}
document.addEventListener("mousedown", handle);
return () => document.removeEventListener("mousedown", handle);
}, [open]);
const filtered = useMemo(() => {
if (!search) return ALL_ICONS.slice(0, 60);
const q = search.toLowerCase();
return ALL_ICONS.filter((i) => i.label.toLowerCase().includes(q)).slice(0, 60);
}, [search]);
const SelectedIcon = selected?.Icon;
return (
<div ref={ref} className="relative">
<label className="block text-sm text-neutral-400 mb-1.5">Иконка</label>
<button
type="button"
onClick={() => {
setOpen(!open);
setSearch("");
setTimeout(() => inputRef.current?.focus(), 0);
}}
className={`w-full flex items-center gap-2.5 rounded-lg border bg-neutral-800 px-4 py-2.5 text-left text-white outline-none transition-colors ${
open ? "border-gold" : "border-white/10"
}`}
>
{SelectedIcon ? (
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-gold/20 text-gold-light">
<SelectedIcon size={16} />
</span>
) : (
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-white/10 text-neutral-500">?</span>
)}
<span className="text-sm">{selected?.label || value}</span>
</button>
{open && (
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
<div className="p-2 pb-0">
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск иконки... (flame, heart, star...)"
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="p-2 max-h-56 overflow-y-auto">
{filtered.length === 0 ? (
<div className="py-3 text-center text-sm text-neutral-500">Ничего не найдено</div>
) : (
<div className="grid grid-cols-6 gap-1">
{filtered.map(({ key, Icon, label }) => (
<button
key={key}
type="button"
title={label}
onClick={() => {
onChange(key);
setOpen(false);
setSearch("");
}}
className={`flex flex-col items-center gap-0.5 rounded-lg p-2 transition-colors ${
key === value
? "bg-gold/20 text-gold-light"
: "text-neutral-400 hover:bg-white/5 hover:text-white"
}`}
>
<Icon size={20} />
<span className="text-[10px] leading-tight truncate w-full text-center">{label}</span>
</button>
))}
</div>
)}
</div>
</div>
)}
</div>
);
}
const COLOR_SWATCHES: { value: string; bg: string }[] = [
{ value: "rose", bg: "bg-rose-500" },
@@ -63,24 +177,10 @@ export default function ClassesEditorPage() {
value={item.name}
onChange={(v) => updateItem({ ...item, name: v })}
/>
<div>
<label className="block text-sm text-neutral-400 mb-1.5">
Иконка
</label>
<select
value={item.icon}
onChange={(e) =>
updateItem({ ...item, icon: e.target.value })
}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors"
>
{ICON_OPTIONS.map((icon) => (
<option key={icon} value={icon}>
{icon}
</option>
))}
</select>
</div>
<IconPicker
value={item.icon}
onChange={(v) => updateItem({ ...item, icon: v })}
/>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1.5">