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:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user