// Main timetable: stages-as-rows × time-axis Gantt. // - Click empty cell → AddPerformance modal. // - Click block → floating Popover. // - Drag block → move (snap 15min); drag right edge → resize. // - Overlapping bookings stack vertically inside their stage row (lane-packed). // - Parking column on the right shows pending availability + parked bookings; // drag from parking → timetable to schedule, drag block → parking to unschedule. const TT = (function () { const STAGE_COL_W = 220; const PARKING_W = 280; const LANE_PAD = 4; // top/bottom padding inside row // ─── Block ────────────────────────────────────────────────────────── function Block({ perf, artist, stage, sections, pxPerMin, laneTop, laneHeight, conflicting, capWarn, b2bRight, b2bLeft, isSelected, showPercent, onSelect, onStartDragMove, onStartDragResize, }) { const status = window.CrewliHelpers.STATUS[perf.status]; const left = perf.start * pxPerMin; const width = (perf.end - perf.start) * pxPerMin; const time = window.CrewliHelpers.fmtTime(perf.start, window.CrewliData.TIME.startHour) + "–" + window.CrewliHelpers.fmtTime(perf.end, window.CrewliData.TIME.startHour); const adv = window.CrewliHelpers.advanceCount(artist, sections); const advPct = Math.round(adv.done / adv.total * 100); const blockStyle = { left, width, top: laneTop, height: laneHeight, background: status.blockBg, borderColor: conflicting ? "#d63d4b" : status.blockBorder, color: status.blockFg, }; const isCancelled = perf.status === "cancelled"; if (isCancelled) { blockStyle.borderColor = "#c4202f"; blockStyle.background = `repeating-linear-gradient(135deg, rgba(214,61,75,0.32) 0 7px, rgba(214,61,75,0.10) 7px 15px), #ffffff`; blockStyle.color = "#7a1219"; } const compact = laneHeight < 42; const showTime = width > 64; return (
{ if (e.button !== 0) return; onStartDragMove(e, perf); }} onClick={(e) => { e.stopPropagation(); onSelect(perf, e.currentTarget.getBoundingClientRect()); }} >
{artist.name} {!compact && artist.genre && width > 110 && ( {artist.genre} )}
{capWarn && ( capaciteit ${stage.capacity.toLocaleString('nl-NL')}`}> )} {conflicting && ( )} {showPercent && width > 86 && !compact && ( {advPct}% )}
{!compact && (
{showTime && {time}} {showPercent && width > 86 && ( )}
)} {compact && showTime && ( {time} )}
{ e.stopPropagation(); onStartDragResize(e, perf); }} /> {b2bRight && } {b2bLeft && }
); } // ─── TimeAxis ─────────────────────────────────────────────────────── function TimeAxis({ pxPerMin, totalMin, startHour }) { const hours = []; for (let m = 0; m <= totalMin; m += 60) { const h = (startHour + m / 60) % 24; hours.push({ m, h }); } return (
{hours.map(({ m, h }) => (
{String(Math.floor(h)).padStart(2, "0")}:00
))}
); } function GridBg({ pxPerMin, totalMin, totalH, rowDividers }) { const halfHourW = 30 * pxPerMin; const hourW = 60 * pxPerMin; return ( <>
{rowDividers.map((y, i) => (
))} ); } // ─── Parking column ───────────────────────────────────────────────── // Multi-select dropdown for status filter (used inside ParkingColumn) function StatusMultiSelect({ statuses, STATUS, statusOn, counts, onSet }) { const [open, setOpen] = React.useState(false); const ref = React.useRef(null); React.useEffect(() => { if (!open) return; const onDown = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; window.addEventListener("mousedown", onDown); return () => window.removeEventListener("mousedown", onDown); }, [open]); const onCount = statuses.filter(k => statusOn[k]).length; const total = statuses.length; const allOn = onCount === total; const lbl = allOn ? "Alle statussen" : onCount === 0 ? "Geen statussen" : onCount === 1 ? STATUS[statuses.find(k => statusOn[k])].label : `${onCount} statussen`; function toggle(k) { onSet({ ...statusOn, [k]: !statusOn[k] }); } function setAll(v) { const next = {}; statuses.forEach(k => { next[k] = v; }); onSet(next); } return (
{open && (
·
{statuses.map(k => { const s = STATUS[k]; const on = !!statusOn[k]; const cnt = counts[k] || 0; return ( ); })}
)}
); } function ParkingColumn({ pending, parked, artists, onStartDragPending, onStartDragParked, isDropTarget, isDragOrigin, onSelectQueueItem, selectedQueueId }) { const STATUS = window.CrewliHelpers.STATUS; const GENRES = window.CrewliData.GENRES; // Closed status list. "requested" is mapped to pending items (they don't have an explicit status yet). const STATUSES = ["concept", "requested", "option", "confirmed", "contracted", "cancelled"]; const [search, setSearch] = React.useState(""); const [filterGenre, setFilterGenre] = React.useState("__all"); // Multi-status set: cancelled is OFF by default. const [statusOn, setStatusOn] = React.useState(() => { const s = {}; STATUSES.forEach(k => { s[k] = (k !== "cancelled"); }); return s; }); const [groupBy, setGroupBy] = React.useState("status"); // "status" | "none" const [genreOpen, setGenreOpen] = React.useState(false); // Normalize: each item has { id, kind, artist, status, dur, src } const items = React.useMemo(() => { const out = []; pending.forEach(pa => { const a = artists.find(x => x.id === pa.artist_id); if (a) out.push({ id: pa.id, kind: "pending", artist: a, status: "requested", dur: null, src: pa, sortKey: a.name.toLowerCase() }); }); parked.forEach(p => { const a = artists.find(x => x.id === p.artist_id); if (a) { const computed = p.end - p.start; const dur = Number.isFinite(computed) ? computed : (p.dur || 60); out.push({ id: p.id, kind: "parked", artist: a, status: p.status, dur, src: p, sortKey: a.name.toLowerCase() }); } }); return out.sort((x, y) => x.sortKey.localeCompare(y.sortKey)); }, [pending, parked, artists]); const q = search.trim().toLowerCase(); // Apply filters const filtered = items.filter(it => { if (filterGenre !== "__all" && it.artist.genre !== filterGenre) return false; if (!statusOn[it.status]) return false; if (q && !it.artist.name.toLowerCase().includes(q)) return false; return true; }); const hiddenByStatus = items.length - items.filter(it => statusOn[it.status]).length; // Group const groups = React.useMemo(() => { if (groupBy === "none") return [{ key: "_all", label: null, items: filtered }]; const byStatus = STATUSES.map(s => ({ key: s, label: STATUS[s].label, items: filtered.filter(it => it.status === s) })) .filter(g => g.items.length > 0); return byStatus; }, [filtered, groupBy]); const toggleStatus = (k) => setStatusOn(prev => ({ ...prev, [k]: !prev[k] })); const renderItem = (it) => { const isSelected = selectedQueueId === (it.kind + ":" + it.id); return (
it.kind === "pending" ? onStartDragPending(e, it.src, it) : onStartDragParked(e, it.src, it)}> {it.artist.initials}
{it.artist.name}
{STATUS[it.status].label} · {it.artist.genre} {it.dur != null && ( <> · {Math.floor(it.dur/60)}u{it.dur%60 ? ` ${it.dur%60}m` : ""} )}
); }; const genreLabel = filterGenre === "__all" ? "Alle genres" : filterGenre; return ( ); } // ─── Main Timetable ───────────────────────────────────────────────── function Timetable({ activeDay, stages, performances, parked, pending, artists, dispatch, onSelectPerf, onClickEmptyCell, onEditStage, onScheduleFromParking, onSelectQueueItem, selectedQueueId, pxPerMin, baseRowHeight, density, showPercent, }) { const sections = window.CrewliData.ADVANCE_SECTIONS; const conflicts = window.CrewliHelpers.findConflicts(performances); const b2bLinks = window.CrewliHelpers.findB2B(performances, 5); const b2bRightSet = new Set(b2bLinks.map(l => l.leftId)); const b2bLeftSet = new Set(b2bLinks.map(l => l.rightId)); const [selectedId, setSelectedId] = React.useState(null); const [drag, setDrag] = React.useState(null); // { mode, perfId|paId, dx, dy, basePerf, sourceRect, fromParking } const [stageDrag, setStageDrag] = React.useState(null); // { fromIndex, toIndex, dy } // Compute the stages list to render — live-reordered while a stage drag is active. const displayedStages = React.useMemo(() => { if (!stageDrag) return stages; const arr = [...stages]; const [moved] = arr.splice(stageDrag.fromIndex, 1); arr.splice(stageDrag.toIndex, 0, moved); return arr; }, [stages, stageDrag]); const startStageReorder = (e, fromIndex) => { const startY = e.clientY; const stagesEl = e.currentTarget.closest(".cw-tt-stages"); // Snapshot original row geometries (in original order). const rows = stagesEl ? Array.from(stagesEl.querySelectorAll(".cw-tt-stage")) : []; const stagesRect = stagesEl ? stagesEl.getBoundingClientRect() : { top: 0 }; const heights = rows.map(r => r.getBoundingClientRect().height); const total = heights.reduce((a, b) => a + b, 0); let toIndex = fromIndex; let moved = false; const onMove = (ev) => { if (!moved && Math.abs(ev.clientY - startY) < 4) return; moved = true; const yLocal = ev.clientY - stagesRect.top; // Find target index by midpoint of each row in ORIGINAL geometry let idx = rows.length - 1; let yAcc = 0; for (let i = 0; i < heights.length; i++) { const mid = yAcc + heights[i] / 2; if (yLocal < mid) { idx = i; break; } yAcc += heights[i]; } idx = Math.max(0, Math.min(rows.length - 1, idx)); toIndex = idx; setStageDrag({ fromIndex, toIndex, dy: ev.clientY - startY }); }; const onUp = () => { window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); if (moved && toIndex !== fromIndex) { // Compute displayed (reordered) list of stage IDs and dispatch const arr = [...stages]; const [m] = arr.splice(fromIndex, 1); arr.splice(toIndex, 0, m); dispatch({ type: "reorder_stages", dayStageIds: arr.map(s => s.id) }); } setStageDrag(null); }; window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); e.preventDefault(); }; const totalMin = window.CrewliData.TIME.totalMinutes; const snap = window.CrewliData.TIME.snapMinutes; // ── Sync horizontal scroll between canvas and time-axis ─────── const canvasRef = React.useRef(null); const axisInnerRef = React.useRef(null); React.useEffect(() => { const c = canvasRef.current; if (!c) return; const onScroll = () => { if (axisInnerRef.current) { axisInnerRef.current.style.transform = `translateX(${-c.scrollLeft}px)`; } }; c.addEventListener("scroll", onScroll, { passive: true }); return () => c.removeEventListener("scroll", onScroll); }, []); // ── Lane assignment per stage row (computed against displayedStages) ── // Lane size is FIXED — adding lanes grows the row, never shrinks the blocks. // Block size is FIXED & generous regardless of lane count. const LANE_STEP = density === "compact" ? 56 : density === "comfy" ? 76 : 64; const LANE_BLOCK_H = LANE_STEP - 4; const laneByStage = {}; const rowHeights = []; const rowTops = []; let cumY = 0; for (const s of displayedStages) { const items = performances.filter(p => p.stage_id === s.id); const lanes = window.CrewliHelpers.assignLanes(items); laneByStage[s.id] = lanes; const innerH = lanes.laneCount * LANE_STEP + LANE_PAD * 2; const h = Math.max(baseRowHeight, innerH); rowHeights.push(h); rowTops.push(cumY); cumY += h; } const totalH = cumY; const stageRowIndex = {}; displayedStages.forEach((s, i) => { stageRowIndex[s.id] = i; }); function rowYToIndex(y) { for (let i = 0; i < rowTops.length; i++) { const top = rowTops[i]; const bot = top + rowHeights[i]; if (y >= top && y < bot) return i; } if (y < 0) return 0; return rowTops.length - 1; } // ── Block drag (move) ────────────────────────────────────────── const startDragMove = (e, perf) => { const startX = e.clientX, startY = e.clientY; let moved = false; const onMove = (ev) => { if (!moved && (Math.abs(ev.clientX - startX) > 3 || Math.abs(ev.clientY - startY) > 3)) moved = true; if (!moved) return; setDrag({ mode: "move", perfId: perf.id, dx: ev.clientX - startX, dy: ev.clientY - startY, basePerf: perf, mouseX: ev.clientX, mouseY: ev.clientY }); }; const onUp = (ev) => { const dx = ev.clientX - startX; const dy = ev.clientY - startY; window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); if (!moved) { setDrag(null); return; } // Suppress the click event the browser fires after a drag const suppress = (e) => { e.stopPropagation(); e.preventDefault(); window.removeEventListener("click", suppress, true); }; window.addEventListener("click", suppress, true); setTimeout(() => window.removeEventListener("click", suppress, true), 0); // Check if dropped on parking column const parkingEl = document.querySelector(".cw-parking"); if (parkingEl) { const r = parkingEl.getBoundingClientRect(); if (ev.clientX >= r.left && ev.clientX <= r.right && ev.clientY >= r.top && ev.clientY <= r.bottom) { dispatch({ type: "park_perf", id: perf.id }); setDrag(null); return; } } const minDelta = window.CrewliHelpers.snap(dx / pxPerMin, snap); const newStart = Math.max(0, Math.min(totalMin - (perf.end - perf.start), perf.start + minDelta)); const newEnd = newStart + (perf.end - perf.start); // Cursor-anchored target: row + lane derived from where the cursor is, not block-top. const canvasEl = document.querySelector(".cw-tt-canvas-inner"); const cr = canvasEl.getBoundingClientRect(); const targetY = ev.clientY - cr.top; const newRow = Math.max(0, Math.min(displayedStages.length - 1, rowYToIndex(targetY))); const newStageId = displayedStages[newRow]?.id || perf.stage_id; // Compute explicit lane assignment. let updated = { ...perf, start: newStart, end: newEnd, stage_id: newStageId }; if ("lane_pref" in updated) delete updated.lane_pref; const cascadeUpdates = []; { // Drop logic: pick target lane from Y. If an existing block on that lane // OVERLAPS the new time → that's a "drop ON block": go to oLane + 1. // If target lane is free at that time → drop there exactly (no cascade). const targetStageId = newStageId; const targetLanes = laneByStage[targetStageId]; const rowTop = rowTops[newRow]; const yInRow = targetY - rowTop - LANE_PAD; // Round so a half-step movement snaps to the nearest lane. const rawLane = Math.max(0, Math.min(targetLanes.laneCount, Math.round(yInRow / LANE_STEP))); const stagePerfs = performances.filter(p => p.stage_id === targetStageId && p.id !== perf.id); const laneOfOther = (o) => Number.isInteger(o.lane) ? o.lane : (targetLanes.laneOf[o.id] || 0); const blockAtLaneTime = stagePerfs.find(o => laneOfOther(o) === rawLane && o.start < newEnd && o.end > newStart); let finalLane = rawLane; if (blockAtLaneTime) finalLane = laneOfOther(blockAtLaneTime) + 1; updated.lane = finalLane; // Cascade only kicks in if the chosen lane has a conflict (rare: // could happen if multiple items share the lane below). const queue = [{ lane: finalLane, start: newStart, end: newEnd }]; const queued = new Set([perf.id]); while (queue.length > 0) { const cur = queue.shift(); for (const o of stagePerfs) { if (queued.has(o.id)) continue; const oLane = laneOfOther(o); if (oLane !== cur.lane) continue; if (!(o.start < cur.end && o.end > cur.start)) continue; const bumpLane = oLane + 1; cascadeUpdates.push({ ...o, lane: bumpLane }); queued.add(o.id); queue.push({ lane: bumpLane, start: o.start, end: o.end }); } } } dispatch({ type: "update_perf", perf: updated }); for (const u of cascadeUpdates) dispatch({ type: "update_perf", perf: u }); setDrag(null); }; window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); e.preventDefault(); }; const startDragResize = (e, perf) => { const startX = e.clientX; let resized = false; const onMove = (ev) => { if (!resized && Math.abs(ev.clientX - startX) > 2) resized = true; setDrag({ mode: "resize", perfId: perf.id, dx: ev.clientX - startX }); }; const onUp = (ev) => { const dx = ev.clientX - startX; window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); if (resized) { const suppress = (e) => { e.stopPropagation(); e.preventDefault(); window.removeEventListener("click", suppress, true); }; window.addEventListener("click", suppress, true); setTimeout(() => window.removeEventListener("click", suppress, true), 0); } const delta = window.CrewliHelpers.snap(dx / pxPerMin, snap); const newEnd = Math.max(perf.start + 15, Math.min(totalMin, perf.end + delta)); dispatch({ type: "update_perf", perf: { ...perf, end: newEnd }}); setDrag(null); }; window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); e.preventDefault(); e.stopPropagation(); }; // ── Parking → timetable drag (pending or parked) ─────────────── const startDragFromParking = (e, source, fullItem) => { // source: { kind: 'pending' | 'parked', item } // fullItem: optional — the normalized queue row { kind, id, artist, status, src, ... } const startX = e.clientX, startY = e.clientY; const sourceEl = e.currentTarget; let moved = false; const onMove = (ev) => { if (!moved && (Math.abs(ev.clientX - startX) > 4 || Math.abs(ev.clientY - startY) > 4)) moved = true; if (!moved) return; setDrag({ mode: "from_parking", kind: source.kind, item: source.item, mouseX: ev.clientX, mouseY: ev.clientY, moved: true, }); }; const onUp = (ev) => { window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); if (!moved) { // Pure click → open queue popover setDrag(null); if (fullItem && onSelectQueueItem) { const r = sourceEl.getBoundingClientRect(); onSelectQueueItem(fullItem, r); } return; } // Suppress the synthetic click after drop const suppress = (e) => { e.stopPropagation(); e.preventDefault(); window.removeEventListener("click", suppress, true); }; window.addEventListener("click", suppress, true); setTimeout(() => window.removeEventListener("click", suppress, true), 0); // Drop within parking column = stay parked, do nothing const parkingEl = document.querySelector(".cw-parking"); if (parkingEl) { const pr = parkingEl.getBoundingClientRect(); if (ev.clientX >= pr.left && ev.clientX <= pr.right && ev.clientY >= pr.top && ev.clientY <= pr.bottom) { setDrag(null); return; } } // hit-test against canvas const canvasEl = document.querySelector(".cw-tt-canvas-inner"); if (!canvasEl) { setDrag(null); return; } const r = canvasEl.getBoundingClientRect(); const x = ev.clientX - r.left; const y = ev.clientY - r.top; if (x < 0 || y < 0 || x > r.width || y > totalH) { setDrag(null); return; } const minute = Math.max(0, window.CrewliHelpers.snap(x / pxPerMin, snap)); const rowIdx = rowYToIndex(y); const stage = displayedStages[rowIdx]; if (!stage) { setDrag(null); return; } // Determine target lane from cursor Y within the row. const dur = source.kind === "parked" ? (Number.isFinite(source.item.end - source.item.start) ? (source.item.end - source.item.start) : (source.item.dur || 60)) : (source.item.dur || 60); const newStart = minute; const newEnd = minute + dur; const rowTop = rowTops[rowIdx]; const yInRow = y - rowTop - LANE_PAD; const targetLanes = laneByStage[stage.id]; const rawLane = Math.max(0, Math.min(targetLanes.laneCount, Math.round(yInRow / LANE_STEP))); const excludeId = source.kind === "parked" ? source.item.id : null; const stagePerfs = performances.filter(p => p.stage_id === stage.id && p.id !== excludeId); const laneOfOther = (o) => Number.isInteger(o.lane) ? o.lane : (targetLanes.laneOf[o.id] || 0); const blockAtLaneTime = stagePerfs.find(o => laneOfOther(o) === rawLane && o.start < newEnd && o.end > newStart); let finalLane = rawLane; if (blockAtLaneTime) finalLane = laneOfOther(blockAtLaneTime) + 1; // Cascade: bump conflicting blocks below. const cascadeUpdates = []; const queue = [{ lane: finalLane, start: newStart, end: newEnd }]; const queued = new Set(excludeId ? [excludeId] : []); while (queue.length > 0) { const cur = queue.shift(); for (const o of stagePerfs) { if (queued.has(o.id)) continue; const oLane = laneOfOther(o); if (oLane !== cur.lane) continue; if (!(o.start < cur.end && o.end > cur.start)) continue; const bumpLane = oLane + 1; cascadeUpdates.push({ ...o, lane: bumpLane }); queued.add(o.id); queue.push({ lane: bumpLane, start: o.start, end: o.end }); } } if (source.kind === "pending") { onScheduleFromParking({ pending: source.item, stage, minute, lane: finalLane }); } else { // parked perf — restore with new stage + start + lane const updated = { ...source.item, stage_id: stage.id, start: newStart, end: newEnd, lane: finalLane }; if ("lane_pref" in updated) delete updated.lane_pref; dispatch({ type: "update_perf", perf: updated }); } for (const u of cascadeUpdates) dispatch({ type: "update_perf", perf: u }); setDrag(null); }; window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); e.preventDefault(); }; function startCreateDrag(e, stage) { if (e.target !== e.currentTarget) return; if (e.button !== 0) return; const rowEl = e.currentTarget; const rect = rowEl.getBoundingClientRect(); const startMinute = Math.max(0, window.CrewliHelpers.snap((e.clientX - rect.left) / pxPerMin, snap)); const startY = e.clientY; const startX = e.clientX; // Lane derived from initial Y so the create-ghost lands on the correct sub-swimlane. // Use floor (not round) so anywhere within a lane = that lane, niet de volgende. const yInRow0 = e.clientY - rect.top - LANE_PAD; const stageLanes = laneByStage[stage.id]; const initLane = Math.max(0, Math.min(stageLanes.laneCount, Math.floor(yInRow0 / LANE_STEP))); setDrag({ mode: "create", stage, startMinute, currentMinute: startMinute, lane: initLane, moved: false }); const onMove = (ev) => { const dx = ev.clientX - startX; const dy = ev.clientY - startY; const moved = Math.abs(dx) > 3 || Math.abs(dy) > 3; const x = ev.clientX - rect.left; const m = Math.max(0, Math.min(totalMin, window.CrewliHelpers.snap(x / pxPerMin, snap))); // Live-recompute lane from cursor so ghost follows vertically too. const yInRow = ev.clientY - rect.top - LANE_PAD; const lane = Math.max(0, Math.min(stageLanes.laneCount, Math.floor(yInRow / LANE_STEP))); setDrag({ mode: "create", stage, startMinute, currentMinute: m, lane, moved }); }; const onUp = (ev) => { window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); const x = ev.clientX - rect.left; const endMinute = Math.max(0, Math.min(totalMin, window.CrewliHelpers.snap(x / pxPerMin, snap))); const lo = Math.min(startMinute, endMinute); const hi = Math.max(startMinute, endMinute); // If no drag (pure click), default to 60 min. const finalEnd = (hi - lo) < 15 ? Math.min(totalMin, lo + 60) : hi; const yInRow = ev.clientY - rect.top - LANE_PAD; const lane = Math.max(0, Math.min(stageLanes.laneCount, Math.floor(yInRow / LANE_STEP))); setDrag(null); onClickEmptyCell(stage, lo, finalEnd, lane); }; window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); e.preventDefault(); } const draggingOverParking = (() => { if (!drag || drag.mode !== "move") return false; const parkingEl = document.querySelector(".cw-parking"); if (!parkingEl || !drag.mouseX) return false; const r = parkingEl.getBoundingClientRect(); return drag.mouseX >= r.left && drag.mouseX <= r.right; })(); const rowDividers = rowTops.slice(1).concat([totalH]); return (
Stages {displayedStages.length} actief
{displayedStages.map((s, i) => { const isDragging = stageDrag && stageDrag.toIndex === i; const transform = isDragging ? `translateY(${stageDrag.dy - (rowTops[stageDrag.toIndex] - rowTops[stageDrag.fromIndex])}px)` : null; return (
e.stopPropagation()} onMouseDown={(e) => { e.stopPropagation(); startStageReorder(e, i); }}>
{s.name} cap. {s.capacity.toLocaleString("nl-NL")}
{(() => { const stageConflicts = performances.filter(p => p.stage_id === s.id && conflicts.has(p.id)).length; if (stageConflicts > 0) { return ( 1 ? "en" : ""} op deze stage`}> {stageConflicts} ); } return null; })()}
);})} {displayedStages.length === 0 && (
Geen stages op deze dag.
)}
{displayedStages.map((s, i) => { const lanes = laneByStage[s.id]; const laneStep = LANE_STEP; const laneHeight = LANE_BLOCK_H; const items = performances.filter(p => p.stage_id === s.id); const isRowDragging = stageDrag && stageDrag.toIndex === i; const rowTransform = isRowDragging ? `translateY(${stageDrag.dy - (rowTops[stageDrag.toIndex] - rowTops[stageDrag.fromIndex])}px)` : null; return (
startCreateDrag(e, s)}> {items.map((p) => { const artist = artists.find(a => a.id === p.artist_id); let renderPerf = p; let renderInThisRow = true; if (drag && drag.perfId === p.id && drag.mode === "move") { // Hide original block when dragged over parking column const parkingEl = document.querySelector(".cw-parking"); if (parkingEl && drag.mouseX) { const pr = parkingEl.getBoundingClientRect(); if (drag.mouseX >= pr.left && drag.mouseX <= pr.right) renderInThisRow = false; } const minDelta = window.CrewliHelpers.snap(drag.dx / pxPerMin, snap); // Cursor-anchored: row from cursor Y, not from block-top + dy. const canvasEl = document.querySelector(".cw-tt-canvas-inner"); let newStageId = p.stage_id; if (canvasEl && drag.mouseY) { const cr = canvasEl.getBoundingClientRect(); const targetY = drag.mouseY - cr.top; const newRow = Math.max(0, Math.min(displayedStages.length - 1, rowYToIndex(targetY))); newStageId = displayedStages[newRow]?.id || p.stage_id; } if (newStageId !== p.stage_id) renderInThisRow = false; renderPerf = { ...p, start: p.start + minDelta, end: p.end + minDelta }; } else if (drag && drag.perfId === p.id && drag.mode === "resize") { const delta = window.CrewliHelpers.snap(drag.dx / pxPerMin, snap); renderPerf = { ...p, end: Math.max(p.start + 15, p.end + delta) }; } if (!renderInThisRow) return null; let lane = lanes.laneOf[p.id] ?? 0; // Live lane shift while dragging within the same stage — cursor-anchored. if (drag && drag.perfId === p.id && drag.mode === "move") { const canvasEl = document.querySelector(".cw-tt-canvas-inner"); if (canvasEl && drag.mouseY) { const cr = canvasEl.getBoundingClientRect(); const targetY = drag.mouseY - cr.top; const newRow = Math.max(0, Math.min(displayedStages.length - 1, rowYToIndex(targetY))); const newStageId = displayedStages[newRow]?.id; if (newStageId === p.stage_id) { const yInRow = targetY - rowTops[newRow] - LANE_PAD; const rawLane = Math.max(0, Math.min(lanes.laneCount, Math.round(yInRow / LANE_STEP))); const stagePerfs = performances.filter(o => o.stage_id === p.stage_id && o.id !== p.id); const laneOfOther = (o) => Number.isInteger(o.lane) ? o.lane : (lanes.laneOf[o.id] || 0); const newStart = renderPerf.start; const newEnd = renderPerf.end; const hit = stagePerfs.find(o => laneOfOther(o) === rawLane && o.start < newEnd && o.end > newStart); lane = hit ? laneOfOther(hit) + 1 : rawLane; } } } const laneTop = LANE_PAD + lane * laneStep; return ( { setSelectedId(perf.id); onSelectPerf(perf, rect); }} onStartDragMove={startDragMove} onStartDragResize={startDragResize} /> ); })}
); })} {/* Move-ghost in target row */} {drag && drag.mode === "move" && (() => { const p = performances.find(x => x.id === drag.perfId); if (!p) return null; const artist = artists.find(a => a.id === p.artist_id); const minDelta = window.CrewliHelpers.snap(drag.dx / pxPerMin, snap); const canvasEl = document.querySelector(".cw-tt-canvas-inner"); if (!canvasEl || !drag.mouseY) return null; const cr = canvasEl.getBoundingClientRect(); const targetY = drag.mouseY - cr.top; const newRow = Math.max(0, Math.min(displayedStages.length - 1, rowYToIndex(targetY))); const newStageId = displayedStages[newRow]?.id; if (!newStageId || newStageId === p.stage_id) return null; const targetStage = displayedStages[newRow]; const newStart = p.start + minDelta; const newEnd = p.end + minDelta; const renderPerf = { ...p, start: newStart, end: newEnd }; // Preview lane in the target row, mirror drop logic. const targetLanes = laneByStage[newStageId]; const rowTop = rowTops[newRow]; const yInRow = targetY - rowTop - LANE_PAD; const rawLane = Math.max(0, Math.min(targetLanes.laneCount, Math.round(yInRow / LANE_STEP))); const stagePerfs = performances.filter(o => o.stage_id === newStageId && o.id !== p.id); const laneOfOther = (o) => Number.isInteger(o.lane) ? o.lane : (targetLanes.laneOf[o.id] || 0); const blockAtLaneTime = stagePerfs.find(o => laneOfOther(o) === rawLane && o.start < newEnd && o.end > newStart); const finalLane = blockAtLaneTime ? laneOfOther(blockAtLaneTime) + 1 : rawLane; const ghostLaneTop = LANE_PAD + finalLane * LANE_STEP; return (
{}} onStartDragMove={() => {}} onStartDragResize={() => {}} />
); })()} {/* Create-drag ghost — visible while dragging out a new performance */} {drag && drag.mode === "create" && (() => { const stage = drag.stage; const rowIdx = stageRowIndex[stage.id]; if (rowIdx == null) return null; const lo = Math.min(drag.startMinute, drag.currentMinute); const hi = Math.max(drag.startMinute, drag.currentMinute); const dur = Math.max(0, hi - lo); const previewDur = drag.moved ? dur : 60; const left = lo * pxPerMin; const width = Math.max(40, previewDur * pxPerMin); const ghostLaneTop = LANE_PAD + (drag.lane || 0) * LANE_STEP; return (
{drag.moved && dur >= 15 ? ( <>{dur}m · {window.CrewliHelpers.fmtTime(lo, window.CrewliData.TIME.startHour)}–{window.CrewliHelpers.fmtTime(hi, window.CrewliData.TIME.startHour)} ) : ( {Math.max(0, dur)}m )}
); })()} {/* From-parking ghost */} {drag && drag.mode === "from_parking" && drag.moved && (() => { const canvasEl = document.querySelector(".cw-tt-canvas-inner"); if (!canvasEl) return null; const r = canvasEl.getBoundingClientRect(); const x = drag.mouseX - r.left; const y = drag.mouseY - r.top; if (x < 0 || y < 0 || y > totalH) return null; const minute = Math.max(0, window.CrewliHelpers.snap(x / pxPerMin, snap)); const rowIdx = rowYToIndex(y); const stage = displayedStages[rowIdx]; if (!stage) return null; const dur = drag.kind === "parked" ? (Number.isFinite(drag.item.end - drag.item.start) ? (drag.item.end - drag.item.start) : (drag.item.dur || 60)) : (drag.item.dur || 60); const artistId = drag.kind === "parked" ? drag.item.artist_id : drag.item.artist_id; const artist = artists.find(a => a.id === artistId); const status = drag.kind === "parked" ? drag.item.status : "concept"; const newStart = minute; const newEnd = minute + dur; // Mirror drop logic: pick lane from Y, bump if a block on that lane overlaps in time. const rowTop = rowTops[rowIdx]; const yInRow = y - rowTop - LANE_PAD; const targetLanes = laneByStage[stage.id]; const rawLane = Math.max(0, Math.min(targetLanes.laneCount, Math.round(yInRow / LANE_STEP))); const excludeId = drag.kind === "parked" ? drag.item.id : null; const stagePerfs = performances.filter(p => p.stage_id === stage.id && p.id !== excludeId); const laneOfOther = (o) => Number.isInteger(o.lane) ? o.lane : (targetLanes.laneOf[o.id] || 0); const blockAtLaneTime = stagePerfs.find(o => laneOfOther(o) === rawLane && o.start < newEnd && o.end > newStart); const finalLane = blockAtLaneTime ? laneOfOther(blockAtLaneTime) + 1 : rawLane; const ghostLaneTop = LANE_PAD + finalLane * LANE_STEP; const renderPerf = { id: "ghost", start: minute, end: minute + dur, status, stage_id: stage.id, artist_id: artistId, lane: finalLane }; return (
{}} onStartDragMove={() => {}} onStartDragResize={() => {}} />
); })()} {displayedStages.length === 0 && (

Geen stages op deze dag

Gebruik Lineup per dag om stages te activeren, of voeg een nieuwe stage toe.

)}
startDragFromParking(e, { kind: "pending", item }, full)} onStartDragParked={(e, item, full) => startDragFromParking(e, { kind: "parked", item }, full)} isDropTarget={draggingOverParking} isDragOrigin={drag && drag.mode === "from_parking" && drag.moved} onSelectQueueItem={onSelectQueueItem} selectedQueueId={selectedQueueId} /> {/* Floating cursor chip when dragging a block toward the parking column */} {drag && drag.mode === "move" && draggingOverParking && (() => { const p = performances.find(x => x.id === drag.perfId); if (!p) return null; const artist = artists.find(a => a.id === p.artist_id); return (
{artist.initials}
{artist.name}
Loslaten = parkeren
); })()} {/* Floating cursor chip when dragging from parking — only show OUTSIDE canvas */} {drag && drag.mode === "from_parking" && drag.moved && (() => { const canvasEl = document.querySelector(".cw-tt-canvas-inner"); if (!canvasEl) return null; const r = canvasEl.getBoundingClientRect(); const insideCanvas = drag.mouseX >= r.left && drag.mouseX <= r.right && drag.mouseY >= r.top && drag.mouseY <= r.top + totalH; if (insideCanvas) return null; // ghost in canvas already shows the target slot const artistId = drag.item.artist_id; const artist = artists.find(a => a.id === artistId); return (
{artist.initials}
{artist.name}
Sleep naar tijdvak · loslaten = annuleren
); })()}
); } return { Timetable }; })(); Object.assign(window, { CrewliTimetable: TT });