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:
2026-03-28 00:33:55 +03:00
parent b322c969f2
commit bdeedcfcc8
9 changed files with 102 additions and 37 deletions
+1
View File
@@ -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"
+25 -12
View File
@@ -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(() => {});
}, []);
+31 -14
View File
@@ -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 ${
+13 -8
View File
@@ -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:MMHH:MM" to minutes since midnight */
export function startTimeMinutes(time: string): number {
const start = time.split("")[0]?.trim() ?? "";
+9
View File
@@ -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} />