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",
|
"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 {
|
interface ClassesData {
|
||||||
title: string;
|
title: string;
|
||||||
items: {
|
items: {
|
||||||
@@ -63,6 +82,31 @@ export default function ClassesEditorPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<TextareaField
|
||||||
label="Краткое описание"
|
label="Краткое описание"
|
||||||
value={item.description}
|
value={item.description}
|
||||||
|
|||||||
@@ -31,75 +31,48 @@ const LEVELS = [
|
|||||||
{ value: "Продвинутый", label: "Продвинутый" },
|
{ value: "Продвинутый", label: "Продвинутый" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const COLOR_SWATCHES: { value: string; bg: string }[] = [
|
const GROUP_PALETTE = [
|
||||||
{ value: "rose", bg: "bg-rose-500" },
|
"bg-rose-500/80 border-rose-400",
|
||||||
{ value: "orange", bg: "bg-orange-500" },
|
"bg-orange-500/80 border-orange-400",
|
||||||
{ value: "amber", bg: "bg-amber-500" },
|
"bg-amber-500/80 border-amber-400",
|
||||||
{ value: "yellow", bg: "bg-yellow-400" },
|
"bg-yellow-400/80 border-yellow-300",
|
||||||
{ value: "lime", bg: "bg-lime-500" },
|
"bg-lime-500/80 border-lime-400",
|
||||||
{ value: "emerald", bg: "bg-emerald-500" },
|
"bg-emerald-500/80 border-emerald-400",
|
||||||
{ value: "teal", bg: "bg-teal-500" },
|
"bg-teal-500/80 border-teal-400",
|
||||||
{ value: "cyan", bg: "bg-cyan-500" },
|
"bg-cyan-500/80 border-cyan-400",
|
||||||
{ value: "sky", bg: "bg-sky-500" },
|
"bg-sky-500/80 border-sky-400",
|
||||||
{ value: "blue", bg: "bg-blue-500" },
|
"bg-blue-500/80 border-blue-400",
|
||||||
{ value: "indigo", bg: "bg-indigo-500" },
|
"bg-indigo-500/80 border-indigo-400",
|
||||||
{ value: "violet", bg: "bg-violet-500" },
|
"bg-violet-500/80 border-violet-400",
|
||||||
{ value: "purple", bg: "bg-purple-500" },
|
"bg-purple-500/80 border-purple-400",
|
||||||
{ value: "fuchsia", bg: "bg-fuchsia-500" },
|
"bg-fuchsia-500/80 border-fuchsia-400",
|
||||||
{ value: "pink", bg: "bg-pink-500" },
|
"bg-pink-500/80 border-pink-400",
|
||||||
{ value: "red", bg: "bg-red-500" },
|
"bg-red-500/80 border-red-400",
|
||||||
];
|
];
|
||||||
|
|
||||||
const COLOR_MAP: Record<string, string> = {
|
/** Build a unique group key (trainer + type) */
|
||||||
rose: "bg-rose-500/80 border-rose-400",
|
function groupKey(cls: ScheduleClass): string {
|
||||||
orange: "bg-orange-500/80 border-orange-400",
|
return `${cls.trainer}|${cls.type}`;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTypeColor(type: string, assignments: Record<string, string>): string {
|
/** Assign a color to each unique group across all days */
|
||||||
return assignments[type] ?? "bg-neutral-600/80 border-neutral-500";
|
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
|
// Calendar config
|
||||||
@@ -179,7 +152,7 @@ function ClassBlock({
|
|||||||
index,
|
index,
|
||||||
isOverlapping,
|
isOverlapping,
|
||||||
isDragging,
|
isDragging,
|
||||||
colorAssignments,
|
groupColors,
|
||||||
onClick,
|
onClick,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
}: {
|
}: {
|
||||||
@@ -187,7 +160,7 @@ function ClassBlock({
|
|||||||
index: number;
|
index: number;
|
||||||
isOverlapping: boolean;
|
isOverlapping: boolean;
|
||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
colorAssignments: Record<string, string>;
|
groupColors: Record<string, string>;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
onDragStart: (e: React.MouseEvent) => void;
|
onDragStart: (e: React.MouseEvent) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -199,7 +172,7 @@ function ClassBlock({
|
|||||||
|
|
||||||
const top = minutesToY(startMin);
|
const top = minutesToY(startMin);
|
||||||
const height = Math.max(((endMin - startMin) / 60) * HOUR_HEIGHT, 20);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -239,8 +212,9 @@ function ClassBlock({
|
|||||||
|
|
||||||
// ---------- Edit Modal ----------
|
// ---------- Edit Modal ----------
|
||||||
/** Check if two classes are the same "group" (same trainer + type + time) */
|
/** Check if two classes are the same "group" (same trainer + type + time) */
|
||||||
|
/** Same group = same trainer + type */
|
||||||
function isSameGroup(a: ScheduleClass, b: ScheduleClass): boolean {
|
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({
|
function ClassModal({
|
||||||
@@ -256,13 +230,10 @@ function ClassModal({
|
|||||||
cls: ScheduleClass;
|
cls: ScheduleClass;
|
||||||
trainers: string[];
|
trainers: string[];
|
||||||
classTypes: string[];
|
classTypes: string[];
|
||||||
/** For edit: saves to current day + manages group days. For new: creates on selected days. */
|
onSave: (cls: ScheduleClass, dayTimes: Record<string, string>) => void;
|
||||||
onSave: (cls: ScheduleClass, days: string[]) => void;
|
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
/** All schedule days (sorted) */
|
|
||||||
allDays: ScheduleDay[];
|
allDays: ScheduleDay[];
|
||||||
/** Current day name */
|
|
||||||
currentDay: string;
|
currentDay: string;
|
||||||
}) {
|
}) {
|
||||||
const [draft, setDraft] = useState<ScheduleClass>(cls);
|
const [draft, setDraft] = useState<ScheduleClass>(cls);
|
||||||
@@ -270,36 +241,43 @@ function ClassModal({
|
|||||||
const typeOptions = classTypes.map((t) => ({ value: t, label: t }));
|
const typeOptions = classTypes.map((t) => ({ value: t, label: t }));
|
||||||
const isNew = !onDelete;
|
const isNew = !onDelete;
|
||||||
|
|
||||||
// Find which days already have this exact group (for edit mode)
|
// For edit mode: build per-day times from existing group entries
|
||||||
const groupDays = useMemo(() => {
|
const initialDayTimes = useMemo(() => {
|
||||||
if (isNew) return new Set<string>();
|
if (isNew) return { [currentDay]: cls.time };
|
||||||
const days = new Set<string>();
|
const times: Record<string, string> = {};
|
||||||
for (const day of allDays) {
|
for (const day of allDays) {
|
||||||
if (day.classes.some((c) => isSameGroup(c, cls))) {
|
const match = day.classes.find((c) => isSameGroup(c, cls));
|
||||||
days.add(day.day);
|
if (match) times[day.day] = match.time;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return days;
|
return times;
|
||||||
}, [allDays, cls, isNew]);
|
}, [allDays, cls, isNew, currentDay]);
|
||||||
|
|
||||||
const [selectedDays, setSelectedDays] = useState<Set<string>>(
|
const [dayTimes, setDayTimes] = useState<Record<string, string>>(initialDayTimes);
|
||||||
() => isNew ? new Set([currentDay]) : new Set(groupDays)
|
|
||||||
);
|
const selectedDays = new Set(Object.keys(dayTimes));
|
||||||
|
|
||||||
function toggleDay(day: string) {
|
function toggleDay(day: string) {
|
||||||
setSelectedDays((prev) => {
|
setDayTimes((prev) => {
|
||||||
const next = new Set(prev);
|
// Must keep at least one day
|
||||||
// Must have at least one day selected
|
if (day in prev && Object.keys(prev).length <= 1) return prev;
|
||||||
if (next.has(day) && next.size <= 1) return next;
|
if (day in prev) {
|
||||||
if (next.has(day)) next.delete(day);
|
const next = { ...prev };
|
||||||
else next.add(day);
|
delete next[day];
|
||||||
return next;
|
return next;
|
||||||
|
}
|
||||||
|
// New day gets time from current day
|
||||||
|
return { ...prev, [day]: prev[currentDay] || cls.time };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute what changed for the hint
|
function updateDayTime(day: string, time: string) {
|
||||||
const addedDays = [...selectedDays].filter((d) => !groupDays.has(d));
|
setDayTimes((prev) => ({ ...prev, [day]: time }));
|
||||||
const removedDays = [...groupDays].filter((d) => !selectedDays.has(d));
|
}
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
<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>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Day selector — always visible */}
|
{/* Day selector */}
|
||||||
{allDays.length > 1 && (
|
{allDays.length > 1 && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-neutral-400 mb-2">Дни</label>
|
<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">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{allDays.map((d) => {
|
{isNew
|
||||||
const active = selectedDays.has(d.day);
|
? allDays.map((d) => {
|
||||||
return (
|
const active = selectedDays.has(d.day);
|
||||||
<button
|
return (
|
||||||
key={d.day}
|
<button
|
||||||
type="button"
|
key={d.day}
|
||||||
onClick={() => toggleDay(d.day)}
|
type="button"
|
||||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
|
onClick={() => toggleDay(d.day)}
|
||||||
active
|
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
|
||||||
? "bg-gold/20 text-gold border border-gold/40"
|
active
|
||||||
: "border border-white/10 text-neutral-500 hover:text-white hover:border-white/20"
|
? "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>
|
{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>
|
</div>
|
||||||
|
|
||||||
{!isNew && (addedDays.length > 0 || removedDays.length > 0) && (
|
{!isNew && (addedDays.length > 0 || removedDays.length > 0) && (
|
||||||
<div className="mt-1.5 flex flex-wrap gap-x-3 text-[11px]">
|
<div className="mt-1.5 flex flex-wrap gap-x-3 text-[11px]">
|
||||||
{addedDays.length > 0 && (
|
{addedDays.length > 0 && (
|
||||||
@@ -357,11 +374,23 @@ function ClassModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TimeRangeField
|
{/* Time — only for new class (edit mode has per-day times above) */}
|
||||||
label="Время"
|
{isNew && (
|
||||||
value={draft.time}
|
<TimeRangeField
|
||||||
onChange={(v) => setDraft({ ...draft, time: v })}
|
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
|
<SelectField
|
||||||
label="Тренер"
|
label="Тренер"
|
||||||
value={draft.trainer}
|
value={draft.trainer}
|
||||||
@@ -400,7 +429,7 @@ function ClassModal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSave(draft, [...selectedDays]);
|
onSave(draft, dayTimes);
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
className="flex-1 rounded-lg bg-gold px-4 py-2.5 text-sm font-medium text-black hover:opacity-90 transition-opacity"
|
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,
|
trainers,
|
||||||
addresses,
|
addresses,
|
||||||
classTypes,
|
classTypes,
|
||||||
classColors,
|
|
||||||
onColorChange,
|
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
location: ScheduleLocation;
|
location: ScheduleLocation;
|
||||||
trainers: string[];
|
trainers: string[];
|
||||||
addresses: string[];
|
addresses: string[];
|
||||||
classTypes: string[];
|
classTypes: string[];
|
||||||
classColors: Record<string, string>;
|
|
||||||
onColorChange: (typeName: string, color: string) => void;
|
|
||||||
onChange: (loc: ScheduleLocation) => void;
|
onChange: (loc: ScheduleLocation) => void;
|
||||||
}) {
|
}) {
|
||||||
const [editingClass, setEditingClass] = useState<{
|
const [editingClass, setEditingClass] = useState<{
|
||||||
@@ -452,15 +477,14 @@ function CalendarGrid({
|
|||||||
cls: ScheduleClass;
|
cls: ScheduleClass;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
// Compute color assignments (explicit + smart fallback)
|
// Compute group-based colors for calendar blocks
|
||||||
const colorAssignments = useMemo(
|
const sortedDaysForColors = sortDaysByWeekday(location.days);
|
||||||
() => buildColorAssignments(classTypes, classColors),
|
const groupColors = useMemo(
|
||||||
[classTypes, classColors]
|
() => 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
|
// Hover highlight state
|
||||||
const [hover, setHover] = useState<{ dayIndex: number; startMin: number } | null>(null);
|
const [hover, setHover] = useState<{ dayIndex: number; startMin: number } | null>(null);
|
||||||
|
|
||||||
@@ -683,7 +707,7 @@ function CalendarGrid({
|
|||||||
const dragPreview = drag?.moved ? (() => {
|
const dragPreview = drag?.moved ? (() => {
|
||||||
const sourceDay = sortedDays[drag.sourceDayIndex];
|
const sourceDay = sortedDays[drag.sourceDayIndex];
|
||||||
const cls = sourceDay.classes[drag.classIndex];
|
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 top = minutesToY(drag.previewStartMin);
|
||||||
const height = (drag.durationMin / 60) * HOUR_HEIGHT;
|
const height = (drag.durationMin / 60) * HOUR_HEIGHT;
|
||||||
const newStart = formatMinutes(drag.previewStartMin);
|
const newStart = formatMinutes(drag.previewStartMin);
|
||||||
@@ -709,61 +733,6 @@ function CalendarGrid({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Calendar */}
|
||||||
{sortedDays.length > 0 && (
|
{sortedDays.length > 0 && (
|
||||||
<div className="overflow-x-auto rounded-lg border border-white/10" ref={gridRef}>
|
<div className="overflow-x-auto rounded-lg border border-white/10" ref={gridRef}>
|
||||||
@@ -876,7 +845,7 @@ function CalendarGrid({
|
|||||||
drag.classIndex === ci &&
|
drag.classIndex === ci &&
|
||||||
drag.moved
|
drag.moved
|
||||||
}
|
}
|
||||||
colorAssignments={colorAssignments}
|
groupColors={groupColors}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (justDraggedRef.current) return;
|
if (justDraggedRef.current) return;
|
||||||
setEditingClass({ dayIndex: di, classIndex: ci });
|
setEditingClass({ dayIndex: di, classIndex: ci });
|
||||||
@@ -915,17 +884,15 @@ function CalendarGrid({
|
|||||||
classTypes={classTypes}
|
classTypes={classTypes}
|
||||||
allDays={sortedDays}
|
allDays={sortedDays}
|
||||||
currentDay={sortedDays[editingClass.dayIndex]?.day}
|
currentDay={sortedDays[editingClass.dayIndex]?.day}
|
||||||
onSave={(updated, days) => {
|
onSave={(updated, dayTimes) => {
|
||||||
const original = editingData.cls;
|
const original = editingData.cls;
|
||||||
const selectedSet = new Set(days);
|
|
||||||
|
|
||||||
const updatedDays = location.days.map((d) => {
|
const updatedDays = location.days.map((d) => {
|
||||||
const inSelected = selectedSet.has(d.day);
|
|
||||||
// Remove old matching group entries
|
// Remove old matching group entries
|
||||||
let classes = d.classes.filter((c) => !isSameGroup(c, original));
|
let classes = d.classes.filter((c) => !isSameGroup(c, original));
|
||||||
// Add updated class to selected days
|
// Add updated class with per-day time
|
||||||
if (inSelected) {
|
if (d.day in dayTimes) {
|
||||||
classes = [...classes, updated];
|
classes = [...classes, { ...updated, time: dayTimes[d.day] }];
|
||||||
}
|
}
|
||||||
return { ...d, classes };
|
return { ...d, classes };
|
||||||
});
|
});
|
||||||
@@ -952,11 +919,10 @@ function CalendarGrid({
|
|||||||
classTypes={classTypes}
|
classTypes={classTypes}
|
||||||
allDays={sortedDays}
|
allDays={sortedDays}
|
||||||
currentDay={sortedDays[newClass.dayIndex]?.day}
|
currentDay={sortedDays[newClass.dayIndex]?.day}
|
||||||
onSave={(created, days) => {
|
onSave={(created, dayTimes) => {
|
||||||
const targetDayNames = new Set(days);
|
|
||||||
const updatedDays = location.days.map((d) => {
|
const updatedDays = location.days.map((d) => {
|
||||||
if (targetDayNames.has(d.day)) {
|
if (d.day in dayTimes) {
|
||||||
return { ...d, classes: [...d.classes, created] };
|
return { ...d, classes: [...d.classes, { ...created, time: dayTimes[d.day] }] };
|
||||||
}
|
}
|
||||||
return d;
|
return d;
|
||||||
});
|
});
|
||||||
@@ -975,8 +941,6 @@ export default function ScheduleEditorPage() {
|
|||||||
const [trainers, setTrainers] = useState<string[]>([]);
|
const [trainers, setTrainers] = useState<string[]>([]);
|
||||||
const [addresses, setAddresses] = useState<string[]>([]);
|
const [addresses, setAddresses] = useState<string[]>([]);
|
||||||
const [classTypes, setClassTypes] = 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(() => {
|
useEffect(() => {
|
||||||
fetch("/api/admin/team")
|
fetch("/api/admin/team")
|
||||||
@@ -995,36 +959,12 @@ export default function ScheduleEditorPage() {
|
|||||||
|
|
||||||
fetch("/api/admin/sections/classes")
|
fetch("/api/admin/sections/classes")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((classes: { title: string; items?: { name: string; color?: string }[] }) => {
|
.then((classes: { items?: { name: string }[] }) => {
|
||||||
const items = classes.items ?? [];
|
setClassTypes((classes.items ?? []).map((c) => c.name));
|
||||||
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);
|
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.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 (
|
return (
|
||||||
<SectionEditor<ScheduleData> sectionKey="schedule" title="Расписание">
|
<SectionEditor<ScheduleData> sectionKey="schedule" title="Расписание">
|
||||||
{(data, update) => {
|
{(data, update) => {
|
||||||
@@ -1105,8 +1045,6 @@ export default function ScheduleEditorPage() {
|
|||||||
trainers={trainers}
|
trainers={trainers}
|
||||||
addresses={addresses}
|
addresses={addresses}
|
||||||
classTypes={classTypes}
|
classTypes={classTypes}
|
||||||
classColors={classColors}
|
|
||||||
onColorChange={handleColorChange}
|
|
||||||
onChange={updateLocation}
|
onChange={updateLocation}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user