fix: schedule status system — auto-key, config order, label lookup
- Auto-generate status key from label (admin doesn't need to set keys) - Remove visible key field from status config editor - Order statuses/levels in filters by config order (matches admin panel) - Shared findStatusConfig() for robust label lookup (by key, label, or derived key) - Custom status badges in DayCard, GroupCard, MobileSchedule - Simplified filter logic with clsStatus helper - Removed dead code: TIME_PRESETS, StatusFilter type - SelectField: blur input after selection to prevent re-open
This commit is contained in:
@@ -251,6 +251,7 @@ export function SelectField({
|
||||
onChange(opt.value);
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
inputRef.current?.blur();
|
||||
}}
|
||||
className={`w-full px-4 py-2 text-left text-sm transition-colors hover:bg-white/5 ${
|
||||
opt.value === value ? "text-gold bg-gold/5" : "text-white"
|
||||
|
||||
@@ -50,11 +50,16 @@ function buildLevelOptions(config: ScheduleConfig) {
|
||||
];
|
||||
}
|
||||
|
||||
function statusKey(s: { key: string; label: string }): string {
|
||||
// Use explicit key if set, otherwise derive from label
|
||||
return s.key || s.label.toLowerCase().replace(/[^a-zа-яё0-9]/gi, "_").replace(/_+/g, "_");
|
||||
}
|
||||
|
||||
function buildStatusOptions(config: ScheduleConfig) {
|
||||
const statuses = config.statuses ?? DEFAULT_CONFIG.statuses;
|
||||
return [
|
||||
{ value: "", label: "Без статуса" },
|
||||
...statuses.map((s) => ({ value: s.key, label: s.label })),
|
||||
...statuses.map((s) => ({ value: statusKey(s), label: s.label })),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -275,7 +280,16 @@ function ClassModal({
|
||||
statusOptions: { value: string; label: string }[];
|
||||
statusHint: string;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<ScheduleClass>(cls);
|
||||
const [draft, setDraft] = useState<ScheduleClass>(() => {
|
||||
// Migrate legacy booleans to status field
|
||||
if (!cls.status && (cls.hasSlots || cls.recruiting)) {
|
||||
return {
|
||||
...cls,
|
||||
status: cls.hasSlots ? "hasSlots" : "recruiting",
|
||||
};
|
||||
}
|
||||
return cls;
|
||||
});
|
||||
const trainerOptions = trainers.map((t) => ({ value: t, label: t }));
|
||||
const typeOptions = classTypes.map((t) => ({ value: t, label: t }));
|
||||
const isNew = !onDelete;
|
||||
@@ -514,7 +528,7 @@ function ClassModal({
|
||||
/>
|
||||
<SelectField
|
||||
label="Статус"
|
||||
value={draft.status || (draft.recruiting ? "recruiting" : draft.hasSlots ? "hasSlots" : "")}
|
||||
value={draft.status || ""}
|
||||
onChange={(v) => setDraft({
|
||||
...draft,
|
||||
status: v || undefined,
|
||||
@@ -1199,23 +1213,17 @@ function ConfigEditor({ cfg, updateCfg, onSync }: { cfg: ScheduleConfig; updateC
|
||||
<ArrayEditor
|
||||
items={normalized.statuses}
|
||||
onChange={(statuses) => updateCfg({ ...normalized, statuses })}
|
||||
createItem={() => ({ key: `status_${Date.now()}`, label: "", description: "" })}
|
||||
createItem={() => ({ key: "", 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 })}
|
||||
onChange={(v) => update({ ...item, label: v, key: v.toLowerCase().replace(/[^a-zа-яё0-9]/gi, "_").replace(/_+/g, "_") })}
|
||||
placeholder="Название статуса"
|
||||
/>
|
||||
<InputField
|
||||
@@ -1262,7 +1270,12 @@ export default function ScheduleEditorPage() {
|
||||
|
||||
adminFetch("/api/admin/sections/scheduleConfig")
|
||||
.then((r) => r.json())
|
||||
.then((c: ScheduleConfig) => { if (c?.levels) setConfig(c); })
|
||||
.then((c: Partial<ScheduleConfig>) => {
|
||||
setConfig({
|
||||
levels: c?.levels ?? DEFAULT_CONFIG.levels,
|
||||
statuses: c?.statuses ?? DEFAULT_CONFIG.statuses,
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -192,19 +192,37 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
|
||||
for (const cls of day.classes) {
|
||||
typeSet.add(cls.type);
|
||||
trainerSet.add(cls.trainer);
|
||||
if (cls.status) statusSet.add(cls.status);
|
||||
if (cls.hasSlots) statusSet.add("hasSlots");
|
||||
if (cls.recruiting) statusSet.add("recruiting");
|
||||
const clsStatus = cls.status || (cls.recruiting ? "recruiting" : cls.hasSlots ? "hasSlots" : "");
|
||||
if (clsStatus) statusSet.add(clsStatus);
|
||||
if (cls.level) levelSet.add(cls.level);
|
||||
}
|
||||
}
|
||||
// Also include all configured statuses/levels so they appear in filters
|
||||
if (scheduleConfig?.statuses) {
|
||||
for (const s of scheduleConfig.statuses) if (s.key) statusSet.add(s.key);
|
||||
}
|
||||
if (scheduleConfig?.levels) {
|
||||
for (const l of scheduleConfig.levels) if (l.value) levelSet.add(l.value);
|
||||
}
|
||||
// Order statuses by config order, then any extras from data
|
||||
const configStatusOrder = (scheduleConfig?.statuses ?? []).map((s) => s.key).filter(Boolean);
|
||||
const orderedStatuses = [
|
||||
...configStatusOrder.filter((k) => statusSet.has(k)),
|
||||
...Array.from(statusSet).filter((k) => !configStatusOrder.includes(k)),
|
||||
];
|
||||
// Order levels by config order
|
||||
const configLevelOrder = (scheduleConfig?.levels ?? []).map((l) => l.value).filter(Boolean);
|
||||
const orderedLevels = [
|
||||
...configLevelOrder.filter((v) => levelSet.has(v)),
|
||||
...Array.from(levelSet).filter((v) => !configLevelOrder.includes(v)),
|
||||
];
|
||||
return {
|
||||
types: Array.from(typeSet).sort(),
|
||||
availableStatuses: Array.from(statusSet),
|
||||
levels: Array.from(levelSet).sort(),
|
||||
availableStatuses: orderedStatuses,
|
||||
levels: orderedLevels,
|
||||
trainerNames: Array.from(trainerSet).sort(),
|
||||
};
|
||||
}, [activeDays]);
|
||||
}, [activeDays, scheduleConfig]);
|
||||
|
||||
// Parse time range for filtering
|
||||
const activeTimeRange = isTimeFilterActive(filterTime)
|
||||
@@ -227,19 +245,17 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
|
||||
.map((day) => ({
|
||||
...day,
|
||||
classes: day.classes.filter(
|
||||
(cls) =>
|
||||
(filterTrainerSet.size === 0 || filterTrainerSet.has(cls.trainer)) &&
|
||||
(cls) => {
|
||||
const clsStatus = cls.status || (cls.recruiting ? "recruiting" : cls.hasSlots ? "hasSlots" : "");
|
||||
return (filterTrainerSet.size === 0 || filterTrainerSet.has(cls.trainer)) &&
|
||||
(filterTypes.size === 0 || filterTypes.has(cls.type)) &&
|
||||
(filterStatusSet.size === 0 ||
|
||||
(cls.status && filterStatusSet.has(cls.status as StatusTag)) ||
|
||||
(filterStatusSet.has("hasSlots" as StatusTag) && cls.hasSlots) ||
|
||||
(filterStatusSet.has("recruiting" as StatusTag) && cls.recruiting)) &&
|
||||
(filterStatusSet.size === 0 || (clsStatus && filterStatusSet.has(clsStatus))) &&
|
||||
(!filterLevel || cls.level === filterLevel) &&
|
||||
(!activeTimeRange || (() => {
|
||||
const m = startTimeMinutes(cls.time);
|
||||
return m >= activeTimeRange[0] && m < activeTimeRange[1];
|
||||
})())
|
||||
),
|
||||
})());
|
||||
}),
|
||||
}))
|
||||
.filter((day) => day.classes.length > 0);
|
||||
}, [activeDays, filterTrainerSet, filterTypes, filterStatusSet, filterLevel, filterTime, activeTimeRange, filterDaySet]);
|
||||
@@ -443,6 +459,7 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
|
||||
showLocation={isAllMode}
|
||||
onBook={(v) => dispatch({ type: "SET_BOOKING", value: v })}
|
||||
trainerPhotos={trainerPhotos}
|
||||
scheduleConfig={scheduleConfig}
|
||||
/>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
@@ -44,6 +44,11 @@ function ClassRow({
|
||||
набор
|
||||
</span>
|
||||
)}
|
||||
{cls.status && cls.status !== "hasSlots" && cls.status !== "recruiting" && (
|
||||
<span className="shrink-0 rounded-full bg-gold/15 border border-gold/25 px-2 py-0.5 text-[10px] font-semibold text-gold">
|
||||
{cls.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleFilterTrainer(cls.trainer)}
|
||||
|
||||
@@ -4,7 +4,9 @@ import { useMemo } from "react";
|
||||
import Image from "next/image";
|
||||
import { User, Calendar } from "lucide-react";
|
||||
import { GroupCard } from "@/components/ui/GroupCard";
|
||||
import { findStatusConfig } from "./constants";
|
||||
import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants";
|
||||
import type { SiteContent } from "@/types/content";
|
||||
|
||||
interface ScheduleGroup {
|
||||
trainer: string;
|
||||
@@ -12,6 +14,7 @@ interface ScheduleGroup {
|
||||
level?: string;
|
||||
hasSlots: boolean;
|
||||
recruiting: boolean;
|
||||
status?: string;
|
||||
location?: string;
|
||||
locationAddress?: string;
|
||||
slots: { day: string; dayShort: string; time: string }[];
|
||||
@@ -33,6 +36,7 @@ function buildGroups(days: ScheduleDayMerged[]): ScheduleGroup[] {
|
||||
existing.slots.push({ day: day.day, dayShort: day.dayShort, time: cls.time });
|
||||
if (cls.hasSlots) existing.hasSlots = true;
|
||||
if (cls.recruiting) existing.recruiting = true;
|
||||
if (cls.status && !existing.status) existing.status = cls.status;
|
||||
if (cls.level && !existing.level) existing.level = cls.level;
|
||||
} else {
|
||||
map.set(key, {
|
||||
@@ -41,6 +45,7 @@ function buildGroups(days: ScheduleDayMerged[]): ScheduleGroup[] {
|
||||
level: cls.level,
|
||||
hasSlots: !!cls.hasSlots,
|
||||
recruiting: !!cls.recruiting,
|
||||
status: cls.status,
|
||||
location: cls.locationName,
|
||||
locationAddress: cls.locationAddress,
|
||||
slots: [{ day: day.day, dayShort: day.dayShort, time: cls.time }],
|
||||
@@ -126,6 +131,7 @@ interface GroupViewProps {
|
||||
showLocation?: boolean;
|
||||
onBook?: (groupInfo: string) => void;
|
||||
trainerPhotos?: Record<string, string>;
|
||||
scheduleConfig?: SiteContent["scheduleConfig"];
|
||||
}
|
||||
|
||||
const WEEKDAY_NAMES = ["Воскресенье", "Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота"];
|
||||
@@ -140,6 +146,7 @@ export function GroupView({
|
||||
showLocation,
|
||||
onBook,
|
||||
trainerPhotos = {},
|
||||
scheduleConfig,
|
||||
}: GroupViewProps) {
|
||||
const groups = buildGroups(filteredDays);
|
||||
const byTrainer = groupByTrainer(groups);
|
||||
@@ -234,6 +241,8 @@ export function GroupView({
|
||||
level={group.level}
|
||||
recruiting={group.recruiting}
|
||||
hasSlots={group.hasSlots}
|
||||
status={group.status}
|
||||
statusLabel={findStatusConfig(scheduleConfig?.statuses, group.status ?? "")?.label}
|
||||
address={group.locationAddress}
|
||||
location={group.location}
|
||||
merged={merged}
|
||||
|
||||
@@ -61,6 +61,11 @@ function ClassRow({
|
||||
набор
|
||||
</span>
|
||||
)}
|
||||
{cls.status && cls.status !== "hasSlots" && cls.status !== "recruiting" && (
|
||||
<span className="shrink-0 rounded-full bg-gold/15 border border-gold/25 px-1.5 py-px text-[9px] font-semibold text-gold">
|
||||
{cls.status}
|
||||
</span>
|
||||
)}
|
||||
{cls.level && (
|
||||
<span className="shrink-0 rounded-full bg-rose-500/15 border border-rose-500/25 px-1.5 py-px text-[9px] font-semibold text-rose-600 dark:text-rose-400">
|
||||
{cls.level}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { X, SlidersHorizontal } from "lucide-react";
|
||||
import {
|
||||
pillBase,
|
||||
isTimeFilterActive,
|
||||
findStatusConfig,
|
||||
type StatusTag,
|
||||
type TimeFilter,
|
||||
} from "./constants";
|
||||
@@ -160,7 +161,7 @@ export function ScheduleFilters({
|
||||
{/* Desktop — card layout */}
|
||||
<div className="hidden sm:block space-y-2">
|
||||
{availableStatuses.map((statusKey) => {
|
||||
const cfg = scheduleConfig?.statuses?.find((s) => s.key === statusKey);
|
||||
const cfg = findStatusConfig(scheduleConfig?.statuses, statusKey);
|
||||
const label = cfg?.label || statusKey;
|
||||
const desc = cfg?.description;
|
||||
const active = filterStatusSet.has(statusKey);
|
||||
@@ -199,7 +200,7 @@ export function ScheduleFilters({
|
||||
{/* Mobile — compact pills */}
|
||||
<div className="flex flex-wrap gap-2 sm:hidden">
|
||||
{availableStatuses.map((statusKey) => {
|
||||
const cfg = scheduleConfig?.statuses?.find((s) => s.key === statusKey);
|
||||
const cfg = findStatusConfig(scheduleConfig?.statuses, statusKey);
|
||||
const label = cfg?.label || statusKey;
|
||||
const active = filterStatusSet.has(statusKey);
|
||||
return (
|
||||
@@ -228,7 +229,7 @@ export function ScheduleFilters({
|
||||
const desc = scheduleConfig?.levels?.find((l) => l.value === level)?.description;
|
||||
const active = filterLevel === level;
|
||||
return (
|
||||
<span key={level} className="relative group inline-flex items-center gap-1">
|
||||
<span key={level} className="relative inline-flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setFilterLevel(active ? null : level)}
|
||||
className={`rounded-xl px-4 py-2 text-xs font-semibold transition-all cursor-pointer border ${
|
||||
|
||||
@@ -63,8 +63,19 @@ export function buildTypeDots(
|
||||
}
|
||||
|
||||
export type StatusTag = string;
|
||||
/** @deprecated Use Set<StatusTag> instead */
|
||||
export type StatusFilter = "all" | "hasSlots" | "recruiting";
|
||||
|
||||
/** Find a status config entry by key, matching by key, label, or derived key */
|
||||
export function findStatusConfig(
|
||||
statuses: { key: string; label: string; description: string }[] | undefined,
|
||||
statusKey: string,
|
||||
): { key: string; label: string; description: string } | undefined {
|
||||
if (!statuses) return undefined;
|
||||
return statuses.find((s) =>
|
||||
s.key === statusKey ||
|
||||
s.label === statusKey ||
|
||||
s.label.toLowerCase().replace(/[^a-zа-яё0-9]/gi, "_").replace(/_+/g, "_") === statusKey
|
||||
);
|
||||
}
|
||||
export interface TimeFilter {
|
||||
from: string; // "HH:MM" or ""
|
||||
to: string; // "HH:MM" or ""
|
||||
@@ -76,12 +87,6 @@ export function isTimeFilterActive(t: TimeFilter): boolean {
|
||||
return t.from !== "" || t.to !== "";
|
||||
}
|
||||
|
||||
export const TIME_PRESETS: { label: string; from: string; to: string }[] = [
|
||||
{ label: "Утро", from: "06:00", to: "12:00" },
|
||||
{ label: "День", from: "12:00", to: "18:00" },
|
||||
{ label: "Вечер", from: "18:00", to: "23:00" },
|
||||
];
|
||||
|
||||
/** Parse start time from "HH:MM–HH:MM" to minutes since midnight */
|
||||
export function startTimeMinutes(time: string): number {
|
||||
const start = time.split("–")[0]?.trim() ?? "";
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface GroupCardProps {
|
||||
level?: string;
|
||||
recruiting?: boolean;
|
||||
hasSlots?: boolean;
|
||||
status?: string;
|
||||
statusLabel?: string;
|
||||
address?: string;
|
||||
location?: string;
|
||||
merged: GroupCardSlot[];
|
||||
@@ -32,6 +34,8 @@ export function GroupCard({
|
||||
level,
|
||||
recruiting,
|
||||
hasSlots,
|
||||
status,
|
||||
statusLabel,
|
||||
address,
|
||||
location,
|
||||
merged,
|
||||
@@ -85,6 +89,11 @@ export function GroupCard({
|
||||
набор
|
||||
</span>
|
||||
)}
|
||||
{status && status !== "hasSlots" && status !== "recruiting" && (
|
||||
<span className={`rounded-full bg-gold/15 border border-gold/25 ${badgeSize} font-semibold text-gold`}>
|
||||
{statusLabel || status}
|
||||
</span>
|
||||
)}
|
||||
{showLocation && (address || location) && (
|
||||
<span className={`inline-flex items-center gap-1 rounded-full bg-white/[0.04] border border-white/[0.08] ${locSize} font-medium text-white/40`}>
|
||||
<MapPin size={locIcon} />
|
||||
|
||||
Reference in New Issue
Block a user