fix: schedule status labels, Open Day halls, unsaved data guards

Schedule:
- Status badges use admin config labels (not hardcoded text) everywhere
- DayCard: level badge moved next to status badge
- Single location: hide "Все студии" tab, auto-select the only hall
- Group view: hide per-card address when all share same location
- Filter tooltip z-index fixed (above dropdowns)
- Trainer bio: status labels from config, not raw keys

Open Day:
- Hall name + address shown in schedule grid headers
- Only one class card editable at a time (edit/create mutually exclusive)
- Bigger action buttons (cancel/delete) on class cards
- Create as empty draft (not pre-filled with published status)
- Fix discount threshold input (allow delete to empty)
- Skip auto-save during partial date input

Admin:
- SectionEditor: unsaved data guard (force-save before navigation)
- Open Day + Team: same navigation guards
- Contact: removed working hours field
- TimeRangeField: allow end time hour changes
- Schedule cards: visible borders, 90min default duration
- Trainer bio: RichTextarea for description
- Open Day: RichTextarea for description
This commit is contained in:
2026-03-30 22:57:36 +03:00
parent 06be6b48ce
commit ae30be8f9d
19 changed files with 286 additions and 129 deletions
+1 -1
View File
@@ -585,7 +585,7 @@ export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFiel
}
function handleEndChange(newEnd: string) {
if (start && newEnd && newEnd <= start) return;
// Always allow the change — validation handles the error display
update(start, newEnd);
}
@@ -28,6 +28,7 @@ export function SectionEditor<T>({
const [error, setError] = useState("");
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const initialLoadRef = useRef(true);
const pendingSaveRef = useRef(false);
useEffect(() => {
adminFetch(`/api/admin/sections/${sectionKey}`)
@@ -68,6 +69,7 @@ export function SectionEditor<T>({
return;
}
pendingSaveRef.current = true;
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
if (validate && !validate(data)) return;
@@ -79,6 +81,41 @@ export function SectionEditor<T>({
};
}, [data, save]);
// Clear pending flag after save completes
useEffect(() => {
if (status === "saved") pendingSaveRef.current = false;
}, [status]);
// Warn before leaving with unsaved changes
useEffect(() => {
function onBeforeUnload(e: BeforeUnloadEvent) {
if (pendingSaveRef.current) e.preventDefault();
}
function onLinkClick(e: MouseEvent) {
if (!pendingSaveRef.current) return;
const link = (e.target as HTMLElement).closest("a");
if (!link || link.target === "_blank") return;
const href = link.getAttribute("href");
if (!href || href.startsWith("#")) return;
// Force save immediately before navigating
if (timerRef.current) clearTimeout(timerRef.current);
if (data && (!validate || validate(data))) {
e.preventDefault();
e.stopPropagation();
save(data).then(() => {
pendingSaveRef.current = false;
window.location.href = href;
});
}
}
window.addEventListener("beforeunload", onBeforeUnload);
document.addEventListener("click", onLinkClick, true);
return () => {
window.removeEventListener("beforeunload", onBeforeUnload);
document.removeEventListener("click", onLinkClick, true);
};
}, [data, save, validate]);
if (loading) {
return (
<div className="flex items-center gap-2 text-neutral-400">
-6
View File
@@ -230,12 +230,6 @@ export default function ContactEditorPage() {
/>
</div>
<InputField
label="Часы работы"
value={data.workingHours}
onChange={(v) => update({ ...data, workingHours: v })}
/>
<CollapsibleSection title="Адреса">
<AddressList
items={data.addresses ?? []}
+82 -39
View File
@@ -5,7 +5,7 @@ import {
Plus, X, Loader2, Calendar, Trash2, Ban, CheckCircle2, RotateCcw, Sparkles,
} from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import { ParticipantLimits, SelectField } from "../_components/FormField";
import { ParticipantLimits, SelectField, RichTextarea } from "../_components/FormField";
import { PriceField } from "../_components/PriceField";
// --- Types ---
@@ -104,16 +104,13 @@ function EventSettings({
</div>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Описание</label>
<textarea
value={event.description || ""}
onChange={(e) => onChange({ description: e.target.value || undefined })}
rows={2}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors resize-none"
placeholder="Описание мероприятия..."
/>
</div>
<RichTextarea
label="Описание"
value={event.description || ""}
onChange={(v) => onChange({ description: v || undefined })}
rows={3}
placeholder="Описание мероприятия..."
/>
<div className="sm:max-w-xs">
<PriceField
@@ -153,8 +150,8 @@ function EventSettings({
<label className="block text-sm text-neutral-400 mb-1.5">От N занятий</label>
<input
type="number"
value={event.discountThreshold}
onChange={(e) => onChange({ discountThreshold: parseInt(e.target.value) || 1 })}
value={event.discountThreshold || ""}
onChange={(e) => onChange({ discountThreshold: parseInt(e.target.value) || 0 })}
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"
/>
</div>
@@ -244,10 +241,10 @@ function NewClassForm({
<div ref={formRef} className="p-2 space-y-1.5 ring-1 ring-gold/30 rounded-lg">
<SelectField label="" value={style} onChange={setStyle} options={styles.map((s) => ({ value: s, label: s }))} placeholder="Стиль..." />
<SelectField label="" value={trainer} onChange={setTrainer} options={trainers.map((t) => ({ value: t, label: t }))} placeholder="Тренер..." />
<div className="flex gap-1 justify-end">
<button onClick={onCancel} className="text-[10px] text-neutral-500 hover:text-white px-1">Отмена</button>
<div className="flex gap-2 justify-end mt-2">
<button onClick={onCancel} className="rounded-md border border-white/10 px-3 py-1 text-xs text-neutral-400 hover:text-white hover:border-white/25 transition-colors">Отмена</button>
<button onClick={() => canSave && onSave({ trainer, style, endTime })} disabled={!canSave}
className="text-[10px] text-gold hover:text-gold-light px-1 font-medium disabled:opacity-30 disabled:cursor-not-allowed">OK</button>
className="rounded-md bg-gold/20 border border-gold/30 px-3 py-1 text-xs font-medium text-gold hover:bg-gold/30 transition-colors disabled:opacity-30 disabled:cursor-not-allowed">Сохранить</button>
</div>
</div>
);
@@ -260,6 +257,8 @@ function ClassCell({
minBookings,
trainers,
styles,
editing,
onEdit,
onUpdate,
onDelete,
onCancel,
@@ -268,11 +267,12 @@ function ClassCell({
minBookings: number;
trainers: string[];
styles: string[];
editing: boolean;
onEdit: (id: number | null) => void;
onUpdate: (id: number, data: Partial<OpenDayClass>) => void;
onDelete: (id: number) => void;
onCancel: (id: number) => void;
}) {
const [editing, setEditing] = useState(false);
const [trainer, setTrainer] = useState(cls.trainer);
const [style, setStyle] = useState(cls.style);
@@ -281,7 +281,7 @@ function ClassCell({
function save() {
if (trainer.trim() && style.trim()) {
onUpdate(cls.id, { trainer: trainer.trim(), style: style.trim() });
setEditing(false);
onEdit(null);
}
}
@@ -290,12 +290,12 @@ function ClassCell({
<div className="p-2 space-y-1.5 rounded-lg">
<SelectField label="" value={style} onChange={setStyle} options={styles.map((s) => ({ value: s, label: s }))} placeholder="Стиль..." />
<SelectField label="" value={trainer} onChange={setTrainer} options={trainers.map((t) => ({ value: t, label: t }))} placeholder="Тренер..." />
<div className="flex gap-1 justify-end">
<button onClick={() => setEditing(false)} className="text-[10px] text-neutral-500 hover:text-white px-1">
<div className="flex gap-2 justify-end mt-2">
<button onClick={() => onEdit(null)} className="rounded-md border border-white/10 px-3 py-1 text-xs text-neutral-400 hover:text-white hover:border-white/25 transition-colors">
Отмена
</button>
<button onClick={save} className="text-[10px] text-gold hover:text-gold-light px-1 font-medium">
OK
<button onClick={save} className="rounded-md bg-gold/20 border border-gold/30 px-3 py-1 text-xs font-medium text-gold hover:bg-gold/30 transition-colors">
Сохранить
</button>
</div>
</div>
@@ -311,7 +311,7 @@ function ClassCell({
? "bg-red-500/5 border border-red-500/20"
: "bg-gold/5 border border-gold/15 hover:border-gold/30"
}`}
onClick={() => setEditing(true)}
onClick={() => onEdit(cls.id)}
>
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-white truncate">{cls.style}</span>
@@ -334,20 +334,20 @@ function ClassCell({
{cls.cancelled && <span className="text-[9px] text-neutral-500">отменено</span>}
</div>
{/* Actions */}
<div className="absolute top-1 right-1 hidden group-hover:flex gap-0.5">
<div className="absolute top-1.5 right-1.5 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => { e.stopPropagation(); onCancel(cls.id); }}
className={`rounded p-0.5 ${cls.cancelled ? "text-neutral-500 hover:text-emerald-400" : "text-neutral-500 hover:text-yellow-400"}`}
className={`rounded-md p-1 transition-colors ${cls.cancelled ? "text-neutral-500 hover:text-emerald-400 hover:bg-emerald-400/10" : "text-neutral-500 hover:text-yellow-400 hover:bg-yellow-400/10"}`}
title={cls.cancelled ? "Восстановить" : "Отменить"}
>
{cls.cancelled ? <RotateCcw size={10} /> : <Ban size={10} />}
{cls.cancelled ? <RotateCcw size={14} /> : <Ban size={14} />}
</button>
<button
onClick={(e) => { e.stopPropagation(); onDelete(cls.id); }}
className="rounded p-0.5 text-neutral-500 hover:text-red-400"
className="rounded-md p-1 text-neutral-500 hover:text-red-400 hover:bg-red-400/10 transition-colors"
title="Удалить"
>
<Trash2 size={10} />
<Trash2 size={14} />
</button>
</div>
</div>
@@ -374,6 +374,7 @@ function ScheduleGrid({
onClassesChange: () => void;
}) {
const [selectedHall, setSelectedHall] = useState(halls[0] ?? "");
const [editingClassId, setEditingClassId] = useState<number | null>(null);
const timeSlots = generateTimeSlots(10, 22);
// Build lookup: time -> class for selected hall
@@ -469,6 +470,8 @@ function ScheduleGrid({
minBookings={minBookings}
trainers={trainers}
styles={styles}
editing={editingClassId === cls.id}
onEdit={(id) => { setEditingClassId(id); if (id) setCreatingTime(null); }}
onUpdate={updateClass}
onDelete={deleteClass}
onCancel={cancelClass}
@@ -483,7 +486,7 @@ function ScheduleGrid({
/>
) : (
<button
onClick={() => setCreatingTime(time)}
onClick={() => { setCreatingTime(time); setEditingClassId(null); }}
className="w-full rounded-lg border border-dashed border-white/5 p-2 text-neutral-600 hover:text-gold hover:border-gold/20 transition-colors"
>
<Plus size={12} className="mx-auto" />
@@ -512,7 +515,8 @@ export default function OpenDayAdminPage() {
const [trainers, setTrainers] = useState<string[]>([]);
const [styles, setStyles] = useState<string[]>([]);
const [halls, setHalls] = useState<string[]>([]);
const saveTimerRef = { current: null as ReturnType<typeof setTimeout> | null };
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingSaveRef = useRef(false);
// Load data
useEffect(() => {
@@ -543,8 +547,11 @@ export default function OpenDayAdminPage() {
}
// Auto-save event changes
const eventRef = useRef<OpenDayEvent | null>(null);
const saveEvent = useCallback(
(updated: OpenDayEvent) => {
eventRef.current = updated;
pendingSaveRef.current = true;
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(async () => {
setSaving(true);
@@ -555,6 +562,7 @@ export default function OpenDayAdminPage() {
body: JSON.stringify(updated),
});
setSaveStatus(res.ok ? "saved" : "error");
if (res.ok) pendingSaveRef.current = false;
} catch {
setSaveStatus("error");
}
@@ -565,31 +573,66 @@ export default function OpenDayAdminPage() {
[]
);
// Warn before leaving with unsaved changes
useEffect(() => {
function onBeforeUnload(e: BeforeUnloadEvent) {
if (pendingSaveRef.current) e.preventDefault();
}
function onLinkClick(e: MouseEvent) {
if (!pendingSaveRef.current) return;
const link = (e.target as HTMLElement).closest("a");
if (!link || link.target === "_blank") return;
const href = link.getAttribute("href");
if (!href || href.startsWith("#")) return;
e.preventDefault();
e.stopPropagation();
// Force save then navigate
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
const data = eventRef.current;
if (data) {
adminFetch("/api/admin/open-day", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}).finally(() => { window.location.href = href; });
} else {
window.location.href = href;
}
}
window.addEventListener("beforeunload", onBeforeUnload);
document.addEventListener("click", onLinkClick, true);
return () => {
window.removeEventListener("beforeunload", onBeforeUnload);
document.removeEventListener("click", onLinkClick, true);
};
}, []);
function handleEventChange(patch: Partial<OpenDayEvent>) {
if (!event) return;
const updated = { ...event, ...patch };
setEvent(updated);
// Skip auto-save only if date is partially typed (prevents 400 errors)
if (updated.date && updated.date.length > 0 && updated.date.length < 10) return;
saveEvent(updated);
}
async function createEvent() {
const today = new Date().toISOString().split("T")[0];
const res = await adminFetch("/api/admin/open-day", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ date: today }),
body: JSON.stringify({ date: "" }),
});
const { id } = await res.json();
setEvent({
id,
date: today,
title: "День открытых дверей",
pricePerClass: 30,
discountPrice: 20,
discountThreshold: 3,
minBookings: 4,
date: "",
title: "",
pricePerClass: 0,
discountPrice: 0,
discountThreshold: 0,
minBookings: 0,
maxParticipants: 0,
active: true,
active: false,
});
}
+13 -8
View File
@@ -221,10 +221,10 @@ function ClassBlock({
? { backgroundImage: "repeating-linear-gradient(135deg, transparent, transparent 4px, rgba(239,68,68,0.35) 4px, rgba(239,68,68,0.35) 8px)" }
: {}),
}}
className={`absolute left-1 right-1 rounded-md border-l-3 px-2 py-0.5 text-left text-xs text-white cursor-grab active:cursor-grabbing overflow-hidden select-none ${colors} ${
className={`absolute left-1 right-1 rounded-md border border-white/20 border-l-3 px-2 py-0.5 text-left text-xs text-white cursor-grab active:cursor-grabbing overflow-hidden select-none ${colors} ${
isOverlapping ? "ring-2 ring-red-500 ring-offset-1 ring-offset-neutral-900" : ""
} ${isDragging ? "opacity-30" : "hover:opacity-90"}`}
title={`${cls.time}\n${cls.type}\n${cls.trainer}${cls.level ? ` (${cls.level})` : ""}`}
} ${isDragging ? "opacity-30" : "hover:opacity-90 hover:border-white/40"}`}
title={`${cls.time}\n${cls.type}\n${cls.trainer}${cls.level ? ` · ${cls.level}` : ""}${cls.status ? ` · ${cls.status}` : ""}`}
>
<div className="font-semibold truncate leading-tight">
{parts[0]?.trim()}{parts[1]?.trim()}
@@ -235,6 +235,11 @@ function ClassBlock({
{height > 48 && (
<div className="truncate text-white/70 leading-tight">{cls.trainer}</div>
)}
{height > 64 && (cls.level || cls.status) && (
<div className="truncate text-white/50 leading-tight text-[10px]">
{[cls.level, cls.status].filter(Boolean).join(" · ")}
</div>
)}
{isOverlapping && (
<div className="text-red-200 font-bold leading-tight"> Пересечение</div>
)}
@@ -876,10 +881,10 @@ function CalendarGrid({
const y = e.clientY - rect.top;
const rawMin = yToMinutes(y);
snapped = Math.round((rawMin - 30) / SNAP_MINUTES) * SNAP_MINUTES;
snapped = Math.max(HOUR_START * 60, Math.min(snapped, HOUR_END * 60 - 60));
snapped = Math.max(HOUR_START * 60, Math.min(snapped, HOUR_END * 60 - 90));
}
const startTime = formatMinutes(snapped);
const endTime = formatMinutes(snapped + 60);
const endTime = formatMinutes(snapped + 90);
setHover(null);
setNewClass({
@@ -1005,8 +1010,8 @@ function CalendarGrid({
{sortedDays.map((day, di) => {
const showHover = hover && hover.dayIndex === di && !drag && !newClass && !editingClass;
const hoverTop = showHover ? minutesToY(hover.startMin) : 0;
const hoverHeight = HOUR_HEIGHT; // 1 hour
const hoverEndMin = showHover ? hover.startMin + 60 : 0;
const hoverHeight = HOUR_HEIGHT * 1.5; // 1.5 hours
const hoverEndMin = showHover ? hover.startMin + 90 : 0;
return (
<div
@@ -1026,7 +1031,7 @@ function CalendarGrid({
const rawMin = yToMinutes(y);
// Snap to 15-min and offset so the block is centered on cursor
const snapped = Math.round((rawMin - 30) / SNAP_MINUTES) * SNAP_MINUTES;
const clamped = Math.max(HOUR_START * 60, Math.min(snapped, HOUR_END * 60 - 60));
const clamped = Math.max(HOUR_START * 60, Math.min(snapped, HOUR_END * 60 - 90));
setHover({ dayIndex: di, startMin: clamped });
}}
onMouseLeave={() => setHover(null)}
+2 -2
View File
@@ -4,7 +4,7 @@ import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter, useParams } from "next/navigation";
import Image from "next/image";
import { Save, Loader2, Check, ArrowLeft, Upload, AlertCircle } from "lucide-react";
import { InputField, TextareaField, VictoryListField, AutocompleteMulti } from "../../_components/FormField";
import { InputField, TextareaField, RichTextarea, VictoryListField, AutocompleteMulti } from "../../_components/FormField";
import { useToast } from "../../_components/Toast";
import { adminFetch } from "@/lib/csrf";
import type { RichListItem } from "@/types/content";
@@ -397,7 +397,7 @@ function TeamMemberEditor() {
rows={2}
placeholder="1-2 предложения для карусели"
/>
<TextareaField
<RichTextarea
label="Полное описание (для страницы тренера)"
value={data.description}
onChange={(v) => setData({ ...data, description: v })}
+31 -1
View File
@@ -32,11 +32,12 @@ export default function TeamEditorPage() {
// Auto-save section title with debounce (skip initial load)
const titleChangeCount = useRef(0);
const pendingSaveRef = useRef(false);
useEffect(() => {
if (!titleLoadedRef.current) return;
titleChangeCount.current++;
// Skip the first change (initial load setting the value)
if (titleChangeCount.current <= 1) return;
pendingSaveRef.current = true;
if (titleTimerRef.current) clearTimeout(titleTimerRef.current);
titleTimerRef.current = setTimeout(async () => {
const res = await adminFetch("/api/admin/sections/team", {
@@ -45,11 +46,40 @@ export default function TeamEditorPage() {
body: JSON.stringify({ title: sectionTitle }),
});
setSaveStatus(res.ok ? "saved" : "error");
if (res.ok) pendingSaveRef.current = false;
setTimeout(() => setSaveStatus("idle"), 2000);
}, 800);
return () => { if (titleTimerRef.current) clearTimeout(titleTimerRef.current); };
}, [sectionTitle]);
// Warn before leaving with unsaved title
useEffect(() => {
function onBeforeUnload(e: BeforeUnloadEvent) {
if (pendingSaveRef.current) e.preventDefault();
}
function onLinkClick(e: MouseEvent) {
if (!pendingSaveRef.current) return;
const link = (e.target as HTMLElement).closest("a");
if (!link || link.target === "_blank") return;
const href = link.getAttribute("href");
if (!href || href.startsWith("#") || href.startsWith("/admin/team/")) return;
e.preventDefault();
e.stopPropagation();
if (titleTimerRef.current) clearTimeout(titleTimerRef.current);
adminFetch("/api/admin/sections/team", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: sectionTitle }),
}).finally(() => { window.location.href = href; });
}
window.addEventListener("beforeunload", onBeforeUnload);
document.addEventListener("click", onLinkClick, true);
return () => {
window.removeEventListener("beforeunload", onBeforeUnload);
document.removeEventListener("click", onLinkClick, true);
};
}, [sectionTitle]);
const saveOrder = useCallback(async (updated: Member[]) => {
setMembers(updated);
const res = await adminFetch("/api/admin/team/reorder", {
+2 -2
View File
@@ -24,8 +24,8 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) {
try {
const body = await request.json();
if (!body.date || typeof body.date !== "string") {
return NextResponse.json({ error: "date is required" }, { status: 400 });
if (body.date === undefined) {
return NextResponse.json({ error: "date field is required" }, { status: 400 });
}
const id = createOpenDayEvent(body);
return NextResponse.json({ ok: true, id });
+2 -2
View File
@@ -45,8 +45,8 @@ export default function HomePage() {
/>
)}
{content?.classes && <Classes data={content.classes} />}
{content?.team && <Team data={content.team} schedule={content.schedule?.locations} />}
{openDayData && content?.popups && <OpenDay data={openDayData} popups={content.popups} teamMembers={content.team?.members ?? []} />}
{content?.team && <Team data={content.team} schedule={content.schedule?.locations} scheduleConfig={content.scheduleConfig} />}
{openDayData && content?.popups && <OpenDay data={openDayData} popups={content.popups} teamMembers={content.team?.members ?? []} locations={content.schedule?.locations} />}
{content?.schedule && <Schedule data={content.schedule} scheduleConfig={content.scheduleConfig} classItems={content.classes?.items ?? []} teamMembers={content.team?.members ?? []} />}
{content?.pricing && <Pricing data={content.pricing} />}
{content?.masterClasses && <MasterClasses data={content.masterClasses} regCounts={mcRegCounts} popups={content.popups} />}