diff --git a/src/app/admin/classes/page.tsx b/src/app/admin/classes/page.tsx index 71e7e03..326bbec 100644 --- a/src/app/admin/classes/page.tsx +++ b/src/app/admin/classes/page.tsx @@ -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() { +
+ +
+ {COLOR_SWATCHES.map((c) => { + const isUsed = data.items.some( + (other) => other !== item && other.color === c.value + ); + if (isUsed) return null; + return ( +
+
= { - 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): Record { - const result: Record = {}; - const usedPalette = new Set(); - - // 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 { - 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 { + const seen = new Set(); + 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 = {}; + 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; + groupColors: Record; 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 (
void; + onSave: (cls: ScheduleClass, dayTimes: Record) => void; onDelete?: () => void; onClose: () => void; - /** All schedule days (sorted) */ allDays: ScheduleDay[]; - /** Current day name */ currentDay: string; }) { const [draft, setDraft] = useState(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(); - const days = new Set(); + // For edit mode: build per-day times from existing group entries + const initialDayTimes = useMemo(() => { + if (isNew) return { [currentDay]: cls.time }; + const times: Record = {}; 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>( - () => isNew ? new Set([currentDay]) : new Set(groupDays) - ); + const [dayTimes, setDayTimes] = useState>(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 (
@@ -317,29 +295,68 @@ function ClassModal({
- {/* Day selector — always visible */} + {/* Day selector */} {allDays.length > 1 && (
+ + {/* Selected days with per-day time (edit mode) or simple buttons (new mode) */} + {!isNew && selectedDays.size > 0 && ( +
+ {allDays.filter((d) => selectedDays.has(d.day)).map((d) => ( +
+ +
+ updateDayTime(d.day, v)} + /> +
+
+ ))} +
+ )} + + {/* Unselected days — toggle buttons */}
- {allDays.map((d) => { - const active = selectedDays.has(d.day); - return ( - - ); - })} + {isNew + ? allDays.map((d) => { + const active = selectedDays.has(d.day); + return ( + + ); + }) + : allDays.filter((d) => !selectedDays.has(d.day)).map((d) => ( + + )) + }
+ {!isNew && (addedDays.length > 0 || removedDays.length > 0) && (
{addedDays.length > 0 && ( @@ -357,11 +374,23 @@ function ClassModal({
)} - setDraft({ ...draft, time: v })} - /> + {/* Time — only for new class (edit mode has per-day times above) */} + {isNew && ( + { + setDraft({ ...draft, time: v }); + // Update all selected day times + setDayTimes((prev) => { + const next: Record = {}; + for (const day of Object.keys(prev)) next[day] = v; + return next; + }); + }} + /> + )} + { - 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; - 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(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({ />
- {/* Legend with color picker */} -
- {classTypes.map((type) => { - const colors = getTypeColor(type, colorAssignments); - const bgClass = colors.split(" ")[0] || "bg-neutral-600/80"; - const isOpen = colorPicker === type; - return ( -
- - {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 ( -
-
- {COLOR_SWATCHES.filter((c) => !usedColors.has(c.value)).map((c) => ( -
-
- ); - })()} -
- ); - })} -
- {/* Calendar */} {sortedDays.length > 0 && (
@@ -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([]); const [addresses, setAddresses] = useState([]); const [classTypes, setClassTypes] = useState([]); - const [classColors, setClassColors] = useState>({}); - 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 = {}; - 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 ( 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} /> )}