feat: flexible group management in schedule editor

- Group = trainer + type (time-independent)
- Edit modal shows per-day time fields (Mon 12:00, Fri 18:00)
- Calendar blocks colored by group, not class type
- Color picker for site dots moved to classes editor
- New class: single time + multi-day selector
- Edit class: per-day times, add/remove days from group
- Delete removes group from all days

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 21:31:05 +03:00
parent b5262b4adc
commit bfa59a8d18
2 changed files with 209 additions and 227 deletions

View File

@@ -9,6 +9,25 @@ const ICON_OPTIONS = [
"heart", "music", "dumbbell", "trophy",
];
const COLOR_SWATCHES: { value: string; bg: string }[] = [
{ value: "rose", bg: "bg-rose-500" },
{ value: "orange", bg: "bg-orange-500" },
{ value: "amber", bg: "bg-amber-500" },
{ value: "yellow", bg: "bg-yellow-400" },
{ value: "lime", bg: "bg-lime-500" },
{ value: "emerald", bg: "bg-emerald-500" },
{ value: "teal", bg: "bg-teal-500" },
{ value: "cyan", bg: "bg-cyan-500" },
{ value: "sky", bg: "bg-sky-500" },
{ value: "blue", bg: "bg-blue-500" },
{ value: "indigo", bg: "bg-indigo-500" },
{ value: "violet", bg: "bg-violet-500" },
{ value: "purple", bg: "bg-purple-500" },
{ value: "fuchsia", bg: "bg-fuchsia-500" },
{ value: "pink", bg: "bg-pink-500" },
{ value: "red", bg: "bg-red-500" },
];
interface ClassesData {
title: string;
items: {
@@ -63,6 +82,31 @@ export default function ClassesEditorPage() {
</select>
</div>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1.5">
Цвет в расписании
</label>
<div className="flex flex-wrap gap-1.5">
{COLOR_SWATCHES.map((c) => {
const isUsed = data.items.some(
(other) => other !== item && other.color === c.value
);
if (isUsed) return null;
return (
<button
key={c.value}
type="button"
onClick={() => updateItem({ ...item, color: c.value })}
className={`h-6 w-6 rounded-full ${c.bg} transition-all ${
item.color === c.value
? "ring-2 ring-white ring-offset-1 ring-offset-neutral-900 scale-110"
: "opacity-50 hover:opacity-100"
}`}
/>
);
})}
</div>
</div>
<TextareaField
label="Краткое описание"
value={item.description}

View File

@@ -31,75 +31,48 @@ const LEVELS = [
{ value: "Продвинутый", label: "Продвинутый" },
];
const COLOR_SWATCHES: { value: string; bg: string }[] = [
{ value: "rose", bg: "bg-rose-500" },
{ value: "orange", bg: "bg-orange-500" },
{ value: "amber", bg: "bg-amber-500" },
{ value: "yellow", bg: "bg-yellow-400" },
{ value: "lime", bg: "bg-lime-500" },
{ value: "emerald", bg: "bg-emerald-500" },
{ value: "teal", bg: "bg-teal-500" },
{ value: "cyan", bg: "bg-cyan-500" },
{ value: "sky", bg: "bg-sky-500" },
{ value: "blue", bg: "bg-blue-500" },
{ value: "indigo", bg: "bg-indigo-500" },
{ value: "violet", bg: "bg-violet-500" },
{ value: "purple", bg: "bg-purple-500" },
{ value: "fuchsia", bg: "bg-fuchsia-500" },
{ value: "pink", bg: "bg-pink-500" },
{ value: "red", bg: "bg-red-500" },
const GROUP_PALETTE = [
"bg-rose-500/80 border-rose-400",
"bg-orange-500/80 border-orange-400",
"bg-amber-500/80 border-amber-400",
"bg-yellow-400/80 border-yellow-300",
"bg-lime-500/80 border-lime-400",
"bg-emerald-500/80 border-emerald-400",
"bg-teal-500/80 border-teal-400",
"bg-cyan-500/80 border-cyan-400",
"bg-sky-500/80 border-sky-400",
"bg-blue-500/80 border-blue-400",
"bg-indigo-500/80 border-indigo-400",
"bg-violet-500/80 border-violet-400",
"bg-purple-500/80 border-purple-400",
"bg-fuchsia-500/80 border-fuchsia-400",
"bg-pink-500/80 border-pink-400",
"bg-red-500/80 border-red-400",
];
const COLOR_MAP: Record<string, string> = {
rose: "bg-rose-500/80 border-rose-400",
orange: "bg-orange-500/80 border-orange-400",
amber: "bg-amber-500/80 border-amber-400",
yellow: "bg-yellow-400/80 border-yellow-300",
lime: "bg-lime-500/80 border-lime-400",
emerald: "bg-emerald-500/80 border-emerald-400",
teal: "bg-teal-500/80 border-teal-400",
cyan: "bg-cyan-500/80 border-cyan-400",
sky: "bg-sky-500/80 border-sky-400",
blue: "bg-blue-500/80 border-blue-400",
indigo: "bg-indigo-500/80 border-indigo-400",
violet: "bg-violet-500/80 border-violet-400",
purple: "bg-purple-500/80 border-purple-400",
fuchsia: "bg-fuchsia-500/80 border-fuchsia-400",
pink: "bg-pink-500/80 border-pink-400",
red: "bg-red-500/80 border-red-400",
};
const FALLBACK_PALETTE = COLOR_SWATCHES.map((c) => COLOR_MAP[c.value]);
function buildColorAssignments(classTypes: string[], classColors: Record<string, string>): Record<string, string> {
const result: Record<string, string> = {};
const usedPalette = new Set<number>();
// First pass: assign explicit colors
for (const type of classTypes) {
const c = classColors[type];
if (c && COLOR_MAP[c]) {
result[type] = COLOR_MAP[c];
const idx = COLOR_SWATCHES.findIndex((s) => s.value === c);
if (idx >= 0) usedPalette.add(idx);
}
}
// Second pass: assign remaining types to unused palette slots
let nextSlot = 0;
for (const type of classTypes) {
if (result[type]) continue;
while (usedPalette.has(nextSlot) && nextSlot < FALLBACK_PALETTE.length) nextSlot++;
result[type] = FALLBACK_PALETTE[nextSlot % FALLBACK_PALETTE.length];
usedPalette.add(nextSlot);
nextSlot++;
}
return result;
/** Build a unique group key (trainer + type) */
function groupKey(cls: ScheduleClass): string {
return `${cls.trainer}|${cls.type}`;
}
function getTypeColor(type: string, assignments: Record<string, string>): string {
return assignments[type] ?? "bg-neutral-600/80 border-neutral-500";
/** Assign a color to each unique group across all days */
function buildGroupColors(days: ScheduleDay[]): Record<string, string> {
const seen = new Set<string>();
const keys: string[] = [];
for (const day of days) {
for (const cls of day.classes) {
const k = groupKey(cls);
if (!seen.has(k)) {
seen.add(k);
keys.push(k);
}
}
}
const result: Record<string, string> = {};
keys.forEach((k, i) => {
result[k] = GROUP_PALETTE[i % GROUP_PALETTE.length];
});
return result;
}
// Calendar config
@@ -179,7 +152,7 @@ function ClassBlock({
index,
isOverlapping,
isDragging,
colorAssignments,
groupColors,
onClick,
onDragStart,
}: {
@@ -187,7 +160,7 @@ function ClassBlock({
index: number;
isOverlapping: boolean;
isDragging: boolean;
colorAssignments: Record<string, string>;
groupColors: Record<string, string>;
onClick: () => void;
onDragStart: (e: React.MouseEvent) => void;
}) {
@@ -199,7 +172,7 @@ function ClassBlock({
const top = minutesToY(startMin);
const height = Math.max(((endMin - startMin) / 60) * HOUR_HEIGHT, 20);
const colors = getTypeColor(cls.type, colorAssignments);
const colors = groupColors[groupKey(cls)] ?? "bg-neutral-600/80 border-neutral-500";
return (
<div
@@ -239,8 +212,9 @@ function ClassBlock({
// ---------- Edit Modal ----------
/** Check if two classes are the same "group" (same trainer + type + time) */
/** Same group = same trainer + type */
function isSameGroup(a: ScheduleClass, b: ScheduleClass): boolean {
return a.trainer === b.trainer && a.type === b.type && a.time === b.time;
return a.trainer === b.trainer && a.type === b.type;
}
function ClassModal({
@@ -256,13 +230,10 @@ function ClassModal({
cls: ScheduleClass;
trainers: string[];
classTypes: string[];
/** For edit: saves to current day + manages group days. For new: creates on selected days. */
onSave: (cls: ScheduleClass, days: string[]) => void;
onSave: (cls: ScheduleClass, dayTimes: Record<string, string>) => void;
onDelete?: () => void;
onClose: () => void;
/** All schedule days (sorted) */
allDays: ScheduleDay[];
/** Current day name */
currentDay: string;
}) {
const [draft, setDraft] = useState<ScheduleClass>(cls);
@@ -270,36 +241,43 @@ function ClassModal({
const typeOptions = classTypes.map((t) => ({ value: t, label: t }));
const isNew = !onDelete;
// Find which days already have this exact group (for edit mode)
const groupDays = useMemo(() => {
if (isNew) return new Set<string>();
const days = new Set<string>();
// For edit mode: build per-day times from existing group entries
const initialDayTimes = useMemo(() => {
if (isNew) return { [currentDay]: cls.time };
const times: Record<string, string> = {};
for (const day of allDays) {
if (day.classes.some((c) => isSameGroup(c, cls))) {
days.add(day.day);
}
const match = day.classes.find((c) => isSameGroup(c, cls));
if (match) times[day.day] = match.time;
}
return days;
}, [allDays, cls, isNew]);
return times;
}, [allDays, cls, isNew, currentDay]);
const [selectedDays, setSelectedDays] = useState<Set<string>>(
() => isNew ? new Set([currentDay]) : new Set(groupDays)
);
const [dayTimes, setDayTimes] = useState<Record<string, string>>(initialDayTimes);
const selectedDays = new Set(Object.keys(dayTimes));
function toggleDay(day: string) {
setSelectedDays((prev) => {
const next = new Set(prev);
// Must have at least one day selected
if (next.has(day) && next.size <= 1) return next;
if (next.has(day)) next.delete(day);
else next.add(day);
return next;
setDayTimes((prev) => {
// Must keep at least one day
if (day in prev && Object.keys(prev).length <= 1) return prev;
if (day in prev) {
const next = { ...prev };
delete next[day];
return next;
}
// New day gets time from current day
return { ...prev, [day]: prev[currentDay] || cls.time };
});
}
// Compute what changed for the hint
const addedDays = [...selectedDays].filter((d) => !groupDays.has(d));
const removedDays = [...groupDays].filter((d) => !selectedDays.has(d));
function updateDayTime(day: string, time: string) {
setDayTimes((prev) => ({ ...prev, [day]: time }));
}
// Compute what changed for the hint (edit mode only)
const originalDays = new Set(Object.keys(initialDayTimes));
const addedDays = [...selectedDays].filter((d) => !originalDays.has(d));
const removedDays = [...originalDays].filter((d) => !selectedDays.has(d));
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
@@ -317,29 +295,68 @@ function ClassModal({
</div>
<div className="space-y-4">
{/* Day selector — always visible */}
{/* Day selector */}
{allDays.length > 1 && (
<div>
<label className="block text-sm text-neutral-400 mb-2">Дни</label>
{/* Selected days with per-day time (edit mode) or simple buttons (new mode) */}
{!isNew && selectedDays.size > 0 && (
<div className="space-y-1.5 mb-2">
{allDays.filter((d) => selectedDays.has(d.day)).map((d) => (
<div key={d.day} className="flex items-center gap-2">
<button
type="button"
onClick={() => toggleDay(d.day)}
className="shrink-0 rounded-lg px-2.5 py-1 text-xs font-medium bg-gold/20 text-gold border border-gold/40 min-w-[36px]"
>
{d.dayShort}
</button>
<div className="flex-1">
<TimeRangeField
label=""
value={dayTimes[d.day] || ""}
onChange={(v) => updateDayTime(d.day, v)}
/>
</div>
</div>
))}
</div>
)}
{/* Unselected days — toggle buttons */}
<div className="flex flex-wrap gap-1.5">
{allDays.map((d) => {
const active = selectedDays.has(d.day);
return (
<button
key={d.day}
type="button"
onClick={() => toggleDay(d.day)}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
active
? "bg-gold/20 text-gold border border-gold/40"
: "border border-white/10 text-neutral-500 hover:text-white hover:border-white/20"
}`}
>
{d.dayShort}
</button>
);
})}
{isNew
? allDays.map((d) => {
const active = selectedDays.has(d.day);
return (
<button
key={d.day}
type="button"
onClick={() => toggleDay(d.day)}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
active
? "bg-gold/20 text-gold border border-gold/40"
: "border border-white/10 text-neutral-500 hover:text-white hover:border-white/20"
}`}
>
{d.dayShort}
</button>
);
})
: allDays.filter((d) => !selectedDays.has(d.day)).map((d) => (
<button
key={d.day}
type="button"
onClick={() => toggleDay(d.day)}
className="rounded-lg px-3 py-1.5 text-xs font-medium border border-white/10 text-neutral-500 hover:text-white hover:border-white/20 transition-all"
>
+ {d.dayShort}
</button>
))
}
</div>
{!isNew && (addedDays.length > 0 || removedDays.length > 0) && (
<div className="mt-1.5 flex flex-wrap gap-x-3 text-[11px]">
{addedDays.length > 0 && (
@@ -357,11 +374,23 @@ function ClassModal({
</div>
)}
<TimeRangeField
label="Время"
value={draft.time}
onChange={(v) => setDraft({ ...draft, time: v })}
/>
{/* Time — only for new class (edit mode has per-day times above) */}
{isNew && (
<TimeRangeField
label="Время"
value={draft.time}
onChange={(v) => {
setDraft({ ...draft, time: v });
// Update all selected day times
setDayTimes((prev) => {
const next: Record<string, string> = {};
for (const day of Object.keys(prev)) next[day] = v;
return next;
});
}}
/>
)}
<SelectField
label="Тренер"
value={draft.trainer}
@@ -400,7 +429,7 @@ function ClassModal({
<button
type="button"
onClick={() => {
onSave(draft, [...selectedDays]);
onSave(draft, dayTimes);
onClose();
}}
className="flex-1 rounded-lg bg-gold px-4 py-2.5 text-sm font-medium text-black hover:opacity-90 transition-opacity"
@@ -431,16 +460,12 @@ function CalendarGrid({
trainers,
addresses,
classTypes,
classColors,
onColorChange,
onChange,
}: {
location: ScheduleLocation;
trainers: string[];
addresses: string[];
classTypes: string[];
classColors: Record<string, string>;
onColorChange: (typeName: string, color: string) => void;
onChange: (loc: ScheduleLocation) => void;
}) {
const [editingClass, setEditingClass] = useState<{
@@ -452,15 +477,14 @@ function CalendarGrid({
cls: ScheduleClass;
} | null>(null);
// Compute color assignments (explicit + smart fallback)
const colorAssignments = useMemo(
() => buildColorAssignments(classTypes, classColors),
[classTypes, classColors]
// Compute group-based colors for calendar blocks
const sortedDaysForColors = sortDaysByWeekday(location.days);
const groupColors = useMemo(
() => buildGroupColors(sortedDaysForColors),
// eslint-disable-next-line react-hooks/exhaustive-deps
[JSON.stringify(sortedDaysForColors.map((d) => d.classes.map((c) => groupKey(c))))]
);
// Color picker popup
const [colorPicker, setColorPicker] = useState<string | null>(null);
// Hover highlight state
const [hover, setHover] = useState<{ dayIndex: number; startMin: number } | null>(null);
@@ -683,7 +707,7 @@ function CalendarGrid({
const dragPreview = drag?.moved ? (() => {
const sourceDay = sortedDays[drag.sourceDayIndex];
const cls = sourceDay.classes[drag.classIndex];
const colors = getTypeColor(cls.type, colorAssignments);
const colors = groupColors[groupKey(cls)] ?? "bg-neutral-600/80 border-neutral-500";
const top = minutesToY(drag.previewStartMin);
const height = (drag.durationMin / 60) * HOUR_HEIGHT;
const newStart = formatMinutes(drag.previewStartMin);
@@ -709,61 +733,6 @@ function CalendarGrid({
/>
</div>
{/* Legend with color picker */}
<div className="flex flex-wrap gap-2">
{classTypes.map((type) => {
const colors = getTypeColor(type, colorAssignments);
const bgClass = colors.split(" ")[0] || "bg-neutral-600/80";
const isOpen = colorPicker === type;
return (
<div key={type} className="relative">
<button
type="button"
onClick={() => setColorPicker(isOpen ? null : type)}
className={`flex items-center gap-1.5 text-xs rounded-md px-2 py-1 transition-colors ${
isOpen
? "bg-white/10 text-white"
: "text-neutral-300 hover:bg-white/5 hover:text-white"
}`}
>
<div className={`h-3 w-3 rounded-sm ${bgClass}`} />
{type}
</button>
{isOpen && (() => {
// Build used set from resolved assignments (includes both explicit & fallback)
const usedColors = new Set(
Object.entries(colorAssignments)
.filter(([t]) => t !== type)
.map(([, cls]) => COLOR_SWATCHES.find((s) => COLOR_MAP[s.value] === cls)?.value)
.filter(Boolean)
);
return (
<div className="absolute top-full left-0 mt-1 z-50 rounded-lg border border-white/10 bg-neutral-800 p-2 shadow-xl">
<div className="flex gap-1.5">
{COLOR_SWATCHES.filter((c) => !usedColors.has(c.value)).map((c) => (
<button
key={c.value}
type="button"
onClick={() => {
onColorChange(type, c.value);
setColorPicker(null);
}}
className={`h-6 w-6 rounded-full ${c.bg} transition-all ${
classColors[type] === c.value
? "ring-2 ring-white ring-offset-1 ring-offset-neutral-800 scale-110"
: "opacity-60 hover:opacity-100"
}`}
/>
))}
</div>
</div>
);
})()}
</div>
);
})}
</div>
{/* Calendar */}
{sortedDays.length > 0 && (
<div className="overflow-x-auto rounded-lg border border-white/10" ref={gridRef}>
@@ -876,7 +845,7 @@ function CalendarGrid({
drag.classIndex === ci &&
drag.moved
}
colorAssignments={colorAssignments}
groupColors={groupColors}
onClick={() => {
if (justDraggedRef.current) return;
setEditingClass({ dayIndex: di, classIndex: ci });
@@ -915,17 +884,15 @@ function CalendarGrid({
classTypes={classTypes}
allDays={sortedDays}
currentDay={sortedDays[editingClass.dayIndex]?.day}
onSave={(updated, days) => {
onSave={(updated, dayTimes) => {
const original = editingData.cls;
const selectedSet = new Set(days);
const updatedDays = location.days.map((d) => {
const inSelected = selectedSet.has(d.day);
// Remove old matching group entries
let classes = d.classes.filter((c) => !isSameGroup(c, original));
// Add updated class to selected days
if (inSelected) {
classes = [...classes, updated];
// Add updated class with per-day time
if (d.day in dayTimes) {
classes = [...classes, { ...updated, time: dayTimes[d.day] }];
}
return { ...d, classes };
});
@@ -952,11 +919,10 @@ function CalendarGrid({
classTypes={classTypes}
allDays={sortedDays}
currentDay={sortedDays[newClass.dayIndex]?.day}
onSave={(created, days) => {
const targetDayNames = new Set(days);
onSave={(created, dayTimes) => {
const updatedDays = location.days.map((d) => {
if (targetDayNames.has(d.day)) {
return { ...d, classes: [...d.classes, created] };
if (d.day in dayTimes) {
return { ...d, classes: [...d.classes, { ...created, time: dayTimes[d.day] }] };
}
return d;
});
@@ -975,8 +941,6 @@ export default function ScheduleEditorPage() {
const [trainers, setTrainers] = useState<string[]>([]);
const [addresses, setAddresses] = useState<string[]>([]);
const [classTypes, setClassTypes] = useState<string[]>([]);
const [classColors, setClassColors] = useState<Record<string, string>>({});
const classesDataRef = useRef<{ title: string; items: { name: string; color?: string; [k: string]: unknown }[] } | null>(null);
useEffect(() => {
fetch("/api/admin/team")
@@ -995,36 +959,12 @@ export default function ScheduleEditorPage() {
fetch("/api/admin/sections/classes")
.then((r) => r.json())
.then((classes: { title: string; items?: { name: string; color?: string }[] }) => {
const items = classes.items ?? [];
classesDataRef.current = { title: classes.title, items };
setClassTypes(items.map((c) => c.name));
const colors: Record<string, string> = {};
for (const item of items) {
if (item.color) colors[item.name] = item.color;
}
setClassColors(colors);
.then((classes: { items?: { name: string }[] }) => {
setClassTypes((classes.items ?? []).map((c) => c.name));
})
.catch(() => {});
}, []);
const handleColorChange = useCallback((typeName: string, color: string) => {
setClassColors((prev) => ({ ...prev, [typeName]: color }));
// Save to classes section
const data = classesDataRef.current;
if (!data) return;
const updatedItems = data.items.map((item) =>
item.name === typeName ? { ...item, color } : item
);
classesDataRef.current = { ...data, items: updatedItems };
fetch("/api/admin/sections/classes", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...data, items: updatedItems }),
}).catch(() => {});
}, []);
return (
<SectionEditor<ScheduleData> sectionKey="schedule" title="Расписание">
{(data, update) => {
@@ -1105,8 +1045,6 @@ export default function ScheduleEditorPage() {
trainers={trainers}
addresses={addresses}
classTypes={classTypes}
classColors={classColors}
onColorChange={handleColorChange}
onChange={updateLocation}
/>
)}